From 4659275c66cab78c60f0e2f7b1d0cab379b3cf05 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 12 Mar 2026 21:37:37 +0200 Subject: [PATCH] feat(docs): add comprehensive guides for using the Sendama editor --- README.md | 2 + docs/Editor.md | 101 +- docs/guides/README.md | 65 + docs/guides/building-scenes.md | 229 ++++ docs/guides/getting-started.md | 130 ++ docs/guides/inspector-and-properties.md | 215 ++++ docs/guides/layout-and-navigation.md | 127 ++ docs/guides/playtest-and-debug.md | 127 ++ docs/guides/reference.md | 209 ++++ docs/guides/working-with-assets.md | 178 +++ src/Commands/EditGame.php | 11 +- src/Commands/NewGame.php | 61 +- src/Editor/Editor.php | 538 +++++++- src/Editor/EditorSettings.php | 6 +- src/Editor/GameSettings.php | 7 +- src/Editor/IO/InputManager.php | 9 +- src/Editor/SceneLoader.php | 13 +- src/Editor/Widgets/AssetsPanel.php | 77 +- src/Editor/Widgets/InspectorPanel.php | 1100 ++++++++++++++++- src/Editor/Widgets/MainPanel.php | 87 +- .../AbstractAssetFileGenerationStrategy.php | 6 +- .../SceneFileGenerationStrategy.php | 4 +- .../ScriptFileGenerationStrategy.php | 6 +- .../TextureFileGenerationStrategy.php | 4 +- src/Util/Config/ComposerConfig.php | 4 +- src/Util/Path.php | 370 +++--- src/Util/ProjectNormalizer.php | 317 +++++ tests/Unit/AssetsPanelTest.php | 60 + tests/Unit/CliAssetsDirectoryTest.php | 206 +++ tests/Unit/EditorAssetRenameTest.php | 57 + tests/Unit/EditorSettingsTest.php | 22 + tests/Unit/InputManagerTest.php | 9 + tests/Unit/InspectorPanelTest.php | 713 +++++++++++ tests/Unit/MainPanelTest.php | 145 ++- tests/Unit/ProjectNormalizerTest.php | 50 + 35 files changed, 4972 insertions(+), 293 deletions(-) create mode 100644 docs/guides/README.md create mode 100644 docs/guides/building-scenes.md create mode 100644 docs/guides/getting-started.md create mode 100644 docs/guides/inspector-and-properties.md create mode 100644 docs/guides/layout-and-navigation.md create mode 100644 docs/guides/playtest-and-debug.md create mode 100644 docs/guides/reference.md create mode 100644 docs/guides/working-with-assets.md create mode 100644 src/Util/ProjectNormalizer.php create mode 100644 tests/Unit/CliAssetsDirectoryTest.php create mode 100644 tests/Unit/EditorAssetRenameTest.php create mode 100644 tests/Unit/ProjectNormalizerTest.php diff --git a/README.md b/README.md index 16dda96..31ef6d7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Sendama CLI is a console application that provides a command line interface for For the current editor manual, see [Editor.md](docs/Editor.md). +For the modular guide set, see [docs/guides/README.md](docs/guides/README.md). + ## Requirements - PHP 8.3 or newer diff --git a/docs/Editor.md b/docs/Editor.md index 2cb0622..010df3c 100644 --- a/docs/Editor.md +++ b/docs/Editor.md @@ -4,6 +4,8 @@ This document is a living guide to the Sendama editor. It is meant to track the editor as it exists today, including current hotkeys, panel workflows, and known behavior. Update this file whenever the editor gains new tools, panels, controls, or shortcuts. +For the task-oriented guide set, start with [docs/guides/README.md](guides/README.md). + ## Starting the Editor Open the editor from inside a Sendama project: @@ -21,15 +23,21 @@ sendama edit --directory /path/to/project The editor expects a valid Sendama project workspace. In particular: - editor settings and project settings must be present -- the project should contain an `Assets` folder +- the project should contain an `Assets` folder, or a legacy lowercase `assets` folder - 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: + +- `Normalize`: create the missing directories and bootstrap files +- `Cancel` or `Escape`: continue without changing the project + ## Layout Overview The editor currently uses five main panels: - `Hierarchy`: scene tree and scene object management -- `Assets`: project browser rooted at the project's `Assets` folder +- `Assets`: project browser rooted at the active asset root, preferring `Assets` but compatible with legacy `assets` - `Main`: workspace area with `Scene`, `Game`, and `Sprite` tabs - `Console`: project log view - `Inspector`: object and asset details, plus property editing @@ -48,7 +56,12 @@ These shortcuts work regardless of the currently focused panel unless a modal is | `Shift+5` | Toggle play mode globally | | `Ctrl+C` | Close the editor gracefully | | `Ctrl+S` | Save the loaded scene | -| `Shift+A` | Open the Hierarchy add workflow, or create a Sprite asset when the Sprite tab is focused | + +`Shift+A` is panel-local: + +- in `Hierarchy`, it opens the add-object workflow +- in `Assets`, it opens the create-asset workflow +- in `Inspector`, it opens the add-component menu when a hierarchy object is loaded ## Panel List Modal @@ -89,7 +102,7 @@ Current scene rendering behavior: - texture paths are resolved relative to the editor's configured project directory - scene coordinates are rendered into a scrollable viewport - UI text objects render their `text` -- objects without a visible representation are not currently drawn in the scene tab +- selected objects without a visible representation render as a muted `x` - 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. @@ -173,19 +186,11 @@ Controls: - `Shift+2`: open the character selector modal for special characters - `Space`: place a blank character - `Backspace`: erase the current cell -- `Shift+A`: open the create-asset modal - `Ctrl+Z`: undo the last grid change - `Ctrl+Y`: redo the last undone grid change - `Shift+R`: reset the loaded asset back to the state it had when it was opened - `Delete`: open the delete-asset confirmation modal -Create workflow: - -- `Shift+A` opens a modal with `Texture`, `Tile Map`, and `Cancel` -- choosing `Texture` creates a new `.texture` file in `Assets/Textures` -- choosing `Tile Map` creates a new `.tmap` file in `Assets/Maps` -- the new asset is loaded into the sprite editor immediately - Delete workflow: - `Delete` opens a confirmation modal for the currently loaded asset @@ -267,6 +272,7 @@ Controls: - `Right`: expand a folder, or move into it - `Left`: collapse a folder, or move to its parent - `Enter`: load the selected asset into the Inspector +- `Shift+A`: open the asset create workflow - `Delete`: open the delete confirmation dialog Inspector type mapping: @@ -288,6 +294,25 @@ Controls: - `Enter`: confirm the selection - `Escape`: cancel +### Asset Create Workflow + +Press `Shift+A` while the Assets panel has focus to open the create modal. + +Current create targets: + +- `Script` +- `Scene` +- `Texture` +- `Tile Map` +- `Event` + +Behavior: + +- selecting an asset type runs the corresponding Sendama generator command in the opened project directory +- the editor creates the asset with the next available default name for that asset family +- after creation, the Assets tree refreshes, the new asset is selected, and the Inspector loads it +- if the created asset is a texture or tile map, the Sprite tab loads it too + ## Inspector Panel The Inspector shows details for the currently inspected target. @@ -304,7 +329,8 @@ For file assets, the Inspector currently shows: - editable `Name` - read-only `Path` -Renaming a texture or tile map from the Inspector renames the file on disk and updates known scene references in memory, such as `sprite.texture.path` and `environmentTileMapPath`. +Renaming a file asset from the Inspector renames the file on disk. If the current scene references that file through `sprite.texture.path` or `environmentTileMapPath`, those scene references are updated in memory and should be saved with `Ctrl+S`. +If the renamed asset is a PHP script under `Assets/Scripts`, the editor also rewrites the class declaration inside the source file to match the new filename. ### Inspector Hotkeys @@ -312,6 +338,11 @@ When the Inspector has focus: - `Tab`: move to the next control - `Shift+Tab`: move to the previous control +- `Shift+A`: open the add-component menu for the currently inspected hierarchy object +- `Shift+W`: enter or leave component move mode when a component header is selected +- `Delete`: open the remove-component confirmation modal when a component header is selected + +The Inspector help line updates dynamically to show the active controls on the left and the current mode on the right. The Inspector uses a small state machine. @@ -325,6 +356,9 @@ Controls: - `Up` / `Down`: move between controls - `Enter`: activate the selected control +- `Shift+A`: open the add-component menu when a hierarchy object is being inspected +- `Shift+W`: toggle component move mode when a component header is focused +- `Delete`: open the remove-component confirmation modal when a component header is focused - `/`: toggle the focused collapsible section, such as `Transform`, `Renderer`, or a component block #### 2. Property Selection @@ -385,6 +419,47 @@ For hierarchy objects, the Inspector currently renders: Component headers are visually marked as collapsible sections. +### Add Component Workflow + +When the Inspector is focused on a hierarchy object other than the scene root, press `Shift+A` to open `Add Component`. + +Current component candidates come from: + +- built-in engine component defaults +- PHP classes discovered under `Assets/Scripts` +- component classes already present in the loaded scene + +Behavior: + +- selecting a component appends it to the object's `components` list +- if the editor can discover serializable default data for that component, it adds that data immediately +- the new component section appears in the Inspector right away + +### Component Remove Workflow + +When a component header is focused, press `Delete` to open the remove confirmation modal. + +Behavior: + +- the modal asks whether to remove the selected component from the current object +- `Delete`: confirm removal +- `Cancel` or `Escape`: abort removal +- confirming removes that component from the object's `components` list immediately + +### Component Reorder Workflow + +When a component header is focused, press `Shift+W` to enter component move mode. + +Behavior: + +- `Up`: move the selected component one slot earlier, wrapping to the end from the first slot +- `Down`: move the selected component one slot later, wrapping to the start from the last slot +- `Escape`: leave move mode + +Current limit: + +- component reordering is currently driven from the component header itself, not from nested component fields + ### Renderer Section The renderer reads from the object's `sprite` metadata. diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 0000000..e50f7be --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,65 @@ +# Sendama Editor Guides + +This guide set explains how the current Sendama editor works in practice so you can use it to build scenes, draw assets, tune properties, and iterate on your game quickly. + +## Start Here + +Read these guides in order if you are learning the editor for the first time: + +1. [Getting Started](getting-started.md) +2. [Layout and Navigation](layout-and-navigation.md) +3. [Building Scenes](building-scenes.md) +4. [Working with Assets](working-with-assets.md) +5. [Inspector and Properties](inspector-and-properties.md) +6. [Playtest and Debug](playtest-and-debug.md) +7. [Reference](reference.md) + +## What The Editor Can Do Today + +- Open a Sendama project and load its active scene. +- Browse the `Assets` tree. +- Inspect scene roots, scene objects, and file assets. +- Add top-level `GameObject`, `Text`, and `Label` entries to the active scene. +- 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. +- 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. + +## 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. + +For those tasks, the usual companion commands are: + +```bash +sendama generate:scene level01 +sendama generate:script PlayerController +sendama play +``` + +## Recommended Build Loop + +Use the editor in this order when building a level or game screen: + +1. Open the project with `sendama edit`. +2. Set the scene size and background tile map. +3. Add scene objects from the Hierarchy panel. +4. Assign textures, text, and components in the Inspector. +5. Draw or update textures and tile maps in the Sprite tab. +6. Save the scene with `Ctrl+S`. +7. Check logs in the Console panel and run the game with `sendama play` when you need a full playthrough. + +## A Few Important Rules To Remember + +- Scene edits are not written to disk until you press `Ctrl+S`. +- Sprite and tile map edits are written to disk immediately. +- File renames happen immediately, but you should save the scene afterward if that scene references the renamed asset. +- Asset deletions happen immediately and can leave broken references behind if the deleted file was still in use. diff --git a/docs/guides/building-scenes.md b/docs/guides/building-scenes.md new file mode 100644 index 0000000..42a2fd0 --- /dev/null +++ b/docs/guides/building-scenes.md @@ -0,0 +1,229 @@ +# Building Scenes + +This guide covers the main scene-building loop: define the scene, create objects, place them visually, and save the result back to the scene file. + +## The Scene Build Loop + +A reliable workflow looks like this: + +1. Inspect the scene root from `Hierarchy`. +2. Set scene dimensions and the environment tile map. +3. Add scene objects from `Hierarchy`. +4. Edit object properties and add components in `Inspector`. +5. Place visible objects in the `Scene` tab. +6. Save with `Ctrl+S`. + +## Editing The Scene Root + +In `Hierarchy`, the scene root is the top row. Press `Enter` on it to load the scene into the `Inspector`. + +The scene inspector currently exposes: + +- `Type` +- `Name` +- `Width` +- `Height` +- `Environment Tile Map` + +What these fields do: + +- `Name`: becomes the scene file name the next time you save +- `Width` and `Height`: control the canvas size used in Scene view +- `Environment Tile Map`: sets the background map rendered behind scene objects + +Important detail: + +- changing the scene name does not rename the file immediately +- the rename happens when you press `Ctrl+S` + +## Setting The Background Map + +The scene background comes from `environmentTileMapPath`. + +Best practice: + +- create or choose a `.tmap` file in `Assets/Maps` +- assign it through the scene root's `Environment Tile Map` field +- keep the map close to your scene dimensions so panning stays readable + +The background map: + +- renders behind scene objects +- is not selectable in the Scene tab +- is resolved relative to the project and asset directories + +## Adding Objects + +Move focus to `Hierarchy` and press `Shift+A`. + +The add flow currently supports: + +1. `GameObject` +2. `UIElement` + +If you choose `UIElement`, you can then choose: + +- `Text` +- `Label` + +Default objects are created with starter values: + +### `GameObject` + +- `name`: `GameObject #` +- `tag`: `None` +- `position`: `0,0` +- `rotation`: `0,0` +- `scale`: `1,1` +- `components`: empty + +### `Text` and `Label` + +- `name`: ` #` +- `tag`: `UI` +- `position`: `0,0` +- `size`: `1,1` +- `text`: same as the object name + +Current limitation: + +- new objects are added at the scene root +- there is no UI for reparenting or inserting them under an existing object yet + +## Selecting Objects + +You can select objects in two places: + +- `Hierarchy` +- `Main -> Scene` + +When you select a visible object in `Scene`, the selection syncs back to the `Hierarchy` and `Inspector`. + +This is useful for a two-step workflow: + +1. select an object visually +2. edit the exact numbers in `Inspector` + +## Scene Tab Modes + +The Scene tab has three interaction modes: + +| Key | Mode | +| --- | --- | +| `Shift+Q` | Select | +| `Shift+W` | Move | +| `Shift+E` | Pan | + +### Select Mode + +Use Select mode to move through visible scene objects. + +Controls: + +- `Up` / `Left`: previous visible object +- `Down` / `Right`: next visible object +- `Enter`: reload the selected object in `Inspector` + +Notes: + +- selection only cycles through objects that have something visible to render +- UI text objects render their `text` +- sprite-backed objects render the cropped part of their `.texture` file + +### Move Mode + +Use Move mode to place the currently selected object. + +Controls: + +- `Up`: decrease `position.y` +- `Right`: increase `position.x` +- `Down`: increase `position.y` +- `Left`: decrease `position.x` + +Moving an object: + +- updates the loaded scene in memory +- marks the scene dirty +- updates the Inspector immediately if that object is currently loaded there + +### Pan Mode + +Use Pan mode when the scene is bigger than the visible viewport. + +Controls: + +- `Up` +- `Right` +- `Down` +- `Left` + +Panning changes the viewport only. It does not change object data. + +## Editing Object Details + +Once an object is selected, use `Inspector` to edit it. The current object workflow supports: + +- renaming +- retagging +- changing transform values +- changing `size` for UI elements that expose it +- changing renderer texture path, offset, and size +- editing visible text on text-based UI objects +- editing serialized component data that is already present in the scene metadata +- appending new components from the Inspector add-component menu + +### Adding Components + +When a hierarchy object is loaded in `Inspector`, press `Shift+A` to open `Add Component`. + +Current component candidates come from: + +- built-in engine component defaults +- project scripts under `Assets/Scripts` +- component classes already present on the current object or elsewhere in the loaded scene + +Selecting a component: + +- appends it to the object's `components` list +- loads any serializable default data the editor can discover +- immediately refreshes the Inspector so you can keep editing the new component + +Current limit: + +- components can be added, but they cannot yet be removed or reordered from the editor UI + +For a full breakdown, continue with [Inspector and Properties](inspector-and-properties.md). + +## Saving + +Press `Ctrl+S` to write the active scene back to disk. + +Save includes: + +- scene root changes +- object property edits +- scene-view moves +- hierarchy additions +- hierarchy deletions + +After a successful save: + +- the dirty marker disappears from the scene root +- the scene source file is updated +- a renamed scene is written to its new `.scene.php` filename + +## Example Workflow: Build A Small Level + +Here is a practical level-building sequence: + +1. Open the scene root and set `Width` and `Height`. +2. Assign a background tile map such as `Maps/level`. +3. Add a `GameObject` for the player. +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`. + +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 new file mode 100644 index 0000000..8454d5b --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,130 @@ +# Getting Started + +This guide covers the minimum setup the editor expects and the first decisions that shape your editing workflow. + +## What The Editor Opens + +Open the editor from inside a Sendama project: + +```bash +sendama edit +``` + +Or point it at a project directory explicitly: + +```bash +sendama edit --directory /path/to/project +``` + +The editor expects a valid Sendama workspace. At minimum, make sure these are in place: + +- `sendama.json` +- an `Assets` directory, or a legacy lowercase `assets` directory +- a scene file inside `Assets/Scenes` or `assets/Scenes` + +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` +- missing `logs` files +- missing standard asset subdirectories + +A typical project layout looks like this: + +```text +your-game/ +├── sendama.json +├── game.php +├── Assets/ +│ ├── Scenes/ +│ │ └── level01.scene.php +│ ├── Textures/ +│ ├── Maps/ +│ ├── Scripts/ +│ ├── Events/ +│ └── Prefabs/ +└── logs/ + ├── debug.log + └── error.log +``` + +## How The Active Scene Is Chosen + +If you configure editor scenes in `sendama.json`, the editor loads the entry at `editor.scenes.loaded[editor.scenes.active]`. + +Example: + +```json +{ + "editor": { + "scenes": { + "active": 0, + "loaded": [ + "Scenes/level01.scene.php", + "Scenes/level02.scene.php" + ] + }, + "console": { + "refreshInterval": 5 + } + } +} +``` + +A few notes: + +- New projects use uppercase `Assets`. +- The editor and generators still support older projects that use lowercase `assets`. +- The editor also accepts older top-level `scenes` data if your project already uses it. +- Scene references can be bare names such as `level01` or explicit paths such as `Scenes/level01.scene.php`. +- If no configured scene is found, the editor falls back to the first `*.scene.php` file it finds in the scenes directory. + +## What You See On Startup + +The editor opens into a five-panel layout: + +- `Hierarchy` in the top-left +- `Assets` in the bottom-left +- `Main` in the center +- `Console` below the main panel +- `Inspector` on the right + +The `Main` panel starts focused. That is usually where you will spend most of your time switching between the `Scene`, `Game`, and `Sprite` tabs. + +## Your First Five Minutes + +A good first session looks like this: + +1. Press `Shift+1` and confirm you can jump between panels. +2. In `Hierarchy`, select the scene root and press `Enter`. +3. In `Inspector`, set the scene `Name`, `Width`, `Height`, and `Environment Tile Map`. +4. In `Assets`, inspect your existing textures and maps. +5. In `Hierarchy`, press `Shift+A` to create your first scene object. +6. In `Assets`, press `Shift+A` if you need a new script, scene, texture, tile map, or event file. +7. Press `Ctrl+S` after your first scene change so you know where persistence happens. + +## What Gets Saved, And When + +This is the single most important mental model for the editor: + +- Scene edits are kept in memory until you press `Ctrl+S`. +- Scene rename, object moves, object creation, deletion, and inspector edits all follow that rule. +- Texture and tile map edits are written to disk as soon as you draw. +- Asset renames and asset deletions also happen immediately. + +If you only remember one workflow rule, make it this one: + +```text +Change scene data -> Ctrl+S +Change sprite or tile map pixels -> already saved +``` + +## When To Leave The Editor + +The editor is strong at content editing, but not every task is exposed in the UI yet. Step out to the CLI or code when you need to: + +- write the PHP logic inside scripts and events generated by the editor +- reparent hierarchy objects +- run the full game runtime + +Use [Building Scenes](building-scenes.md) next if you want to start turning a blank level into something playable. diff --git a/docs/guides/inspector-and-properties.md b/docs/guides/inspector-and-properties.md new file mode 100644 index 0000000..9d38b2d --- /dev/null +++ b/docs/guides/inspector-and-properties.md @@ -0,0 +1,215 @@ +# Inspector and Properties + +The `Inspector` is where most precise editing happens. It turns the current selection into editable controls and pushes the result back into the loaded scene or selected asset. + +## What Can Be Inspected + +The `Inspector` can load targets from three places: + +- `Hierarchy` +- `Scene` +- `Assets` + +That gives you three main editing modes: + +- scene settings +- object and component settings +- file asset metadata + +## Inspector Navigation Model + +The Inspector uses three interaction states. + +### 1. Control Selection + +This is the default state. + +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 +- `/`: collapse or expand the selected section header +- `Tab` / `Shift+Tab`: move forward or backward through focusable controls + +### 2. Property Selection + +This state appears for compound controls such as vectors. + +Examples: + +- `Position` +- `Rotation` +- `Scale` +- renderer `Offset` +- renderer `Size` +- UI `Size` + +Controls: + +- `Up` / `Down`: move between sub-properties +- `Enter`: edit the selected property +- `Escape`: return to control selection + +### 3. Control Edit + +This state edits a concrete value. + +Common edit rules: + +- `Enter`: commit +- `Escape`: cancel +- `Backspace`: delete backward when supported +- `Left` / `Right`: move the cursor when supported +- `Up` / `Down`: increment or decrement when supported + +## Scene Controls + +When the scene root is inspected, the current editable fields are: + +- `Name` +- `Width` +- `Height` +- `Environment Tile Map` + +Practical use: + +- rename the scene before saving if you want a new scene filename +- resize the scene before you place objects +- point `Environment Tile Map` at the map file you want rendered behind the scene + +## Object Controls + +When a hierarchy object is inspected, the Inspector renders these groups. + +### Global Properties + +- `Type` +- `Name` +- `Tag` + +### Transform + +- `Position` +- `Rotation` +- `Scale` +- `Size` when the object type exposes it + +### Renderer + +- `Texture` +- `Offset` +- `Size` +- `Preview` +- `Text` when the object includes a text field + +### Components + +Each serialized component becomes its own collapsible section. The section title is the class name without the namespace. + +If the component exposes serialized data, the Inspector renders typed controls for it. + +### Add Component Menu + +When the Inspector is showing a hierarchy object other than the scene root, press `Shift+A` to open `Add Component`. + +The menu can pull candidates from: + +- built-in engine component defaults +- PHP classes discovered under `Assets/Scripts` +- component classes already present in the loaded scene + +When you choose a component: + +- it is appended to the object's `components` array +- any serializable default data the editor can discover is added immediately +- the new section appears in the Inspector right away + +## Asset Controls + +When a file asset is inspected, the Inspector renders: + +- `Type` +- editable `Name` +- read-only `Path` + +If the file is a PHP script under `Assets/Scripts`, renaming it in the Inspector also updates the class declaration inside the source file to match the new filename. + +When a folder is inspected, the Inspector renders: + +- `Type` +- read-only `Name` +- read-only `Path` + +## Supported Control Types + +The current control factory maps values to controls like this: + +- booleans -> checkbox controls such as `[x]` +- integers and floats -> number inputs +- vector-like arrays such as `{x, y}` -> vector inputs +- flat scalar option lists -> select controls +- everything else -> text inputs + +That means existing serialized component data can already be quite useful in the Inspector, even if you authored the component in code. + +## Path Inputs + +Path fields use a two-step workflow. + +Press `Enter` on a path field and you will see: + +- `Choose file` +- `Edit path` + +### Choose File + +This opens a file tree rooted at the field's working directory. + +Examples: + +- texture fields filter to `.texture` +- environment map fields filter to `.tmap` + +The dialog hides folders that do not contain matching files, which keeps large projects easier to browse. + +Controls: + +- `Up` / `Down` +- `Right` / `Left` +- `Enter` +- `Escape` + +### Edit Path + +This drops into normal text editing so you can type the path yourself. + +## Preview Window + +Renderer previews use the same texture path, crop offset, and crop size that the Scene tab uses for sprite rendering. + +That makes the preview good for verifying: + +- you picked the right texture +- the crop rectangle is correct +- the sprite will show what you expect in Scene view + +## Editing Tips + +These habits make the Inspector much easier to use: + +- select visually in `Scene`, then fine-tune numerically in `Inspector` +- collapse sections you are not working on with `/` +- use the file picker for paths when possible to avoid typos +- save after scene-level edits so your in-memory changes become persistent + +## Current Limits + +The Inspector edits what already exists in the loaded data model. It does not currently provide UI for: + +- removing components +- reordering components +- changing hierarchy parenting +- creating new nested child objects + +Use [Reference](reference.md) for the full control map and persistence rules. diff --git a/docs/guides/layout-and-navigation.md b/docs/guides/layout-and-navigation.md new file mode 100644 index 0000000..53cea30 --- /dev/null +++ b/docs/guides/layout-and-navigation.md @@ -0,0 +1,127 @@ +# Layout and Navigation + +The editor is keyboard-first, but it does support a few mouse interactions for focus and selection. + +## Default Layout + +The editor uses a fixed five-panel layout: + +- `Hierarchy`: top-left +- `Assets`: bottom-left +- `Main`: center +- `Console`: bottom-center +- `Inspector`: right side + +The layout resizes with the terminal. If the terminal changes size while the editor is open, the panels reflow automatically. + +## Focus Model + +Only one panel is focused at a time. The focused panel gets the active border color and receives keyboard input. + +Global focus controls: + +| Key | Action | +| --- | --- | +| `Shift+Up` | Focus the panel above the current panel | +| `Shift+Right` | Focus the panel to the right | +| `Shift+Down` | Focus the panel below | +| `Shift+Left` | Focus the panel to the left | +| `Shift+1` | Open the panel list modal | + +Panel list modal controls: + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move selection | +| `Enter` | Focus the selected panel | +| `Escape` | Close the modal | + +## Mouse Support + +Mouse support is intentionally small and practical: + +- click a panel to focus it +- click a row in `Hierarchy` or `Assets` to select it +- click a tab title in the `Main` panel to switch tabs + +You should still expect the editor to behave primarily like a keyboard UI. + +## Main Panel Tabs + +The `Main` panel has three tabs: + +- `Scene` +- `Game` +- `Sprite` + +Use these controls to move between them: + +| Key | Action | +| --- | --- | +| `Tab` | Next Main-panel tab when `Main` is focused | +| `Shift+Tab` | Previous Main-panel tab when `Main` is focused | + +The `Inspector` also uses `Tab` and `Shift+Tab`, but there they move between controls instead of tabs. + +## Global Editor Shortcuts + +These work across the editor unless a modal is open: + +| Key | Action | +| --- | --- | +| `Ctrl+C` | Close the editor | +| `Ctrl+S` | Save the loaded scene | +| `Shift+5` | Toggle play state | + +## Modal Behavior + +The editor uses modals for focused tasks such as: + +- panel selection +- add-object flow +- asset creation +- delete confirmations +- sprite quick creation +- add-component selection +- special character selection +- path input actions +- file selection for texture and map paths + +Most modals follow the same muscle memory: + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move selection | +| `Enter` | Confirm | +| `Escape` | Cancel or go back | + +Tree-style modals such as the file picker also use `Left` and `Right` to collapse and expand folders. + +## How Navigation Changes By Panel + +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. +- `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. + +You will get the fastest results if you think in terms of panel roles: + +- use `Hierarchy` to decide what exists +- use `Scene` to place things +- use `Inspector` to edit details +- use `Assets` and `Sprite` to manage ASCII art +- use `Console` to confirm what the game is doing + +## Suggested Navigation Habits + +These habits keep the editor feeling predictable: + +1. Use `Shift+1` whenever you lose track of focus. +2. Do selection in `Hierarchy` or `Scene`, then make changes in `Inspector`. +3. Return to `Main` before using scene move tools or sprite editing tools. +4. Save with `Ctrl+S` any time you finish a scene-level change. + +Continue with [Building Scenes](building-scenes.md) for the core level-building workflow. diff --git a/docs/guides/playtest-and-debug.md b/docs/guides/playtest-and-debug.md new file mode 100644 index 0000000..2eeff5a --- /dev/null +++ b/docs/guides/playtest-and-debug.md @@ -0,0 +1,127 @@ +# Playtest and Debug + +This guide explains what the editor's current play mode does, how the Console panel works, and how to combine the editor with `sendama play` for a full iteration loop. + +## The Game Tab + +When the `Game` tab is selected and the editor is not in play state, it shows an idle screen with the prompt: + +```text +Shift+5 to Play +``` + +This is the editor's signal that you can enter play state. + +## What `Shift+5` Does + +Press `Shift+5` anywhere in the editor to toggle play state. + +Current behavior: + +- focus jumps to `Main` +- the `Game` tab becomes active +- the Main panel border changes to the play-state color +- the Console panel switches into automatic log refresh + +What it does not currently do: + +- it does not embed a live in-editor runtime surface yet + +So treat play state as a lightweight run-and-monitor mode inside the editor, not as a full replacement for launching the game normally. + +## When To Use `sendama play` + +Use the editor's play state when you want to: + +- switch the editor into its play-oriented UI state +- keep the `Game` tab active +- let the Console panel auto-refresh while you inspect logs + +Use `sendama play` in a separate terminal when you need to: + +- run the full game runtime +- verify input, flow, and timing outside the editor shell +- test a more realistic play session + +Typical command: + +```bash +sendama play +``` + +Or: + +```bash +sendama play --directory /path/to/project +``` + +## Console Panel Overview + +The `Console` panel reads from: + +- `logs/debug.log` +- `logs/error.log` + +Tabs: + +- `Debug` +- `Error` + +On startup, each tab loads the last three lines from its log file if the file exists. + +## Console Controls + +| Key | Action | +| --- | --- | +| `Tab` | Next console tab | +| `Shift+Tab` | Previous console tab | +| `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 | + +Tag colors: + +- `[ERROR]`: red +- `[WARN]`: yellow +- `[INFO]`: blue +- `[DEBUG]`: light gray + +## Auto Refresh + +While the editor is in play state, the Console panel refreshes itself from disk every `editor.console.refreshInterval` seconds. + +Example config: + +```json +{ + "editor": { + "console": { + "refreshInterval": 5 + } + } +} +``` + +If you do not configure a value, the editor uses `5` seconds. + +## Recommended Debug Loop + +For most gameplay iteration, this loop works well: + +1. Edit scene data or component values in the editor. +2. Press `Ctrl+S` if you changed the scene. +3. Run `sendama play` in another terminal when you need a full runtime check. +4. Keep the editor open to inspect assets and logs. +5. Use the Console panel to watch `debug.log` and `error.log`. +6. Return to the editor, make the next change, and repeat. + +## Practical Expectations + +Right now, the editor is strongest as a content authoring and inspection tool. The most effective workflow is: + +- author content in the editor +- save scene changes +- run the game with `sendama play` +- use the editor console and inspector to support the next iteration + +If you need exact controls at a glance, see [Reference](reference.md). diff --git a/docs/guides/reference.md b/docs/guides/reference.md new file mode 100644 index 0000000..1b48cfe --- /dev/null +++ b/docs/guides/reference.md @@ -0,0 +1,209 @@ +# Reference + +This page gathers the most useful shortcuts, file locations, persistence rules, and current editor limits in one place. + +## Global Shortcuts + +| Key | Action | +| --- | --- | +| `Shift+Up` | Focus panel above | +| `Shift+Right` | Focus panel to the right | +| `Shift+Down` | Focus panel below | +| `Shift+Left` | Focus panel to the left | +| `Shift+1` | Open panel list | +| `Shift+5` | Toggle play state | +| `Shift+A` | Panel-local create action in `Hierarchy`, `Assets`, `Inspector`, and `Main -> Sprite` | +| `Ctrl+S` | Save the loaded scene | +| `Ctrl+C` | Close the editor | + +## Hierarchy Panel + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move selection | +| `Right` | Expand node or move into children | +| `Left` | Collapse node or move to parent | +| `Enter` | Inspect selection | +| `Shift+A` | Add object | +| `Delete` | Delete selected object | + +Add-object types: + +- `GameObject` +- `UIElement -> Text` +- `UIElement -> Label` + +## Assets Panel + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move selection | +| `Right` | Expand folder or move into children | +| `Left` | Collapse folder or move to parent | +| `Enter` | Inspect file or folder | +| `Shift+A` | Create asset from the Assets create menu | +| `Delete` | Delete selected asset | + +Create targets: + +- `Script` +- `Scene` +- `Texture` +- `Tile Map` +- `Event` + +## Main Panel + +### Tabs + +| Key | Action | +| --- | --- | +| `Tab` | Next tab | +| `Shift+Tab` | Previous tab | + +### Scene Tab + +Mode shortcuts: + +| Key | Action | +| --- | --- | +| `Shift+Q` | Select mode | +| `Shift+W` | Move mode | +| `Shift+E` | Pan mode | + +Select mode: + +| Key | Action | +| --- | --- | +| `Up` / `Left` | Previous visible object | +| `Down` / `Right` | Next visible object | +| `Enter` | Inspect selected object | + +Move mode: + +| Key | Action | +| --- | --- | +| `Up` | `position.y - 1` | +| `Right` | `position.x + 1` | +| `Down` | `position.y + 1` | +| `Left` | `position.x - 1` | + +Pan mode: + +| Key | Action | +| --- | --- | +| `Up` / `Right` / `Down` / `Left` | Move viewport | + +### Sprite Tab + +| Key | Action | +| --- | --- | +| `Up` / `Right` / `Down` / `Left` | Move cursor | +| printable character | Draw character | +| `Space` | Write a blank | +| `Backspace` | Erase current cell | +| `Shift+2` | Open character picker | +| `Ctrl+Z` | Undo | +| `Ctrl+Y` | Redo | +| `Shift+R` | Reset loaded asset | +| `Delete` | Delete active asset | + +## Inspector Panel + +Selection state: + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move between controls | +| `Enter` | Activate control | +| `Shift+A` | Add a component to the inspected hierarchy object | +| `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 | + +Property-selection state: + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move between sub-properties | +| `Enter` | Edit selected sub-property | +| `Escape` | Return to control selection | + +Edit state: + +| Key | Action | +| --- | --- | +| `Enter` | Commit edit | +| `Escape` | Cancel edit | +| `Backspace` | Delete backward when supported | +| `Left` / `Right` | Move cursor when supported | +| `Up` / `Down` | Increment or decrement when supported | + +Path inputs: + +| Key | Action | +| --- | --- | +| `Enter` | Open `Choose file` or `Edit path` | +| `Escape` | Close the path action modal or go back | + +## Console Panel + +| Key | Action | +| --- | --- | +| `Tab` | Next log tab | +| `Shift+Tab` | Previous log tab | +| `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 | + +## Common Modal Controls + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move selection | +| `Enter` | Confirm | +| `Escape` | Cancel or close | + +Tree-style modals also use: + +| Key | Action | +| --- | --- | +| `Right` | Expand folder | +| `Left` | Collapse folder or move to parent | + +## Where Data Is Stored + +| Change | Written To | When | +| --- | --- | --- | +| scene root edits | active `.scene.php` file | when you press `Ctrl+S` | +| 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` | +| scene rename | renamed `.scene.php` file | when you press `Ctrl+S` | +| asset creation from `Assets` or `Sprite` | generated asset file | immediately | +| texture and tile map drawing | selected asset file | immediately | +| file asset rename | selected asset file path | immediately | +| asset delete | selected file or folder | immediately | + +Special rename behavior: + +- renaming a script under `Assets/Scripts` also rewrites its PHP class declaration immediately + +## Current Editor Limits + +These limits matter when planning your workflow: + +- new hierarchy items are added at the scene root only +- there is no UI for reparenting objects +- component removal and reordering operate from focused component headers only +- the dedicated canvas editor only supports `.texture` and `.tmap` files +- deleting an asset does not automatically repair scene references +- play state currently changes editor behavior and console refresh, but does not embed the full runtime in the Game tab + +## Practical Workflow Tips + +- Save the scene after any asset rename that affects textures or tile maps already used in the scene. +- Treat folder deletion as destructive because it is recursive. +- Use the Inspector file picker for path fields to avoid typos. +- 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 new file mode 100644 index 0000000..6911648 --- /dev/null +++ b/docs/guides/working-with-assets.md @@ -0,0 +1,178 @@ +# Working with Assets + +This guide explains how the editor handles the asset tree, how the `Sprite` tab works, and what to watch out for when renaming or deleting files. + +## The Assets Panel + +The `Assets` panel is a tree browser rooted at the active project asset root. + +By default that root is `Assets`. Legacy lowercase `assets` projects are still supported. + +Controls: + +| Key | Action | +| --- | --- | +| `Up` / `Down` | Move selection | +| `Right` | Expand a folder or move into its children | +| `Left` | Collapse a folder or move to its parent | +| `Enter` | Inspect the selected folder or file | +| `Shift+A` | Open the asset create modal | +| `Delete` | Open the delete confirmation dialog | + +What inspection does: + +- folders open in the `Inspector` as `Folder` +- files open in the `Inspector` as `File` +- `.texture` and `.tmap` files also load into `Main -> Sprite` + +## Sprite Tab Overview + +The `Sprite` tab is the editor's character-grid workspace for: + +- `.texture` files +- `.tmap` files + +How loading works: + +- select a `.texture` or `.tmap` in `Assets` +- press `Enter` +- the file opens in `Inspector` +- the same file loads into `Sprite` + +## Creating New Assets + +When the `Assets` panel has focus, press `Shift+A`. + +Current create options: + +- `Script` +- `Scene` +- `Texture` +- `Tile Map` +- `Event` + +Behavior: + +- the editor runs the matching Sendama generator command in the opened project directory +- files are created in the active project asset root +- the editor picks the next available default name for that asset family +- the new file is selected in `Assets` and loaded into the `Inspector` + +Default name families: + +- scripts: `new-script-1`, `new-script-2`, and so on +- scenes: `new-scene-1`, `new-scene-2`, and so on +- textures: `new-texture-1`, `new-texture-2`, and so on +- tile maps: `new-map-1`, `new-map-2`, and so on +- events: `new-event-1`, `new-event-2`, and so on + +If the created asset is a `.texture` or `.tmap`, the editor also loads it into `Main -> Sprite`. + +## Quick Create From The Sprite Tab + +There is also a faster create path for art assets. + +When `Main` is focused and the `Sprite` tab is active, press `Shift+A` to create: + +- `Texture` +- `Tile Map` + +This quick-create flow: + +- is limited to sprite-editable asset types +- creates the file immediately under the active asset root's `Textures` or `Maps` directory +- opens the new asset directly in the sprite editor + +## Sprite Editing Controls + +Once a `.texture` or `.tmap` is loaded, these controls are active: + +| Key | Action | +| --- | --- | +| `Up` / `Right` / `Down` / `Left` | Move the cursor | +| printable character | Draw that character | +| `Space` | Place a blank character | +| `Backspace` | Erase the current cell | +| `Shift+2` | Open the special-character picker | +| `Ctrl+Z` | Undo | +| `Ctrl+Y` | Redo | +| `Shift+R` | Reset to the state from when the asset was opened | +| `Delete` | Delete the active asset after confirmation | + +The help line in the Main panel shows the live cursor position as `Col x Row`. + +## Character Picker + +Press `Shift+2` to open the curated character list. This is useful for common ASCII art building blocks such as: + +- blocks and shades +- triangles and arrows +- corners and line pieces +- circles, squares, hearts, and stars + +Use: + +- `Up` / `Down` to choose a character +- `Enter` to insert it at the cursor +- `Escape` to cancel + +## How Paths Work In The Renderer + +Renderer texture paths and environment tile map paths are relative asset paths. + +Common examples: + +- `Textures/player` +- `Textures/player.texture` +- `Maps/level` +- `Maps/level.tmap` + +The editor's renderer accepts both extensionless and extensionful paths. If you choose a file through the Inspector file picker, it writes the relative path it selected. + +## Renaming Assets + +File assets can be renamed from the `Inspector` by editing their `Name` field. + +Behavior to know: + +- folder names are read-only in the current Inspector UI +- renaming preserves the current file extension +- if the current scene references that file through `sprite.texture.path` or `environmentTileMapPath`, those in-memory scene references are updated +- renaming a script file also rewrites the PHP class declaration inside that file to match the new filename + +Very important: + +- the file rename happens immediately +- the scene reference update is only in memory until you save the scene + +So the safe rename workflow is: + +1. rename the asset in `Inspector` +2. confirm the scene still looks correct +3. press `Ctrl+S` + +## Deleting Assets + +There are two delete entry points: + +- press `Delete` in `Assets` +- press `Delete` in `Sprite` while an asset is loaded + +Deletion behavior: + +- the delete happens immediately after confirmation +- deleting from `Sprite` also clears the loaded sprite editor view +- folder deletion is recursive +- deleting an asset does not automatically repair broken scene references + +That means you should delete carefully, especially from shared folders. + +## Best Practices For Asset Work + +- Keep textures under `Assets/Textures` and maps under `Assets/Maps`. +- Prefer the canonical uppercase `Assets` root in new projects. +- Save scene changes after renaming assets that are already in use. +- Use small texture crops in renderer settings instead of duplicating large texture files. +- Build backgrounds as tile maps first, then place scene objects over them. + +Once your asset exists, the next step is usually wiring it into an object through [Inspector and Properties](inspector-and-properties.md). diff --git a/src/Commands/EditGame.php b/src/Commands/EditGame.php index c751646..4d51263 100644 --- a/src/Commands/EditGame.php +++ b/src/Commands/EditGame.php @@ -3,8 +3,8 @@ namespace Sendama\Console\Commands; use Sendama\Console\Editor\Editor; +use Sendama\Console\Editor\GameSettings; use Sendama\Console\Exceptions\IOException; -use Sendama\Console\Util\Config\ProjectConfig; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -35,14 +35,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln("Opening game configuration for editing...", OutputInterface::VERBOSITY_VERBOSE); $directory = $input->getOption('directory') ?? '.'; - - $projectConfig = new ProjectConfig($input, $output); - $projectConfig->load(); - - $editor = new Editor(name: $projectConfig->get("name"), workingDirectory: $directory); + $gameSettings = GameSettings::loadFromDirectory($directory); + $editor = new Editor(name: $gameSettings->name, workingDirectory: $directory); $editor->run(); $output->writeln("Finished editing game configuration.", OutputInterface::VERBOSITY_VERBOSE); return Command::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Commands/NewGame.php b/src/Commands/NewGame.php index d67066b..f381943 100644 --- a/src/Commands/NewGame.php +++ b/src/Commands/NewGame.php @@ -4,6 +4,7 @@ use RuntimeException; use Sendama\Console\Util\Path; +use Sendama\Console\Util\ProjectNormalizer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; @@ -137,12 +138,12 @@ private function getProjectConfiguration(string $projectName): string { $mainFilename = strtolower(filter_string($projectName)) . '.php'; - return json_encode([ - 'name' => $projectName, - 'description' => 'A 2D ASCII terminal game.', - 'version' => '0.0.1', - 'main' => $mainFilename, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return ProjectNormalizer::buildSendamaConfiguration( + projectName: $projectName, + description: 'A 2D ASCII terminal game.', + version: '0.0.1', + mainFile: $mainFilename, + ); } /** @@ -171,7 +172,7 @@ private function getComposerConfiguration(string $packageName): string ], 'autoload' => [ 'psr-4' => [ - $namespace => 'assets/' + $namespace => 'Assets/' ] ], 'config' => [ @@ -233,18 +234,13 @@ private function createProjectConfiguration(string $projectName): void 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"); - $targetConfigFilename = Path::join($this->targetDirectory, 'config', 'input.php'); - // Get the config/input.php template - $sourceInputConfigFilename = Path::join(dirname(__DIR__, 2), 'templates', 'config', 'input.php'); - $inputConfigContents = file_get_contents($sourceInputConfigFilename); - $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)); - } + $this->createInputConfigurationFile($packageName); $targetConfigFilename = Path::join($this->targetDirectory, 'composer.json'); if (false === file_put_contents($targetConfigFilename, $this->getComposerConfiguration($packageName))) { @@ -256,6 +252,35 @@ private function createProjectConfiguration(string $projectName): void } } + 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)); + } + } + /** * Create the main file. * @@ -291,7 +316,7 @@ 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 + ## 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)); @@ -412,7 +437,7 @@ private function createAssetsScenesDirectory(string $assetsDirectory): void */ private function createAssetsDirectory(): string { - $assetsDirectory = Path::join($this->targetDirectory, 'assets'); + $assetsDirectory = Path::join($this->targetDirectory, 'Assets'); if (file_exists($assetsDirectory)) { return $assetsDirectory; } @@ -561,4 +586,4 @@ private function createReadmeFile(string $targetDirectory): void throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceReadmeFilename, $targetReadmeFilename)); } } -} \ No newline at end of file +} diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 9626687..357f10a 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -7,6 +7,11 @@ use Atatusoft\Termutil\Events\Traits\ObservableTrait; use Atatusoft\Termutil\IO\Console\Console; use Atatusoft\Termutil\UI\Windows\Window; +use Sendama\Console\Commands\GenerateEvent; +use Sendama\Console\Commands\GenerateScene; +use Sendama\Console\Commands\GenerateScript; +use Sendama\Console\Commands\GenerateTexture; +use Sendama\Console\Commands\GenerateTilemap; use Sendama\Console\Debug\Debug; use Sendama\Console\Editor\Enumerations\ChronoUnit; use Sendama\Console\Editor\Events\EditorEvent; @@ -25,11 +30,17 @@ use Sendama\Console\Editor\Widgets\HierarchyPanel; use Sendama\Console\Editor\Widgets\InspectorPanel; use Sendama\Console\Editor\Widgets\MainPanel; +use Sendama\Console\Editor\Widgets\OptionListModal; use Sendama\Console\Editor\Widgets\PanelListModal; use Sendama\Console\Editor\Widgets\Widget; use Sendama\Console\Exceptions\IOException; use Sendama\Console\Exceptions\SendamaConsoleException; use Sendama\Console\Util\Path; +use Sendama\Console\Util\ProjectNormalizer; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Throwable; @@ -128,9 +139,12 @@ final class Editor implements ObservableInterface protected int $terminalWidth = DEFAULT_TERMINAL_WIDTH; protected int $terminalHeight = DEFAULT_TERMINAL_HEIGHT; protected PanelListModal $panelListModal; + protected ?OptionListModal $projectNormalizationModal = null; protected bool $shouldRefreshBackgroundUnderModal = false; protected bool $didRenderOverlayLastFrame = false; protected SceneWriter $sceneWriter; + protected ?ProjectNormalizer $projectNormalizer = null; + protected array $projectDiscrepancies = []; /** * @param string $name @@ -156,6 +170,7 @@ public function __construct( $this->sceneWriter = new SceneWriter(); $this->initializeWidgets(); $this->initializeEditorStates(); + $this->initializeProjectIntegrityCheck(); $this->splashScreen = new SplashScreen( Console::cursor(), new ConsoleOutput(), @@ -360,6 +375,12 @@ private function update(): void $this->refreshTerminalSize(); } + if ($this->projectNormalizationModal?->isVisible()) { + $this->handleProjectNormalizationModalInput(); + $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); + return; + } + $this->editorState->update(); $this->handlePanelKeyboardWorkflow(); @@ -375,6 +396,7 @@ private function update(): void } $this->synchronizeAssetDeletions(); + $this->synchronizeAssetCreations(); $this->synchronizeHierarchyDeletions(); $this->synchronizeHierarchyAdditions(); $this->synchronizeMainPanelSceneChanges(); @@ -389,6 +411,23 @@ private function update(): void private function render(): void { $this->frameCount++; + if ($this->projectNormalizationModal?->isVisible()) { + $this->didRenderOverlayLastFrame = true; + + if ($this->shouldRefreshBackgroundUnderModal) { + $this->renderEditorFrame(); + } + + if ($this->shouldRefreshBackgroundUnderModal || $this->projectNormalizationModal->isDirty()) { + $this->projectNormalizationModal->render(); + $this->projectNormalizationModal->markClean(); + $this->shouldRefreshBackgroundUnderModal = false; + } + + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + return; + } + if ($this->panelListModal->isVisible()) { $this->didRenderOverlayLastFrame = true; @@ -526,6 +565,32 @@ private function initializeEditorStates(): void $this->setState($this->editState); } + private function initializeProjectIntegrityCheck(): void + { + $this->projectNormalizer = new ProjectNormalizer($this->workingDirectory); + $this->projectNormalizationModal = new OptionListModal(title: 'Normalize Project'); + $this->projectNormalizationModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->projectDiscrepancies = $this->projectNormalizer->inspect(); + + if ($this->projectDiscrepancies === []) { + return; + } + + $issueCount = count($this->projectDiscrepancies); + $issueLabel = $issueCount === 1 ? 'issue' : 'issues'; + + $this->projectNormalizationModal->show( + ['Normalize', 'Cancel'], + title: sprintf('Normalize Project? (%d %s)', $issueCount, $issueLabel), + ); + $this->projectNormalizationModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->shouldRefreshBackgroundUnderModal = true; + + foreach ($this->projectDiscrepancies as $discrepancy) { + $this->consolePanel->append('[WARN] - ' . $discrepancy); + } + } + private function togglePlayMode(): void { if ($this->editorState instanceof PlayState) { @@ -592,6 +657,7 @@ private function initializeWidgets(): void ); $this->assetsPanel = new AssetsPanel( assetsDirectoryPath: $this->assetsDirectoryPath, + workingDirectory: $this->workingDirectory, ); $this->mainPanel = new MainPanel( sceneObjects: $this->loadedScene?->hierarchy ?? [], @@ -608,6 +674,7 @@ private function initializeWidgets(): void $this->inspectorPanel = new InspectorPanel( workingDirectory: $this->workingDirectory, ); + $this->inspectorPanel->setSceneHierarchy($this->loadedScene?->hierarchy ?? []); $this->panels->add($this->hierarchyPanel); $this->panels->add($this->assetsPanel); @@ -622,6 +689,14 @@ private function initializeWidgets(): void private function handlePanelFocus(): void { + if ( + $this->projectNormalizationModal?->isVisible() + || $this->panelListModal->isVisible() + || $this->focusedPanel?->hasActiveModal() + ) { + return; + } + if (!Input::isLeftMouseButtonDown()) { return; } @@ -693,18 +768,6 @@ private function handlePanelKeyboardWorkflow(): void return; } - if (Input::getCurrentInput() === 'A' && !($this->editorState instanceof PlayState)) { - if ($this->focusedPanel === $this->mainPanel && $this->mainPanel->beginSpriteCreateWorkflow()) { - $this->shouldRefreshBackgroundUnderModal = true; - return; - } - - $this->setFocusedPanel($this->hierarchyPanel); - $this->hierarchyPanel->beginAddWorkflow(); - $this->shouldRefreshBackgroundUnderModal = true; - return; - } - if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_UP)) { $this->focusSiblingPanel('top'); return; @@ -759,7 +822,11 @@ private function refreshTerminalSize(bool $force = false): void $this->layoutPanels(); - if ($this->panelListModal->isVisible() || $this->focusedPanel?->hasActiveModal()) { + if ( + $this->projectNormalizationModal?->isVisible() + || $this->panelListModal->isVisible() + || $this->focusedPanel?->hasActiveModal() + ) { $this->shouldRefreshBackgroundUnderModal = true; } } @@ -793,9 +860,70 @@ private function layoutPanels(): void $this->inspectorPanel->setDimensions($rightPanelWidth, $availableHeight); $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->projectNormalizationModal?->syncLayout($this->terminalWidth, $this->terminalHeight); $this->focusedPanel?->syncModalLayout($this->terminalWidth, $this->terminalHeight); } + private function handleProjectNormalizationModalInput(): void + { + if (!$this->projectNormalizationModal instanceof OptionListModal) { + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::ESCAPE)) { + $this->projectNormalizationModal->hide(); + $this->shouldRefreshBackgroundUnderModal = true; + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::UP)) { + $this->projectNormalizationModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::DOWN)) { + $this->projectNormalizationModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(IO\Enumerations\KeyCode::ENTER)) { + return; + } + + $selectedOption = $this->projectNormalizationModal->getSelectedOption(); + $this->projectNormalizationModal->hide(); + + if ($selectedOption === 'Normalize') { + $this->normalizeLoadedProject(); + } + + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function normalizeLoadedProject(): void + { + if (!$this->projectNormalizer instanceof ProjectNormalizer) { + return; + } + + $changes = $this->projectNormalizer->normalize(); + + $this->initializeSettings(); + $this->initializeLoadedScene(); + $this->initializeWidgets(); + + if ($changes === []) { + $this->consolePanel->append('[INFO] - Project structure is already normalized.'); + return; + } + + foreach ($changes as $change) { + $this->consolePanel->append('[INFO] - ' . $change); + } + + $this->projectDiscrepancies = $this->projectNormalizer->inspect(); + } + private function configurePanelGraph(): void { $this->hierarchyPanel->setSiblings( @@ -1084,6 +1212,30 @@ private function synchronizeAssetDeletions(): void $this->inspectorPanel->inspectTarget(null); } + private function synchronizeAssetCreations(): void + { + $creationRequest = $this->assetsPanel->consumeCreationRequest(); + + if ( + !is_array($creationRequest) + || !is_string($creationRequest['kind'] ?? null) + || $creationRequest['kind'] === '' + ) { + return; + } + + $createdAsset = $this->createAssetUsingCliCommand($creationRequest['kind']); + + if (!is_array($createdAsset)) { + return; + } + + $this->assetsPanel->reloadAssets(); + $this->assetsPanel->selectAssetByAbsolutePath($createdAsset['path']); + $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($createdAsset)); + $this->mainPanel->loadSpriteAsset($createdAsset); + } + private function synchronizeMainPanelSceneChanges(): void { $mutation = $this->mainPanel->consumeHierarchyMutation(); @@ -1268,6 +1420,254 @@ private function deleteAssetPath(string $path): bool return rmdir($path); } + private function createAssetUsingCliCommand(string $kind): ?array + { + $definition = $this->resolveAssetCreationDefinition($kind); + + if ($definition === null) { + $this->consolePanel->append('[ERROR] - Unsupported asset type selected.'); + return null; + } + + $originalWorkingDirectory = getcwd(); + + if ($originalWorkingDirectory === false) { + $this->consolePanel->append('[ERROR] - Failed to resolve the current working directory.'); + return null; + } + + try { + if (!@chdir($this->workingDirectory)) { + $this->consolePanel->append('[ERROR] - Failed to switch to the project directory.'); + return null; + } + + for ($index = 1; $index <= 200; $index++) { + $candidateName = $definition['baseName'] . '-' . $index; + $result = $this->runAssetGenerationCommand($definition, $candidateName); + + if (($result['status'] ?? null) === 'success' && is_array($result['asset'] ?? null)) { + return $result['asset']; + } + + if (($result['status'] ?? null) === 'fatal') { + return null; + } + } + } finally { + @chdir($originalWorkingDirectory); + } + + $this->consolePanel->append('[ERROR] - Failed to create asset after multiple attempts.'); + + return null; + } + + private function resolveAssetCreationDefinition(string $kind): ?array + { + return match ($kind) { + 'script' => [ + 'command' => GenerateScript::class, + 'baseName' => 'new-script', + ], + 'scene' => [ + 'command' => GenerateScene::class, + 'baseName' => 'new-scene', + ], + 'texture' => [ + 'command' => GenerateTexture::class, + 'baseName' => 'new-texture', + ], + 'tilemap' => [ + 'command' => GenerateTilemap::class, + 'baseName' => 'new-map', + ], + 'event' => [ + 'command' => GenerateEvent::class, + 'baseName' => 'new-event', + ], + default => null, + }; + } + + private function runAssetGenerationCommand(array $definition, string $candidateName): array + { + $commandClass = $definition['command'] ?? null; + + if (!is_string($commandClass) || !is_a($commandClass, Command::class, true)) { + return ['status' => 'fatal']; + } + + /** @var Command $command */ + $command = new $commandClass(); + $input = new ArrayInput([ + 'name' => $candidateName, + ]); + $input->setInteractive(false); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, false); + $exitCode = $command->run($input, $output); + $commandOutput = trim($output->fetch()); + + if ($exitCode !== Command::SUCCESS) { + if (str_contains($commandOutput, 'already exists')) { + return ['status' => 'retry']; + } + + $message = $commandOutput !== '' + ? preg_replace('/\s+/', ' ', strip_tags($commandOutput)) + : 'Asset generation failed.'; + $this->consolePanel->append('[ERROR] - ' . $message); + + return ['status' => 'fatal']; + } + + $relativeFilename = $this->extractCreatedRelativeFilename($commandOutput); + + if (!is_string($relativeFilename) || $relativeFilename === '') { + $this->consolePanel->append('[ERROR] - Asset generation succeeded but the created file could not be resolved.'); + return ['status' => 'fatal']; + } + + $absolutePath = $this->resolveGeneratedAssetAbsolutePath($relativeFilename); + + if (!is_string($absolutePath) || !is_file($absolutePath)) { + $this->consolePanel->append('[ERROR] - Generated asset file could not be found.'); + return ['status' => 'fatal']; + } + + $finalPath = $this->relocateGeneratedAssetToActiveRoot($absolutePath, $relativeFilename); + + if (!is_string($finalPath) || !is_file($finalPath)) { + $this->consolePanel->append('[ERROR] - Generated asset file could not be activated in the current assets directory.'); + return ['status' => 'fatal']; + } + + $this->consolePanel->append('[INFO] - Created asset ' . $this->buildRelativeAssetPath($finalPath) . '.'); + + return [ + 'status' => 'success', + 'asset' => [ + 'name' => basename($finalPath), + 'path' => $finalPath, + 'relativePath' => $this->buildRelativeAssetPath($finalPath), + 'isDirectory' => false, + 'children' => [], + ], + ]; + } + + private function extractCreatedRelativeFilename(string $output): ?string + { + if (preg_match('/CREATE\s+([^\s]+)\s+\(\d+\s+bytes\)/i', $output, $matches) === 1) { + return $matches[1]; + } + + if (preg_match('/\b([Aa]ssets\/[^\s]+)\b/', $output, $matches) === 1) { + return $matches[1]; + } + + return null; + } + + private function resolveGeneratedAssetAbsolutePath(string $relativeFilename): ?string + { + $normalizedRelativeFilename = str_replace('\\', '/', $relativeFilename); + $candidatePaths = [ + Path::join($this->workingDirectory, $normalizedRelativeFilename), + ]; + + if (is_string($this->assetsDirectoryPath) && $this->assetsDirectoryPath !== '') { + $segments = explode('/', $normalizedRelativeFilename); + + if (count($segments) > 1 && strcasecmp($segments[0], 'assets') === 0) { + array_shift($segments); + $candidatePaths[] = Path::join($this->assetsDirectoryPath, ...$segments); + } + } + + foreach ($candidatePaths as $candidatePath) { + if (is_file($candidatePath)) { + return $candidatePath; + } + } + + return null; + } + + private function relocateGeneratedAssetToActiveRoot(string $absolutePath, string $relativeFilename): string + { + if (!is_string($this->assetsDirectoryPath) || $this->assetsDirectoryPath === '') { + return $absolutePath; + } + + $normalizedRelativeFilename = str_replace('\\', '/', $relativeFilename); + $segments = explode('/', $normalizedRelativeFilename); + $generatedRootSegment = $segments[0] ?? 'Assets'; + + if (count($segments) <= 1 || strcasecmp($segments[0], 'assets') !== 0) { + return $absolutePath; + } + + array_shift($segments); + $targetPath = Path::join($this->assetsDirectoryPath, ...$segments); + + if ($targetPath === $absolutePath) { + return $absolutePath; + } + + if (file_exists($targetPath)) { + return $targetPath; + } + + if (!is_dir(dirname($targetPath))) { + mkdir(dirname($targetPath), 0777, true); + } + + if (!rename($absolutePath, $targetPath)) { + return $absolutePath; + } + + $generatedAssetsRoot = Path::join($this->workingDirectory, $generatedRootSegment); + + if (str_starts_with($absolutePath, $generatedAssetsRoot)) { + $this->cleanupEmptyDirectories(dirname($absolutePath), $generatedAssetsRoot); + } + + return $targetPath; + } + + private function cleanupEmptyDirectories(string $directory, string $stopAt): void + { + $currentDirectory = $directory; + + while ($currentDirectory !== $stopAt && str_starts_with($currentDirectory, $stopAt)) { + if (!is_dir($currentDirectory)) { + break; + } + + $entries = scandir($currentDirectory); + + if ($entries === false || array_diff($entries, ['.', '..']) !== []) { + break; + } + + rmdir($currentDirectory); + $currentDirectory = dirname($currentDirectory); + } + + if ( + $currentDirectory === $stopAt + && $currentDirectory !== $this->assetsDirectoryPath + && is_dir($currentDirectory) + ) { + $entries = scandir($currentDirectory); + + if ($entries !== false && array_diff($entries, ['.', '..']) === []) { + rmdir($currentDirectory); + } + } + } + private function renameAssetAndCascadeReferences( string $currentAbsolutePath, mixed $currentRelativePath, @@ -1302,6 +1702,18 @@ private function renameAssetAndCascadeReferences( : $this->buildRelativeAssetPath($currentAbsolutePath); $newRelativePath = $this->buildRelativeAssetPath($targetAbsolutePath); + if (!$this->synchronizeScriptClassNameWithFileRename($targetAbsolutePath, $oldRelativePath, $newRelativePath)) { + if ( + $targetAbsolutePath !== $currentAbsolutePath + && is_file($targetAbsolutePath) + && !file_exists($currentAbsolutePath) + ) { + @rename($targetAbsolutePath, $currentAbsolutePath); + } + + return null; + } + if ($this->updateSceneAssetReferences($oldRelativePath, $newRelativePath)) { if ($this->loadedScene instanceof DTOs\SceneDTO) { $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; @@ -1322,6 +1734,105 @@ private function renameAssetAndCascadeReferences( ]; } + private function synchronizeScriptClassNameWithFileRename( + string $targetAbsolutePath, + string $oldRelativePath, + string $newRelativePath, + ): bool { + if (!$this->isScriptAssetPath($oldRelativePath) && !$this->isScriptAssetPath($newRelativePath)) { + return true; + } + + if (strtolower((string) pathinfo($targetAbsolutePath, PATHINFO_EXTENSION)) !== 'php') { + return true; + } + + $source = file_get_contents($targetAbsolutePath); + + if (!is_string($source) || $source === '') { + $this->consolePanel->append('[ERROR] - Failed to update the renamed script source.'); + return false; + } + + $oldClassName = $this->derivePhpAssetClassNameFromRelativePath($oldRelativePath); + $newClassName = $this->derivePhpAssetClassNameFromRelativePath($newRelativePath); + + if ($newClassName === '') { + $this->consolePanel->append('[ERROR] - Failed to derive the renamed script class name.'); + return false; + } + + $updatedSource = $source; + + if ($oldClassName !== '') { + $updatedSource = preg_replace( + '/\b(class\s+)' . preg_quote($oldClassName, '/') . '(\b)/', + '${1}' . $newClassName . '$2', + $updatedSource, + 1, + $replacementCount, + ); + + if (!is_string($updatedSource)) { + $this->consolePanel->append('[ERROR] - Failed to update the renamed script class.'); + return false; + } + + if (($replacementCount ?? 0) === 0) { + $updatedSource = $source; + } + } + + if ($updatedSource === $source) { + $updatedSource = preg_replace( + '/\bclass\s+[A-Za-z_][A-Za-z0-9_]*\b/', + 'class ' . $newClassName, + $source, + 1, + ); + + if (!is_string($updatedSource) || $updatedSource === $source) { + $this->consolePanel->append('[ERROR] - Failed to locate the script class declaration after rename.'); + return false; + } + } + + if (file_put_contents($targetAbsolutePath, $updatedSource) === false) { + $this->consolePanel->append('[ERROR] - Failed to write the renamed script source.'); + return false; + } + + return true; + } + + private function isScriptAssetPath(string $relativePath): bool + { + return str_starts_with(str_replace('\\', '/', $relativePath), 'Scripts/'); + } + + private function derivePhpAssetClassNameFromRelativePath(string $relativePath): string + { + $baseName = (string) pathinfo(basename(str_replace('\\', '/', $relativePath)), PATHINFO_FILENAME); + + if ($baseName === '') { + return ''; + } + + $tokens = preg_split('/[^A-Za-z0-9]+/', $baseName) ?: []; + $tokens = array_values(array_filter($tokens, static fn(string $token): bool => $token !== '')); + + if (count($tokens) <= 1) { + return preg_match('/[A-Z]/', $baseName) === 1 + ? ucfirst($baseName) + : to_pascal_case($baseName); + } + + return implode('', array_map( + static fn(string $token): string => ucfirst($token), + array_map('strtolower', $tokens), + )); + } + private function normalizeAssetFileName(string $requestedName, string $currentAbsolutePath): string { $trimmedName = trim(str_replace('\\', '/', $requestedName)); @@ -1543,6 +2054,7 @@ private function syncScenePanels(bool $isDirty): void $this->loadedScene->height, $this->loadedScene->environmentTileMapPath, ); + $this->inspectorPanel->setSceneHierarchy($this->loadedScene->hierarchy); $this->mainPanel->setSceneDimensions($this->loadedScene->width, $this->loadedScene->height); $this->mainPanel->setEnvironmentTileMapPath($this->loadedScene->environmentTileMapPath); } diff --git a/src/Editor/EditorSettings.php b/src/Editor/EditorSettings.php index dcad5f5..db64287 100644 --- a/src/Editor/EditorSettings.php +++ b/src/Editor/EditorSettings.php @@ -44,7 +44,7 @@ public static function loadFromDirectory(string $workingDirectory): self $filename = Path::join($workingDirectory, 'sendama.json'); if (!file_exists($filename)) { - throw new SendamaConsoleException("$filename does not exist!"); + return self::fromArray([]); } $settingsJsonFileContents = file_get_contents($filename); @@ -55,6 +55,10 @@ public static function loadFromDirectory(string $workingDirectory): self $data = json_decode($settingsJsonFileContents, true); + if (!is_array($data)) { + return self::fromArray([]); + } + return self::fromArray($data); } diff --git a/src/Editor/GameSettings.php b/src/Editor/GameSettings.php index b0106e5..f4aca51 100644 --- a/src/Editor/GameSettings.php +++ b/src/Editor/GameSettings.php @@ -57,6 +57,11 @@ public static function loadFromDirectory(string $directory): self $data = json_decode($settingsJsonFileContents, true); + if (!is_array($data)) { + Debug::warn("Invalid JSON in $settingsFile."); + return new self(name: 'Untitled Game'); + } + return self::fromArray($data); } @@ -78,4 +83,4 @@ public static function fromArray(array $data): self showDebugInfo: $data['showDebugInfo'] ?? false, ); } -} \ No newline at end of file +} diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index f9814ae..2828025 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -118,7 +118,9 @@ public static function handleInput(): void { self::$previousKeyPress = self::$keyPress; - $incomingTokens = self::tokenizeInput(stream_get_contents(STDIN) ?: ''); + $incomingTokens = self::tokenizeInput( + self::normalizeBufferedInput(stream_get_contents(STDIN)) + ); if ($incomingTokens !== []) { self::$inputQueue = [...self::$inputQueue, ...$incomingTokens]; @@ -373,6 +375,11 @@ private static function normalizeInput(string $input): string return self::tokenizeInput($input)[0] ?? ''; } + private static function normalizeBufferedInput(string|false $input): string + { + return $input === false ? '' : $input; + } + private static function tokenizeInput(string $input): array { if ($input === '') { diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index 6768182..67ea6a4 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -42,18 +42,9 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO public function resolveAssetsDirectory(): ?string { - $candidates = [ - Path::join($this->workingDirectory, 'Assets'), - Path::join($this->workingDirectory, 'assets'), - ]; + $assetsDirectory = Path::resolveAssetsDirectory($this->workingDirectory); - foreach ($candidates as $candidate) { - if (is_dir($candidate)) { - return $candidate; - } - } - - return null; + return is_dir($assetsDirectory) ? $assetsDirectory : null; } public function resolveActiveScenePath(EditorSceneSettings $sceneSettings): ?string diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index 6681703..02b100a 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -15,6 +15,7 @@ */ class AssetsPanel extends Widget { + private const string CREATE_MODAL_ASSET_KIND = 'create_asset_kind'; private const string DELETE_MODAL_CONFIRM = 'delete_confirm'; private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; @@ -28,6 +29,8 @@ class AssetsPanel extends Widget protected ?string $selectedPath = null; protected ?array $pendingInspectionTarget = null; protected ?array $pendingDeletionTarget = null; + protected ?array $pendingCreationRequest = null; + protected OptionListModal $createAssetModal; protected OptionListModal $deleteConfirmModal; protected ?string $modalState = null; @@ -35,10 +38,12 @@ public function __construct( array $position = ['x' => 1, 'y' => 15], int $width = 35, int $height = 14, - protected ?string $assetsDirectoryPath = null + protected ?string $assetsDirectoryPath = null, + protected ?string $workingDirectory = null, ) { parent::__construct('Assets', '', $position, $width, $height); + $this->createAssetModal = new OptionListModal(title: 'Create Asset'); $this->deleteConfirmModal = new OptionListModal(title: 'Delete Asset'); $this->loadAssetEntries(); $this->refreshContent(); @@ -146,28 +151,52 @@ public function consumeDeletionRequest(): ?array return $pendingDeletionTarget; } + public function consumeCreationRequest(): ?array + { + $pendingCreationRequest = $this->pendingCreationRequest; + $this->pendingCreationRequest = null; + + return $pendingCreationRequest; + } + + public function beginCreateWorkflow(): void + { + $this->modalState = self::CREATE_MODAL_ASSET_KIND; + $this->createAssetModal->show( + ['Script', 'Scene', 'Texture', 'Tile Map', 'Event'], + title: 'Create Asset', + ); + } + public function hasActiveModal(): bool { - return $this->deleteConfirmModal->isVisible(); + return $this->createAssetModal->isVisible() || $this->deleteConfirmModal->isVisible(); } public function isModalDirty(): bool { - return $this->deleteConfirmModal->isDirty(); + return $this->createAssetModal->isDirty() || $this->deleteConfirmModal->isDirty(); } public function markModalClean(): void { + $this->createAssetModal->markClean(); $this->deleteConfirmModal->markClean(); } public function syncModalLayout(int $terminalWidth, int $terminalHeight): void { + $this->createAssetModal->syncLayout($terminalWidth, $terminalHeight); $this->deleteConfirmModal->syncLayout($terminalWidth, $terminalHeight); } public function renderActiveModal(): void { + if ($this->createAssetModal->isVisible()) { + $this->createAssetModal->render(); + return; + } + if ($this->deleteConfirmModal->isVisible()) { $this->deleteConfirmModal->render(); } @@ -223,6 +252,11 @@ public function update(): void return; } + if (Input::getCurrentInput() === 'A') { + $this->beginCreateWorkflow(); + return; + } + if (Input::isKeyDown(KeyCode::UP)) { $this->moveSelection(-1); return; @@ -526,6 +560,7 @@ private function showDeleteConfirmModal(): void private function dismissModal(): void { + $this->createAssetModal->hide(); $this->deleteConfirmModal->hide(); $this->modalState = null; } @@ -537,17 +572,24 @@ private function handleModalInput(): void return; } - if ($this->modalState !== self::DELETE_MODAL_CONFIRM) { + if ( + $this->modalState !== self::DELETE_MODAL_CONFIRM + && $this->modalState !== self::CREATE_MODAL_ASSET_KIND + ) { return; } + $activeModal = $this->modalState === self::CREATE_MODAL_ASSET_KIND + ? $this->createAssetModal + : $this->deleteConfirmModal; + if (Input::isKeyDown(KeyCode::UP)) { - $this->deleteConfirmModal->moveSelection(-1); + $activeModal->moveSelection(-1); return; } if (Input::isKeyDown(KeyCode::DOWN)) { - $this->deleteConfirmModal->moveSelection(1); + $activeModal->moveSelection(1); return; } @@ -555,7 +597,28 @@ private function handleModalInput(): void return; } - $selection = $this->deleteConfirmModal->getSelectedOption(); + $selection = $activeModal->getSelectedOption(); + + if ($this->modalState === self::CREATE_MODAL_ASSET_KIND) { + $assetKind = match ($selection) { + 'Script' => 'script', + 'Scene' => 'scene', + 'Texture' => 'texture', + 'Tile Map' => 'tilemap', + 'Event' => 'event', + default => null, + }; + + if ($assetKind !== null) { + $this->pendingCreationRequest = [ + 'kind' => $assetKind, + 'workingDirectory' => $this->workingDirectory, + ]; + } + + $this->dismissModal(); + return; + } if ($selection !== 'Delete') { $this->dismissModal(); diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 43e02cc..3eb8721 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -3,9 +3,14 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use ReflectionClass; +use Throwable; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\IO\Enumerations\KeyCode; use Sendama\Console\Editor\IO\Input; +use Sendama\Console\Util\Path; use Sendama\Console\Editor\Widgets\Controls\CompoundInputControl; use Sendama\Console\Editor\Widgets\Controls\InputControl; use Sendama\Console\Editor\Widgets\Controls\InputControlFactory; @@ -29,6 +34,15 @@ class InspectorPanel extends Widget private const string SELECTED_CONTROL_ACTIVE_SEQUENCE = "\033[5;30;46m"; private const string EDITING_CONTROL_SEQUENCE = "\033[30;43m"; private const string EDITING_CONTROL_ACTIVE_SEQUENCE = "\033[5;30;43m"; + private const array DEFAULT_COMPONENT_CANDIDATES = [ + 'Sendama\\Engine\\Core\\Behaviours\\SimpleQuitListener', + 'Sendama\\Engine\\Core\\Behaviours\\SimpleBackListener', + 'Sendama\\Engine\\Core\\Behaviours\\CharacterMovement', + 'Sendama\\Engine\\Physics\\Rigidbody', + 'Sendama\\Engine\\Physics\\Collider', + 'Sendama\\Engine\\Physics\\CharacterController', + 'Sendama\\Engine\\Animation\\AnimationController', + ]; protected ?array $inspectionTarget = null; protected array $elements = []; @@ -44,11 +58,20 @@ class InspectorPanel extends Widget protected ?PreviewWindowControl $rendererPreviewControl = null; protected OptionListModal $pathInputActionModal; protected FileDialogModal $fileDialogModal; + protected OptionListModal $addComponentModal; + protected OptionListModal $deleteComponentModal; protected ?PathInputControl $activePathInputControl = null; protected array $controlBindings = []; + protected array $controlMetadata = []; protected ?array $pendingHierarchyMutation = null; protected ?array $pendingAssetMutation = null; protected string $projectDirectory; + protected array $sceneHierarchy = []; + protected array $componentMenuDefinitions = []; + protected ?array $cachedProjectComponentCandidates = null; + protected bool $isComponentMoveModeActive = false; + protected ?int $pendingComponentDeletionIndex = null; + protected string $modeHelpLabel = ''; public function __construct( array $position = ['x' => 135, 'y' => 1], @@ -61,11 +84,18 @@ public function __construct( $this->inputControlFactory = new InputControlFactory(); $this->pathInputActionModal = new OptionListModal(title: 'Path Input'); $this->fileDialogModal = new FileDialogModal(); + $this->addComponentModal = new OptionListModal(title: 'Add Component'); + $this->deleteComponentModal = new OptionListModal(title: 'Remove Component'); $this->projectDirectory = is_string($workingDirectory) && $workingDirectory !== '' ? $workingDirectory : (getcwd() ?: '.'); } + public function setSceneHierarchy(array $hierarchy): void + { + $this->sceneHierarchy = $hierarchy; + } + public function inspectTarget(?array $target): void { $this->inspectionTarget = $target; @@ -79,10 +109,16 @@ public function inspectTarget(?array $target): void $this->rendererPreviewControl = null; $this->pathInputActionModal->hide(); $this->fileDialogModal->hide(); + $this->addComponentModal->hide(); + $this->deleteComponentModal->hide(); $this->activePathInputControl = null; $this->controlBindings = []; + $this->controlMetadata = []; $this->pendingHierarchyMutation = null; $this->pendingAssetMutation = null; + $this->componentMenuDefinitions = []; + $this->isComponentMoveModeActive = false; + $this->pendingComponentDeletionIndex = null; if ($target === null) { $this->content = []; @@ -134,24 +170,34 @@ public function blur(FocusTargetContext $context): void public function hasActiveModal(): bool { - return $this->pathInputActionModal->isVisible() || $this->fileDialogModal->isVisible(); + return $this->pathInputActionModal->isVisible() + || $this->fileDialogModal->isVisible() + || $this->addComponentModal->isVisible() + || $this->deleteComponentModal->isVisible(); } public function isModalDirty(): bool { - return $this->pathInputActionModal->isDirty() || $this->fileDialogModal->isDirty(); + return $this->pathInputActionModal->isDirty() + || $this->fileDialogModal->isDirty() + || $this->addComponentModal->isDirty() + || $this->deleteComponentModal->isDirty(); } public function markModalClean(): void { $this->pathInputActionModal->markClean(); $this->fileDialogModal->markClean(); + $this->addComponentModal->markClean(); + $this->deleteComponentModal->markClean(); } public function syncModalLayout(int $terminalWidth, int $terminalHeight): void { $this->pathInputActionModal->syncLayout($terminalWidth, $terminalHeight); $this->fileDialogModal->syncLayout($terminalWidth, $terminalHeight); + $this->addComponentModal->syncLayout($terminalWidth, $terminalHeight); + $this->deleteComponentModal->syncLayout($terminalWidth, $terminalHeight); } public function renderActiveModal(): void @@ -163,6 +209,14 @@ public function renderActiveModal(): void if ($this->fileDialogModal->isVisible()) { $this->fileDialogModal->render(); } + + if ($this->addComponentModal->isVisible()) { + $this->addComponentModal->render(); + } + + if ($this->deleteComponentModal->isVisible()) { + $this->deleteComponentModal->render(); + } } public function consumeHierarchyMutation(): ?array @@ -191,12 +245,35 @@ public function syncHierarchyTarget(string $path, array $value): void return; } + $selectedControl = $this->getSelectedControl(); + $selectedControlMetadata = $this->getSelectedControlMetadata($selectedControl); + $selectedComponentIndex = is_int($selectedControlMetadata['componentIndex'] ?? null) + ? $selectedControlMetadata['componentIndex'] + : null; + $shouldPreserveComponentMoveMode = $this->isComponentMoveModeActive + && $this->isSelectedComponentHeader($selectedControl); + $target = $this->inspectionTarget; $target['name'] = $value['name'] ?? ($target['name'] ?? 'Unnamed Object'); $target['type'] = $this->resolveDisplayType($target, $value); $target['value'] = $value; $this->inspectTarget($target); + + if (is_int($selectedComponentIndex)) { + $componentCount = is_array($value['components'] ?? null) + ? count($value['components']) + : 0; + + if ($componentCount > 0) { + $this->focusComponentHeaderByIndex(min($selectedComponentIndex, $componentCount - 1)); + } + } + + if ($shouldPreserveComponentMoveMode) { + $selectedControl = $this->getSelectedControl(); + $this->isComponentMoveModeActive = $this->isSelectedComponentHeader($selectedControl); + } } public function syncSceneTarget(array $value): void @@ -262,6 +339,16 @@ public function update(): void return; } + if ($this->addComponentModal->isVisible()) { + $this->handleAddComponentModalInput(); + return; + } + + if ($this->deleteComponentModal->isVisible()) { + $this->handleDeleteComponentModalInput(); + return; + } + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { $this->handlePathInputActionInput(); return; @@ -316,6 +403,15 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ }; } + protected function buildBorderLine(string $label, bool $isTopBorder): string + { + if ($isTopBorder) { + return parent::buildBorderLine($label, true); + } + + return $this->buildSplitHelpBorder($this->help, $this->modeHelpLabel); + } + private function decorateSectionHeaderLine(string $line, ?Color $contentColor, int $lineIndex): string { $contentIndex = $lineIndex - $this->padding->topPadding; @@ -331,9 +427,18 @@ private function decorateSectionHeaderLine(string $line, ?Color $contentColor, i $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; $rightBorder = mb_substr($visibleLine, -1); $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; - $sectionSequence = $lineState === 'selected' && $this->hasFocus() - ? self::SECTION_HEADER_SELECTED_SEQUENCE - : self::SECTION_HEADER_SEQUENCE; + $selectedControl = $this->getSelectedControl(); + $sectionSequence = match (true) { + $lineState === 'selected' + && $this->hasFocus() + && $this->isComponentMoveModeActive + && $this->isSelectedComponentHeader($selectedControl) => self::EDITING_CONTROL_ACTIVE_SEQUENCE, + $lineState === 'selected' + && $this->isComponentMoveModeActive + && $this->isSelectedComponentHeader($selectedControl) => self::EDITING_CONTROL_SEQUENCE, + $lineState === 'selected' && $this->hasFocus() => self::SECTION_HEADER_SELECTED_SEQUENCE, + default => self::SECTION_HEADER_SEQUENCE, + }; return $this->wrapWithColor($leftBorder, $borderColor) . $this->wrapWithSequence($middle, $sectionSequence) @@ -518,7 +623,11 @@ private function addScriptComponents(mixed $components): void $this->addControl( $this->addSectionHeader( $this->resolveClassName($component['class'] ?? null, 'Component'), - ) + ), + [ + 'kind' => 'component_header', + 'componentIndex' => $componentIndex, + ], ); $this->addComponentPropertyControls( $serializedComponentData, @@ -536,7 +645,11 @@ private function addScriptComponents(mixed $components): void $this->addControl( $this->addSectionHeader( $this->resolveClassName($component['class'] ?? null, 'Component'), - ) + ), + [ + 'kind' => 'component_header', + 'componentIndex' => $componentIndex, + ], ); if (!is_array($legacyComponentData) || $legacyComponentData === []) { @@ -631,19 +744,23 @@ private function addSectionHeader(string $title, int $indentLevel = 0): SectionC return new SectionControl($title, $indentLevel); } - private function addControl(InputControl $control): void + private function addControl(InputControl $control, array $metadata = []): void { $this->elements[] = [ 'kind' => 'control', 'control' => $control, ]; $this->focusableControls[] = $control; + + if ($metadata !== []) { + $this->controlMetadata[spl_object_id($control)] = $metadata; + } } - private function addBoundControl(InputControl $control, array $valuePath): void + private function addBoundControl(InputControl $control, array $valuePath, array $metadata = []): void { $this->bindControl($control, $valuePath); - $this->addControl($control); + $this->addControl($control, $metadata); } private function bindControl(InputControl $control, array $valuePath): void @@ -653,6 +770,7 @@ private function bindControl(InputControl $control, array $valuePath): void private function refreshContent(): void { + $this->updateHelpInfo(); $this->refreshDerivedControls(); $content = []; $lineKinds = []; @@ -695,6 +813,102 @@ private function refreshContent(): void $this->lineStates = $lineStates; } + private function updateHelpInfo(): void + { + if ($this->addComponentModal->isVisible()) { + $this->help = 'Up/Down choose Enter add Esc cancel'; + $this->modeHelpLabel = 'Mode: Add Component'; + return; + } + + if ($this->deleteComponentModal->isVisible()) { + $this->help = 'Up/Down choose Enter confirm Esc cancel'; + $this->modeHelpLabel = 'Mode: Remove Component'; + return; + } + + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { + $this->help = 'Up/Down choose Enter select Esc cancel'; + $this->modeHelpLabel = 'Mode: Path Action'; + return; + } + + if ($this->interactionState === self::STATE_PATH_INPUT_FILE_DIALOG) { + $this->help = 'Up/Down move Left/Right tree Enter choose Esc back'; + $this->modeHelpLabel = 'Mode: File Picker'; + return; + } + + $selectedControl = $this->getSelectedControl(); + + if ($this->interactionState === self::STATE_CONTROL_EDIT) { + if ($selectedControl instanceof NumberInputControl) { + $this->help = 'Type edit Up/Down adjust Left/Right move Enter save Esc cancel'; + $this->modeHelpLabel = 'Mode: Number Edit'; + return; + } + + if ($selectedControl instanceof TextInputControl || $selectedControl instanceof PathInputControl) { + $this->help = 'Type edit Left/Right move Backspace delete Enter save Esc cancel'; + $this->modeHelpLabel = 'Mode: Text Edit'; + return; + } + + $this->help = 'Edit value Enter save Esc cancel'; + $this->modeHelpLabel = 'Mode: Control Edit'; + return; + } + + if ($this->interactionState === self::STATE_PROPERTY_SELECTION) { + $this->help = 'Up/Down property Enter edit Esc back'; + $this->modeHelpLabel = 'Mode: Property Select'; + return; + } + + if ($this->isComponentMoveModeActive && $this->isSelectedComponentHeader($selectedControl)) { + $this->help = 'Up/Down reorder Shift+W done Esc cancel'; + $this->modeHelpLabel = 'Mode: Component Move'; + return; + } + + if ($this->isSelectedComponentHeader($selectedControl)) { + $this->help = 'Up/Down select / toggle Shift+A add Shift+W move Del remove'; + $this->modeHelpLabel = 'Mode: Control Select'; + return; + } + + if ($selectedControl instanceof CompoundInputControl) { + $this->help = 'Up/Down select Enter properties Tab next'; + $this->modeHelpLabel = 'Mode: Control Select'; + return; + } + + if ($selectedControl instanceof PathInputControl) { + $this->help = 'Up/Down select Enter path options 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'; + } + + private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): string + { + $availableLabelWidth = max(0, $this->width - 3); + $visibleRightLabel = $this->clipContentToWidth($rightLabel, $availableLabelWidth); + $remainingWidth = max(0, $availableLabelWidth - mb_strlen($visibleRightLabel)); + $visibleLeftLabel = $this->clipContentToWidth($leftLabel, $remainingWidth); + $fillerWidth = max(0, $availableLabelWidth - mb_strlen($visibleLeftLabel) - mb_strlen($visibleRightLabel)); + + return $this->borderPack->bottomLeft + . $this->borderPack->horizontal + . $visibleLeftLabel + . str_repeat($this->borderPack->horizontal, $fillerWidth) + . $visibleRightLabel + . $this->borderPack->bottomRight; + } + private function refreshDerivedControls(): void { if ( @@ -776,11 +990,49 @@ private function moveControlSelection(int $offset): void % count($visibleControlIndexes); $this->selectedControlIndex = $visibleControlIndexes[$nextVisibleControlPosition]; $this->applyControlSelection(); + $selectedControl = $this->getSelectedControl(); + + if (!$this->isSelectedComponentHeader($selectedControl)) { + $this->isComponentMoveModeActive = false; + } + $this->refreshContent(); } private function handleControlSelectionInput(InputControl $selectedControl): void { + if (Input::getCurrentInput() === 'A' && $this->canOpenAddComponentModal()) { + $this->showAddComponentModal(); + return; + } + + if (Input::getCurrentInput() === 'W') { + $this->handleComponentMoveModeToggle($selectedControl); + return; + } + + if (Input::isKeyDown(KeyCode::DELETE) && $this->isSelectedComponentHeader($selectedControl)) { + $this->showDeleteComponentModal($selectedControl); + return; + } + + if ($this->isComponentMoveModeActive && $this->isSelectedComponentHeader($selectedControl)) { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->isComponentMoveModeActive = false; + return; + } + + if (Input::isKeyPressed(KeyCode::UP)) { + $this->moveSelectedComponent(-1); + return; + } + + if (Input::isKeyPressed(KeyCode::DOWN)) { + $this->moveSelectedComponent(1); + return; + } + } + if (Input::isKeyDown(KeyCode::UP)) { $this->moveControlSelection(-1); return; @@ -859,26 +1111,26 @@ private function handleControlEditInput(InputControl $selectedControl): void return; } - if (Input::isKeyDown(KeyCode::BACKSPACE)) { + if (Input::isKeyPressed(KeyCode::BACKSPACE)) { $selectedControl->deleteBackward(); return; } - if (Input::isKeyDown(KeyCode::LEFT)) { + if (Input::isKeyPressed(KeyCode::LEFT)) { $selectedControl->moveCursorLeft(); return; } - if (Input::isKeyDown(KeyCode::RIGHT)) { + if (Input::isKeyPressed(KeyCode::RIGHT)) { $selectedControl->moveCursorRight(); return; } - if (Input::isKeyDown(KeyCode::UP) && $selectedControl->increment()) { + if (Input::isKeyPressed(KeyCode::UP) && $selectedControl->increment()) { return; } - if (Input::isKeyDown(KeyCode::DOWN) && $selectedControl->decrement()) { + if (Input::isKeyPressed(KeyCode::DOWN) && $selectedControl->decrement()) { return; } @@ -927,6 +1179,8 @@ private function cancelSelectedEdit(InputControl $selectedControl): void private function resetInteractionState(): void { $this->closePathInputModals(); + $this->closeAddComponentModal(); + $this->closeDeleteComponentModal(); $selectedControl = $this->getSelectedControl(); if ($selectedControl instanceof CompoundInputControl) { @@ -940,6 +1194,79 @@ private function resetInteractionState(): void } $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->isComponentMoveModeActive = false; + } + + private function handleAddComponentModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->closeAddComponentModal(); + $this->refreshContent(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->addComponentModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->addComponentModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + 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(); + } + + private function handleDeleteComponentModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->closeDeleteComponentModal(); + $this->refreshContent(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->deleteComponentModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->deleteComponentModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selection = $this->deleteComponentModal->getSelectedOption(); + + if ($selection === 'Delete' && is_int($this->pendingComponentDeletionIndex)) { + $this->removeComponentAtIndex($this->pendingComponentDeletionIndex); + } + + $this->closeDeleteComponentModal(); + $this->refreshContent(); } private function handlePathInputActionInput(): void @@ -1064,6 +1391,749 @@ private function closePathInputModals(): void $this->activePathInputControl = null; } + private function canOpenAddComponentModal(): bool + { + return is_array($this->inspectionTarget) + && ($this->inspectionTarget['context'] ?? null) === 'hierarchy' + && is_string($this->inspectionTarget['path'] ?? null) + && ($this->inspectionTarget['path'] ?? null) !== 'scene' + && is_array($this->inspectionTarget['value'] ?? null); + } + + private function showAddComponentModal(): void + { + $this->componentMenuDefinitions = $this->resolveAvailableComponentDefinitions(); + $options = array_keys($this->componentMenuDefinitions); + $options[] = 'Cancel'; + $this->addComponentModal->show($options, 0, 'Add Component'); + $terminalSize = get_max_terminal_size(); + $terminalWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH; + $terminalHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT; + $this->syncModalLayout($terminalWidth, $terminalHeight); + } + + private function closeAddComponentModal(): void + { + $this->addComponentModal->hide(); + $this->componentMenuDefinitions = []; + } + + private function closeDeleteComponentModal(): void + { + $this->deleteComponentModal->hide(); + $this->pendingComponentDeletionIndex = null; + } + + private function resolveAvailableComponentDefinitions(): array + { + $currentItem = is_array($this->inspectionTarget['value'] ?? null) + ? $this->inspectionTarget['value'] + : []; + $candidateClasses = $this->resolveComponentCandidateClasses($currentItem); + $definitions = $this->loadComponentDefinitionsInIsolatedProcess($candidateClasses, $currentItem); + + if ($definitions === []) { + return []; + } + + usort( + $definitions, + fn(array $left, array $right): int => strcmp( + (string) ($left['label'] ?? $left['class'] ?? ''), + (string) ($right['label'] ?? $right['class'] ?? '') + ), + ); + + $resolvedDefinitions = []; + $usedLabels = []; + + foreach ($definitions as $definition) { + $componentClass = is_string($definition['class'] ?? null) ? $definition['class'] : null; + + if ($componentClass === null || $componentClass === '') { + continue; + } + + $label = $this->buildUniqueComponentMenuLabel( + is_string($definition['label'] ?? null) && $definition['label'] !== '' + ? $definition['label'] + : $this->resolveClassName($componentClass, $componentClass), + $componentClass, + $usedLabels, + ); + + $resolvedDefinitions[$label] = [ + 'class' => $componentClass, + 'data' => is_array($definition['data'] ?? null) ? $definition['data'] : [], + ]; + } + + return $resolvedDefinitions; + } + + private function resolveComponentCandidateClasses(array $currentItem): array + { + $currentComponentClasses = $this->collectComponentClassesFromComponents($currentItem['components'] ?? []); + $sceneComponentClasses = $this->collectComponentClassesFromHierarchy($this->sceneHierarchy); + $projectComponentClasses = $this->discoverProjectComponentCandidates(); + + $candidates = array_values(array_unique(array_filter( + [ + ...self::DEFAULT_COMPONENT_CANDIDATES, + ...$projectComponentClasses, + ...$sceneComponentClasses, + ...$currentComponentClasses, + ], + static fn(mixed $class): bool => is_string($class) && $class !== '', + ))); + + return $candidates; + } + + private function discoverProjectComponentCandidates(): array + { + if (is_array($this->cachedProjectComponentCandidates)) { + return $this->cachedProjectComponentCandidates; + } + + $scriptsDirectory = Path::join($this->resolveAssetsWorkingDirectory(), 'Scripts'); + + if (!is_dir($scriptsDirectory)) { + return $this->cachedProjectComponentCandidates = []; + } + + $componentCandidates = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($scriptsDirectory, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (!$file->isFile() || strtolower((string) $file->getExtension()) !== 'php') { + continue; + } + + $classReference = $this->extractClassReferenceFromPhpFile($file->getPathname()); + + if (is_string($classReference) && $classReference !== '') { + $componentCandidates[] = $classReference; + } + } + + return $this->cachedProjectComponentCandidates = array_values(array_unique($componentCandidates)); + } + + private function extractClassReferenceFromPhpFile(string $filePath): ?string + { + $source = file_get_contents($filePath); + + if ($source === false || $source === '') { + return null; + } + + $tokens = token_get_all($source); + $namespace = ''; + $className = null; + $tokenCount = count($tokens); + + for ($index = 0; $index < $tokenCount; $index++) { + $token = $tokens[$index]; + + if (is_array($token) && $token[0] === T_NAMESPACE) { + $namespace = ''; + + for ($lookahead = $index + 1; $lookahead < $tokenCount; $lookahead++) { + $namespaceToken = $tokens[$lookahead]; + + if ( + is_string($namespaceToken) + && ($namespaceToken === ';' || $namespaceToken === '{') + ) { + break; + } + + if ( + is_array($namespaceToken) + && in_array($namespaceToken[0], [T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR], true) + ) { + $namespace .= $namespaceToken[1]; + } + } + + continue; + } + + if (!is_array($token) || $token[0] !== T_CLASS) { + continue; + } + + $previousToken = $tokens[$index - 1] ?? null; + + if ( + is_array($previousToken) + && in_array($previousToken[0], [T_DOUBLE_COLON, T_NEW], true) + ) { + continue; + } + + for ($lookahead = $index + 1; $lookahead < $tokenCount; $lookahead++) { + $classToken = $tokens[$lookahead]; + + if (is_array($classToken) && $classToken[0] === T_STRING) { + $className = $classToken[1]; + break 2; + } + } + } + + if (!is_string($className) || $className === '') { + return null; + } + + return $namespace !== '' + ? $namespace . '\\' . $className + : $className; + } + + private function collectComponentClassesFromHierarchy(array $hierarchy): array + { + $componentClasses = []; + + foreach ($hierarchy as $item) { + if (!is_array($item)) { + continue; + } + + $componentClasses = [ + ...$componentClasses, + ...$this->collectComponentClassesFromComponents($item['components'] ?? []), + ...$this->collectComponentClassesFromHierarchy( + is_array($item['children'] ?? null) ? $item['children'] : [] + ), + ]; + } + + return array_values(array_unique($componentClasses)); + } + + private function collectComponentClassesFromComponents(mixed $components): array + { + if (!is_array($components)) { + return []; + } + + $componentClasses = []; + + foreach ($components as $component) { + if (!is_array($component)) { + continue; + } + + $componentClass = $component['class'] ?? null; + + if (is_string($componentClass) && $componentClass !== '') { + $componentClasses[] = $componentClass; + } + } + + return array_values(array_unique($componentClasses)); + } + + private function loadComponentDefinitionsInIsolatedProcess(array $candidateClasses, array $item): array + { + $candidateClasses = array_values(array_unique(array_filter( + $candidateClasses, + static fn(mixed $class): bool => is_string($class) && $class !== '', + ))); + + if ($candidateClasses === []) { + return []; + } + + $autoloadPath = Path::join($this->projectDirectory, 'vendor', 'autoload.php'); + + if (!is_file($autoloadPath)) { + return array_map( + fn(string $componentClass): array => [ + 'class' => $componentClass, + 'label' => $this->resolveClassName($componentClass, $componentClass), + 'data' => [], + ], + $candidateClasses, + ); + } + + $script = <<<'PHP' +$autoloadPath = $argv[1] ?? ''; +$candidateClasses = json_decode($argv[2] ?? '[]', true); +$item = json_decode($argv[3] ?? '[]', true); + +function normalize_editor_value(mixed $value): mixed +{ + if (is_array($value)) { + $normalized = []; + + foreach ($value as $key => $item) { + $normalized[$key] = normalize_editor_value($item); + } + + return $normalized; + } + + if ($value instanceof UnitEnum) { + return $value instanceof BackedEnum ? $value->value : $value->name; + } + + if (!is_object($value)) { + return $value; + } + + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => normalize_editor_value($value->getX()), + 'y' => normalize_editor_value($value->getY()), + ]; + } + + if (method_exists($value, 'getName')) { + try { + return $value->getName(); + } catch (Throwable) { + } + } + + if (method_exists($value, '__serialize')) { + try { + $serialized = $value->__serialize(); + + return is_array($serialized) + ? normalize_editor_value($serialized) + : normalize_editor_value((array) $serialized); + } catch (Throwable) { + } + } + + if ($value instanceof Stringable) { + return (string) $value; + } + + return get_class($value); +} + +function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?object +{ + if (!class_exists('\Sendama\Engine\Core\Vector2')) { + return null; + } + + $vectorValue = is_array($value) ? $value : $default; + + return new \Sendama\Engine\Core\Vector2( + (int) ($vectorValue['x'] ?? $default['x']), + (int) ($vectorValue['y'] ?? $default['y']), + ); +} + +function build_dummy_game_object(array $item): ?object +{ + if (!class_exists('\Sendama\Engine\Core\GameObject')) { + return null; + } + + $tag = is_string($item['tag'] ?? null) && $item['tag'] !== 'None' + ? $item['tag'] + : null; + + return new \Sendama\Engine\Core\GameObject( + is_string($item['name'] ?? null) ? $item['name'] : 'GameObject', + $tag, + build_vector($item['position'] ?? null) ?? new \Sendama\Engine\Core\Vector2(), + build_vector($item['rotation'] ?? null) ?? new \Sendama\Engine\Core\Vector2(), + build_vector($item['scale'] ?? ['x' => 1, 'y' => 1], ['x' => 1, 'y' => 1]) ?? new \Sendama\Engine\Core\Vector2(1, 1), + null, + ); +} + +function extract_component_serializable_data(object $component): array +{ + $serializedData = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() + || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []; + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + try { + $serializedData[$property->getName()] = $property->getValue($component); + } catch (Throwable) { + continue; + } + } + + return $serializedData; +} + +function serialize_component_data(string $componentClass, array $item): ?array +{ + if ( + !class_exists($componentClass) + || !class_exists('\Sendama\Engine\Core\Component') + || !is_a($componentClass, '\Sendama\Engine\Core\Component', true) + ) { + return null; + } + + try { + $reflection = new ReflectionClass($componentClass); + + if ($reflection->isAbstract()) { + return null; + } + + $gameObject = build_dummy_game_object($item); + + if (!is_object($gameObject)) { + return null; + } + + $component = new $componentClass($gameObject); + + return normalize_editor_value(extract_component_serializable_data($component)); + } catch (Throwable) { + return null; + } +} + +function short_class_name(string $classReference): string +{ + $segments = explode('\\', ltrim($classReference, '\\')); + return end($segments) ?: $classReference; +} + +if (is_file($autoloadPath)) { + require $autoloadPath; +} + +$definitions = []; + +foreach ((array) $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) + || !class_exists('\Sendama\Engine\Core\Component') + || !is_a($candidateClass, '\Sendama\Engine\Core\Component', true) + ) { + continue; + } + + try { + $reflection = new ReflectionClass($candidateClass); + + if ($reflection->isAbstract()) { + continue; + } + } catch (Throwable) { + continue; + } + + $definitions[] = [ + 'class' => $candidateClass, + 'label' => short_class_name($candidateClass), + 'data' => serialize_component_data($candidateClass, is_array($item) ? $item : []) ?? [], + ]; +} + +echo json_encode($definitions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +PHP; + + $command = [ + PHP_BINARY, + '-d', + 'display_errors=stderr', + '-r', + $script, + $autoloadPath, + json_encode($candidateClasses, JSON_UNESCAPED_SLASHES) ?: '[]', + json_encode($item, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}', + ]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptors, $pipes, $this->projectDirectory); + + if (!is_resource($process)) { + return []; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0 || !is_string($stdout)) { + return []; + } + + $definitions = json_decode($stdout, true); + + return is_array($definitions) ? $definitions : []; + } + + private function buildUniqueComponentMenuLabel(string $baseLabel, string $componentClass, array &$usedLabels): string + { + if (!isset($usedLabels[$baseLabel])) { + $usedLabels[$baseLabel] = true; + return $baseLabel; + } + + $uniqueLabel = $baseLabel . ' (' . ltrim($componentClass, '\\') . ')'; + $usedLabels[$uniqueLabel] = true; + + return $uniqueLabel; + } + + private function appendComponentToInspectionTarget(array $componentDefinition): void + { + if ( + !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !is_string($this->inspectionTarget['path'] ?? null) + || !is_array($this->inspectionTarget['value'] ?? null) + ) { + return; + } + + $componentClass = is_string($componentDefinition['class'] ?? null) + ? $componentDefinition['class'] + : null; + + if ($componentClass === null || $componentClass === '') { + return; + } + + $inspectionValue = $this->inspectionTarget['value']; + $inspectionValue['components'] = is_array($inspectionValue['components'] ?? null) + ? array_values($inspectionValue['components']) + : []; + + $componentEntry = ['class' => $componentClass]; + + if (array_key_exists('data', $componentDefinition) && is_array($componentDefinition['data'])) { + $componentEntry['data'] = $componentDefinition['data']; + } + + $inspectionValue['components'][] = $componentEntry; + $updatedTarget = $this->inspectionTarget; + $updatedTarget['value'] = $inspectionValue; + $this->inspectTarget($updatedTarget); + $this->pendingHierarchyMutation = [ + 'path' => $updatedTarget['path'], + 'value' => $inspectionValue, + ]; + } + + private function getSelectedControlMetadata(?InputControl $control): array + { + if (!$control instanceof InputControl) { + return []; + } + + return $this->controlMetadata[spl_object_id($control)] ?? []; + } + + private function isSelectedComponentHeader(?InputControl $control): bool + { + if (!$control instanceof SectionControl) { + return false; + } + + $metadata = $this->getSelectedControlMetadata($control); + + return ($metadata['kind'] ?? null) === 'component_header' + && is_int($metadata['componentIndex'] ?? null); + } + + private function handleComponentMoveModeToggle(InputControl $selectedControl): void + { + if (!$this->isSelectedComponentHeader($selectedControl)) { + $this->isComponentMoveModeActive = false; + return; + } + + $this->isComponentMoveModeActive = !$this->isComponentMoveModeActive; + } + + private function showDeleteComponentModal(InputControl $selectedControl): void + { + $metadata = $this->getSelectedControlMetadata($selectedControl); + $componentIndex = $metadata['componentIndex'] ?? null; + + if (!is_int($componentIndex)) { + return; + } + + $inspectionValue = is_array($this->inspectionTarget['value'] ?? null) + ? $this->inspectionTarget['value'] + : []; + $components = is_array($inspectionValue['components'] ?? null) + ? array_values($inspectionValue['components']) + : []; + $component = $components[$componentIndex] ?? null; + + if (!is_array($component)) { + return; + } + + $componentName = $this->resolveClassName($component['class'] ?? null, 'this component'); + $this->pendingComponentDeletionIndex = $componentIndex; + $this->isComponentMoveModeActive = false; + $this->deleteComponentModal->show( + ['Delete', 'Cancel'], + 1, + 'Remove ' . $componentName . ' from this object?' + ); + $terminalSize = get_max_terminal_size(); + $terminalWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH; + $terminalHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT; + $this->syncModalLayout($terminalWidth, $terminalHeight); + } + + private function removeComponentAtIndex(int $componentIndex): void + { + if ( + !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !is_string($this->inspectionTarget['path'] ?? null) + || !is_array($this->inspectionTarget['value'] ?? null) + ) { + return; + } + + $inspectionValue = $this->inspectionTarget['value']; + $components = is_array($inspectionValue['components'] ?? null) + ? array_values($inspectionValue['components']) + : []; + + if (!array_key_exists($componentIndex, $components)) { + return; + } + + array_splice($components, $componentIndex, 1); + $inspectionValue['components'] = array_values($components); + + $nextComponentIndex = $components === [] + ? null + : min($componentIndex, count($components) - 1); + + $this->rebuildHierarchyInspection($inspectionValue, $nextComponentIndex); + } + + private function moveSelectedComponent(int $direction): void + { + $selectedControl = $this->getSelectedControl(); + $metadata = $this->getSelectedControlMetadata($selectedControl); + $componentIndex = $metadata['componentIndex'] ?? null; + + if ( + !is_int($componentIndex) + || !in_array($direction, [-1, 1], true) + || !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !is_array($this->inspectionTarget['value'] ?? null) + ) { + return; + } + + $inspectionValue = $this->inspectionTarget['value']; + $components = is_array($inspectionValue['components'] ?? null) + ? array_values($inspectionValue['components']) + : []; + $componentCount = count($components); + + if ($componentCount < 2 || !array_key_exists($componentIndex, $components)) { + return; + } + + $targetIndex = ($componentIndex + $direction + $componentCount) % $componentCount; + $component = $components[$componentIndex]; + array_splice($components, $componentIndex, 1); + array_splice($components, $targetIndex, 0, [$component]); + $inspectionValue['components'] = array_values($components); + + $this->rebuildHierarchyInspection($inspectionValue, $targetIndex, true); + } + + private function rebuildHierarchyInspection( + array $inspectionValue, + ?int $focusComponentIndex = null, + bool $preserveMoveMode = false, + ): void + { + if ( + !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !is_string($this->inspectionTarget['path'] ?? null) + ) { + return; + } + + $updatedTarget = $this->inspectionTarget; + $updatedTarget['value'] = $inspectionValue; + $updatedTarget['name'] = $inspectionValue['name'] ?? ($updatedTarget['name'] ?? 'Unnamed Object'); + $updatedTarget['type'] = $this->resolveDisplayType($updatedTarget, $inspectionValue); + $this->inspectTarget($updatedTarget); + + if (is_int($focusComponentIndex)) { + $this->focusComponentHeaderByIndex($focusComponentIndex); + } + + $this->isComponentMoveModeActive = $preserveMoveMode && is_int($focusComponentIndex); + $this->pendingHierarchyMutation = [ + 'path' => $updatedTarget['path'], + 'value' => $inspectionValue, + ]; + } + + private function focusComponentHeaderByIndex(int $componentIndex): void + { + foreach ($this->focusableControls as $index => $control) { + if (!$control instanceof InputControl) { + continue; + } + + $metadata = $this->getSelectedControlMetadata($control); + + if ( + ($metadata['kind'] ?? null) === 'component_header' + && ($metadata['componentIndex'] ?? null) === $componentIndex + ) { + $this->selectedControlIndex = $index; + $this->applyControlSelection(); + $this->refreshContent(); + return; + } + } + } + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array { if ($texturePath === 'None') { diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index 7b53cef..1383f48 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\IO\Enumerations\KeyCode; use Sendama\Console\Editor\IO\Input; use Sendama\Console\Util\Path; @@ -135,6 +136,18 @@ public function getActiveTab(): string return self::TAB_TITLES[$this->activeTabIndex]; } + public function focus(FocusTargetContext $context): void + { + parent::focus($context); + $this->refreshContent(); + } + + public function blur(FocusTargetContext $context): void + { + parent::blur($context); + $this->refreshContent(); + } + public function activateNextTab(): void { $this->activeTabIndex = ($this->activeTabIndex + 1) % count(self::TAB_TITLES); @@ -377,11 +390,6 @@ public function update(): void return; } - if (Input::getCurrentInput() === 'A') { - $this->showCreateSpriteAssetModal(); - return; - } - if (Input::getCurrentInput() === '@') { $this->showCharacterPickerModal(); return; @@ -673,12 +681,12 @@ private function updateHelpInfo(): void if ($this->isSpriteTabActive()) { if ($this->activeSpriteAsset === null) { - $this->help = 'Select .texture or .tmap Shift+A new Shift+2 chars'; + $this->help = 'Select .texture or .tmap'; $this->modeHelpLabel = 'Mode: Sprite Editor'; return; } - $this->help = 'Arrows move Type draw Shift+2 chars Shift+A new Ctrl+Z/Y undo redo Shift+R reset Del delete'; + $this->help = 'Arrows move Type draw Shift+2 chars Ctrl+Z/Y undo redo Shift+R reset Del delete'; $this->modeHelpLabel = 'Mode: Sprite Editor ' . $this->buildSpriteCursorPositionLabel(); return; } @@ -1029,6 +1037,10 @@ private function decorateSceneLine(string $line, ?Color $contentColor, int $cont return parent::decorateContentLine($line, $contentColor, $contentIndex); } + if (!$this->hasFocus() && ($highlight['kind'] ?? null) !== 'placeholder') { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + $visibleLine = mb_substr($line, 0, $this->width); $visibleLength = mb_strlen($visibleLine); @@ -1119,7 +1131,7 @@ private function buildSceneCanvasContent(): array : []; if ($renderLines === []) { - if (($sceneObject['path'] ?? null) !== $this->selectedScenePath) { + if (($sceneObject['path'] ?? null) !== $this->selectedScenePath || !$this->hasFocus()) { continue; } @@ -1585,18 +1597,9 @@ private function deleteActiveSpriteAsset(): void private function resolveAssetsDirectory(): ?string { - $candidates = [ - Path::join($this->projectDirectory, 'Assets'), - Path::join($this->projectDirectory, 'assets'), - ]; + $assetsDirectory = Path::resolveAssetsDirectory($this->projectDirectory); - foreach ($candidates as $candidate) { - if (is_dir($candidate)) { - return $candidate; - } - } - - return null; + return is_dir($assetsDirectory) ? $assetsDirectory : null; } private function createNextAvailableAssetPath(string $targetDirectory, string $baseName, string $extension): string @@ -1692,6 +1695,10 @@ private function getSelectedSceneObjectIndex(): ?int private function resolveSceneObjectRenderLines(array $item): array { + if ($this->isSceneObjectRendererDisabled($item)) { + return []; + } + $spriteRenderLines = $this->buildSpriteRenderLines($item); if ($spriteRenderLines !== []) { @@ -1707,6 +1714,48 @@ private function resolveSceneObjectRenderLines(array $item): array return []; } + private function isSceneObjectRendererDisabled(array $item): bool + { + if (($item['renderer']['enabled'] ?? null) === false || ($item['render']['enabled'] ?? null) === false) { + return true; + } + + if (($item['sprite']['enabled'] ?? null) === false) { + return true; + } + + $components = $item['components'] ?? null; + + if (!is_array($components)) { + return false; + } + + foreach ($components as $component) { + if (!is_array($component)) { + continue; + } + + $componentClass = is_string($component['class'] ?? null) ? $component['class'] : ''; + + if ($componentClass === '') { + continue; + } + + $normalizedClass = ltrim($componentClass, '\\'); + $normalizedClass = preg_replace('/::class$/', '', $normalizedClass) ?? $normalizedClass; + + if ($normalizedClass !== 'Sendama\\Engine\\Core\\Rendering\\Renderer' && !str_ends_with($normalizedClass, '\\Renderer')) { + continue; + } + + if (($component['data']['enabled'] ?? null) === false || ($component['enabled'] ?? null) === false) { + return true; + } + } + + return false; + } + private function resolveInspectableType(array $item): string { $type = $item['type'] ?? null; diff --git a/src/Strategies/AssetFileGeneration/AbstractAssetFileGenerationStrategy.php b/src/Strategies/AssetFileGeneration/AbstractAssetFileGenerationStrategy.php index acd205e..39001e9 100644 --- a/src/Strategies/AssetFileGeneration/AbstractAssetFileGenerationStrategy.php +++ b/src/Strategies/AssetFileGeneration/AbstractAssetFileGenerationStrategy.php @@ -18,6 +18,7 @@ */ abstract class AbstractAssetFileGenerationStrategy implements AssetFileGenerationStrategyInterface { + protected string $assetsDirectoryName = 'Assets'; /** * The class path. * @@ -61,6 +62,7 @@ public function __construct( $this->projectConfig = new ProjectConfig($input, $output); $this->composerConfig = new ComposerConfig($input, $output); $this->inspector = new Inspector($input, $output); + $this->assetsDirectoryName = basename(Path::getWorkingDirectoryAssetsPath()) ?: 'Assets'; $nameTokens = explode('/', $this->filename); @@ -77,7 +79,7 @@ public function __construct( $this->className = basename($this->classPath); - $this->relativeFilename = Path::join('assets', $this->classPath . ($this->fileExtension ?? '.php')); + $this->relativeFilename = Path::join($this->assetsDirectoryName, $this->classPath . ($this->fileExtension ?? '.php')); $this->configure(); } @@ -118,4 +120,4 @@ public function generate(): int $this->output->writeln("CREATE $this->relativeFilename ($bytes bytes)"); return Command::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php b/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php index d217adb..33a2000 100644 --- a/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php +++ b/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php @@ -31,7 +31,7 @@ protected function configure(): void { if ($this->asMetaFile) { $filename = Path::join(dirname($this->classPath), to_kebab_case($this->className)); - $this->relativeFilename = Path::join('assets', $filename . ($this->fileExtension ?? '.php')); + $this->relativeFilename = Path::join($this->assetsDirectoryName, $filename . ($this->fileExtension ?? '.php')); $this->content = <<composerConfig->getNamespace() . 'Scripts'; - $namespaceTail = preg_replace('/assets\/Scripts\/?/', '', dirname($this->relativeFilename)); + $namespaceTail = preg_replace('/^[Aa]ssets\/Scripts\/?/', '', dirname($this->relativeFilename)); - if ($namespaceTail && $namespaceTail !== 'assets/Scripts') { + if ($namespaceTail) { $namespace .= '\\' . to_pascal_case(str_replace('/', '\\', $namespaceTail)); } @@ -41,4 +41,4 @@ public function onUpdate(): void PHP; } -} \ No newline at end of file +} diff --git a/src/Strategies/AssetFileGeneration/TextureFileGenerationStrategy.php b/src/Strategies/AssetFileGeneration/TextureFileGenerationStrategy.php index f910412..d6d6e63 100644 --- a/src/Strategies/AssetFileGeneration/TextureFileGenerationStrategy.php +++ b/src/Strategies/AssetFileGeneration/TextureFileGenerationStrategy.php @@ -29,7 +29,7 @@ protected function configure(): void $this->className = basename($this->classPath); - $this->relativeFilename = Path::join('assets', $this->classPath . ($this->fileExtension ?? '.php')); + $this->relativeFilename = Path::join($this->assetsDirectoryName, $this->classPath . ($this->fileExtension ?? '.php')); } -} \ No newline at end of file +} diff --git a/src/Util/Config/ComposerConfig.php b/src/Util/Config/ComposerConfig.php index 32dde4e..7d786a4 100644 --- a/src/Util/Config/ComposerConfig.php +++ b/src/Util/Config/ComposerConfig.php @@ -21,11 +21,11 @@ public function getNamespace(): string|false $namespaces = $this->get('autoload.psr-4') ?? []; foreach ($namespaces as $namespace => $path) { - if ($path === 'assets/') { + if ($path === 'Assets/' || $path === 'assets/') { return $namespace; } } return false; } -} \ No newline at end of file +} diff --git a/src/Util/Path.php b/src/Util/Path.php index ad56945..5083554 100644 --- a/src/Util/Path.php +++ b/src/Util/Path.php @@ -1,168 +1,202 @@ -projectRoot)) { + return ['Project directory is missing.']; + } + + $issues = []; + $configDirectory = Path::join($this->projectRoot, 'config'); + $logsDirectory = Path::join($this->projectRoot, 'logs'); + $assetsDirectory = Path::resolveAssetsDirectory($this->projectRoot); + $assetsLabel = basename($assetsDirectory) ?: 'Assets'; + + if (!is_file(Path::join($this->projectRoot, 'sendama.json'))) { + $issues[] = 'Missing sendama.json.'; + } + + if (!is_file(Path::join($this->projectRoot, 'configuration.json'))) { + $issues[] = 'Missing configuration.json.'; + } + + if (!is_dir($configDirectory)) { + $issues[] = 'Missing config directory.'; + } + + if (!is_file(Path::join($configDirectory, 'input.php'))) { + $issues[] = 'Missing config/input.php.'; + } + + if (!is_dir($logsDirectory)) { + $issues[] = 'Missing logs directory.'; + } + + foreach (self::REQUIRED_LOG_FILES as $logFilename) { + if (!is_file(Path::join($logsDirectory, $logFilename))) { + $issues[] = sprintf('Missing logs/%s.', $logFilename); + } + } + + if (!is_dir($assetsDirectory)) { + $issues[] = sprintf('Missing %s directory.', $assetsLabel); + } + + foreach (self::REQUIRED_ASSET_DIRECTORIES as $directory) { + if (!is_dir(Path::join($assetsDirectory, $directory))) { + $issues[] = sprintf('Missing %s/%s directory.', $assetsLabel, $directory); + } + } + + return $issues; + } + + /** + * @return string[] + */ + public function normalize(): array + { + if (!is_dir($this->projectRoot)) { + return []; + } + + $changes = []; + $configDirectory = Path::join($this->projectRoot, 'config'); + $logsDirectory = Path::join($this->projectRoot, 'logs'); + $assetsDirectory = Path::resolveAssetsDirectory($this->projectRoot); + $assetsLabel = basename($assetsDirectory) ?: 'Assets'; + $projectMetadata = $this->resolveProjectMetadata(); + + $this->ensureDirectory($configDirectory, 'Created config directory.', $changes); + $this->ensureDirectory($logsDirectory, 'Created logs directory.', $changes); + $this->ensureDirectory($assetsDirectory, sprintf('Created %s directory.', $assetsLabel), $changes); + + foreach (self::REQUIRED_ASSET_DIRECTORIES as $directory) { + $this->ensureDirectory( + Path::join($assetsDirectory, $directory), + sprintf('Created %s/%s directory.', $assetsLabel, $directory), + $changes, + ); + } + + $this->ensureFile( + Path::join($this->projectRoot, 'sendama.json'), + self::buildSendamaConfiguration( + projectName: $projectMetadata['name'], + description: $projectMetadata['description'], + version: $projectMetadata['version'], + mainFile: $projectMetadata['main'], + ), + 'Created sendama.json.', + $changes, + ); + + $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.', + $changes, + ); + + $this->ensureFile( + Path::join($configDirectory, 'input.php'), + self::buildInputConfiguration(), + 'Created config/input.php.', + $changes, + ); + + foreach (self::REQUIRED_LOG_FILES as $logFilename) { + $this->ensureFile( + Path::join($logsDirectory, $logFilename), + '', + sprintf('Created logs/%s.', $logFilename), + $changes, + ); + } + + return $changes; + } + + public static function buildSendamaConfiguration( + string $projectName, + string $description = 'A 2D ASCII terminal game.', + string $version = '0.0.1', + string $mainFile = 'main.php', + ): string { + return json_encode([ + 'name' => $projectName, + 'description' => $description, + 'version' => $version, + 'main' => $mainFile, + ], 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 buildInputConfiguration(): string + { + $templatePath = Path::join(dirname(__DIR__, 2), 'templates', 'config', 'input.php'); + $templateContents = file_get_contents($templatePath); + + return $templateContents === false ? " $this->guessProjectName(), + 'description' => 'A 2D ASCII terminal game.', + 'version' => '0.0.1', + 'main' => $this->guessMainFile(), + ]; + + $sendamaPath = Path::join($this->projectRoot, 'sendama.json'); + + if (is_file($sendamaPath)) { + $sendamaContents = file_get_contents($sendamaPath); + $sendamaData = $sendamaContents !== false ? json_decode($sendamaContents, true) : null; + + if (is_array($sendamaData)) { + return [ + 'name' => is_string($sendamaData['name'] ?? null) && trim($sendamaData['name']) !== '' + ? trim($sendamaData['name']) + : $defaultMetadata['name'], + 'description' => is_string($sendamaData['description'] ?? null) + ? $sendamaData['description'] + : $defaultMetadata['description'], + 'version' => is_string($sendamaData['version'] ?? null) + ? $sendamaData['version'] + : $defaultMetadata['version'], + 'main' => is_string($sendamaData['main'] ?? null) && trim($sendamaData['main']) !== '' + ? trim($sendamaData['main']) + : $defaultMetadata['main'], + ]; + } + } + + $configurationPath = Path::join($this->projectRoot, 'configuration.json'); + + if (is_file($configurationPath)) { + $configurationContents = file_get_contents($configurationPath); + $configurationData = $configurationContents !== false ? json_decode($configurationContents, true) : null; + $projectData = is_array($configurationData['project'] ?? null) ? $configurationData['project'] : null; + + if (is_array($projectData)) { + return [ + 'name' => is_string($projectData['name'] ?? null) && trim($projectData['name']) !== '' + ? trim($projectData['name']) + : $defaultMetadata['name'], + 'description' => is_string($projectData['description'] ?? null) + ? $projectData['description'] + : $defaultMetadata['description'], + 'version' => is_string($projectData['version'] ?? null) + ? $projectData['version'] + : $defaultMetadata['version'], + 'main' => is_string($projectData['main'] ?? null) && trim($projectData['main']) !== '' + ? trim($projectData['main']) + : $defaultMetadata['main'], + ]; + } + } + + return $defaultMetadata; + } + + private function guessProjectName(): string + { + $directoryName = basename($this->projectRoot); + $normalizedName = trim((string) preg_replace('/[-_]+/', ' ', $directoryName)); + + if ($normalizedName === '') { + return 'Untitled Game'; + } + + return ucwords($normalizedName); + } + + private function guessMainFile(): string + { + $directoryName = basename($this->projectRoot); + $normalizedName = strtolower(function_exists('filter_string') + ? filter_string($directoryName) + : (string) preg_replace('/[^a-zA-Z0-9_-]+/', '-', $directoryName)); + $normalizedName = trim($normalizedName, '-_'); + + if ($normalizedName === '') { + return 'main.php'; + } + + return $normalizedName . '.php'; + } + + /** + * @param string[] $changes + */ + private function ensureDirectory(string $directory, string $message, array &$changes): void + { + if (is_dir($directory)) { + return; + } + + if (!mkdir($directory, 0777, true) && !is_dir($directory)) { + return; + } + + $changes[] = $message; + } + + /** + * @param string[] $changes + */ + private function ensureFile(string $filename, string $contents, string $message, array &$changes): void + { + if (is_file($filename)) { + return; + } + + $directory = dirname($filename); + + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + return; + } + + if (file_put_contents($filename, $contents) === false) { + return; + } + + $changes[] = $message; + } +} diff --git a/tests/Unit/AssetsPanelTest.php b/tests/Unit/AssetsPanelTest.php index cbed7d2..e6d44a6 100644 --- a/tests/Unit/AssetsPanelTest.php +++ b/tests/Unit/AssetsPanelTest.php @@ -1,6 +1,7 @@ consumeDeletionRequest())->toBeNull(); }); + +test('assets panel queues the selected asset type for creation when confirmed', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + workingDirectory: $workspace, + ); + + $panel->beginCreateWorkflow(); + + $handleModalInput = new ReflectionMethod(AssetsPanel::class, 'handleModalInput'); + $handleModalInput->setAccessible(true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + + $handleModalInput->invoke($panel); + + expect($panel->consumeCreationRequest())->toBe([ + 'kind' => 'script', + 'workingDirectory' => $workspace, + ]); +}); + +test('assets panel opens the create modal with shift+a while focused', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + workingDirectory: $workspace, + ); + + $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'); + + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + expect($panel->consumeCreationRequest())->toBeNull(); +}); diff --git a/tests/Unit/CliAssetsDirectoryTest.php b/tests/Unit/CliAssetsDirectoryTest.php new file mode 100644 index 0000000..7c730b6 --- /dev/null +++ b/tests/Unit/CliAssetsDirectoryTest.php @@ -0,0 +1,206 @@ + 'CLI Test Game', + ], JSON_PRETTY_PRINT)); + + file_put_contents($workspace . '/composer.json', json_encode([ + 'name' => 'tmp/cli-test-game', + 'require' => [ + 'sendamaphp/engine' => '*', + ], + 'autoload' => [ + 'psr-4' => [ + 'Tmp\\CliTest\\' => 'Assets/', + ], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + mkdir($workspace . '/Assets', 0777, true); + + return $workspace; +} + +function createLegacyCliAssetsWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-cli-assets-legacy-' . uniqid(); + mkdir($workspace, 0777, true); + + file_put_contents($workspace . '/sendama.json', json_encode([ + 'name' => 'CLI Legacy Test Game', + ], JSON_PRETTY_PRINT)); + + file_put_contents($workspace . '/composer.json', json_encode([ + 'name' => 'tmp/cli-legacy-test-game', + 'require' => [ + 'sendamaphp/engine' => '*', + ], + 'autoload' => [ + 'psr-4' => [ + 'Tmp\\CliLegacy\\' => 'assets/', + ], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + mkdir($workspace . '/assets', 0777, true); + + return $workspace; +} + +function runGeneratorCommandInWorkspace(object $command, string $workspace, array $arguments): int +{ + $originalWorkingDirectory = getcwd(); + $input = new ArrayInput($arguments); + $input->setInteractive(false); + $output = new BufferedOutput(); + + chdir($workspace); + + try { + return $command->run($input, $output); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +} + +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); + + expect($configuration['autoload']['psr-4']['SendamaEngine\\TestGame\\'])->toBe('Assets/'); +}); + +test('new game creates an Assets directory', function () { + $workspace = sys_get_temp_dir() . '/sendama-new-game-assets-' . 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, 'createAssetsDirectory'); + $method->setAccessible(true); + $assetsDirectory = $method->invoke($command); + + expect($assetsDirectory)->toBe($workspace . '/Assets'); + expect(is_dir($workspace . '/Assets'))->toBeTrue(); +}); + +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'), + true, + flags: JSON_THROW_ON_ERROR + ); + + expect($configuration['project']['name'])->toBe('Test Game'); + expect($configuration['project']['main'])->toBe('test-game.php'); +}); + +test('generate script creates files under Assets', function () { + $workspace = createCliAssetsWorkspace(); + $exitCode = runGeneratorCommandInWorkspace( + new GenerateScript(), + $workspace, + ['name' => 'player'], + ); + + expect($exitCode)->toBe(0); + expect(is_file($workspace . '/Assets/Scripts/Player.php'))->toBeTrue(); +}); + +test('generate texture creates files under Assets', function () { + $workspace = createCliAssetsWorkspace(); + $exitCode = runGeneratorCommandInWorkspace( + new GenerateTexture(), + $workspace, + ['name' => 'player'], + ); + + expect($exitCode)->toBe(0); + expect(is_file($workspace . '/Assets/Textures/player.texture'))->toBeTrue(); +}); + +test('generate scene creates files under Assets', function () { + $workspace = createCliAssetsWorkspace(); + $exitCode = runGeneratorCommandInWorkspace( + new GenerateScene(), + $workspace, + ['name' => 'level01'], + ); + + expect($exitCode)->toBe(0); + expect(is_file($workspace . '/Assets/Scenes/level01.scene.php'))->toBeTrue(); +}); + +test('working directory assets path prefers Assets', function () { + $workspace = createCliAssetsWorkspace(); + $originalWorkingDirectory = getcwd(); + chdir($workspace); + + try { + expect(Path::getWorkingDirectoryAssetsPath())->toBe($workspace . '/Assets'); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +}); + +test('generate script uses the existing lowercase assets directory in legacy projects', function () { + $workspace = createLegacyCliAssetsWorkspace(); + $exitCode = runGeneratorCommandInWorkspace( + new GenerateScript(), + $workspace, + ['name' => 'player'], + ); + + expect($exitCode)->toBe(0); + expect(is_file($workspace . '/assets/Scripts/Player.php'))->toBeTrue(); + expect(is_dir($workspace . '/Assets'))->toBeFalse(); +}); + +test('working directory assets path falls back to legacy lowercase assets when needed', function () { + $workspace = createLegacyCliAssetsWorkspace(); + $originalWorkingDirectory = getcwd(); + chdir($workspace); + + try { + expect(Path::getWorkingDirectoryAssetsPath())->toBe($workspace . '/assets'); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +}); diff --git a/tests/Unit/EditorAssetRenameTest.php b/tests/Unit/EditorAssetRenameTest.php new file mode 100644 index 0000000..2dc75d1 --- /dev/null +++ b/tests/Unit/EditorAssetRenameTest.php @@ -0,0 +1,57 @@ +newInstanceWithoutConstructor(); + + $workingDirectory = $editorReflection->getProperty('workingDirectory'); + $assetsDirectoryPath = $editorReflection->getProperty('assetsDirectoryPath'); + $consolePanel = $editorReflection->getProperty('consolePanel'); + $workingDirectory->setAccessible(true); + $assetsDirectoryPath->setAccessible(true); + $consolePanel->setAccessible(true); + $workingDirectory->setValue($editor, $workspace); + $assetsDirectoryPath->setValue($editor, $workspace . '/Assets'); + $consolePanel->setValue($editor, new ConsolePanel()); + + $renameMethod = $editorReflection->getMethod('renameAssetAndCascadeReferences'); + $renameMethod->setAccessible(true); + + $renamedAsset = $renameMethod->invoke( + $editor, + $scriptPath, + 'Scripts/PlayerController.php', + 'EnemyController.php', + ); + + expect($renamedAsset)->toBe([ + 'name' => 'EnemyController.php', + 'path' => $workspace . '/Assets/Scripts/EnemyController.php', + 'relativePath' => 'Scripts/EnemyController.php', + 'isDirectory' => false, + 'children' => [], + ]); + expect(file_exists($scriptPath))->toBeFalse(); + expect(is_file($workspace . '/Assets/Scripts/EnemyController.php'))->toBeTrue(); + expect(file_get_contents($workspace . '/Assets/Scripts/EnemyController.php')) + ->toContain('class EnemyController extends Behaviour'); +}); diff --git a/tests/Unit/EditorSettingsTest.php b/tests/Unit/EditorSettingsTest.php index 73697d1..702adfb 100644 --- a/tests/Unit/EditorSettingsTest.php +++ b/tests/Unit/EditorSettingsTest.php @@ -64,3 +64,25 @@ expect($settings->scenes->loaded)->toBe(['Scenes/level01.scene.php']); expect($settings->consoleRefreshIntervalSeconds)->toBe(3.0); }); + +test('editor settings fall back to defaults when sendama json is missing', function () { + $workspace = sys_get_temp_dir() . '/sendama-editor-settings-missing-' . uniqid(); + mkdir($workspace, 0777, true); + + $settings = EditorSettings::loadFromDirectory($workspace); + + expect($settings->scenes->loaded)->toBe([]); + expect($settings->scenes->active)->toBe(0); + expect($settings->consoleRefreshIntervalSeconds)->toBe(5.0); +}); + +test('editor settings fall back to defaults when sendama json is invalid', function () { + $workspace = sys_get_temp_dir() . '/sendama-editor-settings-invalid-' . uniqid(); + mkdir($workspace, 0777, true); + file_put_contents($workspace . '/sendama.json', '{invalid json'); + + $settings = EditorSettings::loadFromDirectory($workspace); + + expect($settings->scenes->loaded)->toBe([]); + expect($settings->consoleRefreshIntervalSeconds)->toBe(5.0); +}); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 8b4562e..8ba23c2 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -86,6 +86,15 @@ expect($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'); +}); + test('input manager tokenizes mixed escape sequences and printable characters', function () { $tokenizeInput = new ReflectionMethod(InputManager::class, 'tokenizeInput'); $tokenizeInput->setAccessible(true); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index f6855a6..7c027b7 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -1,6 +1,173 @@ setAccessible(true); + $hasFocus->setValue($panel, true); +} + +function setInspectorInput(string $current, string $previous = ''): void +{ + $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($previous); + $keyPress->setValue($current); +} + +function selectInspectorControlByLabel(InspectorPanel $panel, string $label): void +{ + $focusableControls = new ReflectionProperty(InspectorPanel::class, 'focusableControls'); + $selectedControlIndex = new ReflectionProperty(InspectorPanel::class, 'selectedControlIndex'); + $applyControlSelection = new ReflectionMethod(InspectorPanel::class, 'applyControlSelection'); + $refreshContent = new ReflectionMethod(InspectorPanel::class, 'refreshContent'); + $focusableControls->setAccessible(true); + $selectedControlIndex->setAccessible(true); + $applyControlSelection->setAccessible(true); + $refreshContent->setAccessible(true); + + /** @var array $controls */ + $controls = $focusableControls->getValue($panel); + + foreach ($controls as $index => $control) { + if ($control->getLabel() !== $label) { + continue; + } + + $selectedControlIndex->setValue($panel, $index); + $applyControlSelection->invoke($panel); + $refreshContent->invoke($panel); + return; + } + + throw new RuntimeException('Unable to locate inspector control labeled ' . $label); +} + +function inspectorComponentHeaders(InspectorPanel $panel): array +{ + return array_values(array_filter( + $panel->content, + static fn(string $line): bool => str_starts_with($line, '▼ ') + && !in_array($line, ['▼ Transform', '▼ Renderer'], true), + )); +} + +function createInspectorComponentWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-inspector-components-' . uniqid(); + 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 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\Engine\Core\Behaviours { + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + + abstract class Behaviour extends Component + { + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + } + } +} + +namespace { + require __DIR__ . '/../Assets/Scripts/PlayerController.php'; +} +PHP + ); + + file_put_contents( + $workspace . '/Assets/Scripts/PlayerController.php', + <<<'PHP' +toContain("\033[30;104m"); }); +test('inspector panel styles component headers with a warm highlight in component move mode', function () { + $panelWidth = 40; + $panel = new InspectorPanel(width: $panelWidth, height: 18); + + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'GameObject::class', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + ['class' => 'Sendama\\Game\\AlphaComponent', 'data' => ['speed' => 2]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'AlphaComponent'); + + setInspectorInput('W'); + $panel->update(); + + $lineIndex = array_search('▼ AlphaComponent', $panel->content, true); + expect($lineIndex)->not->toBeFalse(); + + $decorateContentLine = new ReflectionMethod($panel, 'decorateContentLine'); + $decorateContentLine->setAccessible(true); + $line = '|' . str_pad($panel->content[$lineIndex], $panelWidth - 2) . '|'; + $renderedLine = $decorateContentLine->invoke($panel, $line, null, $lineIndex); + + expect($renderedLine)->toContain("\033[5;30;43m"); +}); + test('inspector panel renders serialized component data with typed controls', function () { $panel = new InspectorPanel(width: 48, height: 24); @@ -328,6 +534,61 @@ ]); }); +test('inspector panel text edit supports repeated backspace input while held', function () { + $panel = new InspectorPanel(width: 48, height: 16); + + $panel->inspectTarget([ + 'context' => 'asset', + 'name' => 'player.texture', + 'type' => 'File', + 'value' => [ + 'name' => 'player.texture', + 'path' => '/tmp/project/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $panel->cycleFocusForward(); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + foreach (str_split('12') as $character) { + $previousKeyPress->setValue(''); + $keyPress->setValue($character); + $panel->update(); + } + + $previousKeyPress->setValue(''); + $keyPress->setValue("\177"); + $panel->update(); + + $previousKeyPress->setValue("\177"); + $keyPress->setValue("\177"); + $panel->update(); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + expect($panel->consumeAssetMutation())->toBe([ + 'path' => '/tmp/project/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'name' => 'player.texture', + ]); +}); + test('inspector panel renders editable scene controls', function () { $panel = new InspectorPanel(width: 48, height: 16); @@ -578,3 +839,455 @@ ], ]); }); + +test('inspector panel opens a component menu with shift+a and appends the selected component', function () { + $workspace = createInspectorComponentWorkspace(); + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + $panel->setSceneHierarchy([]); + + $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' => [], + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $previousKeyPress->setValue(''); + $keyPress->setValue('A'); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect($panel->content)->toContain('▼ PlayerController'); + expect($panel->content)->toContain(' Speed: 2'); + expect($panel->content)->toContain(' Lives: 3'); + expect($panel->consumeHierarchyMutation())->toBe([ + '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\\PlayerController', + 'data' => [ + 'speed' => 2, + 'lives' => 3, + ], + ], + ], + ], + ]); +}); + +test('inspector panel opens a delete modal for the selected component header and cancels safely', 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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'BetaComponent'); + + setInspectorInput("\033[3~"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + setInspectorInput("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect($panel->consumeHierarchyMutation())->toBeNull(); + expect($panel->content)->toContain('▼ AlphaComponent'); + expect($panel->content)->toContain('▼ BetaComponent'); +}); + +test('inspector panel removes the selected component when deletion is confirmed', 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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'BetaComponent'); + + setInspectorInput("\033[3~"); + $panel->update(); + + setInspectorInput("\033[A"); + $panel->update(); + + setInspectorInput("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect($panel->content)->toContain('▼ AlphaComponent'); + expect($panel->content)->not->toContain('▼ BetaComponent'); + expect($panel->content)->toContain('▼ GammaComponent'); + expect($panel->consumeHierarchyMutation())->toBe([ + '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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); +}); + +test('inspector panel reorders components upward with wraparound in move mode', 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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'AlphaComponent'); + + setInspectorInput('W'); + $panel->update(); + + setInspectorInput("\033[A"); + $panel->update(); + + expect(inspectorComponentHeaders($panel))->toBe([ + '▼ BetaComponent', + '▼ GammaComponent', + '▼ AlphaComponent', + ]); + expect($panel->consumeHierarchyMutation())->toBe([ + '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\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ['class' => 'Sendama\\Game\\AlphaComponent', 'data' => ['speed' => 2]], + ], + ], + ]); +}); + +test('inspector panel reorders components downward with wraparound in move mode', 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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'GammaComponent'); + + setInspectorInput('W'); + $panel->update(); + + setInspectorInput("\033[B"); + $panel->update(); + + expect(inspectorComponentHeaders($panel))->toBe([ + '▼ GammaComponent', + '▼ AlphaComponent', + '▼ BetaComponent', + ]); + expect($panel->consumeHierarchyMutation())->toBe([ + '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\\GammaComponent', 'data' => ['range' => 4]], + ['class' => 'Sendama\\Game\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ], + ], + ]); +}); + +test('inspector panel keeps reordering components while move mode remains active', 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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'AlphaComponent'); + + setInspectorInput('W'); + $panel->update(); + + setInspectorInput("\033[A"); + $panel->update(); + + expect(inspectorComponentHeaders($panel))->toBe([ + '▼ BetaComponent', + '▼ GammaComponent', + '▼ AlphaComponent', + ]); + + setInspectorInput("\033[A", "\033[A"); + $panel->update(); + + expect(inspectorComponentHeaders($panel))->toBe([ + '▼ AlphaComponent', + '▼ BetaComponent', + '▼ GammaComponent', + ]); + expect($panel->consumeHierarchyMutation())->toBe([ + '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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); +}); + +test('inspector panel preserves component move mode across hierarchy syncs', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + ['class' => 'Sendama\\Game\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'AlphaComponent'); + + setInspectorInput('W'); + $panel->update(); + + setInspectorInput("\033[A"); + $panel->update(); + + $firstMutation = $panel->consumeHierarchyMutation(); + expect($firstMutation)->toBe([ + '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\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ['class' => 'Sendama\\Game\\AlphaComponent', 'data' => ['speed' => 2]], + ], + ], + ]); + + $panel->syncHierarchyTarget($firstMutation['path'], $firstMutation['value']); + + setInspectorInput("\033[A", "\033[A"); + $panel->update(); + + expect(inspectorComponentHeaders($panel))->toBe([ + '▼ AlphaComponent', + '▼ BetaComponent', + '▼ GammaComponent', + ]); + expect($panel->consumeHierarchyMutation())->toBe([ + '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\\AlphaComponent', 'data' => ['speed' => 2]], + ['class' => 'Sendama\\Game\\BetaComponent', 'data' => ['power' => 3]], + ['class' => 'Sendama\\Game\\GammaComponent', 'data' => ['range' => 4]], + ], + ], + ]); +}); + +test('inspector panel updates help text for control selection and component move mode', function () { + $panel = new InspectorPanel(width: 64, 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\\AlphaComponent', 'data' => ['speed' => 2]], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'AlphaComponent'); + + $help = new ReflectionProperty(InspectorPanel::class, 'help'); + $modeHelpLabel = new ReflectionProperty(InspectorPanel::class, 'modeHelpLabel'); + $help->setAccessible(true); + $modeHelpLabel->setAccessible(true); + + expect($help->getValue($panel))->toBe('Up/Down select / toggle Shift+A add Shift+W move Del remove'); + expect($modeHelpLabel->getValue($panel))->toBe('Mode: Control Select'); + + setInspectorInput('W'); + $panel->update(); + + expect($help->getValue($panel))->toBe('Up/Down reorder Shift+W done Esc cancel'); + expect($modeHelpLabel->getValue($panel))->toBe('Mode: Component Move'); +}); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 1f96017..e600ef3 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -360,6 +360,123 @@ function createMainPanelWorkspace(): string expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x')))->toBeTrue(); }); +test('main panel restores selected renderable scene objects to their normal visibility on blur', function () { + $workspace = createMainPanelWorkspace(); + $panelWidth = 40; + $panel = new MainPanel( + width: $panelWidth, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $decorateSceneLine = new ReflectionMethod(MainPanel::class, 'decorateSceneLine'); + $decorateSceneLine->setAccessible(true); + $refreshContent = new ReflectionMethod(MainPanel::class, 'refreshContent'); + $refreshContent->setAccessible(true); + + $hasFocus->setValue($panel, true); + $refreshContent->invoke($panel); + + $focusedLine = '|' . str_pad($panel->content[3], $panelWidth - 2) . '|'; + $focusedRenderedLine = $decorateSceneLine->invoke($panel, $focusedLine, null, 3); + + 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); + + expect($blurredRenderedLine)->not->toContain("\033[5;30;46m"); + expect($blurredRenderedLine)->not->toContain("\033[30;46m"); +}); + +test('main panel hides the selected placeholder marker on blur for non-renderable scene objects', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Game Manager', + 'position' => ['x' => 3, 'y' => 2], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $refreshContent = new ReflectionMethod(MainPanel::class, 'refreshContent'); + $refreshContent->setAccessible(true); + + $hasFocus->setValue($panel, true); + pressMainPanelKey('Q'); + $panel->update(); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x')))->toBeTrue(); + + $hasFocus->setValue($panel, false); + $refreshContent->invoke($panel); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x')))->toBeFalse(); +}); + +test('main panel hides the selected placeholder marker on blur when the renderer is disabled', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 3, 'y' => 2], + 'renderer' => ['enabled' => false], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $refreshContent = new ReflectionMethod(MainPanel::class, 'refreshContent'); + $refreshContent->setAccessible(true); + + $hasFocus->setValue($panel, true); + pressMainPanelKey('Q'); + $panel->update(); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x')))->toBeTrue(); + + $hasFocus->setValue($panel, false); + $refreshContent->invoke($panel); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x')))->toBeFalse(); +}); + test('main panel move mode updates the selected scene object position', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel( @@ -643,7 +760,7 @@ function createMainPanelWorkspace(): string expect($spriteGridHeight->getValue($panel))->toBe(16); }); -test('main panel sprite tab can create a new texture asset', function () { +test('main panel sprite create workflow can create a new texture asset', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); @@ -651,9 +768,7 @@ function createMainPanelWorkspace(): string $hasFocus->setValue($panel, true); $panel->selectTab('Sprite'); - - pressMainPanelKey('A'); - $panel->update(); + expect($panel->beginSpriteCreateWorkflow())->toBeTrue(); pressMainPanelKey("\n"); $panel->update(); @@ -667,7 +782,7 @@ function createMainPanelWorkspace(): string expect($assetSyncRequest['inspectionTarget']['value']['relativePath'] ?? null)->toBe('Textures/new-texture-1.texture'); }); -test('main panel sprite tab creates tile maps at the current terminal-size bounds', function () { +test('main panel sprite create workflow creates tile maps at the current terminal-size bounds', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); @@ -675,9 +790,7 @@ function createMainPanelWorkspace(): string $hasFocus->setValue($panel, true); $panel->selectTab('Sprite'); - - pressMainPanelKey('A'); - $panel->update(); + expect($panel->beginSpriteCreateWorkflow())->toBeTrue(); pressMainPanelKey("\033[B"); $panel->update(); @@ -696,6 +809,22 @@ function createMainPanelWorkspace(): string expect(mb_strlen($lines[0] ?? ''))->toBe($expectedWidth); }); +test('main panel sprite tab ignores shift+a during normal focused input', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + + pressMainPanelKey('A'); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect($panel->consumeAssetSyncRequest())->toBeNull(); +}); + test('main panel sprite tab can delete the active asset after confirmation', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); diff --git a/tests/Unit/ProjectNormalizerTest.php b/tests/Unit/ProjectNormalizerTest.php new file mode 100644 index 0000000..093b854 --- /dev/null +++ b/tests/Unit/ProjectNormalizerTest.php @@ -0,0 +1,50 @@ +inspect(); + + expect($issues)->toContain('Missing sendama.json.'); + expect($issues)->toContain('Missing configuration.json.'); + expect($issues)->toContain('Missing config/input.php.'); + expect($issues)->toContain('Missing logs/debug.log.'); + expect($issues)->toContain('Missing logs/error.log.'); + expect($issues)->toContain('Missing Assets/Scenes directory.'); + expect($issues)->toContain('Missing Assets/Scripts directory.'); +}); + +test('project normalizer creates missing structure while respecting legacy lowercase assets roots', function () { + $workspace = sys_get_temp_dir() . '/sendama-project-normalizer-legacy-' . uniqid(); + mkdir($workspace, 0777, true); + mkdir($workspace . '/assets', 0777, true); + + file_put_contents($workspace . '/sendama.json', json_encode([ + 'name' => 'Legacy Test Game', + 'description' => 'Legacy description', + 'version' => '1.2.3', + 'main' => 'legacy.php', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $changes = (new ProjectNormalizer($workspace))->normalize(); + + expect($changes)->toContain('Created configuration.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 . '/config/input.php'))->toBeTrue(); + expect(is_file($workspace . '/logs/debug.log'))->toBeTrue(); + expect(is_file($workspace . '/logs/error.log'))->toBeTrue(); + expect(is_dir($workspace . '/assets/Scenes'))->toBeTrue(); + 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'); +});