A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
game-maker / suite (push) Failing after 11m6s
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.
This commit is contained in:
1 parent
30090fc48e
commit
1e9d21cd5d
30 files changed
+976
-57
No files matched your search
@@ -344,6 +344,31 @@ export class ResetAssetPropertyCommand extends AssetPropertyCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '';
|
||||
|
||||
@@ -125,10 +125,20 @@ export interface NodeContext {
|
||||
* because the asset says what a sound *is* and the node only colours it. Every call is a voice of
|
||||
* its own: two plays overlap, whether they loop or not, which is what makes one sound effect
|
||||
* fired twice in a row audible twice.
|
||||
*
|
||||
* The voice belongs to the behavior that started it and ends when that behavior is destroyed –
|
||||
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
|
||||
* the one case where the rule is in the way: the death sound of an object that destroys itself.
|
||||
*/
|
||||
playSound(assetId: string, volume: number, loop: boolean | undefined): void;
|
||||
playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean): void;
|
||||
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||||
stopSound(assetId: string): void;
|
||||
/**
|
||||
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
|
||||
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
|
||||
* the kind of thing nobody can debug. The last request of a frame is the one that happens.
|
||||
*/
|
||||
loadScene(sceneId: string): void;
|
||||
/**
|
||||
* The position is local to `parent` (world when none); null when the asset does not exist or
|
||||
* cannot be placed. A spawned behavior runs its `On start` on the next tick.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/**
|
||||
* The `scene` input; unconnected it shows the scene picker, the way `soundPort` shows the sound
|
||||
* one. A `Scene` literal is what feeds it through an edge.
|
||||
*/
|
||||
const scenePort = (): PortDef => ({
|
||||
key: 'scene',
|
||||
label: 'scene',
|
||||
type: 'string',
|
||||
field: { key: 'scene', label: 'Scene', kind: 'scene', default: '' },
|
||||
});
|
||||
|
||||
export const LoadScene: NodeDef = {
|
||||
type: 'LoadScene',
|
||||
label: 'Load scene',
|
||||
category: 'action',
|
||||
description:
|
||||
'Replaces the running scene with another one. Everything of the old scene is gone – its ' +
|
||||
'objects, their behaviors and the sounds those behaviors were playing; what a Variables asset ' +
|
||||
'holds and where a global state machine stands carry over, because those belong to the run and ' +
|
||||
'not to the scene. Naming the scene that is already running rebuilds it, which is how a level ' +
|
||||
'is reset without losing the score. The swap happens once the frame has finished, so the rest ' +
|
||||
'of this graph still runs; asked twice in one frame, the last answer wins.',
|
||||
inputs: [exec(), scenePort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const scene = asString(inputs.scene);
|
||||
if (scene) ctx.loadScene(scene);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString, type Value } from '../../values';
|
||||
import { asBoolean, asNumber, asString, type Value } from '../../values';
|
||||
import { exec, num, soundPort, withDefault } from '../shared';
|
||||
|
||||
/**
|
||||
@@ -34,6 +34,19 @@ export function loopChoice(value: Value | undefined): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way out of the ownership rule, off by default. Needed for one shape that is common enough to
|
||||
* deserve it: an object that plays its own death sound and destroys itself in the same flow – the
|
||||
* voice would be cut off in the instant it started, because the behavior holding it is gone.
|
||||
*/
|
||||
const detachedPort = (): PortDef => ({
|
||||
key: 'detached',
|
||||
label: 'detached',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
||||
});
|
||||
|
||||
export const PlaySound: NodeDef = {
|
||||
type: 'PlaySound',
|
||||
label: 'Play sound',
|
||||
@@ -42,13 +55,15 @@ export const PlaySound: NodeDef = {
|
||||
'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' +
|
||||
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
|
||||
'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' +
|
||||
'twice, overlapping.',
|
||||
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort()],
|
||||
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
|
||||
'unless "detached" is on.',
|
||||
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const sound = asString(inputs.sound);
|
||||
if (sound) ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop));
|
||||
if (sound)
|
||||
ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop), asBoolean(inputs.detached));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -40,6 +40,11 @@ export const SoundLiteral = literal(
|
||||
'sound',
|
||||
'A constant sound, for a "Play sound" or a "Stop sound" to work on.',
|
||||
);
|
||||
export const SceneLiteral = literal(
|
||||
'Scene',
|
||||
'scene',
|
||||
'A constant scene, for a "Load scene" to work on.',
|
||||
);
|
||||
export const ListLiteral = literal(
|
||||
'List',
|
||||
'list',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NodeDef, registerNode } from '../NodeRegistry';
|
||||
import { Destroy } from './action/Destroy';
|
||||
import { Emit } from './action/Emit';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
import { Log } from './action/Log';
|
||||
import { Move } from './action/Move';
|
||||
import { PlaySound } from './action/PlaySound';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
BooleanLiteral,
|
||||
ListLiteral,
|
||||
NumberLiteral,
|
||||
SceneLiteral,
|
||||
SoundLiteral,
|
||||
StateMachineLiteral,
|
||||
StringLiteral,
|
||||
@@ -150,6 +152,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
TextureLiteral,
|
||||
StateMachineLiteral,
|
||||
SoundLiteral,
|
||||
SceneLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
@@ -201,6 +204,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Emit,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
LoadScene,
|
||||
Log,
|
||||
StopGame,
|
||||
RestartGame,
|
||||
|
||||
@@ -17,10 +17,28 @@ export interface Project {
|
||||
assets: Asset[];
|
||||
/** Empty string = none. */
|
||||
activeSceneId: string;
|
||||
/**
|
||||
* Where the game begins – what a player would see first, as opposed to whatever scene happens to
|
||||
* be open in the editor. Empty (or dangling) means none has been named, and a run falls back to
|
||||
* the active scene; no format bump needed for that, since an older project reads as exactly that.
|
||||
*/
|
||||
startSceneId: string;
|
||||
}
|
||||
|
||||
export function createEmptyProject(id: string, name: string): Project {
|
||||
return { formatVersion: PROJECT_FORMAT_VERSION, id, name, assets: [], activeSceneId: '' };
|
||||
return {
|
||||
formatVersion: PROJECT_FORMAT_VERSION,
|
||||
id,
|
||||
name,
|
||||
assets: [],
|
||||
activeSceneId: '',
|
||||
startSceneId: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The scene a real run starts from: the named one, or the active one while none is named. */
|
||||
export function startScene(project: Project): SceneAsset | undefined {
|
||||
return findScene(project, project.startSceneId) ?? findScene(project, project.activeSceneId);
|
||||
}
|
||||
|
||||
export function findAsset(project: Project, assetId: string): Asset | undefined {
|
||||
|
||||
@@ -120,6 +120,20 @@ export const KINDS = [
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
/** A scene asset's id – a text, so it is tagged in a list like a texture. */
|
||||
{
|
||||
kind: 'scene',
|
||||
label: 'Scene',
|
||||
mark: '▣',
|
||||
travels: 'string',
|
||||
empty: '',
|
||||
coerce: text,
|
||||
tagged: true,
|
||||
reference: true,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
|
||||
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
|
||||
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
|
||||
|
||||
@@ -178,12 +178,20 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
activeSceneId = first ? first.id : '';
|
||||
}
|
||||
|
||||
// A dangling start scene reads as none at all – the run then falls back to the active scene,
|
||||
// which is also what every project written before there was such a field does.
|
||||
const startSceneId =
|
||||
typeof r.startSceneId === 'string' && assets.some((a) => a.type === 'scene' && a.id === r.startSceneId)
|
||||
? r.startSceneId
|
||||
: '';
|
||||
|
||||
const project: Project = {
|
||||
formatVersion: PROJECT_FORMAT_VERSION,
|
||||
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
assets,
|
||||
activeSceneId,
|
||||
startSceneId,
|
||||
};
|
||||
return project;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user