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>
This commit is contained in:
1 parent
da35f10d1a
commit
ae3aa7d3bd
32 files changed
+240
-246
No files matched your search
@@ -24,7 +24,8 @@ way in.
|
|||||||
- A **behavior** is logic as a node graph, placed like anything else and parented to the object it
|
- 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.
|
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
|
- 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
|
- **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back
|
||||||
exactly what was saved.
|
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
|
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).
|
[Development](https://git.nockal.com/meleki/game-maker/wiki/Development).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
+1
-1
@@ -306,7 +306,7 @@ function routePlugins(array $parts, string $method): never
|
|||||||
if ($source === null) {
|
if ($source === null) {
|
||||||
Response::error('Plugin not found', 404);
|
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.
|
// and the server has no way of knowing which project the asking tab has open.
|
||||||
header('Content-Type: text/javascript; charset=utf-8');
|
header('Content-Type: text/javascript; charset=utf-8');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import { isLiveInstance } from '../model/instance';
|
import { isLiveInstance } from '../model/instance';
|
||||||
|
import type { Asset } from '../model/Asset';
|
||||||
import { findAsset, type ObjectHolder, type Project } from '../model/Project';
|
import { findAsset, type ObjectHolder, type Project } from '../model/Project';
|
||||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||||
@@ -9,12 +10,9 @@ import { type Command, CommandError } from './Command';
|
|||||||
import { nextAssetName } from './naming';
|
import { nextAssetName } from './naming';
|
||||||
import { requireAsset, requireHolder, requireStructuralObject } from './require';
|
import { requireAsset, requireHolder, requireStructuralObject } from './require';
|
||||||
|
|
||||||
/**
|
/** A collider or a rigidbody: a new asset, placed as a child at the object's centre. */
|
||||||
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
|
abstract class AddPartCommand implements Command {
|
||||||
* defaults) and places it as a child at the object's centre.
|
abstract readonly label: string;
|
||||||
*/
|
|
||||||
export class AddColliderCommand implements Command {
|
|
||||||
readonly label = 'Add collider';
|
|
||||||
readonly assetId = newId();
|
readonly assetId = newId();
|
||||||
readonly objectId = newId();
|
readonly objectId = newId();
|
||||||
|
|
||||||
@@ -23,25 +21,20 @@ export class AddColliderCommand implements Command {
|
|||||||
private readonly targetId: string,
|
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 {
|
execute(project: Project): void {
|
||||||
const holder = requireHolder(project, this.holderId);
|
const holder = requireHolder(project, this.holderId);
|
||||||
const target = requireStructuralObject(project, holder, this.targetId);
|
const target = requireStructuralObject(project, holder, this.targetId);
|
||||||
const targetAsset = requireAsset(project, target.assetId);
|
const targetAsset = requireAsset(project, target.assetId);
|
||||||
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
|
project.assets.push(this.build(project, holder, target, targetAsset, target.name || targetAsset.name));
|
||||||
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);
|
|
||||||
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
|
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. */
|
/** 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 {
|
export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
|
||||||
const type = findAsset(project, target.assetId)?.type;
|
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;
|
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. */
|
/** Creates a rigidbody asset with the schema defaults. */
|
||||||
export class AddRigidbodyCommand implements Command {
|
export class AddRigidbodyCommand extends AddPartCommand {
|
||||||
readonly label = 'Add rigidbody';
|
readonly label = 'Add rigidbody';
|
||||||
readonly assetId = newId();
|
|
||||||
readonly objectId = newId();
|
|
||||||
|
|
||||||
constructor(
|
protected build(
|
||||||
private readonly holderId: string,
|
project: Project,
|
||||||
private readonly targetId: string,
|
holder: ObjectHolder,
|
||||||
) {}
|
target: SceneObject,
|
||||||
|
_targetAsset: Asset,
|
||||||
execute(project: Project): void {
|
owner: string,
|
||||||
const holder = requireHolder(project, this.holderId);
|
): Asset {
|
||||||
const target = requireStructuralObject(project, holder, this.targetId);
|
|
||||||
const targetAsset = requireAsset(project, target.assetId);
|
|
||||||
const refusal = rigidbodyRefusal(project, holder, target);
|
const refusal = rigidbodyRefusal(project, holder, target);
|
||||||
if (refusal) throw new CommandError(refusal);
|
if (refusal) throw new CommandError(refusal);
|
||||||
const asset = buildAsset(
|
return buildAsset('rigidbody', nextAssetName(project, `${owner} rigidbody`), this.assetId);
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,10 +74,6 @@ export function findScene(project: Project, sceneId: string): SceneAsset | undef
|
|||||||
return asset && isSceneAsset(asset) ? asset : undefined;
|
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 {
|
export function findGraphAsset(project: Project, assetId: string): GraphAsset | undefined {
|
||||||
const asset = findAsset(project, assetId);
|
const asset = findAsset(project, assetId);
|
||||||
return asset && isGraphAsset(asset) ? asset : undefined;
|
return asset && isGraphAsset(asset) ? asset : undefined;
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ export interface CollisionFilter {
|
|||||||
mask: number;
|
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
|
* 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
|
* 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;
|
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);
|
return 1 << layerIndexOf(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ function placed(outline: TileOutline, x: number, y: number, size: number): TileO
|
|||||||
export type OneWayFilter = boolean | number;
|
export type OneWayFilter = boolean | number;
|
||||||
|
|
||||||
/** The angle a one-way tile lands things within, held to 0 … 90°. */
|
/** 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]);
|
return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
|
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
|
||||||
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
|
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
|
||||||
import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
|
import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
|
||||||
|
import { dot } from '../util/vec';
|
||||||
import {
|
import {
|
||||||
roundedContact,
|
roundedContact,
|
||||||
roundedContains,
|
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. */
|
/** A world point in the box's own frame: origin at its centre, axes along its sides. */
|
||||||
function toBoxFrame(box: Box, p: Vec2): Vec2 {
|
function toBoxFrame(box: Box, p: Vec2): Vec2 {
|
||||||
const [ux, uy] = axesOf(box);
|
const [ux, uy] = axesOf(box);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* the real distance between them is taken. The radii are then taken off.
|
* the real distance between them is taken. The radii are then taken off.
|
||||||
*/
|
*/
|
||||||
import type { Vec2 } from '../model/Properties';
|
import type { Vec2 } from '../model/Properties';
|
||||||
|
import { closestOnSegment, dot } from '../util/vec';
|
||||||
|
|
||||||
export interface Rounded {
|
export interface Rounded {
|
||||||
points: readonly Vec2[];
|
points: readonly Vec2[];
|
||||||
@@ -21,10 +22,6 @@ export interface Contact {
|
|||||||
/** Ties between axes closer than this are settled by the travel. */
|
/** Ties between axes closer than this are settled by the travel. */
|
||||||
const TIE = 1e-9;
|
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 {
|
function unit(v: Vec2): Vec2 {
|
||||||
const length = Math.hypot(v.x, v.y);
|
const length = Math.hypot(v.x, v.y);
|
||||||
return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 0, y: 0 };
|
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];
|
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. */
|
/** The edges of a core as segments; a point is one segment of no length. */
|
||||||
function edgesOf(points: readonly Vec2[]): [Vec2, Vec2][] {
|
function edgesOf(points: readonly Vec2[]): [Vec2, Vec2][] {
|
||||||
if (points.length === 1) return [[points[0]!, points[0]!]];
|
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;
|
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 };
|
return { points: piece.points.map((p) => ({ x: p.x + by.x, y: p.y + by.y })), r: piece.r };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* the core polygon, a circle at each corner and a band along each edge as wide as the radius.
|
* 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 type { Vec2 } from '../model/Properties';
|
||||||
|
import { dot } from '../util/vec';
|
||||||
import { signedArea } from '../model/polygon';
|
import { signedArea } from '../model/polygon';
|
||||||
import { type ColliderShape, piecesOf } from './collider';
|
import { type ColliderShape, piecesOf } from './collider';
|
||||||
import { type Rounded, roundedContact } from './convex';
|
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[] };
|
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
|
* 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
|
* null. A shape the ray starts inside is not met – so a ray cast from the middle of the thing casting
|
||||||
|
|||||||
@@ -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
@@ -318,20 +318,6 @@ export class Editor implements PlayHost {
|
|||||||
};
|
};
|
||||||
this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
|
this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
|
||||||
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 = {
|
this.assetEditors = {
|
||||||
graph: this.graphEditor,
|
graph: this.graphEditor,
|
||||||
statemachine: this.stateMachineEditor,
|
statemachine: this.stateMachineEditor,
|
||||||
@@ -342,6 +328,13 @@ export class Editor implements PlayHost {
|
|||||||
tile: this.tileEditor,
|
tile: this.tileEditor,
|
||||||
container: this.containerEditor,
|
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.
|
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||||
this.graphEditor.onRetarget = (from, to) => {
|
this.graphEditor.onRetarget = (from, to) => {
|
||||||
const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${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.leftColumn.minWidth = this.leftWidth;
|
||||||
this.inspector.minWidth = this.rightWidth;
|
this.inspector.minWidth = this.rightWidth;
|
||||||
this.leftSplitter.onDrag = (delta) => {
|
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.leftColumn.minWidth = this.leftWidth;
|
||||||
this.workspace.relayout();
|
this.workspace.relayout();
|
||||||
};
|
};
|
||||||
this.rightSplitter.onDrag = (delta) => {
|
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.inspector.minWidth = this.rightWidth;
|
||||||
this.workspace.relayout();
|
this.workspace.relayout();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
drawControlBox,
|
drawControlBox,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
HStack,
|
|
||||||
Label,
|
Label,
|
||||||
Popup,
|
Popup,
|
||||||
pressCount,
|
pressCount,
|
||||||
ScrollContainer,
|
ScrollContainer,
|
||||||
Spacer,
|
|
||||||
Theme,
|
Theme,
|
||||||
textStyle,
|
textStyle,
|
||||||
Widget,
|
Widget,
|
||||||
} from '../ui';
|
} 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. */
|
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||||||
export const PICKER_WIDTH = 560;
|
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.detail = new Label(words.idle ?? '', { style: 'muted' });
|
||||||
this.content.add(this.detail);
|
this.content.add(this.detail);
|
||||||
|
|
||||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
|
||||||
const uploadButton = new Button('Upload…');
|
const uploadButton = new Button('Upload…');
|
||||||
uploadButton.onClick = this.closeThen(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 = new Button(words.use, { variant: 'primary' });
|
||||||
this.use.onClick = () => this.confirm();
|
this.use.onClick = () => this.confirm();
|
||||||
this.use.setEnabled(false);
|
this.use.setEnabled(false);
|
||||||
buttons.add(this.use);
|
this.content.add(dialogButtons(this.closeThen(), this.use, uploadButton));
|
||||||
this.content.add(buttons);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
|
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
|
||||||
|
|||||||
@@ -31,19 +31,25 @@ export abstract class FormDialog extends Popup {
|
|||||||
|
|
||||||
/** Cancel and the confirming button, right-aligned; the dialog closes before `onConfirm` runs. */
|
/** Cancel and the confirming button, right-aligned; the dialog closes before `onConfirm` runs. */
|
||||||
protected addButtons(text: string, variant: ButtonOptions['variant'], onConfirm: () => void): Button {
|
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 });
|
const confirm = new Button(text, { variant });
|
||||||
confirm.onClick = this.closeThen(onConfirm);
|
confirm.onClick = this.closeThen(onConfirm);
|
||||||
buttons.add(confirm);
|
this.content.add(dialogButtons(this.closeThen(), confirm));
|
||||||
this.content.add(buttons);
|
|
||||||
return 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. */
|
/** 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 } {
|
export function dialogSlot<T extends Popup>(): { current(): T | null; open(dialog: T): T } {
|
||||||
let current: T | null = null;
|
let current: T | null = null;
|
||||||
|
|||||||
@@ -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. */
|
/** One project as the list shows it. */
|
||||||
export interface ProjectChoice {
|
export interface ProjectChoice {
|
||||||
@@ -54,18 +55,11 @@ export class OpenProjectDialog extends Popup {
|
|||||||
this.scroll.setContent(this.list);
|
this.scroll.setContent(this.list);
|
||||||
this.content.add(this.scroll);
|
this.content.add(this.scroll);
|
||||||
|
|
||||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
|
||||||
this.deleteButton = new Button('Delete', { variant: 'danger' });
|
this.deleteButton = new Button('Delete', { variant: 'danger' });
|
||||||
this.deleteButton.onClick = () => this.withPicked(actions.onDelete);
|
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 = new Button('Open', { variant: 'primary' });
|
||||||
this.openButton.onClick = () => this.withPicked(actions.onOpen);
|
this.openButton.onClick = () => this.withPicked(actions.onOpen);
|
||||||
buttons.add(this.openButton);
|
this.content.add(dialogButtons(this.closeThen(), this.openButton, this.deleteButton));
|
||||||
this.content.add(buttons);
|
|
||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +68,6 @@ export class OpenProjectDialog extends Popup {
|
|||||||
return { names: [...this.names], picked: this.picked };
|
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 {
|
private withPicked(action: (id: string) => void): void {
|
||||||
const id = this.picked;
|
const id = this.picked;
|
||||||
if (id) this.closeThen(() => action(id))();
|
if (id) this.closeThen(() => action(id))();
|
||||||
|
|||||||
@@ -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
|
* 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
|
* the collision layers (#152). The top bar carries icons only, so this is the one place the three
|
||||||
* own; the bar carries icons now, so this is the one place they are edited.
|
* are edited.
|
||||||
*
|
*
|
||||||
* Colliders name a layer by its *number*, so a layer taken out of the middle would silently move
|
* 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
|
* every collider below it onto another one; only the last layer can be removed, and the list is
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
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. */
|
/** 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 {
|
export class TabHeader extends HStack {
|
||||||
@@ -12,7 +12,6 @@ export class TabHeader extends HStack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader);
|
drawHeaderBar(this.bg, this.w, this.h);
|
||||||
this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
import { Graphics } from 'pixi.js';
|
||||||
import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore';
|
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';
|
import { loginError, loginProblem } from './loginRules';
|
||||||
|
|
||||||
const CARD_WIDTH = 280;
|
const CARD_WIDTH = 280;
|
||||||
@@ -31,9 +32,11 @@ class Card extends VStack {
|
|||||||
export class LoginScreen extends VStack {
|
export class LoginScreen extends VStack {
|
||||||
private readonly user: TextField;
|
private readonly user: TextField;
|
||||||
private readonly password: TextField;
|
private readonly password: TextField;
|
||||||
private readonly message: Label;
|
|
||||||
private readonly submit: Button;
|
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(
|
constructor(
|
||||||
private readonly auth: AuthStore,
|
private readonly auth: AuthStore,
|
||||||
@@ -64,8 +67,7 @@ export class LoginScreen extends VStack {
|
|||||||
});
|
});
|
||||||
card.add(this.password);
|
card.add(this.password);
|
||||||
|
|
||||||
this.message = new Label('', { style: 'warning' });
|
card.add(this.status.message);
|
||||||
card.add(this.message);
|
|
||||||
this.submit = new Button('Sign in', {
|
this.submit = new Button('Sign in', {
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
width: CARD_WIDTH - Theme.padding * 4,
|
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. */
|
/** What the check scripts read: the message under the form and whether it is waiting. */
|
||||||
info(): { message: string; busy: boolean } {
|
info(): { message: string; busy: boolean } {
|
||||||
return { message: this.message.text, busy: this.busy };
|
return this.status.info();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async signIn(): Promise<void> {
|
private async signIn(): Promise<void> {
|
||||||
if (this.busy) return;
|
if (this.status.busy) return;
|
||||||
const username = this.user.value.trim();
|
const username = this.user.value.trim();
|
||||||
const password = this.password.value;
|
const password = this.password.value;
|
||||||
const problem = loginProblem(username, password);
|
const problem = loginProblem(username, password);
|
||||||
if (problem) {
|
if (problem) {
|
||||||
this.say(problem);
|
this.status.say(problem);
|
||||||
this.refocus(username === '' ? this.user : this.password);
|
refocus(username === '' ? this.user : this.password);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setBusy(true);
|
this.status.setBusy(true);
|
||||||
try {
|
try {
|
||||||
const user = await this.auth.logIn(username, password);
|
const user = await this.auth.logIn(username, password);
|
||||||
this.onSignedIn(user);
|
this.onSignedIn(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.say(loginError(error, error instanceof WrongCredentialsError));
|
this.status.say(loginError(error, error instanceof WrongCredentialsError));
|
||||||
this.setBusy(false);
|
this.status.setBusy(false);
|
||||||
// A second attempt starts where the first one went wrong, not in an empty form.
|
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WrongCredentialsError } from '../../core/store/AuthStore';
|
import { WrongCredentialsError } from '../../core/store/AuthStore';
|
||||||
import { Button, HStack, Label, requestRender, Spacer, TextField, Theme, ui } from '../../ui';
|
import { Button, Label, TextField, ui } from '../../ui';
|
||||||
import { FormDialog } from '../FormDialog';
|
import { dialogButtons, FormDialog } from '../FormDialog';
|
||||||
|
import { FormStatus, refocus } from './FormStatus';
|
||||||
import { MIN_PASSWORD_LENGTH, passwordError, passwordProblem } from './loginRules';
|
import { MIN_PASSWORD_LENGTH, passwordError, passwordProblem } from './loginRules';
|
||||||
|
|
||||||
const FIELD_WIDTH = 200;
|
const FIELD_WIDTH = 200;
|
||||||
@@ -16,9 +17,11 @@ export class PasswordDialog extends FormDialog {
|
|||||||
private readonly current: TextField;
|
private readonly current: TextField;
|
||||||
private readonly next: TextField;
|
private readonly next: TextField;
|
||||||
private readonly repeat: TextField;
|
private readonly repeat: TextField;
|
||||||
private readonly message: Label;
|
|
||||||
private readonly confirm: Button;
|
private readonly confirm: Button;
|
||||||
private busy = false;
|
private readonly status = new FormStatus((busy) => {
|
||||||
|
this.confirm.setEnabled(!busy);
|
||||||
|
this.refreshLayout();
|
||||||
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly change: (current: string, next: string) => Promise<void>,
|
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('New'), this.next));
|
||||||
this.content.add(this.formRow(this.fieldLabel('Repeat'), this.repeat));
|
this.content.add(this.formRow(this.fieldLabel('Repeat'), this.repeat));
|
||||||
|
|
||||||
this.message = new Label('', { style: 'warning' });
|
this.content.add(this.status.message);
|
||||||
this.content.add(this.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() });
|
this.confirm = new Button('Change password', { variant: 'primary', onClick: () => void this.submit() });
|
||||||
buttons.add(this.confirm);
|
this.content.add(dialogButtons(this.closeThen(), this.confirm));
|
||||||
this.content.add(buttons);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts the caret in the first field, so a keyboard alone can go through the form. */
|
/** 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. */
|
/** What the check scripts read: the message under the form and whether it is waiting. */
|
||||||
info(): { message: string; busy: boolean } {
|
info(): { message: string; busy: boolean } {
|
||||||
return { message: this.message.text, busy: this.busy };
|
return this.status.info();
|
||||||
}
|
}
|
||||||
|
|
||||||
private secretField(onSubmit: () => void): TextField {
|
private secretField(onSubmit: () => void): TextField {
|
||||||
@@ -66,44 +62,24 @@ export class PasswordDialog extends FormDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async submit(): Promise<void> {
|
private async submit(): Promise<void> {
|
||||||
if (this.busy) return;
|
if (this.status.busy) return;
|
||||||
const current = this.current.value;
|
const current = this.current.value;
|
||||||
const next = this.next.value;
|
const next = this.next.value;
|
||||||
const problem = passwordProblem(current, next, this.repeat.value);
|
const problem = passwordProblem(current, next, this.repeat.value);
|
||||||
if (problem) {
|
if (problem) {
|
||||||
this.say(problem);
|
this.status.say(problem);
|
||||||
this.refocus(current === '' ? this.current : this.next);
|
refocus(current === '' ? this.current : this.next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setBusy(true);
|
this.status.setBusy(true);
|
||||||
try {
|
try {
|
||||||
await this.change(current, next);
|
await this.change(current, next);
|
||||||
this.close();
|
this.close();
|
||||||
this.onChanged();
|
this.onChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.say(passwordError(error, error instanceof WrongCredentialsError));
|
this.status.say(passwordError(error, error instanceof WrongCredentialsError));
|
||||||
this.setBusy(false);
|
this.status.setBusy(false);
|
||||||
this.refocus(this.current);
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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. */
|
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
|
||||||
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
|
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. */
|
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
||||||
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
||||||
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
/** 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),
|
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
|
||||||
/** The same for particle time, kept apart so one can be held without the other. */
|
/** The same for particle time, kept apart so one can be held without the other. */
|
||||||
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
|
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
|
||||||
particleCompiles: () => particleLibrary.compiles,
|
|
||||||
/** Every source's clock back to zero on the current (usually frozen) second. */
|
/** Every source's clock back to zero on the current (usually frozen) second. */
|
||||||
particlesRestart: () => {
|
particlesRestart: () => {
|
||||||
viewport.restartParticles(particleLibrary.now());
|
viewport.restartParticles(particleLibrary.now());
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
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. */
|
/** Past this a crumb cuts its name short, so a deep trail of long names still fits the tab. */
|
||||||
const CRUMB_MAX_WIDTH = 160;
|
const CRUMB_MAX_WIDTH = 160;
|
||||||
@@ -56,9 +56,7 @@ export class Breadcrumb extends HStack {
|
|||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
super.redraw();
|
super.redraw();
|
||||||
this.bg.clear();
|
drawHeaderBar(this.bg, this.w, this.h);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* stepped by length, so it is sampled and the walked distance decides which steps are drawn.
|
||||||
*/
|
*/
|
||||||
export function dashedBezier(
|
function dashedBezier(
|
||||||
g: Graphics,
|
g: Graphics,
|
||||||
x1: number,
|
x1: number,
|
||||||
y1: number,
|
y1: number,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
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 type { EditorState, SaveStatus } from '../state';
|
||||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
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
|
* 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
|
* the right – icons, no captions. Everything with words is behind the menu: the project name and
|
||||||
* name and the start-scene switch in Project settings, the project list in Open project, and the
|
* the start-scene switch in Project settings, the project list in Open project. The scene is
|
||||||
* scene is picked where the scenes are, in the Assets panel.
|
* picked where the scenes are, in the Assets panel.
|
||||||
*/
|
*/
|
||||||
export class Toolbar extends HStack {
|
export class Toolbar extends HStack {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = new Graphics();
|
||||||
@@ -80,8 +80,8 @@ export class Toolbar extends HStack {
|
|||||||
this.add(this.pauseButton);
|
this.add(this.pauseButton);
|
||||||
this.add(new Spacer(), 1);
|
this.add(new Spacer(), 1);
|
||||||
|
|
||||||
// Flexible, not a fixed strip: it is the one thing here that may shrink, and since the account
|
// Flexible, not a fixed strip: it is the one thing here that may shrink, and with the account
|
||||||
// button (#177) the row no longer fits a narrow window otherwise.
|
// button (#177) the row does not fit a narrow window otherwise.
|
||||||
this.notice = new Label('', { style: 'small', align: 'right' });
|
this.notice = new Label('', { style: 'small', align: 'right' });
|
||||||
this.add(this.notice, 1);
|
this.add(this.notice, 1);
|
||||||
this.status = new Label('', { style: 'small', align: 'right' });
|
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> {
|
get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> {
|
||||||
return {
|
return {
|
||||||
menu: this.menuButton,
|
menu: this.menuButton,
|
||||||
@@ -167,8 +167,6 @@ export class Toolbar extends HStack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.bg.clear();
|
drawHeaderBar(this.bg, this.w, this.h);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* corner there, double-click a corner to take it away. Pure, so the rules are testable without a view.
|
* 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 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. */
|
/** 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 };
|
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. */
|
/** Fewer corners than this are no polygon; the last three cannot be taken away. */
|
||||||
export const MIN_CORNERS = 3;
|
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
|
* 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.
|
* every corner lies on two edges. An outline of one or two corners has no edge to press on yet.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||||
import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix';
|
import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
|
import { dot } from '../../core/util/vec';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
import {
|
import {
|
||||||
type CellPolygon,
|
type CellPolygon,
|
||||||
@@ -420,10 +421,6 @@ function frictionBetween(own: Entity, obstacle: Entity): number {
|
|||||||
return (of(own) + of(obstacle)) / 2;
|
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 {
|
function unitOf(v: Vec2): Vec2 {
|
||||||
const length = Math.hypot(v.x, v.y);
|
const length = Math.hypot(v.x, v.y);
|
||||||
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
|
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
|
||||||
|
|||||||
@@ -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 });
|
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 {
|
export interface GridOptions {
|
||||||
/** Where the origin of the drawn space sits on screen, in the same pixels as `w`/`h`. */
|
/** Where the origin of the drawn space sits on screen, in the same pixels as `w`/`h`. */
|
||||||
originX: number;
|
originX: number;
|
||||||
|
|||||||
+8
-1
@@ -10,7 +10,14 @@ export {
|
|||||||
requestRender,
|
requestRender,
|
||||||
resetRenderLoop,
|
resetRenderLoop,
|
||||||
} from './render';
|
} 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 { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
|
||||||
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
||||||
export { Label, truncate } from './widgets/Label';
|
export { Label, truncate } from './widgets/Label';
|
||||||
|
|||||||
@@ -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.
|
// 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 };
|
if (this.fixedWidth) return { w: this.fixedWidth, h };
|
||||||
// From the full label, never the on-screen one: measuring truncated text would keep a button
|
// 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()));
|
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 };
|
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { gameClock } from '../core/time';
|
|||||||
*/
|
*/
|
||||||
export class ParticleLibrary {
|
export class ParticleLibrary {
|
||||||
private readonly builds = new Map<string, { fingerprint: string; build: ParticlesBuild }>();
|
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;
|
private frozenAt: number | null = null;
|
||||||
|
|
||||||
/** The clock every source's age is measured on: the global one, so a pause reaches the fields. */
|
/** 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 fingerprint = graphFingerprint(asset.graph);
|
||||||
const cached = this.builds.get(asset.id);
|
const cached = this.builds.get(asset.id);
|
||||||
if (cached && cached.fingerprint === fingerprint) return cached.build;
|
if (cached && cached.fingerprint === fingerprint) return cached.build;
|
||||||
this.compiles += 1;
|
|
||||||
const build = compileParticles(asset.graph);
|
const build = compileParticles(asset.graph);
|
||||||
this.builds.set(asset.id, { fingerprint, build });
|
this.builds.set(asset.id, { fingerprint, build });
|
||||||
return build;
|
return build;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { SHEET_EDGE } from '../core/shader/nodes';
|
import { SHEET_EDGE } from '../core/shader/nodes';
|
||||||
import type { NineSlice } from '../core/model/texture';
|
import type { NineSlice } from '../core/model/texture';
|
||||||
import type { Vec2 } from '../core/model/Properties';
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
|
import { closestOnSegment } from '../core/util/vec';
|
||||||
import { Theme } from '../ui';
|
import { Theme } from '../ui';
|
||||||
import type { DrawItem } from './items';
|
import type { DrawItem } from './items';
|
||||||
import { sliceGeometry, sliceKey } from './nineSlice';
|
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 {
|
function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number {
|
||||||
const ab = { x: b.x - a.x, y: b.y - a.y };
|
const q = closestOnSegment(p, a, b);
|
||||||
const length = ab.x * ab.x + ab.y * ab.y;
|
return Math.hypot(p.x - q.x, p.y - q.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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hit area covering only the outline band, in local coordinates and world units. */
|
/** Hit area covering only the outline band, in local coordinates and world units. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,7 +91,7 @@ export async function pause(t) {
|
|||||||
Math.abs(stillHeld.time - held.time) < 0.05,
|
Math.abs(stillHeld.time - held.time) < 0.05,
|
||||||
`x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`,
|
`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
|
// 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.
|
// has turned into the stop square for as long as the run is on.
|
||||||
const heldButtons = await json('__gm.runButtons()');
|
const heldButtons = await json('__gm.runButtons()');
|
||||||
|
|||||||
Reference in new issue
Block a user