Files
feuerwerk/src/editor/ProjectSettingsDialog.ts
T
adminandClaude Opus 5 ae3aa7d3bd
game-maker / gate (push) Successful in 2m0s
game-maker / suite (push) Successful in 7m17s
Refactoring round 5 covers the code since round 4 without changing behaviour: four identical copies of dot() and three of "nearest point on a segment" stood in the collision math, in the hit area of the collider outlines and in the polygon handles, so they moved into the new module src/core/util/vec.ts; the warning line, the busy state, info() and the caret reset were written out line by line in both the sign-in screen and the password dialog and are now FormStatus; the closing row "Cancel plus a confirming button, optionally one on the left" was built by hand in FormDialog, FilePicker, OpenProjectDialog and PasswordDialog and is now dialogButtons(); the header bar was drawn by the toolbar, the TabHeader and the Breadcrumb each on its own and is now drawHeaderBar(); AddColliderCommand and AddRigidbodyCommand share a base, each subclass only builds its own asset; Editor.ts derives the panes from assetEditors instead of repeating eight entries and clamps its splitters with clamp(); jscpd over src falls from 0.19 % to 0.14 %; dead code is gone - listScenes() has been orphaned since the Scene picker fell away (#183), DEFAULT_FILTER (#152) was never used, the two test hooks projectPickerRowAt/rowPosition and particleCompiles are read by no tool, and four functions lost an export they did not need; seven comments that told history ("used to", "no longer", "since #...") describe the state of things now, and that this changed nothing was proved by a rebuild - the bundle is md5 identical, b4ad0dfe... before and after; the README said Logout sits at the far right of the toolbar, which has been the Account menu since #182, and wiki/Architecture.md was missing src/core/util/ altogether; 4 new unit tests cover vec.ts, and switching the clamp on the segment off makes exactly one of them fail while none of the 76 neighbouring tests notice, so that clamping was covered nowhere before (#85)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:24:42 +02:00

183 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 top bar carries icons only, so this is the one place the three
* 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);
}
}