Share a link that plays the game without the editor (#266)

A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.

- Menu -> Release... opens a dialog with the state of the release,
  the link with Copy link and Open, Publish or Update release, and
  Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
  snapshots. Updating keeps the link. Take down kills it, and a new
  Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
  paints Theme.colors.background, zooms with Auto and waits for
  "Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
  are stored per link in the browser. Plugins are not loaded, and the
  dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
  without a login. The public answer carries the token as project.id,
  so the project id stays hidden. Release files are written with 0640
  and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
  Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
  EditorState. Pointer forwarding (gamePointer.ts) and setting up
  textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
  plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
  without the editor.

Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.

Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
  session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 13:30:38 +02:00
1 parent 94c1055895
commit 4194f3e150
42 files changed
+1600 -246

No files matched your search

+7 -39
View File
@@ -1,21 +1,18 @@
import type { Application } from 'pixi.js';
import { projectFiles } from '../core/model/projectFiles';
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpReleaseStore } from '../core/store/HttpReleaseStore';
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
import { HttpUsersStore } from '../core/store/HttpUsersStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
import { loadPhysics } from '../runtime/physics/engine';
import { requestRender, type VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { projectPictureFactory } from '../viewport/generatedPictures';
import { preloadProjectFiles, setUpProjectFiles } from '../viewport/projectFiles';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installPixelArtPictures } from '../viewport/pixelArtPicture';
import type { VStack } from '../ui';
import { pixelCels } from '../viewport/PixelCels';
import { installVectorArtPictures } from '../viewport/vectorArtPicture';
import { LoginScreen } from './auth/LoginScreen';
import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
@@ -27,22 +24,6 @@ import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
/**
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
* static files named by their content hash. Fonts and sounds work the same way, one directory over.
*/
const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
const SOUND_BASE = '/game-maker/data/sounds';
const PIXEL_BASE = '/game-maker/data/pixels';
function preloadProjectFiles(state: EditorState): void {
const files = projectFiles(state.project);
textureLibrary.preload(files.textures);
fontLibrary.preload(files.fonts);
pixelCels.preload(files.cels);
}
/**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
* right away when the session from an earlier visit is still good – which is what lets a
@@ -82,19 +63,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
*/
export async function startEditor(app: Application, root: VStack): Promise<void> {
const auth = new HttpAuthStore(API_ENDPOINT);
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
soundLibrary.configure(SOUND_BASE);
pixelCels.configure(PIXEL_BASE);
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own. Registered once
// for the page, not per session – the files they hold are shared by every account anyway.
textureLibrary.onChange(() => requestRender());
fontLibrary.onChange(() => requestRender());
// Vector art draws its own picture and needs the renderer to do it (#129). Registered once for
// the page like the libraries above: which project it is asked about comes from the factory below.
installVectorArtPictures(app.renderer);
installPixelArtPictures();
setUpProjectFiles(app.renderer);
const openSession = async (): Promise<void> => {
const { user, role } = await signedInUser(app, root, auth);
@@ -105,10 +74,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
// Drawn pictures (#185) are looked up in the project this session holds; the next session
// installs its own, which is also what lets go of everything the old one had drawn.
textureLibrary.setPictureFactory(projectPictureFactory(() => state.project));
// Everything the project names is fetched as soon as it is named, not when it is first drawn:
// an animation reaching a frame from another sheet would otherwise blink out once. Asked on
// every change, which is cheap – a file that is held or on its way is skipped.
state.on('project', () => preloadProjectFiles(state));
// Asked on every change, which is cheap – a file that is held or on its way is skipped.
state.on('project', () => preloadProjectFiles(state.project));
const editor = new Editor(
app,
root,
@@ -122,6 +89,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
new HttpSnapshotStore(API_ENDPOINT),
new HttpReleaseStore(API_ENDPOINT),
{
user,
role,
+9 -1
View File
@@ -38,6 +38,8 @@ import { attachAutosave } from './autosave';
import { PasswordDialog } from './auth/PasswordDialog';
import type { OpenProjectDialog } from './OpenProjectDialog';
import type { SnapshotsDialog } from './SnapshotsDialog';
import type { ReleaseStore } from '../core/store/ReleaseStore';
import type { ReleaseDialog } from './ReleaseDialog';
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
import { dialogSlot } from './FormDialog';
import { ContainerEditor } from './container/ContainerEditor';
@@ -271,6 +273,7 @@ export class Editor implements PlayHost {
fonts: FontStore,
sounds: SoundStore,
snapshots: SnapshotStore,
releases: ReleaseStore,
account: EditorAccount,
) {
this.viewport = new SceneViewport(state);
@@ -307,7 +310,7 @@ export class Editor implements PlayHost {
() => {
if (this.play.isPlaying) this.play.stop();
},
{ store: snapshots, flush: () => this.autosave.flush() },
{ snapshots, releases, flush: () => this.autosave.flush() },
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
// manager, and a project with plugins simply has none of them.
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
@@ -580,6 +583,11 @@ export class Editor implements PlayHost {
return this.projects.snapshotList();
}
/** The open "Release" dialog (#266), for the check scripts. */
releaseDialog(): ReleaseDialog | null {
return this.projects.releaseView();
}
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
hasTab(id: string): boolean {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
+34 -53
View File
@@ -5,18 +5,14 @@
*/
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
import { namedValue } from '../core/graph/values';
import {
addGameSystems,
type BehaviorSystem,
behaviorError,
type Trace,
} from '../runtime/systems/BehaviorSystem';
import { GameRun, type RunServices } from '../runtime/GameRun';
import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem';
import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time';
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import type { World } from '../runtime/World';
import { ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import { saveArea } from './saves';
@@ -58,8 +54,7 @@ export interface PlayHost {
}
export class PlaySession {
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private run: GameRun | null = null;
private readonly input = new Input();
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
private waiting = false;
@@ -78,6 +73,14 @@ export class PlaySession {
});
}
private get world(): World | null {
return this.run?.world ?? null;
}
private get behaviors(): BehaviorSystem | null {
return this.run?.behaviors ?? null;
}
get isPlaying(): boolean {
return this.world !== null;
}
@@ -146,47 +149,26 @@ export class PlaySession {
// A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset();
const runtime = this.state.enterPlay();
const world = World.instantiate(runtime, scene.id);
this.world = world;
const services: RunServices = {
audio: soundLibrary,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
};
this.run = GameRun.start(runtime, scene.id, this.input, services);
this.updates = { structure: 0, values: 0 };
this.input.attach();
this.behaviors = addGameSystems(world, runtime, this.input, {
audio: soundLibrary,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
});
this.host.gameView.setWorld(world);
this.host.gameView.setWorld(this.run.world);
this.host.showGame();
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
if (this.run.world.stopRequested) this.stop();
}
/**
* Swaps the running scene for another one, inside the same run: the world is torn down and built
* again, while what belongs to the run – the clock, the shared variables, the global machines –
* carries on. The sounds of the old scene end with it, because the behaviors playing them are
* destroyed; a voice that was started detached is nobody's and plays on.
*/
private loadScene(sceneId: string): void {
const old = this.world;
const runtime = this.state.runtimeProject;
if (!old || !runtime) return;
const carried = this.behaviors?.shared ?? null;
const physics = readyPhysics();
old.destroy();
const world = World.instantiate(runtime, sceneId);
this.world = world;
this.behaviors = addGameSystems(world, runtime, this.input, {
audio: soundLibrary,
carried,
physics,
clock: gameClock,
saves: saveArea(runtime.id, 'play'),
});
this.host.gameView.setWorld(world);
/** A `Load scene` swapped the world (see `GameRun.loadScene`). */
private sceneLoaded(run: GameRun): void {
this.host.gameView.setWorld(run.world);
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
if (run.world.stopRequested) this.stop();
}
private waitForPhysics(): void {
@@ -222,8 +204,7 @@ export class PlaySession {
soundLibrary.setSuspended(false);
soundLibrary.stopAll();
const world = this.world;
this.world = null;
this.behaviors = null;
this.run = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
@@ -240,8 +221,9 @@ export class PlaySession {
* behaviors that run while paused.
*/
tick(step: number, awake: number): void {
const world = this.world;
if (!world) return;
const run = this.run;
if (!run) return;
const world = run.world;
if (this.state.paused) {
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
// resuming would deliver every one of them in a single frame.
@@ -251,20 +233,19 @@ export class PlaySession {
}
const wasPaused = world.paused;
world.held = this.state.heldObjectIds;
world.tick(world.paused ? awake : step);
this.input.endFrame();
const request = run.tick(step, awake);
if (world.paused !== wasPaused) this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) {
if (request === 'stop') {
this.stop();
return;
}
if (world.restartRequested) {
if (request === 'restart') {
this.stop();
this.play();
return;
}
if (world.loadSceneRequested) {
this.loadScene(world.loadSceneRequested);
if (request === 'scene') {
this.sceneLoaded(run);
return;
}
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
+82 -8
View File
@@ -9,10 +9,13 @@ import {
SetWhilePlayingCommand,
} from '../core/commands/projectCommands';
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
import { dialogSlot } from './FormDialog';
import { OpenProjectDialog } from './OpenProjectDialog';
import { ReleaseDialog } from './ReleaseDialog';
import { releaseState, releaseWarnings } from './releases';
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
import { clearSaved, hasSaved } from './saves';
import { snapshotBlocker, snapshotTime } from './snapshots';
@@ -23,6 +26,7 @@ export class ProjectSession {
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
private readonly releaseDialog = dialogSlot<ReleaseDialog>();
/**
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
@@ -32,8 +36,11 @@ export class ProjectSession {
private readonly state: EditorState,
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
/** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */
private readonly snapshots: { store: SnapshotStore; flush: () => Promise<void> },
/**
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
* what the autosave still holds, so a copy is of the stand on screen.
*/
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
/**
* Runs on the project as it came off the server, before it is read into the model: the plugins
* this project switches on are loaded there (#119), so that what they register is in place
@@ -102,6 +109,7 @@ export class ProjectSession {
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true },
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Release…', onSelect: () => this.openRelease() },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more,
@@ -154,7 +162,7 @@ export class ProjectSession {
const projectId = this.state.project.id;
void this.run(
(async () => {
const list = await this.snapshots.store.list(projectId);
const list = await this.copies.snapshots.list(projectId);
this.snapshotsDialog.open(
new SnapshotsDialog(list, {
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
@@ -171,14 +179,80 @@ export class ProjectSession {
return this.snapshotsDialog.current();
}
/** The link that plays only the game (#266). */
openRelease(): void {
const projectId = this.state.project.id;
void this.run(
(async () => {
const release = await this.copies.releases.of(projectId);
const link = release && releaseUrl(location.origin, release.token);
this.releaseDialog.open(
new ReleaseDialog(
release,
link,
releaseState(release, this.state.revision),
releaseWarnings(this.state.project),
{
onPublish: () => void this.run(this.publish(projectId, release !== null)),
onTakeDown: () => this.confirmTakeDown(projectId),
onCopy: (url) => void this.run(this.copyLink(url)),
onOpen: (url) => window.open(url, '_blank', 'noopener'),
},
),
);
})(),
);
}
/** The open release dialog, for the check scripts. */
releaseView(): ReleaseDialog | null {
return this.releaseDialog.current();
}
/** Publishing copies what the server has stored, so everything on screen is saved first. */
private async publish(projectId: string, again: boolean): Promise<void> {
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus, 'release');
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
await this.copies.releases.publish(projectId);
this.state.setNotice(again ? 'Release updated' : 'Released – the link plays the game');
this.openRelease();
}
private confirmTakeDown(projectId: string): void {
new Dialog('Take the release down?', 'The link stops working. Publishing again makes a new one.', [
{ label: 'Cancel', onClick: () => this.openRelease() },
{
label: 'Take down',
danger: true,
onClick: () =>
void this.run(
(async () => {
await this.copies.releases.takeDown(projectId);
this.state.setNotice('Release taken down');
this.openRelease();
})(),
),
},
]).openCentered();
}
private async copyLink(url: string): Promise<void> {
await navigator.clipboard.writeText(url);
this.state.setNotice('Link copied');
}
private async createSnapshot(projectId: string, name: string): Promise<void> {
await this.snapshots.flush();
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus);
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
const made = await this.snapshots.store.create(projectId, name || snapshotTime(new Date()));
const made = await this.copies.snapshots.create(projectId, name || snapshotTime(new Date()));
this.state.setNotice(`Snapshot "${made.name}" created`);
this.openSnapshots();
}
@@ -201,14 +275,14 @@ export class ProjectSession {
/** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
this.beforeSwitch();
await this.snapshots.flush();
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus);
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
try {
const { before } = await this.snapshots.store.restore(projectId, snapshot.id, this.state.revision);
const { before } = await this.copies.snapshots.restore(projectId, snapshot.id, this.state.revision);
await this.open(projectId);
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
} catch (error) {
@@ -227,7 +301,7 @@ export class ProjectSession {
onClick: () =>
void this.run(
(async () => {
await this.snapshots.store.delete(projectId, snapshot.id);
await this.copies.snapshots.delete(projectId, snapshot.id);
this.openSnapshots();
})(),
),
+58
View File
@@ -0,0 +1,58 @@
import type { ReleaseSummary } from '../core/store/ReleaseStore';
import { Button, HStack, Label, Popup, TextField, Theme } from '../ui';
import { dialogButtons } from './FormDialog';
const LINK_WIDTH = 420;
/**
* "Release…" (#266): the link that plays only the game, with Copy and Open, and Publish – or Update
* release and Take down once there is one. Every action closes the dialog and hands the choice on;
* the caller opens it again on the new state.
*/
export class ReleaseDialog extends Popup {
private readonly linkField: TextField | null = null;
private readonly shown: { state: string; warnings: string[] };
constructor(
release: ReleaseSummary | null,
link: string | null,
state: string,
warnings: readonly string[],
actions: {
onPublish(): void;
onTakeDown(): void;
onCopy(link: string): void;
onOpen(link: string): void;
},
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = LINK_WIDTH;
this.shown = { state, warnings: [...warnings] };
this.content.add(new Label('Release', { style: 'bodyBold' }));
this.content.add(new Label(state, { style: 'muted' }));
if (link) {
this.linkField = new TextField(link, { readOnly: true, width: LINK_WIDTH });
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(this.linkField, 1);
row.add(new Button('Copy link', { onClick: () => actions.onCopy(link) }));
row.add(new Button('Open', { onClick: () => actions.onOpen(link) }));
this.content.add(row);
}
for (const warning of warnings) this.content.add(new Label(warning, { style: 'warning' }));
const publish = new Button(release ? 'Update release' : 'Publish', {
variant: 'primary',
onClick: this.closeThen(() => actions.onPublish()),
});
const takeDown = release
? new Button('Take down', { variant: 'danger', onClick: this.closeThen(() => actions.onTakeDown()) })
: undefined;
this.content.add(dialogButtons(this.closeThen(), publish, takeDown));
}
/** The link as shown – null while there is none – the state line and the warnings (test hook). */
info(): { link: string | null; state: string; warnings: string[] } {
return { link: this.linkField?.value ?? null, ...this.shown, warnings: [...this.shown.warnings] };
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import type { EditorState } from '../state';
import { Theme } from '../../ui';
import { collectHolderItems } from '../../viewport/items';
import { FreeViewport } from '../viewport/FreeViewport';
import type { Frame } from '../viewport/WorldViewport';
import type { Frame } from '../../viewport/WorldViewport';
/**
* The picture above the timeline (#99): the animation's root with every row's value at the
+3 -11
View File
@@ -1,24 +1,16 @@
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
import { effectiveScale, gameClock } from '../../core/time';
import { afterFrames, isRendering, renderCount, requestRender } from '../../ui';
import { afterFrames, isRendering, renderCount, requestRender, stagePixel } from '../../ui';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { type Container, Mesh, Rectangle } from 'pixi.js';
import { type Container, Mesh } from 'pixi.js';
import type { HookContext } from './hookHelpers';
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
export function renderHooks({ app, state, editor }: HookContext) {
const { viewport, gameView } = editor;
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
const pixelAt = (x: number, y: number): string => {
const { pixels } = app.renderer.extract.pixels({
target: app.stage,
frame: new Rectangle(x, y, 1, 1),
});
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
};
const pixelAt = (x: number, y: number): string => stagePixel(app, x, y);
return {
pixelAt,
/**
+2
View File
@@ -255,6 +255,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
const dialog = editor.snapshotsDialog();
return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null;
},
/** The "Release" dialog (#266): the link, the state line and the warnings. */
release: () => editor.releaseDialog()?.info() ?? null,
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
/** The left column: asset panel on top, scene objects below, and the divider between them. */
+24
View File
@@ -0,0 +1,24 @@
import { type Project, startScene } from '../core/model/Project';
import type { ReleaseSummary } from '../core/store/ReleaseStore';
import { snapshotTime } from './snapshots';
/** What the release dialog warns about (#266): what a player would miss, before it is published. */
export function releaseWarnings(project: Project): string[] {
const warnings: string[] = [];
if (!startScene(project)) warnings.push('The project has no scene, so there is nothing to play.');
if (project.plugins.length > 0)
warnings.push('Plugins are not loaded in a released game. Behaviors that need them will not work.');
return warnings;
}
/**
* The line under the dialog's title: not released, or when the copy was taken and whether the saved
* project has moved on since. `revision` is the saved one, which is what publishing copies.
*/
export function releaseState(release: ReleaseSummary | null, revision: number): string {
if (!release) return 'Not released. Publishing makes a link that plays only the game.';
const when = `Released ${snapshotTime(new Date(release.publishedAt))}`;
return release.revision === revision
? `${when} · the link plays the project as saved`
: `${when} · the project has changed since`;
}
+4 -2
View File
@@ -1,14 +1,16 @@
/**
* The saves of persistent Variables assets (#166), kept in this browser per project. Played and
* editor runs write to separate areas, so trying a behavior in the editor leaves the player's save
* alone. Storage is best effort: a browser that refuses `localStorage` plays every run from the
* alone. A released game keeps its own, under the token of its link. Storage is best effort: a browser that refuses `localStorage` plays every run from the
* declared values.
*/
import type { SaveArea, SavedValues } from '../core/graph';
import type { PropertyBag } from '../core/model/Properties';
export type SaveAreaName = 'play' | 'editor';
/** `release` is a released game's (#266), kept per link rather than per project. */
export type SaveAreaName = 'play' | 'editor' | 'release';
/** The areas a project's own saves are kept in – what the editor shows and clears. */
const AREAS: readonly SaveAreaName[] = ['play', 'editor'];
type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
+6 -3
View File
@@ -9,16 +9,19 @@ export function snapshotTime(at: Date): string {
/**
* Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies
* what it has stored, so the editor has to have saved everything first: a snapshot without the
* last edits would be a stand nobody ever saw.
* last edits would be a stand nobody ever saw. A release is a copy as well (#266) and asks the same.
*/
export function snapshotBlocker(status: SaveStatus): string | null {
export function snapshotBlocker(
status: SaveStatus,
copy: 'snapshot' | 'release' = 'snapshot',
): string | null {
switch (status) {
case 'saved':
return null;
case 'conflict':
return 'This project was changed elsewhere. Reload to continue.';
case 'error':
return 'The project could not be saved, so there is nothing current to take a snapshot of';
return `The project could not be saved, so there is nothing current to ${copy === 'release' ? 'release' : 'take a snapshot of'}`;
default:
return 'The latest changes are not saved yet – stop the game or wait a moment';
}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
wheelSteps,
zoomStepBelow,
} from '../../viewport/view';
import { WorldViewport } from './WorldViewport';
import { WorldViewport } from '../../viewport/WorldViewport';
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
@@ -22,7 +22,7 @@ const FIT_MARGIN = 40;
* preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content.
* The zoom only takes whole steps in device pixels (`FREE_ZOOM_STEPS`), so pixel art stays even.
*/
export abstract class FreeViewport extends WorldViewport {
export abstract class FreeViewport extends WorldViewport<EditorState> {
/** World point at the viewport centre, and zoom in device pixels per world unit. */
protected view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
+10 -40
View File
@@ -2,30 +2,18 @@
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
* playing, the model as a still preview otherwise.
*/
import {
type FederatedMouseEvent,
type FederatedPointerEvent,
type FederatedWheelEvent,
Text,
} from 'pixi.js';
import { Text } from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import type { EditorState } from '../state';
import type { World } from '../../runtime/World';
import { Theme, textStyle, ui } from '../../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
import {
cameraFactor,
cameraView,
type GameScale,
isInside,
screenToWorld,
type View,
wheelNotches,
} from '../../viewport/view';
import { type Frame, WorldViewport } from './WorldViewport';
import { routePointer } from '../../viewport/gamePointer';
import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view';
import { type Frame, WorldViewport } from '../../viewport/WorldViewport';
export class GameViewport extends WorldViewport {
export class GameViewport extends WorldViewport<EditorState> {
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
private readonly editorRunCaption = new Text({
text: 'Behaviors run here · keys and clicks go to the game',
@@ -46,19 +34,11 @@ export class GameViewport extends WorldViewport {
this.addChild(this.hint);
this.editorRunCaption.eventMode = 'none';
this.addChild(this.editorRunCaption);
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
this.reportPointer(e);
this.pointerWorld()?.setPointerDown(true);
});
this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e));
this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false));
this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false));
this.on('wheel', (e: FederatedWheelEvent) => {
this.reportPointer(e);
this.pointerWorld()?.turnWheel(wheelNotches(e.deltaY, e.deltaMode));
});
routePointer(
this,
() => this.pointerWorld(),
() => this.currentView(),
);
}
/** null = not playing. */
@@ -155,14 +135,4 @@ export class GameViewport extends WorldViewport {
this.editorRunCaption.visible = this.editorWorld !== null && !this.playWorld;
this.editorRunCaption.position.set(8, 6);
}
/** Outside the camera frame counts as "not in view": that region is letterbox, not game. */
private reportPointer(e: FederatedMouseEvent): void {
const world = this.pointerWorld();
const view = this.currentView();
if (!world || !view) return;
const local = this.toLocal(e.global);
const point = screenToWorld(view, local.x, local.y);
world.movePointer(point.x, point.y, isInside(view, local.x, local.y));
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor';
import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost';
import { type PaintTarget, TilemapPainter } from './tilemapPainting';
import { TilemapSolidOutlines } from './tilemapSolids';
import type { Frame } from './WorldViewport';
import type { Frame } from '../../viewport/WorldViewport';
/** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3;
-604
View File
@@ -1,604 +0,0 @@
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import { type Extent, scaleOf } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds';
import { rectsIntersect } from '../../core/properties/camera';
import type { ClipRegion } from '../../core/properties/clip';
import type { EditorState } from '../state';
import { type Size, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
import { drawSlots } from '../../viewport/drawOrder';
import type { DrawItem } from '../../viewport/items';
import { hashId } from '../../core/particles/nodes';
import { ParticleField } from '../../viewport/particles';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { SHEET_EDGE } from '../../core/shader/nodes';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { type ShaderSource, wornShader } from '../../viewport/shaders';
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols';
import { TextPicture } from '../../viewport/textPicture';
import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { gameClock } from '../../core/time';
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
export const CLIP_GHOST_ALPHA = 0.3;
export interface Frame {
items: DrawItem[];
background: number;
}
export abstract class WorldViewport extends Widget {
protected readonly bg = new Graphics();
protected readonly content = new Container();
protected readonly world = new Container();
/** Ordered by `z`, ties by hierarchy order (see `syncDrawOrder`). */
protected readonly objects = new Container();
/** Editor symbols, always above `objects` so no sprite can hide them whatever the z-order. */
protected readonly overlay = new Container();
protected readonly contentMask = new Graphics();
protected readonly nodes = new Map<string, Graphics>();
/** Text content of text items: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, TextPicture>();
/** Pictures of sprite items: siblings as well, each right under its node. */
private readonly sprites = new Map<string, Mesh>();
/** The drawn particles of each source, one container – and one draw call – per source. */
private readonly fields = new Map<string, ParticleField>();
/** The meshes of each placed tilemap, one container per layer (#174). */
private readonly tilemaps = new Map<string, TilemapField>();
/**
* What clip areas cut off (#260), as a mask per drawn thing: Pixi lets a mask serve only one, and
* switches a shared one back on as content once any of them lets go of it.
*/
private readonly masks = new Container();
private readonly maskOf = new Map<Container, Graphics>();
/** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */
private readonly ghostSprites = new Map<string, Mesh>();
private readonly ghostTexts = new Map<string, TextPicture>();
private visibleCount = 0;
private animated = false;
protected lastItems: DrawItem[] = [];
/**
* What the last full redraw was drawn for: its zoom and view size, and per object node the box
* it is culled by (`null`: never culled, a particle source). What `moveCamera` needs to tell
* whether a pan changes anything but where the world sits.
*/
private drawn: {
scale: number;
w: number;
h: number;
culled: { node: Graphics; extent: Extent | null }[];
} | null = null;
/** Full redraws and camera-only moves since start (test hook). */
private redraws = 0;
private cameraMoves = 0;
private readonly stopFiles: (() => void)[];
protected constructor(protected readonly state: EditorState) {
super();
this.world.addChild(this.masks, this.objects, this.overlay);
this.content.addChild(this.world);
this.addChild(this.bg, this.content, this.contentMask);
this.content.mask = this.contentMask;
this.cursor = 'default';
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
state.on('mode', () => this.redraw());
// While a Time shader is on screen the ticker redraws every frame anyway (`animate`).
state.on('runtime', () => {
if (this.visible && !this.animated) this.redraw();
});
// Unconditional: a picture or a typeface arriving fires once per file, not per frame.
this.stopFiles = [
textureLibrary.onChange(() => this.redraw()),
fontLibrary.onChange(() => this.redraw()),
];
}
/** The libraries outlive a signed-out editor; a stale listener would redraw destroyed graphics. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
for (const stop of this.stopFiles) stop();
super.destroy(options);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
protected abstract currentView(): View | null;
/** Only called when `currentView()` is non-null. */
protected abstract collectFrame(): Frame;
/** Fills the whole widget, behind and around the view rectangle. */
protected abstract get baseColor(): number;
protected paintsBackground(): boolean {
return true;
}
protected get itemsInteractive(): boolean {
return false;
}
protected beforeItems(_view: View | null): void {}
protected afterItems(_items: DrawItem[], _view: View): void {}
/** `extent` is the world rectangle around the item as drawn, turn and size included. */
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _extent: Extent): void {}
/** An item disappeared: drop whatever `syncDecorations` created for it. */
protected disposeDecorations(_id: string): void {}
/** Whether the item's own drawing is switched off – it is then neither seen nor hit. */
protected hidesShape(_item: DrawItem): boolean {
return false;
}
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
/** Whether the part a clip area cuts off the item is shown faintly and can be grabbed (#260). */
protected opensClip(_item: DrawItem): boolean {
return false;
}
/** Whether the object itself is selected – a selected collider shows its area filled. */
protected isSelected(_objectId: string): boolean {
return false;
}
/** Screen-space drawing that follows the camera, redrawn after a full redraw and after a pan. */
protected drawOnScreen(_items: DrawItem[], _view: View): void {}
debugInfo(): { rendering: boolean; visibleItems: number; redraws: number; cameraMoves: number } {
return {
rendering: this.currentView() !== null,
visibleItems: this.visibleCount,
redraws: this.redraws,
cameraMoves: this.cameraMoves,
};
}
/**
* The camera moved and nothing else did – a pan. Zoom and size are the same, so every node is
* drawn as it was; only the world's position and the screen-space overlays follow. Anything
* else, or an object crossing the edge of the view, takes the full redraw, which would collect,
* redraw and re-upload every shape of a pan frame for nothing.
*/
protected moveCamera(): void {
const view = this.currentView();
const drawn = this.drawn;
if (!view || !drawn || view.scale !== drawn.scale || view.rect.w !== drawn.w || view.rect.h !== drawn.h) {
this.redraw();
return;
}
const bounds = worldBounds(view);
for (const { node, extent } of drawn.culled)
if ((extent === null || rectsIntersect(extent, bounds)) !== node.visible) {
this.redraw();
return;
}
this.cameraMoves++;
this.placeWorld(view);
this.drawOnScreen(this.lastItems, view);
}
private placeWorld(view: View): void {
const { rect } = view;
this.world.position.set(
rect.x + rect.w / 2 - view.x * view.scale,
rect.y + rect.h / 2 - view.y * view.scale,
);
this.world.scale.set(view.scale);
}
/**
* The particles of one source as drawn, in world units, oldest first – what a check reads instead
* of guessing from pixels. `limit` keeps a stream of 500 from becoming 500 lines of JSON.
*/
particleInfo(
objectId: string,
limit = 8,
): {
count: number;
drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[];
} | null {
const field = this.fields.get(objectId);
if (!field || !field.container.visible) return null;
return {
count: field.count,
drawn: field.container.particleChildren.slice(0, limit).map((p) => ({
x: Math.round(p.x * 100) / 100,
y: Math.round(p.y * 100) / 100,
scaleX: Math.round(p.scaleX * 1000) / 1000,
alpha: Math.round(p.alpha * 1000) / 1000,
tint: p.tint,
})),
};
}
/**
* Starts every source's clock over at `now`. With particle time frozen this is what makes a
* measurement exact: freeze, restart, move the clock to the second wanted, read.
*/
restartParticles(now: number): void {
for (const field of this.fields.values()) field.restart(now);
this.redraw();
}
/** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */
get animates(): boolean {
return this.animated;
}
/** Called every tick: redraws while the last redraw found a Time shader on screen. */
animate(): void {
if (this.visible && this.animated) this.redraw();
}
layerOf(objectId: string): 'objects' | 'overlay' | null {
const node = this.nodes.get(objectId);
if (!node) return null;
return node.parent === this.overlay ? 'overlay' : 'objects';
}
/** Ids in the order they are drawn, back to front. */
drawOrder(): string[] {
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
}
/** Where an object is drawn, in global screen coordinates. */
objectScreen(objectId: string): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const local = worldToScreen(view, item.x, item.y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
/**
* The screen rectangle around an object, so tests can sample the pixels inside it: centre plus
* size, and for a turned object the box around its corners.
*/
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
const origin = this.getGlobalPosition();
return {
x: origin.x + local.x,
y: origin.y + local.y,
w: (extent.right - extent.left) * view.scale,
h: (extent.bottom - extent.top) * view.scale,
};
}
override redraw(): void {
const view = this.currentView();
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor);
this.beforeItems(view);
this.contentMask.clear();
this.content.visible = view !== null;
this.visibleCount = 0;
this.animated = false;
this.drawn = null;
if (!view) return;
this.redraws++;
const { items, background } = this.collectFrame();
this.lastItems = items;
const { rect } = view;
if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
this.placeWorld(view);
const bounds = worldBounds(view);
const shaders = shaderLibrary.source(this.state.project);
const culled: { node: Graphics; extent: Extent | null }[] = [];
const seen = new Set<string>();
for (const item of items) {
seen.add(item.id);
const size = footprintOf(item.type, item.props, item.frame);
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
// A switched-off object is not drawn at all, in the scene view as in the game; it is
// selected and switched back on from the hierarchy panel. A source is never culled by its
// own little square: its particles may be anywhere on screen while it is not.
const onScreen = item.enabled && (item.type === 'particles' || rectsIntersect(extent, bounds));
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
node.label = item.id;
node.cursor = 'move';
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
this.nodes.set(item.id, node);
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
}
const hidden = this.hidesShape(item);
node.visible = onScreen && !hidden;
if (item.enabled && !hidden) culled.push({ node, extent: item.type === 'particles' ? null : extent });
node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none';
this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale, shaders);
this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen, shaders);
this.syncTilemap(item, onScreen);
if (!onScreen) continue;
this.visibleCount++;
if (hidden) continue;
drawItem(
node,
item,
size,
view.scale * scaleOf(placementOf(item.type, item.m)),
this.isSelected(item.id),
);
}
this.afterItems(items, view);
this.drawOnScreen(items, view);
this.drawn = { scale: view.scale, w: rect.w, h: rect.h, culled };
for (const [id, node] of this.nodes) {
if (seen.has(id)) continue;
this.texts.get(id)?.destroy();
this.texts.delete(id);
const sprite = this.sprites.get(id);
if (sprite) disposeSpriteMesh(sprite);
this.sprites.delete(id);
this.fields.get(id)?.destroy();
this.fields.delete(id);
this.tilemaps.get(id)?.destroy();
this.tilemaps.delete(id);
this.dropGhosts(id);
node.destroy();
this.nodes.delete(id);
this.disposeDecorations(id);
}
this.syncDrawOrder(items);
this.sweepMasks();
}
/**
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order, each layer of a map
* sorted in on its own (#174, see `drawSlots`). Explicit rather than Pixi's lazy
* `sortableChildren` so the order is deterministic and honours hierarchy changes.
*/
private syncDrawOrder(items: DrawItem[]): void {
this.objects.removeChildren();
const byId = new Map(items.map((item) => [item.id, item]));
for (const { id, layerId, row } of drawSlots(items)) {
const clip = byId.get(id)?.clip ?? null;
if (layerId) {
const layer = this.tilemaps.get(id)?.layerContainer(layerId, row);
if (layer) this.objects.addChild(this.clipTo(layer, clip));
continue;
}
// Picture and text are siblings because Graphics may not have children: the picture right
// under its node, which only holds the hit area, the text right above its box. A ghost goes
// right under what it is the rest of.
const ghost = this.ghostSprites.get(id);
if (ghost) this.objects.addChild(this.clipTo(ghost, clip, true));
const sprite = this.sprites.get(id);
if (sprite) this.objects.addChild(this.clipTo(sprite, clip));
const node = this.nodes.get(id);
if (node) this.objects.addChild(node);
const ghostText = this.ghostTexts.get(id);
if (ghostText) this.objects.addChild(this.clipTo(ghostText.mesh, clip, true));
const text = this.texts.get(id);
if (text) this.objects.addChild(this.clipTo(text.mesh, clip));
const field = this.fields.get(id);
if (field) this.objects.addChild(this.clipTo(field.container, clip));
}
}
/**
* Cuts `target` to the region, or with `inverse` to everything outside it. The mask lives in
* world units like the objects, and stays with the target until the target goes.
*/
private clipTo<T extends Container>(target: T, region: ClipRegion | null, inverse = false): T {
let mask = this.maskOf.get(target);
if (!region) {
if (mask) {
target.mask = null;
mask.destroy();
this.maskOf.delete(target);
}
return target;
}
if (!mask) {
mask = new Graphics();
this.masks.addChild(mask);
this.maskOf.set(target, mask);
target.setMask({ mask, inverse });
}
mask.clear();
if (region.length >= 3) mask.poly([...region]).fill(0xffffff);
return target;
}
/** Masks whose target is gone: a tilemap drops the containers of layers it no longer has itself. */
private sweepMasks(): void {
for (const [target, mask] of this.maskOf)
if (target.destroyed) {
mask.destroy();
this.maskOf.delete(target);
}
}
private dropGhosts(id: string): void {
const sprite = this.ghostSprites.get(id);
if (sprite) disposeSpriteMesh(sprite);
this.ghostSprites.delete(id);
this.ghostTexts.get(id)?.destroy();
this.ghostTexts.delete(id);
}
/** Whether the item has a cut-off part to show faintly right now. */
private ghosted(item: DrawItem, onScreen: boolean): boolean {
return onScreen && item.clip !== null && this.opensClip(item);
}
/**
* The particles of one source. Their positions are world units, like everything else under
* `objects`, so the container itself carries no transform: the source's own goes into each
* particle, which is what lets one stay where it was born while its source flies on.
*/
private syncParticles(item: DrawItem, onScreen: boolean, shaders: ShaderSource): void {
let field = this.fields.get(item.id);
if (item.type !== 'particles' || !item.particles || !onScreen) {
if (field) field.container.visible = false;
return;
}
if (!field) {
field = new ParticleField();
this.fields.set(item.id, field);
this.objects.addChild(field.container);
}
field.container.visible = true;
// A source is a moving picture by definition, so the view keeps redrawing while one is on screen.
this.animated = true;
const build = particleLibrary.buildFor(item.particles);
const compiled = build.compiled;
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null;
field.wear(item.props, shaders);
field.sync(
compiled,
item.props,
params,
item.m,
texture ?? Texture.WHITE,
particleLibrary.now(),
item.emits,
hashId(item.id),
);
}
/**
* A painted map, drawn from its own meshes. It keeps redrawing the view while an animated
* tile is on it, the same way a Time shader does.
*/
private syncTilemap(item: DrawItem, onScreen: boolean): void {
let field = this.tilemaps.get(item.id);
if (item.type !== 'tilemap' || !item.tilemap || !onScreen) {
if (field) field.visible = false;
return;
}
if (!field) {
// Its layers are attached by `syncDrawOrder`, which runs after every item is synced.
field = new TilemapField();
this.tilemaps.set(item.id, field);
}
field.visible = true;
field.sync(item, textureLibrary, gameClock.now, this.tilemapFocus(item));
if (field.animated) this.animated = true;
}
/**
* Which layer of a tilemap is singled out and how (#138). The game view shows every map as the
* game does; only the scene view, where one paints, has a say.
*/
protected tilemapFocus(_item: DrawItem): LayerFocus | null {
return null;
}
/** Where the centre of one cell of a placed tilemap is on screen, so a check can sample it. */
cellScreen(objectId: string, x: number, y: number): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!view || !item || item.type !== 'tilemap') return null;
const centre = cellCentre(item, x, y);
const local = worldToScreen(view, centre.x, centre.y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
/** What a placed tilemap drew last redraw: how many meshes, and how many cells in them (test hook). */
tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null {
const field = this.tilemaps.get(objectId);
const item = this.lastItems.find((i) => i.id === objectId);
if (!field?.visible || !item?.tilemap) return null;
return {
drawCalls: field.drawCalls,
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
layers: item.tilemap.layers.length,
};
}
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
let sprite = this.sprites.get(item.id);
if (item.type !== 'sprite' || !onScreen) {
if (sprite) sprite.visible = false;
const ghost = this.ghostSprites.get(item.id);
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
return;
}
if (!sprite) {
sprite = newSpriteMesh();
this.sprites.set(item.id, sprite);
this.objects.addChild(sprite);
}
sprite.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
let ghost = this.ghostSprites.get(item.id);
if (!this.ghosted(item, onScreen)) {
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
return;
}
if (!ghost) {
ghost = newSpriteMesh();
ghost.alpha = CLIP_GHOST_ALPHA;
this.ghostSprites.set(item.id, ghost);
}
syncSpriteMesh(ghost, item, size, textureLibrary, shaders);
}
/**
* The typeface a text is drawn in and how wide that makes it. Both are what a font asset is
* for, and both arrive asynchronously, so a test cannot read them off the model.
*/
textInfo(id: string): ReturnType<TextPicture['info']> | null {
const text = this.texts.get(id);
return text?.mesh.visible ? text.info() : null;
}
private syncText(item: DrawItem, onScreen: boolean, scale: number, shaders: ShaderSource): void {
let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) {
if (text) text.mesh.visible = false;
this.ghostTexts.get(item.id)?.destroy();
this.ghostTexts.delete(item.id);
return;
}
if (!text) {
text = new TextPicture();
this.texts.set(item.id, text);
this.objects.addChild(text.mesh);
}
text.mesh.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
text.sync(item, scale, shaders);
let ghost = this.ghostTexts.get(item.id);
if (!this.ghosted(item, onScreen)) {
ghost?.destroy();
this.ghostTexts.delete(item.id);
return;
}
if (!ghost) {
ghost = new TextPicture();
ghost.mesh.alpha = CLIP_GHOST_ALPHA;
this.ghostTexts.set(item.id, ghost);
}
ghost.sync(item, scale, shaders);
}
}