Two compilers that walk a graph the same way walk it through one emitter (#85)
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s

Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 19:28:28 +02:00
1 parent bf0be66c9d
commit d3563897d8
16 files changed
+376 -379

No files matched your search

+19
View File
@@ -11,6 +11,25 @@ export interface Command {
undo(project: Project): void;
}
/** A rename that is undone by renaming back. */
export abstract class RenameCommand implements Command {
constructor(
readonly label: string,
private readonly from: string,
private readonly to: string,
) {}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
protected abstract rename(project: Project, from: string, to: string): void;
}
/** A rejected edit with a message fit for the user; the editor shows it as a notice. */
export class CommandError extends Error {
constructor(message: string) {
+20 -42
View File
@@ -110,22 +110,24 @@ export function buildTextureAsset(name: string, image: TextureImage, id = newId(
}
/**
* The image is uploaded before the command runs; this only records the result, so undo and redo
* The file is uploaded before the command runs; this only records the result, so undo and redo
* never touch the network.
*/
export class CreateTextureCommand implements Command {
abstract class ImportFileCommand<A extends Asset & { file: string }> implements Command {
readonly label: string;
readonly asset: TextureAsset;
constructor(name: string, image: TextureImage) {
this.asset = buildTextureAsset(name, image);
this.label = `Import texture "${name}"`;
constructor(
readonly asset: A,
private readonly fileNoun: string,
private readonly isStored: (file: string) => boolean,
) {
this.label = `Import ${asset.type} "${asset.name}"`;
}
execute(project: Project): void {
// The name is the hash the server derived from the bytes; anything else was never uploaded.
if (!isTextureFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
if (!this.isStored(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored ${this.fileNoun} file`);
project.assets.push(this.asset);
}
@@ -134,28 +136,19 @@ export class CreateTextureCommand implements Command {
}
}
export class CreateTextureCommand extends ImportFileCommand<TextureAsset> {
constructor(name: string, image: TextureImage) {
super(buildTextureAsset(name, image), 'image', isTextureFile);
}
}
export function buildFontAsset(name: string, file: string, id = newId()): FontAsset {
return { id, name, props: defaultsFor('font'), type: 'font', file };
}
/** The typeface is uploaded before the command runs, as an image is for `CreateTextureCommand`. */
export class CreateFontCommand implements Command {
readonly label: string;
readonly asset: FontAsset;
export class CreateFontCommand extends ImportFileCommand<FontAsset> {
constructor(name: string, file: string) {
this.asset = buildFontAsset(name, file);
this.label = `Import font "${name}"`;
}
execute(project: Project): void {
if (!isFontFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored font file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
super(buildFontAsset(name, file), 'font', isFontFile);
}
}
@@ -163,24 +156,9 @@ export function buildSoundAsset(name: string, file: string, id = newId()): Sound
return { id, name, props: defaultsFor('sound'), type: 'sound', file };
}
/** The sound is uploaded before the command runs, as a typeface is for `CreateFontCommand`. */
export class CreateSoundCommand implements Command {
readonly label: string;
readonly asset: SoundAsset;
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
constructor(name: string, file: string) {
this.asset = buildSoundAsset(name, file);
this.label = `Import sound "${name}"`;
}
execute(project: Project): void {
if (!isSoundFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored sound file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
super(buildSoundAsset(name, file), 'sound', isSoundFile);
}
}
+6 -16
View File
@@ -19,7 +19,7 @@ import {
import type { PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { type Command, CommandError, RenameCommand } from './Command';
import { nextFieldName } from './naming';
import { requireBehavior, requireNode, requireNodeDef } from './require';
@@ -260,27 +260,17 @@ export class SetPortWriteCommand implements Command {
}
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
export class RenamePortCommand implements Command {
readonly label: string;
export class RenamePortCommand extends RenameCommand {
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly from: string,
private readonly to: string,
from: string,
to: string,
) {
this.label = `Rename ${from}`;
super(`Rename ${from}`, from, to);
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
protected rename(project: Project, from: string, to: string): void {
if (from === to) return;
const node = requireNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node);
+11 -31
View File
@@ -17,7 +17,7 @@ import {
variableDef,
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { type Command, CommandError, RenameCommand } from './Command';
import { renameVariablePorts } from './portCommands';
import { requireVariableOwner } from './require';
@@ -117,26 +117,16 @@ export class RemoveVariableCommand implements Command {
}
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
export class RenameVariableCommand implements Command {
readonly label: string;
export class RenameVariableCommand extends RenameCommand {
constructor(
private readonly assetId: string,
private readonly from: string,
private readonly to: string,
from: string,
to: string,
) {
this.label = `Rename variable ${from}`;
super(`Rename variable ${from}`, from, to);
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
protected rename(project: Project, from: string, to: string): void {
const asset = requireVariableOwner(project, this.assetId);
if (from === to) return;
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
@@ -316,27 +306,17 @@ export class RemoveStructFieldCommand implements Command {
}
}
export class RenameStructFieldCommand implements Command {
readonly label: string;
export class RenameStructFieldCommand extends RenameCommand {
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly from: string,
private readonly to: string,
from: string,
to: string,
) {
this.label = `Rename field ${from}`;
super(`Rename field ${from}`, from, to);
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
protected rename(project: Project, from: string, to: string): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);