183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
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);
|
||
}
|
||
}
|