game-maker / suite (push) Successful in 9m10s
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
4.6 KiB
TypeScript
92 lines
4.6 KiB
TypeScript
/**
|
|
* Editor actions that several places trigger the same way (inspector, hierarchy context
|
|
* menu, keyboard): they run the command and put the selection where the user expects it.
|
|
*/
|
|
import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
|
import { AddColliderCommand } from '../core/commands/colliderCommands';
|
|
import { oneStep } from '../core/commands/CompoundCommand';
|
|
import { CreateContainerCommand } from '../core/commands/containerCommands';
|
|
import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
|
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
|
import type { AssetType } from '../core/model/AssetTypes';
|
|
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
|
|
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
|
import type { SceneObject } from '../core/model/SceneObject';
|
|
import { textureRefIds } from '../core/model/texture';
|
|
import { animationsUsing, findPropertyUsages, findUsages } from '../core/model/usages';
|
|
import { Dialog } from '../ui';
|
|
import { type EditorState, objectSelection } from './state';
|
|
|
|
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
|
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
|
const command = new CreateContainerCommand(holderId, objectIds);
|
|
if (!state.execute(command)) return false;
|
|
state.select(objectSelection(holderId, [command.objectId]));
|
|
return true;
|
|
}
|
|
|
|
/** The test `AddColliderCommand` makes, so no place offers what it would refuse. */
|
|
export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
|
|
const type = findAsset(project, object.assetId)?.type;
|
|
return (
|
|
type !== undefined &&
|
|
type !== 'collider' &&
|
|
!isTemplateContent(holder, object.id) &&
|
|
!isLiveInstance(project, holder, object)
|
|
);
|
|
}
|
|
|
|
/** Gives the object a collider child sized like it; selects the collider. */
|
|
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
|
|
const command = new AddColliderCommand(holderId, objectId);
|
|
if (!state.execute(command)) return false;
|
|
state.select(objectSelection(holderId, [command.objectId]));
|
|
return true;
|
|
}
|
|
|
|
/** Removes the objects (with their subtrees) as one undo step. */
|
|
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
|
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
|
|
}
|
|
|
|
/** Deletes graph nodes as one undo step, however many were selected. */
|
|
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
|
|
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
|
|
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
|
|
}
|
|
|
|
/** Asks before deleting an asset, spelling out what goes with it. */
|
|
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
|
const asset = findAsset(state.project, assetId);
|
|
if (!asset) return;
|
|
const usages = findUsages(state.project, assetId).length;
|
|
// A texture is never placed but worn through a property, so counting placed instances would
|
|
// report "unused". Its frames count as the texture itself.
|
|
const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId];
|
|
const references = findPropertyUsages(state.project, ids).length;
|
|
const animations = asset.type === 'texture' ? animationsUsing(state.project, ids).length : 0;
|
|
const detail =
|
|
asset.type === 'scene'
|
|
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
|
: references > 0 || animations > 0
|
|
? wornDetail(references, animations, asset.type)
|
|
: usages === 0
|
|
? 'This cannot be undone with Ctrl+Z after reload.'
|
|
: asset.type === 'container'
|
|
? `${usages} placed container(s) are removed together with their content.`
|
|
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
|
new Dialog(`Delete "${asset.name}"?`, detail, [
|
|
{ label: 'Cancel' },
|
|
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
|
]).openCentered();
|
|
}
|
|
|
|
function wornDetail(sprites: number, animations: number, type: AssetType): string {
|
|
if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`;
|
|
const who = [
|
|
sprites > 0 ? `${sprites} sprite(s)` : '',
|
|
animations > 0 ? `${animations} animation(s)` : '',
|
|
].filter(Boolean);
|
|
return `${who.join(' and ')} reference it and show the plain colour in its place.`;
|
|
}
|