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>
83 lines
3.1 KiB
TypeScript
83 lines
3.1 KiB
TypeScript
/**
|
||
* 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. 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';
|
||
|
||
/** `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'>;
|
||
|
||
function browserStore(): Store | null {
|
||
try {
|
||
return window.localStorage;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function saveKey(projectId: string, area: SaveAreaName): string {
|
||
return `game-maker.saves.${projectId}.${area}`;
|
||
}
|
||
|
||
function readArea(store: Store | null, key: string): SavedValues {
|
||
try {
|
||
const read: unknown = JSON.parse(store?.getItem(key) ?? 'null');
|
||
if (!read || typeof read !== 'object' || Array.isArray(read)) return {};
|
||
const saved: SavedValues = {};
|
||
for (const [assetId, bag] of Object.entries(read))
|
||
if (bag && typeof bag === 'object' && !Array.isArray(bag)) saved[assetId] = bag as PropertyBag;
|
||
return saved;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function writeArea(store: Store | null, key: string, saved: SavedValues): void {
|
||
try {
|
||
if (Object.keys(saved).length === 0) store?.removeItem(key);
|
||
else store?.setItem(key, JSON.stringify(saved));
|
||
} catch {
|
||
// Full or refused: the run goes on with its values, it just will not find them next time.
|
||
}
|
||
}
|
||
|
||
export function saveArea(projectId: string, area: SaveAreaName, store = browserStore()): SaveArea {
|
||
const key = saveKey(projectId, area);
|
||
return {
|
||
read: () => readArea(store, key),
|
||
// Only the assets the run knows are written; one saved by another area or since switched off
|
||
// stays as it was until it is cleared.
|
||
write: (saved) => writeArea(store, key, { ...readArea(store, key), ...saved }),
|
||
};
|
||
}
|
||
|
||
/** Whether either area holds anything for the asset – or, without one, for the project. */
|
||
export function hasSaved(projectId: string, assetId?: string, store = browserStore()): boolean {
|
||
return AREAS.some((area) => {
|
||
const saved = readArea(store, saveKey(projectId, area));
|
||
return assetId === undefined ? Object.keys(saved).length > 0 : assetId in saved;
|
||
});
|
||
}
|
||
|
||
/** Forgets what both areas saved for one asset, or for the whole project without one. */
|
||
export function clearSaved(projectId: string, assetId?: string, store = browserStore()): void {
|
||
for (const area of AREAS) {
|
||
const key = saveKey(projectId, area);
|
||
if (assetId === undefined) {
|
||
writeArea(store, key, {});
|
||
continue;
|
||
}
|
||
const saved = readArea(store, key);
|
||
delete saved[assetId];
|
||
writeArea(store, key, saved);
|
||
}
|
||
}
|