Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ world. The world shows what _can_ run, not a trace of what did run.
| 1–9 | Pick a callee at a violet choice portal |
| Backspace | Back to where this function was entered |
| R | Back to the entrance |
| I | Renderer stats (draws, triangles, …) |
| Esc | Release the mouse |

## Run it locally
Expand Down
6 changes: 4 additions & 2 deletions apps/web/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions apps/web/src/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* A value handed from the render loop to the overlays without rendering the
* app (which would re-render the whole scene): the loop publishes, an open
* overlay subscribes through `useSyncExternalStore`.
*/
type Feed<T> = {
readonly publish: (value: T) => void;
readonly subscribe: (listener: () => void) => () => void;
/** The latest value, the same object until the next publish. */
readonly get: () => T | null;
/** Forgets the latest value, so a stale one is never shown. */
readonly clear: () => void;
};

const createFeed = <T>(): Feed<T> => {
let latest: T | null = null;
const listeners = new Set<() => void>();
const set = (value: T | null) => {
latest = value;
for (const listener of listeners) {
listener();
}
};
return {
publish: set,
clear: () => set(null),
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
get: () => latest,
};
};

export { createFeed };
export type { Feed };
5 changes: 5 additions & 0 deletions apps/web/src/hud.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ describe("Hud", () => {
it("shows the current level while walking", () => {
expect(render(true)).toContain("seed 7 · 20 rooms · quality low");
});

it("links home to the landing page only before walking", () => {
expect(render(false)).toContain('href="/"');
expect(render(true)).not.toContain('href="/"');
});
});
10 changes: 10 additions & 0 deletions apps/web/src/hud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const hints: readonly (readonly [string, string])[] = [
["R", "back to the entrance"],
["N", "next seed"],
["Q", "toggle quality"],
["I", "renderer stats"],
["M", "local map"],
["Tab", "world map"],
["Esc", "release the mouse"],
Expand Down Expand Up @@ -134,6 +135,15 @@ const Hud = ({
}
return (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-8 bg-black/55 px-6 text-center text-amber-50">
{/* A bare URL is the landing page. The click stays here: anywhere else
on the page it would lock the pointer on the way out. */}
<a
href="/"
onClick={(event) => event.stopPropagation()}
className="pointer-events-auto absolute top-4 left-4 text-sm text-amber-100/70 hover:text-amber-50"
>
← Home
</a>
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
Code Backrooms
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("parseWorldParams", () => {
code: null,
eager: false,
view: "world",
stats: false,
});
});

Expand All @@ -32,6 +33,7 @@ describe("parseWorldParams", () => {
code: "demo",
eager: true,
view: "world",
stats: false,
});
});

Expand All @@ -49,6 +51,11 @@ describe("parseWorldParams", () => {
expect(parseWorldParams("?view").view).toBe("world");
});

it("shows the stats only for stats=1", () => {
expect(parseWorldParams("?stats=1").stats).toBe(true);
expect(parseWorldParams("?stats").stats).toBe(false);
});

