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)
game-maker / gate (push) Successful in 2m0s
game-maker / suite (push) Successful in 7m17s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 17:24:42 +02:00
1 parent da35f10d1a
commit ae3aa7d3bd
32 files changed
+240 -246

No files matched your search

+4 -2
View File
@@ -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
+1 -1
View File
@@ -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');
+49 -47
View File
@@ -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);
}
}
-4
View File
@@ -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;
+1 -4
View File
@@ -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);
}
+1 -1
View File
@@ -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]);
}
+1 -4
View File
@@ -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);
+2 -13
View File
@@ -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 };
}
+1 -4
View File
@@ -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
+13
View File
@@ -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 };
}
+9 -16
View File
@@ -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<DocumentId>(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();
};
+2 -10
View File
@@ -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<Item extends PickerItem> 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). */
+13 -7
View File
@@ -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<T extends Popup>(): { current(): T | null; open(dialog: T): T } {
let current: T | null = null;
+3 -14
View File
@@ -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))();
+2 -2
View File
@@ -39,8 +39,8 @@ const PLAY_FROM_LABEL: Record<PlayFrom, string> = {
/**
* 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
+2 -3
View File
@@ -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);
}
}
+46
View File
@@ -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));
}
+15 -34
View File
@@ -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<void> {
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();
}
}
+17 -41
View File
@@ -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<void>,
@@ -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<void> {
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();
}
}
-3
View File
@@ -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());
+2 -4
View File
@@ -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);
}
}
+1 -1
View File
@@ -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,
+8 -10
View File
@@ -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);
}
}
+1 -7
View File
@@ -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.
+1 -4
View File
@@ -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;
+6
View File
@@ -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;
+8 -1
View File
@@ -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';
+1 -1
View File
@@ -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 };
}
-3
View File
@@ -10,8 +10,6 @@ import { gameClock } from '../core/time';
*/
export class ParticleLibrary {
private readonly builds = new Map<string, { fingerprint: string; build: ParticlesBuild }>();
/** 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;
+3 -4
View File
@@ -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. */
+26
View File
@@ -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);
});
});
+1 -1
View File
@@ -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()');