game-maker / suite (push) Failing after 11m6s
Load scene sets a flag on the World like Stop and Restart do; the swap happens after the tick, and the last wish of a tick wins. Exactly one thing crosses over: the SharedState, so global variables and global machines keep what they know while everything local is built anew. Loading the scene that is already running rebuilds it, which is what "reset the level" means when the score must survive. A run starts either from the start scene (a new project field, set from the asset panel and marked with an arrow in the list) or from whatever scene is open. The toggle next to Play says which question is asked and remembers the answer in localStorage: it is a way of working, not part of the game. Sounds no longer end because a scene ends. A voice belongs to the behavior that started it and stops right after On destroy, so the scene swap is no longer a special case at all. For the enemy that plays its explosion and destroys itself in the same breath there is now a detached input on Play sound. The scene selector stays usable while a run plays, because a double click in the asset panel already did the same thing unguarded. It moves what the editor looks at, not what the run plays.
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import { createEmptyGraph } from '../graph/Graph';
|
||
import { newId } from '../ids';
|
||
import type { Asset, FontAsset, SoundAsset, StateMachineAsset, TextureAsset } from '../model/Asset';
|
||
import type { AssetType } from '../model/AssetTypes';
|
||
import { isFontFile } from '../model/font';
|
||
import { isSoundFile } from '../model/sound';
|
||
import { removeSubtrees } from '../model/hierarchy';
|
||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
import { objectHolders, type Project } from '../model/Project';
|
||
import { type Scope, scopeOf, setScope } from '../model/Scope';
|
||
import type { SceneObject } from '../model/SceneObject';
|
||
import { isTextureFile } from '../model/texture';
|
||
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
|
||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||
import { type Command, CommandError } from './Command';
|
||
import { requireAsset, requirePropDef, requireStateMachine } from './require';
|
||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||
|
||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||
const base = { id, name, props: defaultsFor(type) };
|
||
switch (type) {
|
||
case 'texture':
|
||
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id);
|
||
case 'scene':
|
||
return { ...base, type: 'scene', objects: [] };
|
||
case 'camera':
|
||
return { ...base, type: 'camera' };
|
||
case 'sprite':
|
||
return { ...base, type: 'sprite' };
|
||
case 'text':
|
||
return { ...base, type: 'text' };
|
||
case 'behavior':
|
||
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
|
||
case 'variables':
|
||
return { ...base, type: 'variables', variables: [] };
|
||
case 'collider':
|
||
return { ...base, type: 'collider' };
|
||
case 'container':
|
||
return { ...base, type: 'container', objects: [] };
|
||
case 'animation':
|
||
return { ...base, type: 'animation', frames: [] };
|
||
case 'statemachine':
|
||
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
|
||
case 'shader':
|
||
return { ...base, type: 'shader', graph: createEmptyGraph() };
|
||
case 'font':
|
||
return { ...base, type: 'font', file: '' };
|
||
case 'sound':
|
||
return { ...base, type: 'sound', file: '' };
|
||
case 'particles':
|
||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||
}
|
||
}
|
||
|
||
/** Adds a new asset; the first scene of a project becomes the active scene. */
|
||
export class CreateAssetCommand implements Command {
|
||
readonly label: string;
|
||
readonly asset: Asset;
|
||
private previousActiveScene = '';
|
||
|
||
/** `props` replace schema defaults and pass through the schema like any other edit. */
|
||
constructor(
|
||
type: AssetType,
|
||
name: string,
|
||
private readonly props: PropertyBag = {},
|
||
) {
|
||
this.asset = buildAsset(type, name);
|
||
this.label = `Create ${type} "${name}"`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
for (const [key, value] of Object.entries(this.props))
|
||
this.asset.props[key] = coerceValue(requirePropDef(project, this.asset, key), value);
|
||
project.assets.push(this.asset);
|
||
this.previousActiveScene = project.activeSceneId;
|
||
if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
project.assets = removeById(project.assets, this.asset.id);
|
||
project.activeSceneId = this.previousActiveScene;
|
||
}
|
||
}
|
||
|
||
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
|
||
export interface TextureImage {
|
||
file: string;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
/**
|
||
* Apart from `buildAsset` so callers get a `TextureAsset` rather than the `Asset` union.
|
||
*
|
||
* The three fields are copied out one by one rather than spread: callers pass the whole upload
|
||
* result, which carries `bytes` and – since original names are kept – a `name` of its own. A
|
||
* spread would put the *file's* name where the asset's belongs and silently overrule the caller.
|
||
*/
|
||
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
|
||
return {
|
||
id,
|
||
name,
|
||
props: defaultsFor('texture'),
|
||
type: 'texture',
|
||
file: image.file,
|
||
width: image.width,
|
||
height: image.height,
|
||
frames: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The image is uploaded before the command runs; this only records the result, so undo and redo
|
||
* never touch the network.
|
||
*/
|
||
export class CreateTextureCommand implements Command {
|
||
readonly label: string;
|
||
readonly asset: TextureAsset;
|
||
|
||
constructor(name: string, image: TextureImage) {
|
||
this.asset = buildTextureAsset(name, image);
|
||
this.label = `Import texture "${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`);
|
||
project.assets.push(this.asset);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
project.assets = removeById(project.assets, this.asset.id);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
export function buildSoundAsset(name: string, file: string, id = newId()): SoundAsset {
|
||
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;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
|
||
* parented below those – in container templates as well as in scenes.
|
||
*/
|
||
export class DeleteAssetCommand implements Command {
|
||
readonly label = 'Delete asset';
|
||
private removed?: Removed<Asset>;
|
||
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
|
||
private previousActiveScene = '';
|
||
|
||
constructor(private readonly assetId: string) {}
|
||
|
||
execute(project: Project): void {
|
||
requireAsset(project, this.assetId);
|
||
this.removedObjects = [];
|
||
// The cascade runs while the asset is still in the list: a container asset is an object
|
||
// holder itself, so removing it first would change what `objectHolders` reports.
|
||
for (const holder of objectHolders(project)) {
|
||
const roots = holder.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
|
||
if (roots.length === 0) continue;
|
||
this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
|
||
}
|
||
this.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
|
||
this.previousActiveScene = project.activeSceneId;
|
||
if (project.activeSceneId === this.assetId) {
|
||
const next = project.assets.find((a) => a.type === 'scene');
|
||
project.activeSceneId = next ? next.id : '';
|
||
}
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
restoreAt(project.assets, [this.removed]);
|
||
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
|
||
for (const entry of this.removedObjects) {
|
||
const holder = holders.get(entry.holderId);
|
||
if (holder) restoreAt(holder.objects, entry.removed);
|
||
}
|
||
project.activeSceneId = this.previousActiveScene;
|
||
}
|
||
}
|
||
|
||
export class RenameAssetCommand implements Command {
|
||
readonly label = 'Rename asset';
|
||
private previousName = '';
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly name: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireAsset(project, this.assetId);
|
||
this.previousName = asset.name;
|
||
asset.name = this.name;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
requireAsset(project, this.assetId).name = this.previousName;
|
||
}
|
||
}
|
||
|
||
/** A `global` machine keeps one current state for the whole run instead of one per behavior instance. */
|
||
export class SetMachineScopeCommand implements Command {
|
||
readonly label: string;
|
||
private previous: Scope = 'local';
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly scope: Scope,
|
||
) {
|
||
this.label = `Make state machine ${scope}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireStateMachine(project, this.assetId);
|
||
this.previous = scopeOf(asset);
|
||
setScope(asset, this.scope);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
setScope(requireStateMachine(project, this.assetId), this.previous);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Which state a machine starts in, as a state node's id – `''` takes the mark off again, and the
|
||
* machine is back to starting in no state. Only one state can be the start because there is only
|
||
* one field to hold it.
|
||
*/
|
||
export class SetMachineStartCommand implements Command {
|
||
readonly label: string;
|
||
private previous = '';
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly start: string,
|
||
) {
|
||
this.label = start ? 'Set start state' : 'Remove start state';
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireStateMachine(project, this.assetId);
|
||
this.previous = asset.start ?? '';
|
||
this.write(asset, this.start);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.write(requireStateMachine(project, this.assetId), this.previous);
|
||
}
|
||
|
||
/** Kept out of the document when there is none, the way a `local` scope is. */
|
||
private write(asset: StateMachineAsset, start: string): void {
|
||
if (start) asset.start = start;
|
||
else delete asset.start;
|
||
}
|
||
}
|
||
|
||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||
protected constructor(
|
||
private readonly assetId: string,
|
||
key: string,
|
||
label: string,
|
||
) {
|
||
super(key, label);
|
||
}
|
||
|
||
protected override resolveTarget(project: Project): Asset {
|
||
return requireAsset(project, this.assetId);
|
||
}
|
||
|
||
protected override bagOf(asset: Asset): PropertyBag {
|
||
return asset.props;
|
||
}
|
||
}
|
||
|
||
export class SetAssetPropertyCommand extends AssetPropertyCommand {
|
||
constructor(
|
||
assetId: string,
|
||
key: string,
|
||
private readonly value: PropertyValue,
|
||
) {
|
||
super(assetId, key, `Set ${key}`);
|
||
}
|
||
|
||
protected override computeNext(asset: Asset, project: Project): PropertyValue {
|
||
return coerceValue(requirePropDef(project, asset, this.key), this.value);
|
||
}
|
||
}
|
||
|
||
export class ResetAssetPropertyCommand extends AssetPropertyCommand {
|
||
constructor(assetId: string, key: string) {
|
||
super(assetId, key, `Reset ${key}`);
|
||
}
|
||
|
||
protected override computeNext(asset: Asset, project: Project): PropertyValue {
|
||
return requirePropDef(project, asset, this.key).default;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Names the scene a real run begins with, or takes that name back (`''`). A deleted scene is not
|
||
* cleaned up here on purpose: the id then simply reads as none, and an undo of the deletion brings
|
||
* the start scene back with it.
|
||
*/
|
||
export class SetStartSceneCommand implements Command {
|
||
readonly label = 'Set start scene';
|
||
private previous = '';
|
||
|
||
constructor(private readonly sceneId: string) {}
|
||
|
||
execute(project: Project): void {
|
||
if (this.sceneId) {
|
||
const asset = requireAsset(project, this.sceneId);
|
||
if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`);
|
||
}
|
||
this.previous = project.startSceneId;
|
||
project.startSceneId = this.sceneId;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
project.startSceneId = this.previous;
|
||
}
|
||
}
|
||
|
||
export class SetActiveSceneCommand implements Command {
|
||
readonly label = 'Switch scene';
|
||
private previous = '';
|
||
|
||
constructor(private readonly sceneId: string) {}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireAsset(project, this.sceneId);
|
||
if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`);
|
||
this.previous = project.activeSceneId;
|
||
project.activeSceneId = this.sceneId;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
project.activeSceneId = this.previous;
|
||
}
|
||
}
|