it("is eager only for eager=1", () => {
expect(parseWorldParams("?eager=0").eager).toBe(false);
expect(parseWorldParams("?eager=true").eager).toBe(false);
Expand All @@ -65,6 +72,7 @@ describe("parseWorldParams", () => {
code: null,
eager: false,
view: "world",
stats: false,
});
expect(parseWorldParams("?rooms=-5")).toMatchObject({ rooms: 1 });
expect(parseWorldParams("?rooms=20junk&seed=1.5")).toMatchObject({
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type WorldParams = {
readonly eager: boolean;
/** `?view=map`: the whole world as a 2D map, without WebGL. */
readonly view: "world" | "map";
/** `?stats=1`: show the renderer's numbers from the start (I toggles them). */
readonly stats: boolean;
};

/** Random and preset worlds without a `?seed`. */
Expand Down Expand Up @@ -62,6 +64,7 @@ const parseWorldParams = (search: string): WorldParams => {
code: code !== null && isExampleName(code) ? code : null,
eager: params.get("eager") === "1",
view: params.get("view") === "map" ? "map" : "world",
stats: params.get("stats") === "1",
Comment thread
valuecodes marked this conversation as resolved.
};
};

Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/pose-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,16 @@ describe("createPoseFeed", () => {
expect(seen).toEqual([1, 2]);
expect(feed.get()?.position.x).toBe(3);
});

it("forgets the latest pose on clear and tells subscribers", () => {
const feed = createPoseFeed();
feed.publish(pose(1));
let calls = 0;
feed.subscribe(() => {
calls += 1;
});
feed.clear();
expect(feed.get()).toBeNull();
expect(calls).toBe(1);
});
});
36 changes: 6 additions & 30 deletions apps/web/src/pose-feed.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,12 @@
import type { Placement } from "@repo/types";

/**
* Where the player is, handed from the render loop to the overview map
* without rendering the app (which would re-render the whole scene): the
* Player publishes, the open map subscribes through `useSyncExternalStore`.
*/
type PoseFeed = {
readonly publish: (pose: Placement) => void;
readonly subscribe: (listener: () => void) => () => void;
/** The latest pose, the same object until the next publish. */
readonly get: () => Placement | null;
};
import { createFeed } from "~/feed";
import type { Feed } from "~/feed";

const createPoseFeed = (): PoseFeed => {
let latest: Placement | null = null;
const listeners = new Set<() => void>();
return {
publish: (pose) => {
latest = pose;
for (const listener of listeners) {
listener();
}
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
get: () => latest,
};
};
/** Where the player is: the Player publishes, the open maps subscribe. */
type PoseFeed = Feed<Placement>;

const createPoseFeed = (): PoseFeed => createFeed<Placement>();

export { createPoseFeed };
export type { PoseFeed };
1 change: 1 addition & 0 deletions apps/web/src/source-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("showsLanding", () => {
"?code=bogus",
"?eager=1",
"?view=map",
"?stats=1",
])("opens a world directly for %s", (search) => {
expect(showsLanding(NONE, search)).toBe(false);
});
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/source-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const WORLD_KEYS: readonly string[] = [
"code",
"eager",
"view",
"stats",
];

/**
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/stats-line.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RenderSample } from "@repo/renderer/stats";
import { useSyncExternalStore } from "react";

import type { Feed } from "~/feed";
import { statsText } from "~/stats-text";

type StatsLineProps = {
/** Filled from the render loop about twice a second. */
readonly feed: Feed<RenderSample>;
/** Areas generated so far. */
readonly areas: number;
};

/** The renderer's numbers, bottom right; only this line re-renders per sample. */
const StatsLine = ({ feed, areas }: StatsLineProps) => {
const sample = useSyncExternalStore(feed.subscribe, feed.get);
return (
<p className="pointer-events-none absolute right-4 bottom-3 font-mono text-xs text-amber-100/50">
{statsText(sample, areas)}
</p>
);
};

export { StatsLine };
36 changes: 36 additions & 0 deletions apps/web/src/stats-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";

import { statsText } from "./stats-text";

const sample = {
calls: 312,
triangles: 184_321,
geometries: 420,
textures: 36,
frameMs: 16.666,
};

describe("statsText", () => {
it("names the areas alone before the first sample", () => {
expect(statsText(null, 1)).toBe("1 area");
expect(statsText(null, 3)).toBe("3 areas");
});

it("shortens large counts and rounds the frame time", () => {
expect(statsText(sample, 9)).toBe(
"312 draws · 184k tris · 420 geo · 36 tex · 9 areas · 16.7 ms/frame"
);
});

it("writes millions with one decimal", () => {
expect(statsText({ ...sample, triangles: 1_234_567 }, 1)).toContain(
"1.2M tris · 420 geo · 36 tex · 1 area"
);
expect(statsText({ ...sample, triangles: 999_500 }, 1)).toContain(
"1.0M tris"
);
expect(statsText({ ...sample, triangles: 999_499 }, 1)).toContain(
"999k tris"
);
});
});
34 changes: 34 additions & 0 deletions apps/web/src/stats-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { RenderSample } from "@repo/renderer/stats";

/** `420`, `184k`, `1.2M`: short enough to read while walking. */
const shortCount = (count: number): string => {
if (count < 1000) {
return String(count);
}
// 999 500 rounds to 1000k: it reads as 1.0M instead.
const thousands = Math.round(count / 1000);
return thousands < 1000
? `${thousands}k`
: `${(count / 1_000_000).toFixed(1)}M`;
};

/**
* `312 draws · 184k tris · 420 geo · 36 tex · 9 areas · 16.7 ms/frame`, or
* the areas alone before the first sample.
*/
const statsText = (sample: RenderSample | null, areas: number): string => {
const built = areas === 1 ? "1 area" : `${areas} areas`;
if (sample === null) {
return built;
}
return [
`${shortCount(sample.calls)} draws`,
`${shortCount(sample.triangles)} tris`,
`${shortCount(sample.geometries)} geo`,
`${shortCount(sample.textures)} tex`,
built,
`${sample.frameMs.toFixed(1)} ms/frame`,
].join(" · ");
};

export { statsText };
Loading
Loading