diff --git a/README.md b/README.md index 07b8ecd3..d77e8907 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ way in. - A **behavior** is logic as a node graph, placed like anything else and parented to the object it acts on. Play has no built-in behaviour – everything that happens is a graph. - An **account** owns projects: the editor asks for a user name and a password first, and each - account sees only its own projects. Logout sits at the far right of the toolbar. + account sees only its own projects. The Account menu at the far right of the toolbar changes + the password and signs out. - **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back exactly what was saved. @@ -50,7 +51,8 @@ node tools/seed-actor.mjs && node tools/smoke-actor.mjs ``` Every push runs all of it on the Gitea runner – the gate as one job, build and suite as another -beside it – so locally take the gate plus the step your change touches and leave the full run to CI. Details, the GPU requirement and the detached runner are in +beside it – so locally take the gate plus the step your change touches and leave the full run to +CI. Details, the GPU requirement and the detached runner are in [Development](https://git.nockal.com/meleki/game-maker/wiki/Development). ## Documentation diff --git a/api/index.php b/api/index.php index 7c7bb0e6..cd001e19 100644 --- a/api/index.php +++ b/api/index.php @@ -306,7 +306,7 @@ function routePlugins(array $parts, string $method): never if ($source === null) { Response::error('Plugin not found', 404); } - // Every plugin file is handed out: since #119 the project says which of them the editor runs, + // Every plugin file is handed out: the project says which of them the editor runs (#119), // and the server has no way of knowing which project the asking tab has open. header('Content-Type: text/javascript; charset=utf-8'); header('X-Content-Type-Options: nosniff'); diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 307828ed..2301e2b9 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -1,5 +1,6 @@ import { newId } from '../ids'; import { isLiveInstance } from '../model/instance'; +import type { Asset } from '../model/Asset'; import { findAsset, type ObjectHolder, type Project } from '../model/Project'; import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { resolveSceneObjectProps } from '../properties/resolve'; @@ -9,12 +10,9 @@ import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; import { requireAsset, requireHolder, requireStructuralObject } from './require'; -/** - * Creates a collider asset sized like the object (sprites: width × height; anything else: schema - * defaults) and places it as a child at the object's centre. - */ -export class AddColliderCommand implements Command { - readonly label = 'Add collider'; +/** A collider or a rigidbody: a new asset, placed as a child at the object's centre. */ +abstract class AddPartCommand implements Command { + abstract readonly label: string; readonly assetId = newId(); readonly objectId = newId(); @@ -23,25 +21,20 @@ export class AddColliderCommand implements Command { private readonly targetId: string, ) {} + /** The asset to add, named after `owner` – or a `CommandError` saying why the object takes none. */ + protected abstract build( + project: Project, + holder: ObjectHolder, + target: SceneObject, + targetAsset: Asset, + owner: string, + ): Asset; + execute(project: Project): void { const holder = requireHolder(project, this.holderId); const target = requireStructuralObject(project, holder, this.targetId); const targetAsset = requireAsset(project, target.assetId); - if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); - if (isLiveInstance(project, holder, target)) - throw new CommandError('A placed container shows its template; add the collider inside it'); - - const asset = buildAsset( - 'collider', - nextAssetName(project, `${target.name || targetAsset.name} collider`), - this.assetId, - ); - if (targetAsset.type === 'sprite') { - const props = resolveSceneObjectProps(project, targetAsset, target); - asset.props.width = Number(props.width); - asset.props.height = Number(props.height); - } - project.assets.push(asset); + project.assets.push(this.build(project, holder, target, targetAsset, target.name || targetAsset.name)); holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId)); } @@ -52,6 +45,31 @@ export class AddColliderCommand implements Command { } } +/** Creates a collider asset sized like the object (sprites: width × height; anything else: schema defaults). */ +export class AddColliderCommand extends AddPartCommand { + readonly label = 'Add collider'; + + protected build( + project: Project, + holder: ObjectHolder, + target: SceneObject, + targetAsset: Asset, + owner: string, + ): Asset { + if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider'); + if (isLiveInstance(project, holder, target)) + throw new CommandError('A placed container shows its template; add the collider inside it'); + + const asset = buildAsset('collider', nextAssetName(project, `${owner} collider`), this.assetId); + if (targetAsset.type === 'sprite') { + const props = resolveSceneObjectProps(project, targetAsset, target); + asset.props.width = Number(props.width); + asset.props.height = Number(props.height); + } + return asset; + } +} + /** Why the object cannot take a rigidbody, or null when it can – the one rule command and menu share. */ export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null { const type = findAsset(project, target.assetId)?.type; @@ -66,35 +84,19 @@ export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: return taken ? 'The object has a rigidbody already' : null; } -/** Creates a rigidbody asset with the schema defaults and places it as a child at the object's centre. */ -export class AddRigidbodyCommand implements Command { +/** Creates a rigidbody asset with the schema defaults. */ +export class AddRigidbodyCommand extends AddPartCommand { readonly label = 'Add rigidbody'; - readonly assetId = newId(); - readonly objectId = newId(); - constructor( - private readonly holderId: string, - private readonly targetId: string, - ) {} - - execute(project: Project): void { - const holder = requireHolder(project, this.holderId); - const target = requireStructuralObject(project, holder, this.targetId); - const targetAsset = requireAsset(project, target.assetId); + protected build( + project: Project, + holder: ObjectHolder, + target: SceneObject, + _targetAsset: Asset, + owner: string, + ): Asset { const refusal = rigidbodyRefusal(project, holder, target); if (refusal) throw new CommandError(refusal); - const asset = buildAsset( - 'rigidbody', - nextAssetName(project, `${target.name || targetAsset.name} rigidbody`), - this.assetId, - ); - project.assets.push(asset); - holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId)); - } - - undo(project: Project): void { - const holder = requireHolder(project, this.holderId); - holder.objects = removeById(holder.objects, this.objectId); - project.assets = removeById(project.assets, this.assetId); + return buildAsset('rigidbody', nextAssetName(project, `${owner} rigidbody`), this.assetId); } } diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 2df3ff55..3d8ad412 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -74,10 +74,6 @@ export function findScene(project: Project, sceneId: string): SceneAsset | undef return asset && isSceneAsset(asset) ? asset : undefined; } -export function listScenes(project: Project): SceneAsset[] { - return project.assets.filter(isSceneAsset); -} - export function findGraphAsset(project: Project, assetId: string): GraphAsset | undefined { const asset = findAsset(project, assetId); return asset && isGraphAsset(asset) ? asset : undefined; diff --git a/src/core/model/collisionLayers.ts b/src/core/model/collisionLayers.ts index 58a9be91..4c48823b 100644 --- a/src/core/model/collisionLayers.ts +++ b/src/core/model/collisionLayers.ts @@ -27,9 +27,6 @@ export interface CollisionFilter { mask: number; } -/** What a collider carries before anything is set: layer 0, colliding with everything. */ -export const DEFAULT_FILTER: CollisionFilter = Object.freeze({ layer: 0, mask: ALL_LAYERS }); - /** * The names as stored, read off untrusted JSON: the first one is layer 0 and is always there, a * name that is not a text (or is empty) falls back to its number, and anything past the sixteenth @@ -69,7 +66,7 @@ export function layerMaskOf(value: unknown): number { return Number.isFinite(n) && n >= 0 ? n & ALL_LAYERS : ALL_LAYERS; } -export function layerBit(layer: number): number { +function layerBit(layer: number): number { return 1 << layerIndexOf(layer); } diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 1db05255..7c2fd8b3 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -188,7 +188,7 @@ function placed(outline: TileOutline, x: number, y: number, size: number): TileO export type OneWayFilter = boolean | number; /** The angle a one-way tile lands things within, held to 0 … 90°. */ -export function tileOneWayAngle(tile: TileAsset): number { +function tileOneWayAngle(tile: TileAsset): number { return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]); } diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index c3737834..2dc7ffd3 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -6,6 +6,7 @@ import { angleOf, apply, type Mat, scaleOf } from '../model/matrix'; import { convexPieces, pointsOf, signedArea } from '../model/polygon'; import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties'; +import { dot } from '../util/vec'; import { roundedContact, roundedContains, @@ -200,10 +201,6 @@ function axesOf(box: Box): [Vec2, Vec2] { ]; } -function dot(a: Vec2, b: Vec2): number { - return a.x * b.x + a.y * b.y; -} - /** A world point in the box's own frame: origin at its centre, axes along its sides. */ function toBoxFrame(box: Box, p: Vec2): Vec2 { const [ux, uy] = axesOf(box); diff --git a/src/core/properties/convex.ts b/src/core/properties/convex.ts index b573dbf1..5521eea5 100644 --- a/src/core/properties/convex.ts +++ b/src/core/properties/convex.ts @@ -6,6 +6,7 @@ * the real distance between them is taken. The radii are then taken off. */ import type { Vec2 } from '../model/Properties'; +import { closestOnSegment, dot } from '../util/vec'; export interface Rounded { points: readonly Vec2[]; @@ -21,10 +22,6 @@ export interface Contact { /** Ties between axes closer than this are settled by the travel. */ const TIE = 1e-9; -function dot(a: Vec2, b: Vec2): number { - return a.x * b.x + a.y * b.y; -} - function unit(v: Vec2): Vec2 { const length = Math.hypot(v.x, v.y); return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 0, y: 0 }; @@ -73,14 +70,6 @@ function span(points: readonly Vec2[], n: Vec2): [number, number] { return [min, max]; } -/** The closest point to `p` on the segment ab. */ -function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { - const ab = { x: b.x - a.x, y: b.y - a.y }; - const length = dot(ab, ab); - const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0; - return { x: a.x + ab.x * t, y: a.y + ab.y * t }; -} - /** The edges of a core as segments; a point is one segment of no length. */ function edgesOf(points: readonly Vec2[]): [Vec2, Vec2][] { if (points.length === 1) return [[points[0]!, points[0]!]]; @@ -155,7 +144,7 @@ export function roundedExtent(piece: Rounded, n: Vec2): number { return max - min + 2 * piece.r; } -export function translated(piece: Rounded, by: Vec2): Rounded { +function translated(piece: Rounded, by: Vec2): Rounded { return { points: piece.points.map((p) => ({ x: p.x + by.x, y: p.y + by.y })), r: piece.r }; } diff --git a/src/core/properties/ray.ts b/src/core/properties/ray.ts index 44a5d3f8..d05c295c 100644 --- a/src/core/properties/ray.ts +++ b/src/core/properties/ray.ts @@ -5,6 +5,7 @@ * the core polygon, a circle at each corner and a band along each edge as wide as the radius. */ import type { Vec2 } from '../model/Properties'; +import { dot } from '../util/vec'; import { signedArea } from '../model/polygon'; import { type ColliderShape, piecesOf } from './collider'; import { type Rounded, roundedContact } from './convex'; @@ -21,10 +22,6 @@ const INSIDE = 1e-9; type Part = { kind: 'circle'; c: Vec2; r: number } | { kind: 'polygon'; points: readonly Vec2[] }; -function dot(a: Vec2, b: Vec2): number { - return a.x * b.x + a.y * b.y; -} - /** * Where the ray from `from` along the unit direction `dir` first meets `shape` within `length`, or * null. A shape the ray starts inside is not met – so a ray cast from the middle of the thing casting diff --git a/src/core/util/vec.ts b/src/core/util/vec.ts new file mode 100644 index 00000000..54243b30 --- /dev/null +++ b/src/core/util/vec.ts @@ -0,0 +1,13 @@ +import type { Vec2 } from '../model/Properties'; + +export function dot(a: Vec2, b: Vec2): number { + return a.x * b.x + a.y * b.y; +} + +/** The closest point to `p` on the segment ab; a segment of no length is its one point. */ +export function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { + const ab = { x: b.x - a.x, y: b.y - a.y }; + const length = dot(ab, ab); + const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0; + return { x: a.x + ab.x * t, y: a.y + ab.y * t }; +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index ae18b074..4ffafdff 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -318,20 +318,6 @@ export class Editor implements PlayHost { }; this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel(); this.syncTilemapPanel(); - this.panes = { - scene: this.sceneRow, - game: this.gameView, - graph: this.graphEditor, - statemachine: this.stateMachineEditor, - shader: this.shaderEditor, - particles: this.particlesEditor, - texture: this.textureEditor, - animation: this.animationEditor, - tile: this.tileEditor, - container: this.containerEditor, - list: this.listEditor, - plugins: this.pluginsPane, - }; this.assetEditors = { graph: this.graphEditor, statemachine: this.stateMachineEditor, @@ -342,6 +328,13 @@ export class Editor implements PlayHost { tile: this.tileEditor, container: this.containerEditor, }; + this.panes = { + scene: this.sceneRow, + game: this.gameView, + ...this.assetEditors, + list: this.listEditor, + plugins: this.pluginsPane, + }; // Following a call goes on in the same tab: the tab changes its document, not its place. this.graphEditor.onRetarget = (from, to) => { const next = replaceDocument(this.documents, `asset:${from}`, `asset:${to}`); @@ -369,12 +362,12 @@ export class Editor implements PlayHost { this.leftColumn.minWidth = this.leftWidth; this.inspector.minWidth = this.rightWidth; this.leftSplitter.onDrag = (delta) => { - this.leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(MAX_LEFT_WIDTH, this.leftWidth + delta)); + this.leftWidth = clamp(this.leftWidth + delta, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH); this.leftColumn.minWidth = this.leftWidth; this.workspace.relayout(); }; this.rightSplitter.onDrag = (delta) => { - this.rightWidth = Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, this.rightWidth - delta)); + this.rightWidth = clamp(this.rightWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); this.inspector.minWidth = this.rightWidth; this.workspace.relayout(); }; diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts index c4a44b44..80b0d173 100644 --- a/src/editor/FilePicker.ts +++ b/src/editor/FilePicker.ts @@ -15,16 +15,15 @@ import { Button, drawControlBox, formatBytes, - HStack, Label, Popup, pressCount, ScrollContainer, - Spacer, Theme, textStyle, Widget, } from '../ui'; +import { dialogButtons } from './FormDialog'; /** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ export const PICKER_WIDTH = 560; @@ -255,19 +254,12 @@ export abstract class FilePicker extends Popup { this.detail = new Label(words.idle ?? '', { style: 'muted' }); this.content.add(this.detail); - const buttons = new HStack({ gap: Theme.gap, align: 'center' }); const uploadButton = new Button('Upload…'); uploadButton.onClick = this.closeThen(upload); - buttons.add(uploadButton); - buttons.add(new Spacer(), 1); - const cancel = new Button('Cancel'); - cancel.onClick = this.closeThen(); - buttons.add(cancel); this.use = new Button(words.use, { variant: 'primary' }); this.use.onClick = () => this.confirm(); this.use.setEnabled(false); - buttons.add(this.use); - this.content.add(buttons); + this.content.add(dialogButtons(this.closeThen(), this.use, uploadButton)); } /** What the dialog offers, which of it the project already wears, and what is picked (test hook). */ diff --git a/src/editor/FormDialog.ts b/src/editor/FormDialog.ts index 564e27e1..ad9009b6 100644 --- a/src/editor/FormDialog.ts +++ b/src/editor/FormDialog.ts @@ -31,19 +31,25 @@ export abstract class FormDialog extends Popup { /** Cancel and the confirming button, right-aligned; the dialog closes before `onConfirm` runs. */ protected addButtons(text: string, variant: ButtonOptions['variant'], onConfirm: () => void): Button { - const buttons = new HStack({ gap: Theme.gap, align: 'center' }); - buttons.add(new Spacer(), 1); - const cancel = new Button('Cancel'); - cancel.onClick = this.closeThen(); - buttons.add(cancel); const confirm = new Button(text, { variant }); confirm.onClick = this.closeThen(onConfirm); - buttons.add(confirm); - this.content.add(buttons); + this.content.add(dialogButtons(this.closeThen(), confirm)); return confirm; } } +/** The row a dialog ends on: Cancel and `confirm` on the right, `leading` – if any – on the left. */ +export function dialogButtons(onCancel: () => void, confirm: Button, leading?: Button): HStack { + const buttons = new HStack({ gap: Theme.gap, align: 'center' }); + if (leading) buttons.add(leading); + buttons.add(new Spacer(), 1); + const cancel = new Button('Cancel'); + cancel.onClick = onCancel; + buttons.add(cancel); + buttons.add(confirm); + return buttons; +} + /** One open dialog of a kind at a time: opening the next closes the last. `current` is for the check scripts. */ export function dialogSlot(): { current(): T | null; open(dialog: T): T } { let current: T | null = null; diff --git a/src/editor/OpenProjectDialog.ts b/src/editor/OpenProjectDialog.ts index eedad70b..a6179813 100644 --- a/src/editor/OpenProjectDialog.ts +++ b/src/editor/OpenProjectDialog.ts @@ -1,4 +1,5 @@ -import { Button, HStack, Label, ListView, Popup, ScrollContainer, Spacer, Theme } from '../ui'; +import { Button, Label, ListView, Popup, ScrollContainer, Theme } from '../ui'; +import { dialogButtons } from './FormDialog'; /** One project as the list shows it. */ export interface ProjectChoice { @@ -54,18 +55,11 @@ export class OpenProjectDialog extends Popup { 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.content.add(dialogButtons(this.closeThen(), this.openButton, this.deleteButton)); this.refresh(); } @@ -74,11 +68,6 @@ export class OpenProjectDialog extends Popup { 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))(); diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index 552f58ea..f03ddfbd 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -39,8 +39,8 @@ const PLAY_FROM_LABEL: Record = { /** * 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. + * 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 diff --git a/src/editor/TabHeader.ts b/src/editor/TabHeader.ts index c153e8fc..e59a7d6e 100644 --- a/src/editor/TabHeader.ts +++ b/src/editor/TabHeader.ts @@ -1,5 +1,5 @@ import { Graphics } from 'pixi.js'; -import { HStack, Theme } from '../ui'; +import { drawHeaderBar, HStack, Theme } from '../ui'; /** The bar across the top of a tab: its controls in a row on the header colour, a line below. */ export class TabHeader extends HStack { @@ -12,7 +12,6 @@ export class TabHeader extends HStack { } override redraw(): void { - this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader); - this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border); + drawHeaderBar(this.bg, this.w, this.h); } } diff --git a/src/editor/auth/FormStatus.ts b/src/editor/auth/FormStatus.ts new file mode 100644 index 00000000..0f36873b --- /dev/null +++ b/src/editor/auth/FormStatus.ts @@ -0,0 +1,46 @@ +import { Label, requestRender, type TextField, ui } from '../../ui'; + +/** + * What a form that can be refused shows about its last attempt – the sign-in screen and the + * password dialog: the warning line under the fields, and whether an answer is still awaited. + */ +export class FormStatus { + readonly message = new Label('', { style: 'warning' }); + private waiting = false; + + /** `onChange` redoes what the form shows of it: its button while waiting, its layout after a message. */ + constructor(private readonly onChange: (busy: boolean) => void) {} + + get busy(): boolean { + return this.waiting; + } + + /** What the check scripts read. */ + info(): { message: string; busy: boolean } { + return { message: this.message.text, busy: this.waiting }; + } + + say(message: string): void { + this.message.setText(message); + this.changed(); + } + + setBusy(busy: boolean): void { + this.waiting = busy; + this.changed(); + } + + private changed(): void { + this.onChange(this.waiting); + requestRender(); + } +} + +/** + * Puts the caret back into a field after a refused attempt. A field blurs itself once its Enter + * has been handled, and that happens after this call returns – so the focus has to be set again + * behind it, or the next thing typed goes nowhere. + */ +export function refocus(field: TextField): void { + queueMicrotask(() => ui().focus.focus(field)); +} diff --git a/src/editor/auth/LoginScreen.ts b/src/editor/auth/LoginScreen.ts index 34bb50c7..0499f0de 100644 --- a/src/editor/auth/LoginScreen.ts +++ b/src/editor/auth/LoginScreen.ts @@ -1,6 +1,7 @@ import { Graphics } from 'pixi.js'; import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore'; -import { Button, drawControlBox, Label, requestRender, Spacer, TextField, Theme, ui, VStack } from '../../ui'; +import { Button, drawControlBox, Label, Spacer, TextField, Theme, ui, VStack } from '../../ui'; +import { FormStatus, refocus } from './FormStatus'; import { loginError, loginProblem } from './loginRules'; const CARD_WIDTH = 280; @@ -31,9 +32,11 @@ class Card extends VStack { export class LoginScreen extends VStack { private readonly user: TextField; private readonly password: TextField; - private readonly message: Label; private readonly submit: Button; - private busy = false; + private readonly status = new FormStatus((busy) => { + this.submit.setText(busy ? 'Signing in…' : 'Sign in'); + this.submit.setEnabled(!busy); + }); constructor( private readonly auth: AuthStore, @@ -64,8 +67,7 @@ export class LoginScreen extends VStack { }); card.add(this.password); - this.message = new Label('', { style: 'warning' }); - card.add(this.message); + card.add(this.status.message); this.submit = new Button('Sign in', { variant: 'primary', width: CARD_WIDTH - Theme.padding * 4, @@ -84,49 +86,28 @@ export class LoginScreen extends VStack { /** What the check scripts read: the message under the form and whether it is waiting. */ info(): { message: string; busy: boolean } { - return { message: this.message.text, busy: this.busy }; + return this.status.info(); } private async signIn(): Promise { - if (this.busy) return; + if (this.status.busy) return; const username = this.user.value.trim(); const password = this.password.value; const problem = loginProblem(username, password); if (problem) { - this.say(problem); - this.refocus(username === '' ? this.user : this.password); + this.status.say(problem); + refocus(username === '' ? this.user : this.password); return; } - this.setBusy(true); + this.status.setBusy(true); try { const user = await this.auth.logIn(username, password); this.onSignedIn(user); } catch (error) { - this.say(loginError(error, error instanceof WrongCredentialsError)); - this.setBusy(false); + this.status.say(loginError(error, error instanceof WrongCredentialsError)); + this.status.setBusy(false); // A second attempt starts where the first one went wrong, not in an empty form. - this.refocus(this.password); + refocus(this.password); } } - - /** - * Puts the caret back into a field after a refused attempt. A field blurs itself once its Enter - * has been handled, and that happens after this call returns – so the focus has to be set again - * behind it, or the next thing typed goes nowhere. - */ - private refocus(field: TextField): void { - queueMicrotask(() => ui().focus.focus(field)); - } - - private setBusy(busy: boolean): void { - this.busy = busy; - this.submit.setText(busy ? 'Signing in…' : 'Sign in'); - this.submit.setEnabled(!busy); - requestRender(); - } - - private say(message: string): void { - this.message.setText(message); - requestRender(); - } } diff --git a/src/editor/auth/PasswordDialog.ts b/src/editor/auth/PasswordDialog.ts index de3e05d0..c9622a99 100644 --- a/src/editor/auth/PasswordDialog.ts +++ b/src/editor/auth/PasswordDialog.ts @@ -1,6 +1,7 @@ import { WrongCredentialsError } from '../../core/store/AuthStore'; -import { Button, HStack, Label, requestRender, Spacer, TextField, Theme, ui } from '../../ui'; -import { FormDialog } from '../FormDialog'; +import { Button, Label, TextField, ui } from '../../ui'; +import { dialogButtons, FormDialog } from '../FormDialog'; +import { FormStatus, refocus } from './FormStatus'; import { MIN_PASSWORD_LENGTH, passwordError, passwordProblem } from './loginRules'; const FIELD_WIDTH = 200; @@ -16,9 +17,11 @@ export class PasswordDialog extends FormDialog { private readonly current: TextField; private readonly next: TextField; private readonly repeat: TextField; - private readonly message: Label; private readonly confirm: Button; - private busy = false; + private readonly status = new FormStatus((busy) => { + this.confirm.setEnabled(!busy); + this.refreshLayout(); + }); constructor( private readonly change: (current: string, next: string) => Promise, @@ -38,17 +41,10 @@ export class PasswordDialog extends FormDialog { this.content.add(this.formRow(this.fieldLabel('New'), this.next)); this.content.add(this.formRow(this.fieldLabel('Repeat'), this.repeat)); - this.message = new Label('', { style: 'warning' }); - this.content.add(this.message); + this.content.add(this.status.message); - const buttons = new HStack({ gap: Theme.gap, align: 'center' }); - buttons.add(new Spacer(), 1); - const cancel = new Button('Cancel'); - cancel.onClick = this.closeThen(); - buttons.add(cancel); this.confirm = new Button('Change password', { variant: 'primary', onClick: () => void this.submit() }); - buttons.add(this.confirm); - this.content.add(buttons); + this.content.add(dialogButtons(this.closeThen(), this.confirm)); } /** Puts the caret in the first field, so a keyboard alone can go through the form. */ @@ -58,7 +54,7 @@ export class PasswordDialog extends FormDialog { /** What the check scripts read: the message under the form and whether it is waiting. */ info(): { message: string; busy: boolean } { - return { message: this.message.text, busy: this.busy }; + return this.status.info(); } private secretField(onSubmit: () => void): TextField { @@ -66,44 +62,24 @@ export class PasswordDialog extends FormDialog { } private async submit(): Promise { - if (this.busy) return; + if (this.status.busy) return; const current = this.current.value; const next = this.next.value; const problem = passwordProblem(current, next, this.repeat.value); if (problem) { - this.say(problem); - this.refocus(current === '' ? this.current : this.next); + this.status.say(problem); + refocus(current === '' ? this.current : this.next); return; } - this.setBusy(true); + this.status.setBusy(true); try { await this.change(current, next); this.close(); this.onChanged(); } catch (error) { - this.say(passwordError(error, error instanceof WrongCredentialsError)); - this.setBusy(false); - this.refocus(this.current); + this.status.say(passwordError(error, error instanceof WrongCredentialsError)); + this.status.setBusy(false); + refocus(this.current); } } - - /** - * Puts the caret back after a refused attempt. A field blurs itself once its Enter has been - * handled, and that happens after this call returns (see `LoginScreen.refocus`). - */ - private refocus(field: TextField): void { - queueMicrotask(() => ui().focus.focus(field)); - } - - private setBusy(busy: boolean): void { - this.busy = busy; - this.confirm.setEnabled(!busy); - requestRender(); - } - - private say(message: string): void { - this.message.setText(message); - this.refreshLayout(); - requestRender(); - } } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 47abba23..60abb548 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -220,8 +220,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** 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. */ @@ -725,7 +723,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E shaderTime: (at: number | null) => shaderLibrary.freezeTime(at), /** The same for particle time, kept apart so one can be held without the other. */ particleTime: (at: number | null) => particleLibrary.freezeTime(at), - particleCompiles: () => particleLibrary.compiles, /** Every source's clock back to zero on the current (usually frozen) second. */ particlesRestart: () => { viewport.restartParticles(particleLibrary.now()); diff --git a/src/editor/graph/Breadcrumb.ts b/src/editor/graph/Breadcrumb.ts index 46810dc8..5a96a5ce 100644 --- a/src/editor/graph/Breadcrumb.ts +++ b/src/editor/graph/Breadcrumb.ts @@ -1,5 +1,5 @@ import { Graphics } from 'pixi.js'; -import { Button, HStack, Label, Theme, type Widget } from '../../ui'; +import { Button, drawHeaderBar, HStack, Label, Theme, type Widget } from '../../ui'; /** Past this a crumb cuts its name short, so a deep trail of long names still fits the tab. */ const CRUMB_MAX_WIDTH = 160; @@ -56,9 +56,7 @@ export class Breadcrumb extends HStack { override redraw(): void { super.redraw(); - this.bg.clear(); - this.bg.rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader); - this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border); + drawHeaderBar(this.bg, this.w, this.h); } } diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index 65efbaf9..db80ab9a 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -311,7 +311,7 @@ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: numb * The same curve as dashes. Pixi has no dash style, and unlike a straight line a bezier cannot be * stepped by length, so it is sampled and the walked distance decides which steps are drawn. */ -export function dashedBezier( +function dashedBezier( g: Graphics, x1: number, y1: number, diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 01a65732..c2984d97 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -1,5 +1,5 @@ import { Graphics } from 'pixi.js'; -import { Button, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui'; +import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; @@ -38,9 +38,9 @@ function iconButton(name: IconName, onClick: () => void): Button { /** * 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. + * the right – icons, no captions. Everything with words is behind the menu: the project name and + * the start-scene switch in Project settings, the project list in Open project. The scene is + * picked where the scenes are, in the Assets panel. */ export class Toolbar extends HStack { private readonly bg = new Graphics(); @@ -80,8 +80,8 @@ export class Toolbar extends HStack { this.add(this.pauseButton); this.add(new Spacer(), 1); - // Flexible, not a fixed strip: it is the one thing here that may shrink, and since the account - // button (#177) the row no longer fits a narrow window otherwise. + // Flexible, not a fixed strip: it is the one thing here that may shrink, and with the account + // button (#177) the row does not fit a narrow window otherwise. this.notice = new Label('', { style: 'small', align: 'right' }); this.add(this.notice, 1); this.status = new Label('', { style: 'small', align: 'right' }); @@ -113,7 +113,7 @@ export class Toolbar extends HStack { }; } - /** The five icon buttons, so a check can click what no longer carries a caption. */ + /** The five icon buttons, so a check can click what carries no caption. */ get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> { return { menu: this.menuButton, @@ -167,8 +167,6 @@ export class Toolbar extends HStack { } override redraw(): void { - this.bg.clear(); - this.bg.rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader); - this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border); + drawHeaderBar(this.bg, this.w, this.h); } } diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts index 1fc282e8..330c4613 100644 --- a/src/editor/polygonEdit.ts +++ b/src/editor/polygonEdit.ts @@ -4,6 +4,7 @@ * corner there, double-click a corner to take it away. Pure, so the rules are testable without a view. */ import type { Vec2 } from '../core/model/Properties'; +import { closestOnSegment } from '../core/util/vec'; /** What lies under the pointer: a corner, or the edge from corner `index` to the next one. */ export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; index: number; at: Vec2 }; @@ -11,13 +12,6 @@ export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; in /** Fewer corners than this are no polygon; the last three cannot be taken away. */ export const MIN_CORNERS = 3; -function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { - const ab = { x: b.x - a.x, y: b.y - a.y }; - const length = ab.x * ab.x + ab.y * ab.y; - const t = length > 0 ? Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / length)) : 0; - return { x: a.x + ab.x * t, y: a.y + ab.y * t }; -} - /** * The corner or edge within `tolerance` of `p`, the nearest one; a corner wins over an edge, since * every corner lies on two edges. An outline of one or two corners has no edge to press on yet. diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index dd9164fe..d2c47fa0 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -1,6 +1,7 @@ import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; +import { dot } from '../../core/util/vec'; import { findAssetOf } from '../../core/model/Project'; import { type CellPolygon, @@ -420,10 +421,6 @@ function frictionBetween(own: Entity, obstacle: Entity): number { return (of(own) + of(obstacle)) / 2; } -function dot(a: Vec2, b: Vec2): number { - return a.x * b.x + a.y * b.y; -} - function unitOf(v: Vec2): Vec2 { const length = Math.hypot(v.x, v.y); return length > 0 ? { x: v.x / length, y: v.y / length } : v; diff --git a/src/ui/draw.ts b/src/ui/draw.ts index b5572d46..e93b65fc 100644 --- a/src/ui/draw.ts +++ b/src/ui/draw.ts @@ -19,6 +19,12 @@ export function drawControlBox(g: Graphics, w: number, h: number, options: Contr g.roundRect(x + 0.5, y + 0.5, w - 1, h - 1, radius).stroke({ width: 1, color: border }); } +/** A bar across the top of something – the toolbar, a tab's header, the breadcrumb: header colour, a line below. */ +export function drawHeaderBar(g: Graphics, w: number, h: number): void { + g.clear().rect(0, 0, w, h).fill(Theme.colors.panelHeader); + g.rect(0, h - 1, w, 1).fill(Theme.colors.border); +} + export interface GridOptions { /** Where the origin of the drawn space sits on screen, in the same pixels as `w`/`h`. */ originX: number; diff --git a/src/ui/index.ts b/src/ui/index.ts index caf74550..41f5b371 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -10,7 +10,14 @@ export { requestRender, resetRenderLoop, } from './render'; -export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; +export { + drawBand, + drawControlBox, + drawGrid, + drawHeaderBar, + type ControlBoxOptions, + type GridOptions, +} from './draw'; export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { Label, truncate } from './widgets/Label'; diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 4d6a9d44..d2faa6d9 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -148,7 +148,7 @@ export class Button extends Widget { // A given width is a width, not a floor: a picker must not resize around the name it shows. if (this.fixedWidth) return { w: this.fixedWidth, h }; // From the full label, never the on-screen one: measuring truncated text would keep a button - // that was once too narrow too narrow for good. + // that was too narrow once narrow for good. const metrics = CanvasTextMetrics.measureText(this.fullText, textStyle(this.measureToken())); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h }; } diff --git a/src/viewport/ParticleLibrary.ts b/src/viewport/ParticleLibrary.ts index d6bee1a9..1a87ec96 100644 --- a/src/viewport/ParticleLibrary.ts +++ b/src/viewport/ParticleLibrary.ts @@ -10,8 +10,6 @@ import { gameClock } from '../core/time'; */ export class ParticleLibrary { private readonly builds = new Map(); - /** How often a graph went through the compiler – what the checks read to prove a move did not. */ - compiles = 0; private frozenAt: number | null = null; /** The clock every source's age is measured on: the global one, so a pause reaches the fields. */ @@ -27,7 +25,6 @@ export class ParticleLibrary { const fingerprint = graphFingerprint(asset.graph); const cached = this.builds.get(asset.id); if (cached && cached.fingerprint === fingerprint) return cached.build; - this.compiles += 1; const build = compileParticles(asset.graph); this.builds.set(asset.id, { fingerprint, build }); return build; diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 53f7d977..3a2ca4e0 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -12,6 +12,7 @@ import { import { SHEET_EDGE } from '../core/shader/nodes'; import type { NineSlice } from '../core/model/texture'; import type { Vec2 } from '../core/model/Properties'; +import { closestOnSegment } from '../core/util/vec'; import { Theme } from '../ui'; import type { DrawItem } from './items'; import { sliceGeometry, sliceKey } from './nineSlice'; @@ -285,10 +286,8 @@ function traceShape(node: Graphics, shape: ColliderShape): void { } function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { - const ab = { x: b.x - a.x, y: b.y - a.y }; - const length = ab.x * ab.x + ab.y * ab.y; - const t = length > 0 ? Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / length)) : 0; - return Math.hypot(p.x - (a.x + ab.x * t), p.y - (a.y + ab.y * t)); + const q = closestOnSegment(p, a, b); + return Math.hypot(p.x - q.x, p.y - q.y); } /** Hit area covering only the outline band, in local coordinates and world units. */ diff --git a/tests/core/vec.test.ts b/tests/core/vec.test.ts new file mode 100644 index 00000000..a9ed0612 --- /dev/null +++ b/tests/core/vec.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { closestOnSegment, dot } from '../../src/core/util/vec'; + +describe('closestOnSegment', () => { + const a = { x: 0, y: 0 }; + const b = { x: 10, y: 0 }; + + it('drops the point onto the segment', () => { + expect(closestOnSegment({ x: 4, y: 7 }, a, b)).toEqual({ x: 4, y: 0 }); + }); + + it('stops at the ends instead of running along the line', () => { + expect(closestOnSegment({ x: -5, y: 3 }, a, b)).toEqual(a); + expect(closestOnSegment({ x: 25, y: 3 }, a, b)).toEqual(b); + }); + + it('takes a segment of no length as its one point', () => { + expect(closestOnSegment({ x: 3, y: 4 }, b, b)).toEqual(b); + }); +}); + +describe('dot', () => { + it('is zero for perpendicular vectors', () => { + expect(dot({ x: 2, y: 3 }, { x: -3, y: 2 })).toBe(0); + }); +}); diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index 0fae52df..3b7bd98f 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -91,7 +91,7 @@ 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`, ); - // Since #183 the buttons carry no caption, so what they offer is read as the picture each one + // The buttons carry no caption (#183), 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()');