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

+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 };
}