The topbar is icons only: the leading Project dropdown, the Scene picker and the labelled Play/Pause buttons are gone and in their place stand five icon buttons - menu, undo, redo on the left, play and pause in the middle - while Notice, the save state, fps and the Account menu keep the right side; the Play button swaps its icon for a stop square while the project runs and Pause swaps for a second play sign while it is held (Button.setIcon()), and iconSpace() drops the gap between icon and label when a button has no label, which is what kept a 24 px icon button 3 px off its own centre; the menu carries New project, Open project..., a separator and Project settings..., where the picker (src/editor/OpenProjectDialog.ts) preselects the open project and marks it "open", opens on a double click and puts Delete on the left of Cancel and Open - it closes itself before it asks whether to delete, because two modals on top of each other would have left the question standing over a list it no longer speaks about - and the settings dialog (src/editor/ProjectSettingsDialog.ts) holds the name, the "Play starts" switch (Start scene / Active scene, a preference of this browser and so no command) and the collision layers from #152, which retires CollisionLayersDialog.ts and the "Collision layers..." entry; what Apply actually writes is the pure function settingsChanges(), so renaming and the layers are each their own undo step and each drop out when nothing differs; the Scene dropdown falls away without replacement, the active scene is switched by double clicking it in the Assets panel, which is the way that was there anyway; new module 46 runs last with 9 checks (no label left in the bar, the run buttons in edit mode, the four menu entries, the picker with the open project preselected, Cancel leaving the project standing, the settings dialog on the state of the project, renaming with undo and redo through the icon buttons) and it neither opens nor deletes a project, because that would pull the project out from under every earlier module; modules 8, 18 and 35 and the harness follow the new bar, 6 unit tests cover settingsChanges and 3 the icons (#183)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
defd4a86d2
commit
60d0146bde
24 files changed
+751
-284
No files matched your search
@@ -40,7 +40,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1138 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1147 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
|
||||
import { Button, HStack, Label, TextField, Theme, VStack } from '../ui';
|
||||
import { FormDialog } from './FormDialog';
|
||||
|
||||
/**
|
||||
* The project's collision layers (#152). Colliders name a layer by its *number*, so a layer taken
|
||||
* out of the middle would silently move every collider below it onto another one; only the last
|
||||
* layer can be removed, and the list is otherwise grown at the end.
|
||||
*/
|
||||
export class CollisionLayersDialog extends FormDialog {
|
||||
private names: string[];
|
||||
private readonly rows = new VStack({ gap: Theme.gap });
|
||||
private readonly addButton: Button;
|
||||
|
||||
constructor(names: readonly string[], onApply: (names: string[]) => void) {
|
||||
super(320, 'Collision layers');
|
||||
this.names = [...names];
|
||||
this.content.add(
|
||||
new Label('Two colliders meet only when each one names the layer of the other.', {
|
||||
style: 'muted',
|
||||
}),
|
||||
);
|
||||
this.content.add(this.rows);
|
||||
this.addButton = new Button('Add layer', { onClick: () => this.addLayer() });
|
||||
this.content.add(this.formRow(this.addButton));
|
||||
this.addButtons('Apply', 'primary', () => onApply(this.names));
|
||||
this.build();
|
||||
}
|
||||
|
||||
/** The names as the fields stand (test hook). */
|
||||
layerNames(): string[] {
|
||||
return [...this.names];
|
||||
}
|
||||
|
||||
private addLayer(): void {
|
||||
if (this.names.length >= MAX_COLLISION_LAYERS) return;
|
||||
this.names.push(layerFallback(this.names.length));
|
||||
this.build();
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
this.rows.removeChildren();
|
||||
for (const [i, name] of this.names.entries()) {
|
||||
const field = new TextField(name, {
|
||||
onChange: (value) => {
|
||||
// An empty name would leave the layer nameless, which `collisionLayersOf` reads back as
|
||||
// its number – so the field shows that number rather than staying blank.
|
||||
const name = String(value).trim() || layerFallback(i);
|
||||
this.names[i] = name;
|
||||
if (name !== String(value)) field.setValue(name);
|
||||
},
|
||||
});
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(this.fieldLabel(String(i)));
|
||||
row.add(field, 1);
|
||||
// Only the last one: removing any other would move every collider below it onto a new layer.
|
||||
if (i > 0 && i === this.names.length - 1)
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => {
|
||||
this.names.pop();
|
||||
this.build();
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.rows.add(row);
|
||||
}
|
||||
this.addButton.setEnabled(this.names.length < MAX_COLLISION_LAYERS);
|
||||
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
|
||||
// which is exactly what happened here.
|
||||
const size = this.measure();
|
||||
this.resize(size.w, size.h);
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -30,7 +30,8 @@ import { SceneViewport } from './viewport/SceneViewport';
|
||||
import { TAB_OF_ASSET } from './assetTabs';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { PasswordDialog } from './auth/PasswordDialog';
|
||||
import type { CollisionLayersDialog } from './CollisionLayersDialog';
|
||||
import type { OpenProjectDialog } from './OpenProjectDialog';
|
||||
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
||||
import { dialogSlot } from './FormDialog';
|
||||
import { ContainerEditor } from './container/ContainerEditor';
|
||||
import { EditorRun } from './EditorRun';
|
||||
@@ -273,8 +274,7 @@ export class Editor implements PlayHost {
|
||||
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
|
||||
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
|
||||
openMainMenu: (anchor) => this.projects.openMainMenu(anchor),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
@@ -461,9 +461,14 @@ export class Editor implements PlayHost {
|
||||
return this.passwordDialog.current();
|
||||
}
|
||||
|
||||
/** The open "Collision layers" dialog (#152), for the check scripts. */
|
||||
collisionLayersDialog(): CollisionLayersDialog | null {
|
||||
return this.projects.collisionLayersDialog();
|
||||
/** The open "Project settings" dialog (#183), for the check scripts. */
|
||||
projectSettingsDialog(): ProjectSettingsDialog | null {
|
||||
return this.projects.projectSettings();
|
||||
}
|
||||
|
||||
/** The open "Open project" dialog (#183), for the check scripts. */
|
||||
projectPickerDialog(): OpenProjectDialog | null {
|
||||
return this.projects.projectPicker();
|
||||
}
|
||||
|
||||
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
|
||||
@@ -755,18 +760,19 @@ export class Editor implements PlayHost {
|
||||
return this.editorRun.debugInfo();
|
||||
}
|
||||
|
||||
/** What the start-scene switch says, and the switch itself – for the check scripts. */
|
||||
playFromInfo(): { mode: PlayFrom; label: string; scene: string } {
|
||||
return this.toolbar.playFromInfo();
|
||||
/** Which scene Play would start, and under which of the two rules – for the check scripts. */
|
||||
playFromInfo(): { mode: PlayFrom; scene: string } {
|
||||
return { mode: this.state.playFrom, scene: this.state.sceneToPlay()?.name ?? '' };
|
||||
}
|
||||
|
||||
get playFromButton(): Widget {
|
||||
return this.toolbar.playFromButton;
|
||||
/** The two run buttons of the top bar (#183): what they show and whether they take a click. */
|
||||
runButtons(): ReturnType<Toolbar['runButtons']> {
|
||||
return this.toolbar.runButtons();
|
||||
}
|
||||
|
||||
/** The scene selector, so a check can reach it – notably while a run is on (test hook). */
|
||||
get sceneButton(): Widget {
|
||||
return this.toolbar.sceneButton;
|
||||
/** The top bar's icon buttons by name, so a check can click what carries no caption. */
|
||||
get toolbarControls(): Toolbar['controls'] {
|
||||
return this.toolbar.controls;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Button, HStack, Label, ListView, Popup, ScrollContainer, Spacer, Theme } from '../ui';
|
||||
|
||||
/** One project as the list shows it. */
|
||||
export interface ProjectChoice {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const LIST_WIDTH = 320;
|
||||
const LIST_HEIGHT = 220;
|
||||
|
||||
/**
|
||||
* The project picker behind "Open project…" (#183): the list the top bar's Project dropdown used
|
||||
* to be, now a dialog, because it has two things to do with a project rather than one.
|
||||
*
|
||||
* Delete closes this dialog and hands the choice on; the caller asks for confirmation. Two modals
|
||||
* stacked over one another would leave the question standing over a list it is no longer about.
|
||||
*/
|
||||
export class OpenProjectDialog extends Popup {
|
||||
private readonly list = new ListView();
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly openButton: Button;
|
||||
private readonly deleteButton: Button;
|
||||
private picked: string | null;
|
||||
private readonly names: string[];
|
||||
|
||||
constructor(
|
||||
projects: readonly ProjectChoice[],
|
||||
currentId: string,
|
||||
actions: { onOpen(id: string): void; onDelete(id: string): void },
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.picked = projects.some((p) => p.id === currentId) ? currentId : (projects[0]?.id ?? null);
|
||||
this.names = projects.map((p) => p.name);
|
||||
|
||||
this.content.add(new Label('Open project', { style: 'bodyBold' }));
|
||||
this.list.minWidth = LIST_WIDTH;
|
||||
this.list.setItems(
|
||||
projects.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
hint: p.id === currentId ? 'open' : undefined,
|
||||
})),
|
||||
);
|
||||
this.list.select(this.picked, true);
|
||||
this.list.onSelect = (id) => {
|
||||
this.picked = id;
|
||||
this.refresh();
|
||||
};
|
||||
// A double click is how an asset is opened everywhere else in the editor, so it is here too.
|
||||
this.list.onActivate = (id) => this.closeThen(() => actions.onOpen(id))();
|
||||
this.scroll.minWidth = LIST_WIDTH;
|
||||
this.scroll.minHeight = LIST_HEIGHT;
|
||||
this.scroll.setContent(this.list);
|
||||
this.content.add(this.scroll);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
this.deleteButton = new Button('Delete', { variant: 'danger' });
|
||||
this.deleteButton.onClick = () => this.withPicked(actions.onDelete);
|
||||
buttons.add(this.deleteButton);
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.openButton = new Button('Open', { variant: 'primary' });
|
||||
this.openButton.onClick = () => this.withPicked(actions.onOpen);
|
||||
buttons.add(this.openButton);
|
||||
this.content.add(buttons);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** What the list shows and which row is picked (test hook). */
|
||||
info(): { names: string[]; picked: string | null } {
|
||||
return { names: [...this.names], picked: this.picked };
|
||||
}
|
||||
|
||||
/** Global centre of a row, so a check can click it. */
|
||||
rowPosition(id: string): { x: number; y: number } | null {
|
||||
return this.list.rowGlobal(id);
|
||||
}
|
||||
|
||||
private withPicked(action: (id: string) => void): void {
|
||||
const id = this.picked;
|
||||
if (id) this.closeThen(() => action(id))();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
this.openButton.setEnabled(this.picked !== null);
|
||||
this.deleteButton.setEnabled(this.picked !== null);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger it.
|
||||
* Loading, creating, switching and deleting projects, plus the top bar's menu that triggers it.
|
||||
*/
|
||||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { SetCollisionLayersCommand } from '../core/commands/projectCommands';
|
||||
import { listScenes } from '../core/model/Project';
|
||||
import { RenameProjectCommand, SetCollisionLayersCommand } from '../core/commands/projectCommands';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { Dialog, Menu, type MenuEntry, type MenuItem, type Widget } from '../ui';
|
||||
import { CollisionLayersDialog } from './CollisionLayersDialog';
|
||||
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
|
||||
import { dialogSlot } from './FormDialog';
|
||||
import { OpenProjectDialog } from './OpenProjectDialog';
|
||||
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export class ProjectSession {
|
||||
private readonly layersDialog = dialogSlot<CollisionLayersDialog>();
|
||||
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
|
||||
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
||||
|
||||
/**
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
||||
@@ -66,65 +66,86 @@ export class ProjectSession {
|
||||
await this.open(created.project.id);
|
||||
}
|
||||
|
||||
private async deleteCurrent(): Promise<void> {
|
||||
this.beforeSwitch();
|
||||
await this.store.delete(this.state.project.id);
|
||||
await this.openFirstOrCreate();
|
||||
private async deleteProject(id: string): Promise<void> {
|
||||
// Only the project being replaced needs the run stopped; deleting another one leaves the
|
||||
// loaded project, and the run with it, exactly where it was.
|
||||
if (id === this.state.project.id) this.beforeSwitch();
|
||||
await this.store.delete(id);
|
||||
if (id === this.state.project.id) await this.openFirstOrCreate();
|
||||
}
|
||||
|
||||
openProjectMenu(anchor: Widget): void {
|
||||
const open = async () => {
|
||||
const list = await this.store.list();
|
||||
const items: MenuEntry[] = [
|
||||
{ label: 'New project', onSelect: () => void this.create(`Project ${list.length + 1}`) },
|
||||
...list.map((p) => ({
|
||||
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
|
||||
onSelect: () => void this.open(p.id),
|
||||
disabled: p.id === this.state.project.id,
|
||||
})),
|
||||
{ separator: true },
|
||||
{ label: 'Collision layers…', onSelect: () => this.openCollisionLayers() },
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Delete this project…',
|
||||
danger: true,
|
||||
onSelect: () =>
|
||||
new Dialog(
|
||||
`Delete project "${this.state.project.name}"?`,
|
||||
'All assets and scenes are removed permanently.',
|
||||
[
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
|
||||
],
|
||||
).openCentered(),
|
||||
},
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
};
|
||||
void open().catch((error) => this.state.setNotice(String(error), 'error'));
|
||||
/** The one menu of the top bar (#183): everything that is about the project rather than a scene. */
|
||||
openMainMenu(anchor: Widget): void {
|
||||
const items: MenuEntry[] = [
|
||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
{ separator: true },
|
||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
}
|
||||
|
||||
/** The project's named collision layers (#152); one dialog at a time, like the other forms. */
|
||||
openCollisionLayers(): CollisionLayersDialog {
|
||||
return this.layersDialog.open(
|
||||
new CollisionLayersDialog(this.state.project.collisionLayers, (names) =>
|
||||
this.state.execute(new SetCollisionLayersCommand(names)),
|
||||
),
|
||||
private async createNext(): Promise<void> {
|
||||
const list = await this.store.list();
|
||||
await this.create(`Project ${list.length + 1}`);
|
||||
}
|
||||
|
||||
/** The project list as a dialog, with Open and Delete on the one picked. */
|
||||
openProjectPicker(): void {
|
||||
void this.run(
|
||||
(async () => {
|
||||
const list = await this.store.list();
|
||||
this.pickerDialog.open(
|
||||
new OpenProjectDialog(list, this.state.project.id, {
|
||||
onOpen: (id) => void this.run(this.open(id)),
|
||||
onDelete: (id) => this.confirmDelete(id, list.find((p) => p.id === id)?.name ?? id),
|
||||
}),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The open layers dialog, for the check scripts. */
|
||||
collisionLayersDialog(): CollisionLayersDialog | null {
|
||||
return this.layersDialog.current();
|
||||
/** The open project picker, for the check scripts. */
|
||||
projectPicker(): OpenProjectDialog | null {
|
||||
return this.pickerDialog.current();
|
||||
}
|
||||
|
||||
openSceneMenu(anchor: Widget): void {
|
||||
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
|
||||
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
|
||||
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
|
||||
}));
|
||||
if (items.length === 0)
|
||||
items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
|
||||
new Menu(items).openBelow(anchor);
|
||||
private confirmDelete(id: string, name: string): void {
|
||||
new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => void this.run(this.deleteProject(id)) },
|
||||
]).openCentered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Name, the scene Play starts and the collision layers (#152) in one dialog (#183). The name and
|
||||
* the layers are undo steps of their own – one each, and only for what actually changed; which
|
||||
* scene Play starts is a preference of this browser, not of the project (see `settings.ts`).
|
||||
*/
|
||||
openProjectSettings(): ProjectSettingsDialog {
|
||||
const { project } = this.state;
|
||||
const before = {
|
||||
name: project.name,
|
||||
playFrom: this.state.playFrom,
|
||||
collisionLayers: [...project.collisionLayers],
|
||||
};
|
||||
return this.settingsDialog.open(
|
||||
new ProjectSettingsDialog(before, (settings) => {
|
||||
const change = settingsChanges(before, settings);
|
||||
if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name));
|
||||
if (change.collisionLayers !== undefined)
|
||||
this.state.execute(new SetCollisionLayersCommand(change.collisionLayers));
|
||||
this.state.setPlayFrom(change.playFrom);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The open settings dialog, for the check scripts. */
|
||||
projectSettings(): ProjectSettingsDialog | null {
|
||||
return this.settingsDialog.current();
|
||||
}
|
||||
|
||||
private run(work: Promise<void>): Promise<void> {
|
||||
return work.catch((error) => this.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
|
||||
import { Button, HStack, Label, Menu, TextField, Theme, VStack } from '../ui';
|
||||
import type { PlayFrom } from './settings';
|
||||
import { FormDialog } from './FormDialog';
|
||||
|
||||
/** What the dialog hands back when Apply is pressed. */
|
||||
export interface ProjectSettings {
|
||||
name: string;
|
||||
playFrom: PlayFrom;
|
||||
collisionLayers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What Apply has to carry out. Only what actually changed, so opening the dialog and closing it
|
||||
* with Apply is not an undo step: a rename and a change of the layers are one command each, while
|
||||
* the start-scene switch is a preference of this browser and no command at all.
|
||||
*
|
||||
* A blank name is not a change but a fallback to the old one – a project without a name would be
|
||||
* unfindable in the picker.
|
||||
*/
|
||||
export function settingsChanges(
|
||||
before: ProjectSettings,
|
||||
after: ProjectSettings,
|
||||
): { name?: string; collisionLayers?: string[]; playFrom: PlayFrom } {
|
||||
const name = after.name.trim() || before.name;
|
||||
return {
|
||||
...(name === before.name ? {} : { name }),
|
||||
...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers)
|
||||
? {}
|
||||
: { collisionLayers: [...after.collisionLayers] }),
|
||||
playFrom: after.playFrom,
|
||||
};
|
||||
}
|
||||
|
||||
const PLAY_FROM_LABEL: Record<PlayFrom, string> = {
|
||||
start: 'Start scene',
|
||||
active: 'Active scene',
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything about the project that is not an asset (#183): its name, which scene Play starts, and
|
||||
* the collision layers (#152). The three used to sit in the top bar and in a menu entry of their
|
||||
* own; the bar carries icons now, so this is the one place they are edited.
|
||||
*
|
||||
* Colliders name a layer by its *number*, so a layer taken out of the middle would silently move
|
||||
* every collider below it onto another one; only the last layer can be removed, and the list is
|
||||
* otherwise grown at the end.
|
||||
*/
|
||||
export class ProjectSettingsDialog extends FormDialog {
|
||||
private projectName: string;
|
||||
private playFrom: PlayFrom;
|
||||
private layers: string[];
|
||||
private readonly nameField: TextField;
|
||||
private readonly playFromButton: Button;
|
||||
private readonly rows = new VStack({ gap: Theme.gap });
|
||||
private readonly addButton: Button;
|
||||
|
||||
constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) {
|
||||
super(340, 'Project settings');
|
||||
this.projectName = settings.name;
|
||||
this.playFrom = settings.playFrom;
|
||||
this.layers = [...settings.collisionLayers];
|
||||
|
||||
this.nameField = new TextField(this.projectName, {
|
||||
placeholder: 'Project name',
|
||||
// Kept as typed, blank included: Apply is what decides, and it falls back to the old name.
|
||||
onChange: (value) => {
|
||||
this.projectName = String(value);
|
||||
},
|
||||
});
|
||||
this.content.add(this.labelledRow('Name', this.nameField));
|
||||
|
||||
this.playFromButton = new Button(PLAY_FROM_LABEL[this.playFrom], {
|
||||
width: 150,
|
||||
align: 'left',
|
||||
onClick: () => this.openPlayFromMenu(),
|
||||
});
|
||||
this.content.add(this.formRow(this.fieldLabel('Play starts'), this.playFromButton));
|
||||
this.content.add(
|
||||
new Label('"Start scene" is the scene marked in Assets, "Active scene" whatever is open.', {
|
||||
style: 'muted',
|
||||
}),
|
||||
);
|
||||
|
||||
this.content.add(new Label('Collision layers', { style: 'bodyBold' }));
|
||||
this.content.add(
|
||||
new Label('Two colliders meet only when each one names the layer of the other.', {
|
||||
style: 'muted',
|
||||
}),
|
||||
);
|
||||
this.content.add(this.rows);
|
||||
this.addButton = new Button('Add layer', { onClick: () => this.addLayer() });
|
||||
this.content.add(this.formRow(this.addButton));
|
||||
|
||||
this.addButtons('Apply', 'primary', () =>
|
||||
onApply({
|
||||
name: this.projectName,
|
||||
playFrom: this.playFrom,
|
||||
collisionLayers: this.layers,
|
||||
}),
|
||||
);
|
||||
this.buildLayers();
|
||||
}
|
||||
|
||||
/** The names as the fields stand (test hook). */
|
||||
layerNames(): string[] {
|
||||
return [...this.layers];
|
||||
}
|
||||
|
||||
/** Name and start-scene switch as they stand (test hook). */
|
||||
values(): { name: string; playFrom: PlayFrom } {
|
||||
return { name: this.projectName, playFrom: this.playFrom };
|
||||
}
|
||||
|
||||
/** The start-scene switch itself, so a check can click it. */
|
||||
get playFromControl(): Button {
|
||||
return this.playFromButton;
|
||||
}
|
||||
|
||||
/** A label and a field that takes the rest of the row – the layer rows are built the same way. */
|
||||
private labelledRow(text: string, field: TextField): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(this.fieldLabel(text));
|
||||
row.add(field, 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
private openPlayFromMenu(): void {
|
||||
const entry = (from: PlayFrom) => ({
|
||||
label: this.playFrom === from ? `● ${PLAY_FROM_LABEL[from]}` : PLAY_FROM_LABEL[from],
|
||||
onSelect: () => {
|
||||
this.playFrom = from;
|
||||
this.playFromButton.setText(PLAY_FROM_LABEL[from]);
|
||||
},
|
||||
});
|
||||
new Menu([entry('start'), entry('active')]).openBelow(this.playFromButton);
|
||||
}
|
||||
|
||||
private addLayer(): void {
|
||||
if (this.layers.length >= MAX_COLLISION_LAYERS) return;
|
||||
this.layers.push(layerFallback(this.layers.length));
|
||||
this.buildLayers();
|
||||
}
|
||||
|
||||
private buildLayers(): void {
|
||||
this.rows.removeChildren();
|
||||
for (const [i, name] of this.layers.entries()) {
|
||||
const field = new TextField(name, {
|
||||
onChange: (value) => {
|
||||
// An empty name would leave the layer nameless, which `collisionLayersOf` reads back as
|
||||
// its number – so the field shows that number rather than staying blank.
|
||||
const name = String(value).trim() || layerFallback(i);
|
||||
this.layers[i] = name;
|
||||
if (name !== String(value)) field.setValue(name);
|
||||
},
|
||||
});
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(this.fieldLabel(String(i)));
|
||||
row.add(field, 1);
|
||||
// Only the last one: removing any other would move every collider below it onto a new layer.
|
||||
if (i > 0 && i === this.layers.length - 1)
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => {
|
||||
this.layers.pop();
|
||||
this.buildLayers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.rows.add(row);
|
||||
}
|
||||
this.addButton.setEnabled(this.layers.length < MAX_COLLISION_LAYERS);
|
||||
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
|
||||
// which is exactly what happened here.
|
||||
const size = this.measure();
|
||||
this.resize(size.w, size.h);
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -209,8 +209,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
|
||||
collisionLayers: () => ({
|
||||
project: [...state.project.collisionLayers],
|
||||
dialog: editor.collisionLayersDialog()?.layerNames() ?? null,
|
||||
dialog: editor.projectSettingsDialog()?.layerNames() ?? null,
|
||||
}),
|
||||
/** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */
|
||||
projectSettings: () => editor.projectSettingsDialog()?.values() ?? null,
|
||||
/** Where to click the start-scene switch inside that dialog. */
|
||||
projectSettingsPlayFromAt: () => {
|
||||
const dialog = editor.projectSettingsDialog();
|
||||
return dialog ? centreOf(dialog.playFromControl) : null;
|
||||
},
|
||||
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
|
||||
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
|
||||
/** Where to click a project's row in that dialog. */
|
||||
projectPickerRowAt: (id: string) => editor.projectPickerDialog()?.rowPosition(id) ?? null,
|
||||
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
||||
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
||||
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
||||
@@ -594,11 +605,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
playInfo: () => editor.playInfo(),
|
||||
/** The start-scene switch: what it says, and where to click it (its caption is elided). */
|
||||
/** Which scene Play would start, and under which of the two rules (#183: set in the settings). */
|
||||
playFrom: () => editor.playFromInfo(),
|
||||
playFromAt: () => centreOf(editor.playFromButton),
|
||||
/** The scene selector: where it is, and whether it takes a click (it does, play mode or not). */
|
||||
sceneSelect: () => ({ ...centreOf(editor.sceneButton), enabled: editor.sceneButton.enabled }),
|
||||
/** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */
|
||||
runButtons: () => editor.runButtons(),
|
||||
/** Where to click one of the five icon buttons: menu, undo, redo, play, pause. */
|
||||
toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]),
|
||||
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
||||
editorRun: () => editor.editorRunInfo(),
|
||||
physics: () => physicsStatus(),
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { RenameProjectCommand } from '../../core/commands/projectCommands';
|
||||
import { listScenes } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
|
||||
import type { PlayFrom } from '../settings';
|
||||
import { Button, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui';
|
||||
import type { EditorState, SaveStatus } from '../state';
|
||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||
|
||||
/** What the toolbar buttons trigger; the editor supplies the implementations. */
|
||||
export interface ToolbarActions {
|
||||
/** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */
|
||||
openProjectMenu(anchor: Widget): void;
|
||||
openSceneMenu(anchor: Widget): void;
|
||||
/** The one menu (#183): new project, the project picker, the project settings. */
|
||||
openMainMenu(anchor: Widget): void;
|
||||
/** Play in edit mode, Stop in play mode. */
|
||||
togglePlay(): void;
|
||||
/** Hold or resume the running world; only reachable in play mode. */
|
||||
@@ -26,19 +23,38 @@ const SAVE_LABELS: Record<SaveStatus, string> = {
|
||||
conflict: 'Conflict – reload',
|
||||
};
|
||||
|
||||
/** Top bar: project, scene selector, undo/redo, Play/Stop, save status. */
|
||||
/** Square, because an icon has no reading direction that would ask for a wider box. */
|
||||
const ICON_BUTTON = Theme.controlHeight;
|
||||
|
||||
type IconName = keyof typeof TOOLBAR_ICON_SVG;
|
||||
|
||||
/**
|
||||
* An icon button. The caption stays empty, so the icon sits in the middle of the square rather
|
||||
* than beside the gap a label would need.
|
||||
*/
|
||||
function iconButton(name: IconName, onClick: () => void): Button {
|
||||
return new Button('', { width: ICON_BUTTON, icon: TOOLBAR_ICON_SVG[name], onClick });
|
||||
}
|
||||
|
||||
/**
|
||||
* Top bar (#183): menu, undo and redo on the left, play and pause in the middle, the account on
|
||||
* the right – icons, no captions. What it used to spell out lives in the menu now: the project
|
||||
* name and the start-scene switch in Project settings, the project list in Open project, and the
|
||||
* scene is picked where the scenes are, in the Assets panel.
|
||||
*/
|
||||
export class Toolbar extends HStack {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly projectName: TextField;
|
||||
private readonly sceneSelect: Dropdown;
|
||||
private readonly menuButton: Button;
|
||||
private readonly undoButton: Button;
|
||||
private readonly redoButton: Button;
|
||||
private readonly playFrom: Dropdown;
|
||||
private readonly playButton: Button;
|
||||
private readonly pauseButton: Button;
|
||||
private readonly status: Label;
|
||||
private readonly notice: Label;
|
||||
private readonly fps: Label;
|
||||
/** The picture each run button currently wears; `refresh` swaps them with the mode. */
|
||||
private playIcon: IconName = 'play';
|
||||
private pauseIcon: IconName = 'pause';
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
@@ -48,49 +64,19 @@ export class Toolbar extends HStack {
|
||||
this.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
this.addChildAt(this.bg, 0);
|
||||
|
||||
const projectButton = new Dropdown('Project', { onClick: () => actions.openProjectMenu(projectButton) });
|
||||
this.add(projectButton);
|
||||
this.projectName = new TextField('', {
|
||||
width: 180,
|
||||
placeholder: 'Project name',
|
||||
onChange: (name) => {
|
||||
if (name.trim()) state.execute(new RenameProjectCommand(name.trim()));
|
||||
else this.projectName.setValue(state.project.name);
|
||||
},
|
||||
});
|
||||
this.add(this.projectName);
|
||||
this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton));
|
||||
this.add(this.menuButton);
|
||||
this.add(new Spacer(Theme.padding));
|
||||
|
||||
this.add(new Label('Scene', { style: 'muted' }));
|
||||
this.sceneSelect = new Dropdown('—', {
|
||||
width: 160,
|
||||
onClick: () => actions.openSceneMenu(this.sceneSelect),
|
||||
});
|
||||
this.add(this.sceneSelect);
|
||||
this.add(new Spacer(Theme.padding));
|
||||
|
||||
this.undoButton = new Button('Undo', { onClick: () => state.undo() });
|
||||
this.redoButton = new Button('Redo', { onClick: () => state.redo() });
|
||||
this.undoButton = iconButton('undo', () => state.undo());
|
||||
this.redoButton = iconButton('redo', () => state.redo());
|
||||
this.add(this.undoButton);
|
||||
this.add(this.redoButton);
|
||||
|
||||
this.add(new Spacer(), 1);
|
||||
// Next to Play, because it answers a question about Play: which scene does this start?
|
||||
this.playFrom = new Dropdown('—', {
|
||||
width: 130,
|
||||
onClick: () => this.openPlayFromMenu(),
|
||||
});
|
||||
this.add(this.playFrom);
|
||||
this.playButton = new Button('▶ Play', {
|
||||
variant: 'primary',
|
||||
width: 110,
|
||||
onClick: () => actions.togglePlay(),
|
||||
});
|
||||
this.playButton = iconButton('play', () => actions.togglePlay());
|
||||
this.playButton.variant = 'primary';
|
||||
this.add(this.playButton);
|
||||
this.pauseButton = new Button('❚❚ Pause', {
|
||||
width: 110,
|
||||
onClick: () => actions.togglePause(),
|
||||
});
|
||||
this.pauseButton = iconButton('pause', () => actions.togglePause());
|
||||
this.add(this.pauseButton);
|
||||
this.add(new Spacer(), 1);
|
||||
|
||||
@@ -117,31 +103,25 @@ export class Toolbar extends HStack {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The start-scene switch for the check scripts: what it says, and where to click it. */
|
||||
playFromInfo(): { mode: PlayFrom; label: string; scene: string } {
|
||||
/** What the two run buttons show right now (test hook) – they carry no text to read. */
|
||||
runButtons(): { play: IconName; pause: IconName; playEnabled: boolean; pauseEnabled: boolean } {
|
||||
return {
|
||||
mode: this.state.playFrom,
|
||||
label: this.playFrom.text,
|
||||
scene: this.state.sceneToPlay()?.name ?? '',
|
||||
play: this.playIcon,
|
||||
pause: this.pauseIcon,
|
||||
playEnabled: this.playButton.enabled,
|
||||
pauseEnabled: this.pauseButton.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** The switch itself, so a check can open it without hunting for its (elided) caption. */
|
||||
get playFromButton(): Dropdown {
|
||||
return this.playFrom;
|
||||
}
|
||||
|
||||
/** The scene selector, for the check scripts – its caption is a scene name, not a fixed label. */
|
||||
get sceneButton(): Dropdown {
|
||||
return this.sceneSelect;
|
||||
}
|
||||
|
||||
private openPlayFromMenu(): void {
|
||||
const mark = (from: PlayFrom, text: string) => (this.state.playFrom === from ? `● ${text}` : text);
|
||||
new Menu([
|
||||
{ label: mark('start', 'Start scene'), onSelect: () => this.state.setPlayFrom('start') },
|
||||
{ label: mark('active', 'Active scene'), onSelect: () => this.state.setPlayFrom('active') },
|
||||
]).openBelow(this.playFrom);
|
||||
/** The five icon buttons, so a check can click what no longer carries a caption. */
|
||||
get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> {
|
||||
return {
|
||||
menu: this.menuButton,
|
||||
undo: this.undoButton,
|
||||
redo: this.redoButton,
|
||||
play: this.playButton,
|
||||
pause: this.pauseButton,
|
||||
};
|
||||
}
|
||||
|
||||
/** Frame rate readout; `null` while no measurement is available yet. */
|
||||
@@ -151,31 +131,19 @@ export class Toolbar extends HStack {
|
||||
|
||||
private refresh(): void {
|
||||
const { state } = this;
|
||||
this.projectName.setValue(state.project.name);
|
||||
const scene = state.activeScene;
|
||||
const scenes = listScenes(state.project);
|
||||
this.sceneSelect.setText(scene ? scene.name : scenes.length ? 'Select scene' : 'No scene');
|
||||
const editing = state.mode === 'edit';
|
||||
const from = state.sceneToPlay();
|
||||
// The mode, worded exactly like the menu entry it came from – a caption that reads differently
|
||||
// from the thing you picked makes you check twice whether you picked the right one.
|
||||
this.playFrom.setText(state.playFrom === 'active' ? 'Active scene' : 'Start scene');
|
||||
this.playFrom.setEnabled(editing);
|
||||
// Undo/redo work in play mode too – on the runtime copy's own history.
|
||||
this.undoButton.setEnabled(state.commands.canUndo);
|
||||
this.redoButton.setEnabled(state.commands.canRedo);
|
||||
this.playButton.setText(editing ? '▶ Play' : '■ Stop');
|
||||
this.playIcon = editing ? 'play' : 'stop';
|
||||
this.playButton.setIcon(TOOLBAR_ICON_SVG[this.playIcon]);
|
||||
this.playButton.variant = editing ? 'primary' : 'danger';
|
||||
this.playButton.setEnabled(from !== undefined);
|
||||
this.playButton.setEnabled(state.sceneToPlay() !== undefined);
|
||||
this.playButton.invalidate();
|
||||
this.pauseButton.setText(state.paused ? '▶ Resume' : '❚❚ Pause');
|
||||
this.pauseIcon = state.paused ? 'play' : 'pause';
|
||||
this.pauseButton.setIcon(TOOLBAR_ICON_SVG[this.pauseIcon]);
|
||||
this.pauseButton.setEnabled(!editing);
|
||||
this.pauseButton.invalidate();
|
||||
this.projectName.setEnabled(editing);
|
||||
// Stays usable in play mode: it only says which scene the editor looks at, which is the same
|
||||
// thing a double-click in the Assets panel does – and that one was never blocked. The run is
|
||||
// not touched by it; a running scene is swapped by `Load scene`, not from here.
|
||||
this.sceneSelect.setEnabled(true);
|
||||
this.status.setText(SAVE_LABELS[state.saveStatus]);
|
||||
this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- The one menu of the top bar (#183): three bars, the sign every application uses for it. -->
|
||||
<rect x="-6" y="-5.25" width="12" height="1.75" rx="0.5" fill="currentColor" />
|
||||
<rect x="-6" y="-0.875" width="12" height="1.75" rx="0.5" fill="currentColor" />
|
||||
<rect x="-6" y="3.5" width="12" height="1.75" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-5" y="-6" width="3.5" height="12" rx="0.5" fill="currentColor" />
|
||||
<rect x="1.5" y="-6" width="3.5" height="12" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-4.5 -6 L6 0 L-4.5 6 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 152 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- The mirror of undo.svg, every x negated. -->
|
||||
<path d="M3.5 -2 C1.5 -5.5, -4 -5.5, -5 -1.5 C-5.8 1.5, -4.5 4.3, -1.5 5.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M7.2 -4.6 L0.8 -4.6 L4 1.2 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-5.5" y="-5.5" width="11" height="11" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- An arrow bending back on itself. Cubic curves, not arcs: Pixi's parser draws an A in the
|
||||
middle of a path as a spike (see plugins.svg). -->
|
||||
<path d="M-3.5 -2 C-1.5 -5.5, 4 -5.5, 5 -1.5 C5.8 1.5, 4.5 4.3, 1.5 5.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M-7.2 -4.6 L-0.8 -4.6 L-4 1.2 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The top bar's icons (#183). It carries no captions any more: menu, undo and redo on the left,
|
||||
* play and pause in the middle, so each button is its picture and nothing else.
|
||||
*/
|
||||
import menu from './icons/menu.svg?raw';
|
||||
import pause from './icons/pause.svg?raw';
|
||||
import play from './icons/play.svg?raw';
|
||||
import redo from './icons/redo.svg?raw';
|
||||
import stop from './icons/stop.svg?raw';
|
||||
import undo from './icons/undo.svg?raw';
|
||||
|
||||
export const TOOLBAR_ICON_SVG = { menu, undo, redo, play, pause, stop };
|
||||
@@ -42,7 +42,7 @@ export class Button extends Widget {
|
||||
protected readonly padding: number;
|
||||
private readonly style: TextStyleToken;
|
||||
private readonly toggle: boolean;
|
||||
private readonly icon: string | null;
|
||||
private icon: string | null;
|
||||
private readonly iconGfx = new Graphics();
|
||||
/** Colour the icon was last drawn in; it follows the label from muted to the accent text. */
|
||||
private iconColor = -1;
|
||||
@@ -104,6 +104,17 @@ export class Button extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the picture – play for stop (#183). Only for a button that was built with one: an icon
|
||||
* added later would change the measured width, and the row around it has already been laid out.
|
||||
*/
|
||||
setIcon(svg: string): void {
|
||||
if (!this.icon || svg === this.icon) return;
|
||||
this.icon = svg;
|
||||
this.iconColor = -1; // forces the redraw below to re-tint and re-trace the new file
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
setToggled(toggled: boolean): void {
|
||||
if (this.toggled === toggled) return;
|
||||
this.toggled = toggled;
|
||||
@@ -142,9 +153,14 @@ export class Button extends Widget {
|
||||
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h };
|
||||
}
|
||||
|
||||
/** Room the icon takes in front of the label, gap included; 0 without one. */
|
||||
/**
|
||||
* Room the icon takes in front of the label, gap included; 0 without one. An icon button without
|
||||
* a caption (#183) drops the gap – it is what stands between the icon and the label, and with no
|
||||
* label to keep clear of it would push the icon off centre.
|
||||
*/
|
||||
protected iconSpace(): number {
|
||||
return this.icon ? ICON_SIZE + ICON_GAP : 0;
|
||||
if (!this.icon) return 0;
|
||||
return this.fullText ? ICON_SIZE + ICON_GAP : ICON_SIZE;
|
||||
}
|
||||
|
||||
/** Whether the label is drawn in a bold weight (test hook). */
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* What "Apply" in the Project settings dialog (#183) actually carries out. The dialog itself is a
|
||||
* widget and needs a canvas; the rule behind it is a pure function and is tested here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { settingsChanges, type ProjectSettings } from '../../src/editor/ProjectSettingsDialog';
|
||||
|
||||
const before: ProjectSettings = {
|
||||
name: 'Demo project',
|
||||
playFrom: 'start',
|
||||
collisionLayers: ['Default', 'Enemies'],
|
||||
};
|
||||
|
||||
describe('project settings', () => {
|
||||
it('reports nothing to do when nothing was touched', () => {
|
||||
expect(settingsChanges(before, { ...before, collisionLayers: ['Default', 'Enemies'] })).toEqual({
|
||||
playFrom: 'start',
|
||||
});
|
||||
});
|
||||
|
||||
it('renames only when the name really differs', () => {
|
||||
expect(settingsChanges(before, { ...before, name: 'Tetris' }).name).toBe('Tetris');
|
||||
expect(settingsChanges(before, { ...before, name: 'Demo project' }).name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims the name and keeps the old one when only blanks are left', () => {
|
||||
expect(settingsChanges(before, { ...before, name: ' Tetris ' }).name).toBe('Tetris');
|
||||
expect(settingsChanges(before, { ...before, name: ' ' }).name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes the layers when one was added, renamed or dropped', () => {
|
||||
const changed = (layers: string[]) => settingsChanges(before, { ...before, collisionLayers: layers });
|
||||
expect(changed(['Default', 'Enemies', 'Pickups']).collisionLayers).toEqual([
|
||||
'Default',
|
||||
'Enemies',
|
||||
'Pickups',
|
||||
]);
|
||||
expect(changed(['Default', 'Foes']).collisionLayers).toEqual(['Default', 'Foes']);
|
||||
expect(changed(['Default']).collisionLayers).toEqual(['Default']);
|
||||
});
|
||||
|
||||
it('hands back a copy of the layers, not the array the dialog goes on editing', () => {
|
||||
const edited = ['Default', 'Foes'];
|
||||
const change = settingsChanges(before, { ...before, collisionLayers: edited });
|
||||
edited.push('Later');
|
||||
expect(change.collisionLayers).toEqual(['Default', 'Foes']);
|
||||
});
|
||||
|
||||
it('always reports the start-scene switch – it is a preference, not an undo step', () => {
|
||||
expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active');
|
||||
expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBe('start');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LIST_ICON_SVG,
|
||||
PLUGINS_ICON_SVG,
|
||||
} from '../../src/editor/panels/assetIcons';
|
||||
import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons';
|
||||
import { tintSvg } from '../../src/ui/svg';
|
||||
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
|
||||
|
||||
@@ -39,8 +40,30 @@ describe('icon files', () => {
|
||||
['list tab', LIST_ICON_SVG],
|
||||
['game tab', GAME_ICON_SVG],
|
||||
['plugins tab', PLUGINS_ICON_SVG],
|
||||
...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]),
|
||||
];
|
||||
|
||||
it('has one icon per button of the top bar, each its own picture (#183)', () => {
|
||||
expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']);
|
||||
expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6);
|
||||
});
|
||||
|
||||
// The bar carries no captions any more, so a picture shared with a tab or an asset type would be
|
||||
// the only thing left telling the two apart – and it would not.
|
||||
it('draws the top bar in pictures of its own, none of them an asset icon (#183)', () => {
|
||||
const others = [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG];
|
||||
for (const svg of Object.values(TOOLBAR_ICON_SVG)) expect(others).not.toContain(svg);
|
||||
});
|
||||
|
||||
// Pixi's parser draws an A in the middle of a path as a spike (the puzzle piece of #119); undo
|
||||
// and redo are the arcs of this set, so they are the ones that have to keep clear of it.
|
||||
it('bends the top bar arrows with cubic curves rather than arcs (#183)', () => {
|
||||
for (const svg of [TOOLBAR_ICON_SVG.undo, TOOLBAR_ICON_SVG.redo]) {
|
||||
expect(svg).toContain('C');
|
||||
for (const { attrs } of tags(svg).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/);
|
||||
}
|
||||
});
|
||||
|
||||
// Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
|
||||
// shows the file right elsewhere – centred on the origin, it matches what the editor draws.
|
||||
it.each(icons)('%s is centred on its origin', (_, svg) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function colliders(t) {
|
||||
click,
|
||||
doubleClick,
|
||||
clickText,
|
||||
clickToolbar,
|
||||
locate,
|
||||
press,
|
||||
typeText,
|
||||
@@ -135,14 +136,15 @@ export async function colliders(t) {
|
||||
await shot('22-trigger-collider');
|
||||
|
||||
// 8d. Collision layers (#152). The project starts with the one layer every collider is on; a
|
||||
// second one is added and named in the dialog behind Project → "Collision layers…".
|
||||
await clickText('Project');
|
||||
// second one is added and named in the Project settings dialog, which is where the layers
|
||||
// moved with the top bar's rework (#183) – menu button → "Project settings…".
|
||||
await clickToolbar('menu');
|
||||
check(
|
||||
'the Project menu offers the collision layers',
|
||||
(await json('__gm.menuRows()')).includes('Collision layers…'),
|
||||
'the top bar menu offers the project settings',
|
||||
(await json('__gm.menuRows()')).includes('Project settings…'),
|
||||
JSON.stringify(await json('__gm.menuRows()')),
|
||||
);
|
||||
await clickText('Collision layers…');
|
||||
await clickText('Project settings…');
|
||||
const opened = await json('__gm.collisionLayers()');
|
||||
check(
|
||||
'the dialog opens on the one layer a project starts with',
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function pause(t) {
|
||||
has,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
clickToolbar,
|
||||
press,
|
||||
entity,
|
||||
world,
|
||||
@@ -78,7 +78,7 @@ export async function pause(t) {
|
||||
|
||||
// 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
|
||||
// ticking while the picture happens to look the same would pass a position check alone.
|
||||
await clickText('❚❚ Pause');
|
||||
await clickToolbar('pause');
|
||||
const held = await playInfo();
|
||||
const heldX = await boxX();
|
||||
await sleep(1000);
|
||||
@@ -91,12 +91,14 @@ export async function pause(t) {
|
||||
Math.abs(stillHeld.time - held.time) < 0.05,
|
||||
`x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`,
|
||||
);
|
||||
// `locate` throws on a missing label, so the button that must be *gone* is asked for through
|
||||
// the hook, which answers with null.
|
||||
// Since #183 the buttons carry no caption, so what they offer is read as the picture each one
|
||||
// wears: while the run is held, the pause button shows a second play sign, and the play button
|
||||
// has turned into the stop square for as long as the run is on.
|
||||
const heldButtons = await json('__gm.runButtons()');
|
||||
check(
|
||||
'the button offers to resume while it is held, and no longer to pause',
|
||||
(await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false,
|
||||
`resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`,
|
||||
heldButtons.pause === 'play' && heldButtons.play === 'stop' && heldButtons.pauseEnabled === true,
|
||||
JSON.stringify(heldButtons),
|
||||
);
|
||||
await shot('36-paused');
|
||||
|
||||
@@ -261,7 +263,7 @@ export async function pause(t) {
|
||||
|
||||
await sleep(400);
|
||||
const runningClock = await clock();
|
||||
await clickText('❚❚ Pause');
|
||||
await clickToolbar('pause');
|
||||
const heldClock = await clock();
|
||||
await sleep(800);
|
||||
const stillHeldClock = await clock();
|
||||
|
||||
+28
-27
@@ -42,8 +42,9 @@ export async function loadScene(t) {
|
||||
evaluate,
|
||||
mutate,
|
||||
click,
|
||||
doubleClick,
|
||||
clickText,
|
||||
locate,
|
||||
clickToolbar,
|
||||
press,
|
||||
play,
|
||||
stop,
|
||||
@@ -53,10 +54,19 @@ export async function loadScene(t) {
|
||||
} = t;
|
||||
const playInfo = () => json('__gm.playInfo()');
|
||||
const playFrom = () => json('__gm.playFrom()');
|
||||
/** The switch is opened by position: its caption is elided at the width it has. */
|
||||
const openPlayFrom = async () => {
|
||||
const at = await json('__gm.playFromAt()');
|
||||
/**
|
||||
* The start-scene switch sits in the Project settings dialog since the top bar became icons
|
||||
* (#183): menu button → "Project settings…" → the switch → the mode → Apply.
|
||||
*/
|
||||
const setPlayFrom = async (mode) => {
|
||||
await clickToolbar('menu');
|
||||
await clickText('Project settings…');
|
||||
const at = await json('__gm.projectSettingsPlayFromAt()');
|
||||
await click(at.x, at.y);
|
||||
await clickText(mode);
|
||||
const chosen = await json('__gm.projectSettings()');
|
||||
await clickText('Apply');
|
||||
return chosen;
|
||||
};
|
||||
const stats = () => json('__gm.sound.stats()');
|
||||
const waitFor = async (read, done, tries = 25) => {
|
||||
@@ -120,9 +130,7 @@ export async function loadScene(t) {
|
||||
'with no start scene named, Play starts from the active one',
|
||||
(await json('__gm.state.project.startSceneId')) === '' &&
|
||||
(await playFrom()).mode === 'start' &&
|
||||
(await playFrom()).scene === firstName &&
|
||||
// The caption is the mode, worded like the menu entry it came from.
|
||||
(await playFrom()).label === 'Start scene',
|
||||
(await playFrom()).scene === firstName,
|
||||
JSON.stringify(await playFrom()),
|
||||
);
|
||||
|
||||
@@ -163,24 +171,18 @@ export async function loadScene(t) {
|
||||
JSON.stringify(started),
|
||||
);
|
||||
|
||||
// 35d². The scene selector keeps working while the run is on. It only moves the editor's view,
|
||||
// which a double-click on a scene in the Assets panel has always done – disabling the one
|
||||
// and not the other was the inconsistency. The run itself is untouched by it.
|
||||
const select = await json('__gm.sceneSelect()');
|
||||
await click(select.x, select.y);
|
||||
const rows = await json('__gm.menuRows()');
|
||||
// The topmost hit, because the scene's name is on its row in the Assets panel as well.
|
||||
const entry = await locate('Level 2', true);
|
||||
await click(entry.x, entry.y);
|
||||
// 35d². Switching the scene the editor shows keeps working while the run is on. Since the top
|
||||
// bar lost its scene selector (#183) the Assets panel is the one way there, and it was
|
||||
// never blocked in play mode; the run itself is untouched by it.
|
||||
const sceneRow = await json('__gm.assetRow("ls-scene")');
|
||||
await doubleClick(sceneRow.x, sceneRow.y);
|
||||
const playing = await playInfo();
|
||||
check(
|
||||
'the scene selector stays usable in play mode, and the run runs on',
|
||||
select.enabled === true &&
|
||||
rows.some((row) => (row ?? '').includes('Level 2')) &&
|
||||
(await json('__gm.state.project.activeSceneId')) === 'ls-scene' &&
|
||||
'a scene opened from the Assets panel moves the editor in play mode, and the run runs on',
|
||||
(await json('__gm.state.project.activeSceneId')) === 'ls-scene' &&
|
||||
playing.playing === true &&
|
||||
playing.sceneId === 'ls-scene',
|
||||
JSON.stringify({ select, rows, playing }),
|
||||
JSON.stringify({ sceneRow, playing }),
|
||||
);
|
||||
|
||||
// 35e. The swap itself. The score was counted in the old scene; a fresh run would show 0.
|
||||
@@ -219,21 +221,20 @@ export async function loadScene(t) {
|
||||
|
||||
// 35f. The switch is the way to run what you are working on instead – and it is remembered
|
||||
// between runs, because it says how you work rather than what the game does.
|
||||
await openPlayFrom();
|
||||
await clickText('Active scene');
|
||||
const chosen = await setPlayFrom('Active scene');
|
||||
await play();
|
||||
const fromActive = await playInfo();
|
||||
check(
|
||||
'switched to "active scene", Play runs the scene the editor shows',
|
||||
fromActive.sceneId === firstId &&
|
||||
(await playFrom()).mode === 'active' &&
|
||||
(await playFrom()).label === 'Active scene' &&
|
||||
// The dialog held the new mode before Apply, and Apply left the start scene alone.
|
||||
chosen.playFrom === 'active' &&
|
||||
(await json('__gm.state.project.startSceneId')) === 'ls-scene',
|
||||
JSON.stringify({ fromActive, switch: await playFrom() }),
|
||||
JSON.stringify({ fromActive, chosen, switch: await playFrom() }),
|
||||
);
|
||||
await stop();
|
||||
await openPlayFrom();
|
||||
await clickText('Start scene');
|
||||
await setPlayFrom('Start scene');
|
||||
check(
|
||||
'the switch goes back to the start scene',
|
||||
(await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The top bar after its rework (#183): five icon buttons and no captions at all. What it used to
|
||||
* spell out moved into the one menu – the project list into "Open project…", the project name and
|
||||
* the start-scene switch into "Project settings…" next to the collision layers.
|
||||
*
|
||||
* Runs last, and on purpose: the project picker is opened here and left again with Cancel. Opening
|
||||
* or deleting a project for real would pull the project out from under every step before this one.
|
||||
*/
|
||||
export async function topbar(t) {
|
||||
const { check, json, has, shot, clickText, clickToolbar, typeText, press, sleep, waitUntil } = t;
|
||||
|
||||
await press('Escape');
|
||||
|
||||
// 46a. Nothing in the bar is written any more. The five words it used to carry are asked for as
|
||||
// drawn text: the buttons are still there, only their captions are gone.
|
||||
const captions = {
|
||||
project: await has('Project'),
|
||||
undo: await has('Undo'),
|
||||
redo: await has('Redo'),
|
||||
play: await has('Play'),
|
||||
pause: await has('Pause'),
|
||||
};
|
||||
check(
|
||||
'the top bar carries no captions any more – menu, undo, redo, play and pause are icons',
|
||||
Object.values(captions).every((shown) => shown === false),
|
||||
JSON.stringify(captions),
|
||||
);
|
||||
const buttons = await json('__gm.runButtons()');
|
||||
check(
|
||||
'in edit mode the run buttons offer play and nothing to pause',
|
||||
buttons.play === 'play' &&
|
||||
buttons.pause === 'pause' &&
|
||||
buttons.playEnabled === true &&
|
||||
buttons.pauseEnabled === false,
|
||||
JSON.stringify(buttons),
|
||||
);
|
||||
await shot('46-topbar');
|
||||
|
||||
// 46b. The one menu. Four entries, and the separator between what opens a project and what
|
||||
// changes the one that is open.
|
||||
await clickToolbar('menu');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
check(
|
||||
'the menu button opens new project, the project picker and the project settings',
|
||||
JSON.stringify(rows) === JSON.stringify(['New project', 'Open project…', null, 'Project settings…']),
|
||||
JSON.stringify(rows),
|
||||
);
|
||||
|
||||
// 46c. The picker lists what the account owns, with the open project picked, and Cancel leaves
|
||||
// that project exactly where it was.
|
||||
await clickText('Open project…');
|
||||
// The picker fetches the list before it can open, so it is waited for rather than read at once.
|
||||
await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' });
|
||||
const picker = await json('__gm.projectPicker()');
|
||||
const projectId = await json('__gm.state.project.id');
|
||||
const projectName = await json('__gm.state.project.name');
|
||||
check(
|
||||
'"Open project…" lists the projects with the open one picked',
|
||||
picker !== null && picker.picked === projectId && picker.names.includes(projectName),
|
||||
JSON.stringify({ picker, projectId, projectName }),
|
||||
);
|
||||
await clickText('Cancel');
|
||||
check(
|
||||
'Cancel closes the picker and leaves the project loaded',
|
||||
(await json('__gm.projectPicker()')) === null && (await json('__gm.state.project.id')) === projectId,
|
||||
JSON.stringify({ picker: await json('__gm.projectPicker()'), id: await json('__gm.state.project.id') }),
|
||||
);
|
||||
|
||||
// 46d. The settings dialog holds the name, the start-scene switch and the collision layers of
|
||||
// #152 – the three things the bar itself used to carry.
|
||||
await clickToolbar('menu');
|
||||
await clickText('Project settings…');
|
||||
const settings = await json('__gm.projectSettings()');
|
||||
const layers = await json('__gm.collisionLayers()');
|
||||
check(
|
||||
'"Project settings…" opens on the project as it stands',
|
||||
settings?.name === projectName &&
|
||||
(settings.playFrom === 'start' || settings.playFrom === 'active') &&
|
||||
JSON.stringify(layers.dialog) === JSON.stringify(layers.project),
|
||||
JSON.stringify({ settings, layers }),
|
||||
);
|
||||
|
||||
// 46e. Renaming through it, and the icon buttons undoing and redoing it. The name is read off the
|
||||
// project rather than written down here: CI seeds the same project under a different name.
|
||||
const nameField = await t.locate(projectName);
|
||||
await t.doubleClick(nameField.x, nameField.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('Renamed by 46');
|
||||
await press('Enter');
|
||||
await clickText('Apply');
|
||||
await sleep(200);
|
||||
check(
|
||||
'Apply renames the project',
|
||||
(await json('__gm.state.project.name')) === 'Renamed by 46',
|
||||
await json('__gm.state.project.name'),
|
||||
);
|
||||
await clickToolbar('undo');
|
||||
await sleep(200);
|
||||
const undone = await json('__gm.state.project.name');
|
||||
await clickToolbar('redo');
|
||||
await sleep(200);
|
||||
const redone = await json('__gm.state.project.name');
|
||||
check(
|
||||
'the undo and redo buttons of the bar walk the rename back and forth',
|
||||
undone === projectName && redone === 'Renamed by 46',
|
||||
JSON.stringify({ undone, redone, was: projectName }),
|
||||
);
|
||||
// Back to the name the rest of the world knows it by.
|
||||
await clickToolbar('undo');
|
||||
await sleep(200);
|
||||
check(
|
||||
'the project is handed back under its own name',
|
||||
(await json('__gm.state.project.name')) === projectName,
|
||||
await json('__gm.state.project.name'),
|
||||
);
|
||||
await t.saved();
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { shapes } from './42-shapes.mjs';
|
||||
import { queries } from './43-queries.mjs';
|
||||
import { documents } from './44-documents.mjs';
|
||||
import { login } from './45-login.mjs';
|
||||
import { topbar } from './46-topbar.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -95,4 +96,5 @@ export const STEPS = [
|
||||
queries,
|
||||
documents,
|
||||
login,
|
||||
topbar,
|
||||
];
|
||||
+10
-1
@@ -371,13 +371,21 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
await evaluate(`(() => { ${body}; __gm.state.preview(); })()`);
|
||||
await settle();
|
||||
};
|
||||
/**
|
||||
* Clicks one of the top bar's icon buttons – menu, undo, redo, play, pause (#183). They carry no
|
||||
* caption any more, so `clickText` has nothing to find and the position comes from the hook.
|
||||
*/
|
||||
const clickToolbar = async (name) => {
|
||||
const at = await json(`__gm.toolbarAt(${JSON.stringify(name)})`);
|
||||
await click(at.x, at.y);
|
||||
};
|
||||
/**
|
||||
* Starting and stopping a run build or tear down a whole world, which is more than the two frames
|
||||
* `settle()` grants – so these wait for the mode itself to have changed, then for a drawn frame so
|
||||
* the panels that follow the mode are on screen too.
|
||||
*/
|
||||
const play = async () => {
|
||||
await clickText('▶ Play');
|
||||
await clickToolbar('play');
|
||||
await waitUntil("__gm.state.mode === 'play'", { label: 'the run started' });
|
||||
await settle();
|
||||
};
|
||||
@@ -444,6 +452,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
click,
|
||||
doubleClick,
|
||||
clickText,
|
||||
clickToolbar,
|
||||
wheel,
|
||||
drag,
|
||||
dragAsset,
|
||||
|
||||
Reference in new issue
Block a user