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>
159 lines
7.4 KiB
TypeScript
159 lines
7.4 KiB
TypeScript
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
||
import { effectiveScale, gameClock } from '../../core/time';
|
||
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 } 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;
|
||
const pixelAt = (x: number, y: number): string => stagePixel(app, x, y);
|
||
return {
|
||
pixelAt,
|
||
/**
|
||
* A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a
|
||
* stretched one needs dozens of samples, and dozens of round trips would cost more than the
|
||
* step it belongs to.
|
||
*/
|
||
pixelRow: (x: number, y: number, count: number) => {
|
||
const width = Math.max(1, Math.min(1024, Math.round(count)));
|
||
const row: string[] = [];
|
||
// One extract per pixel, not one strip: a wider frame comes back resampled, and a row that
|
||
// blends its neighbours cannot tell a hard border from a soft one – which is what it is for.
|
||
for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y));
|
||
return row;
|
||
},
|
||
/** Every uploaded picture the project names and whether it is in memory, drawn anywhere or not. */
|
||
heldPictures: () =>
|
||
state.project.assets.flatMap((asset) =>
|
||
asset.type === 'texture' && isTextureFile(asset.file)
|
||
? [{ name: asset.name, held: textureLibrary.peek(asset.file) !== null }]
|
||
: [],
|
||
),
|
||
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
|
||
textureFilter: (file: string, rect: FrameRect | null = null) => {
|
||
const texture = textureLibrary.peek(file, rect);
|
||
if (!texture) return null;
|
||
const source = texture.source;
|
||
const renderer = app.renderer as unknown as {
|
||
gl?: WebGL2RenderingContext;
|
||
uid: number;
|
||
texture: { bindSource(source: unknown, location: number): void };
|
||
};
|
||
const gl = renderer.gl;
|
||
const uploaded = Boolean(
|
||
(source as unknown as { _gpuData: Record<number, unknown> })._gpuData[renderer.uid],
|
||
);
|
||
let magFilter = 0;
|
||
let wrapT = 0;
|
||
if (gl) {
|
||
// Through Pixi rather than a raw `bindTexture`, so its idea of what is bound stays true.
|
||
renderer.texture.bindSource(source, 0);
|
||
magFilter = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER) as number;
|
||
wrapT = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T) as number;
|
||
}
|
||
return {
|
||
scaleMode: source.scaleMode,
|
||
gpu: magFilter === gl?.NEAREST ? 'nearest' : magFilter === gl?.LINEAR ? 'linear' : 'none',
|
||
wrap:
|
||
wrapT === gl?.CLAMP_TO_EDGE
|
||
? 'clamp'
|
||
: wrapT === gl?.REPEAT
|
||
? 'repeat'
|
||
: wrapT === gl?.MIRRORED_REPEAT
|
||
? 'mirror'
|
||
: 'none',
|
||
uploaded,
|
||
};
|
||
},
|
||
/**
|
||
* A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model
|
||
* says it is, and the size it actually lies on the GPU in – which is the only place the
|
||
* supersampling of the rasteriser is visible. `kept` false means the key is not in memory,
|
||
* which is what a check reads to see an old revision let go.
|
||
*/
|
||
drawnPicture: (assetId: string) => {
|
||
const ref = resolvePicture(state.project, assetId);
|
||
if (!ref) return null;
|
||
const texture = textureLibrary.peek(ref.file);
|
||
return {
|
||
file: ref.file,
|
||
width: ref.sourceWidth,
|
||
height: ref.sourceHeight,
|
||
pixelWidth: texture?.source.pixelWidth ?? 0,
|
||
pixelHeight: texture?.source.pixelHeight ?? 0,
|
||
kept: texture !== null,
|
||
};
|
||
},
|
||
fps: () => editor.fps,
|
||
/**
|
||
* What the render loop did: frames actually drawn, and whether it is currently awake. The
|
||
* only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and
|
||
* would answer correctly even if the screen had been frozen for a minute.
|
||
*/
|
||
renderStats: () => ({ drawn: renderCount(), awake: isRendering() }),
|
||
/** Draw again, whatever happens next – what a screenshot needs before it is taken. */
|
||
wake: () => requestRender(),
|
||
/**
|
||
* Resolves once the editor has drawn `count` more frames. The checks await this instead of
|
||
* sleeping a guessed number of milliseconds: a frame is the editor saying it has caught up,
|
||
* where a deadline is only the harness hoping it has.
|
||
*/
|
||
afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs),
|
||
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
|
||
filterCount: () => {
|
||
let n = 0;
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (((node.filters as readonly unknown[] | undefined)?.length ?? 0) > 0) n += 1;
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return n;
|
||
},
|
||
/** Visible sprite meshes drawing with a shader of their own – one draw call each, no pass (#77). */
|
||
shaderCount: () => {
|
||
let n = 0;
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (node instanceof Mesh && node.shader) n += 1;
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return n;
|
||
},
|
||
/** The global clock: the second it stands at, the scale set, and the one a pause – either kind – makes of it. */
|
||
globalTime: () => ({
|
||
now: gameClock.now,
|
||
scale: gameClock.scale,
|
||
effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused),
|
||
}),
|
||
/** Sets the global time scale – there is no slider for it in the GUI yet. */
|
||
setTimeScale: (scale: number) => {
|
||
gameClock.scale = scale;
|
||
},
|
||
/** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */
|
||
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
|
||
/** The same for particle time, kept apart so one can be held without the other. */
|
||
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
|
||
/** Every source's clock back to zero on the current (usually frozen) second. */
|
||
particlesRestart: () => {
|
||
viewport.restartParticles(particleLibrary.now());
|
||
gameView.restartParticles(particleLibrary.now());
|
||
},
|
||
/** What a source is drawing in the scene tab, or in the game view while playing. */
|
||
particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) =>
|
||
(where === 'game' ? gameView : viewport).particleInfo(objectId, limit),
|
||
/** Graphs compiled so far; a moved node must not add one (#65). */
|
||
shaderCompiles: () => shaderLibrary.compiles,
|
||
/** Which views redraw every tick because a Time shader is on screen. */
|
||
animates: () => ({ scene: viewport.animates, game: gameView.animates }),
|
||
lowLatency: () =>
|
||
(app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes()
|
||
?.desynchronized === true,
|
||
};
|
||
}
|