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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Guidelines for AI agents and contributors working in this Turborepo monorepo.
| logger | `@repo/logger` | pino logger emitting Cloud Logging shaped JSON |
| types | `@repo/types` | Shared world types: graph, layout and built geometry, no three.js |
| world-generator | `@repo/world-generator` | Graph → layout → walls, doors, corridors and colliders, seeded and three-free |
| renderer | `@repo/renderer` | React Three Fiber components, player and lights for a built world |
| renderer | `@repo/renderer` | React Three Fiber components, player, lights and post-processing for a built world |
| code-graph | `@repo/code-graph` | Language-independent CodeGraph types and the CodeGraph to WorldGraph spatial grammar |
| parser | `@repo/parser` | TypeScript and JavaScript source to CodeGraph via @babel/parser, browser-safe |

Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/hud.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { Hud } from "./hud";

const render = (locked: boolean): string =>
renderToStaticMarkup(
<Hud
locked={locked}
seed={7}
rooms={20}
quality="low"
modules={{ built: 1, total: 1 }}
zone={null}
lastMs={null}
preset={null}
label={null}
place={null}
breadcrumb={null}
prompt={null}
warnings={[]}
notices={[]}
error={null}
/>
);

describe("Hud", () => {
it("lists the quality key and the current level before walking", () => {
const html = render(false);
expect(html).toContain("toggle quality");
expect(html).toContain("seed 7 · 20 rooms · quality low");
});

it("shows the current level while walking", () => {
expect(render(true)).toContain("seed 7 · 20 rooms · quality low");
});
});
7 changes: 6 additions & 1 deletion apps/web/src/hud.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Quality } from "@repo/renderer/effects";

import type { ZoneProgress } from "~/use-area-world";

type HudProps = {
readonly locked: boolean;
readonly seed: number;
readonly rooms: number;
readonly quality: Quality;
/** Modules of a code world generated so far and in all, each its own area. */
readonly modules: { readonly built: number; readonly total: number };
/** The zone the player is in, with its modules generated so far and in all. */
Expand Down Expand Up @@ -32,6 +35,7 @@ const hints: readonly (readonly [string, string])[] = [
["Backspace", "return to the caller"],
["R", "back to the entrance"],
["N", "next seed"],
["Q", "toggle quality"],
["M", "local map"],
["Tab", "world map"],
["Esc", "release the mouse"],
Expand Down Expand Up @@ -93,9 +97,10 @@ const Hud = ({
warnings,
notices,
error,
quality,
...rest
}: HudProps) => {
const where = whereOf(rest);
const where = `${whereOf(rest)} · quality ${quality}`;
const keys = rest.label === null ? hints : [...hints, ...codeHints];
if (locked) {
return (
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/landing-backdrop.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Canvas, useFrame } from "@react-three/fiber";
import { Effects } from "@repo/renderer/effects";
import { World } from "@repo/renderer/world";
import type { Placement } from "@repo/types";
import { useMemo } from "react";

import { driftPose } from "~/backdrop";
import { backdropWorld } from "~/backdrop-world";
import { useQuality } from "~/quality";
import { startPlacement } from "~/world-map-model";

// Loaded lazily by the landing page: the demo world, seen through a camera
Expand All @@ -25,6 +27,7 @@ const DriftCamera = ({ start }: { readonly start: Placement }) => {
/** The demo world filling its parent, or nothing when it cannot be built. */
const LandingBackdrop = () => {
const world = useMemo(() => backdropWorld(), []);
const quality = useQuality();
const start = useMemo(
() => (world === null ? null : startPlacement(world)),
[world]
Expand All @@ -47,6 +50,7 @@ const LandingBackdrop = () => {
>
<World world={world} />
<DriftCamera start={start} />
<Effects quality={quality} />
</Canvas>
);
};
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type { LocalFiles } from "~/zip-files";
const EXAMPLES: readonly string[] = ["demo", "loops", "switches", "repo"];

const HINTS =
"WASD move · Shift sprint · mouse look · E source · Tab world map · Backspace back · Esc release";
"WASD move · Shift sprint · mouse look · E source · Tab world map · Backspace back · Q quality · Esc release";

// The demo world drifting behind the page: its own chunk, fetched after the
// page has painted, so the pickers never wait for three.js.
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/quality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

import {
DEFAULT_QUALITY,
parseQuality,
readQuality,
toggleQuality,
writeQuality,
} from "./quality";

const memoryStorage = () => {
const items = new Map<string, string>();
return {
items,
getItem: (key: string) => items.get(key) ?? null,
setItem: (key: string, value: string) => {
items.set(key, value);
},
};
};

const throwingStorage = () => ({
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("full");
},
});

describe("parseQuality", () => {
it("accepts only the two levels", () => {
expect(parseQuality("low")).toBe("low");
expect(parseQuality("high")).toBe("high");
expect(parseQuality("ultra")).toBeNull();
expect(parseQuality("")).toBeNull();
expect(parseQuality(null)).toBeNull();
});
});

describe("readQuality", () => {
it("falls back to the default without a stored value", () => {
expect(readQuality(memoryStorage())).toBe(DEFAULT_QUALITY);
expect(readQuality(null)).toBe(DEFAULT_QUALITY);
});

it("ignores garbage", () => {
const storage = memoryStorage();
storage.setItem("quality", "medium");
expect(readQuality(storage)).toBe(DEFAULT_QUALITY);
});

it("reads a stored level", () => {
const storage = memoryStorage();
storage.setItem("quality", "low");
expect(readQuality(storage)).toBe("low");
});

it("falls back when storage throws", () => {
expect(readQuality(throwingStorage())).toBe(DEFAULT_QUALITY);
});
});

describe("writeQuality", () => {
it("round-trips through storage", () => {
const storage = memoryStorage();
writeQuality(storage, "low");
expect(readQuality(storage)).toBe("low");
});

it("swallows a throwing storage", () => {
expect(() => writeQuality(throwingStorage(), "low")).not.toThrow();
expect(() => writeQuality(null, "low")).not.toThrow();
});
});

describe("toggleQuality", () => {
it("flips between the two levels", () => {
expect(toggleQuality("high")).toBe("low");
expect(toggleQuality("low")).toBe("high");
});
});
83 changes: 83 additions & 0 deletions apps/web/src/quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { Quality } from "@repo/renderer/effects";
import { useSyncExternalStore } from "react";

// The rendering quality: one value for the whole page, kept in localStorage.
// `low` by default: the post-processing chain costs roughly a frame on top
// of the frame, so it is opted into with Q rather than paid by everyone.

const KEY = "quality";

const DEFAULT_QUALITY: Quality = "low";

type QualityStorage = Pick<Storage, "getItem" | "setItem">;

const parseQuality = (raw: string | null): Quality | null =>
raw === "low" || raw === "high" ? raw : null;

/** The stored quality, or the default when there is none or storage throws. */
const readQuality = (storage: QualityStorage | null): Quality => {
try {
return parseQuality(storage?.getItem(KEY) ?? null) ?? DEFAULT_QUALITY;
} catch {
return DEFAULT_QUALITY;
}
};

/** Stores the quality; a full or blocked storage keeps it for this page only. */
const writeQuality = (storage: QualityStorage | null, quality: Quality) => {
try {
storage?.setItem(KEY, quality);
} catch {
// Nothing to do: the in-memory value still drives the page.
}
};

const toggleQuality = (quality: Quality): Quality =>
quality === "high" ? "low" : "high";

/** `localStorage`, or null where even reading it throws (a blocked origin). */
const localStorageOf = (): QualityStorage | null => {
try {
return globalThis.localStorage;
} catch {
return null;
}
};

let current: Quality | null = null;
const listeners = new Set<() => void>();

const getQuality = (): Quality => {
current ??= readQuality(localStorageOf());
return current;
};

const setQuality = (quality: Quality) => {
current = quality;
writeQuality(localStorageOf(), quality);
for (const listener of listeners) {
listener();
}
};

const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};

/** The page's quality, re-rendering on every change. */
const useQuality = (): Quality =>
useSyncExternalStore(subscribe, getQuality, () => DEFAULT_QUALITY);

export {
DEFAULT_QUALITY,
getQuality,
parseQuality,
readQuality,
setQuality,
toggleQuality,
useQuality,
writeQuality,
};
1 change: 1 addition & 0 deletions apps/web/src/self-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const raw: Readonly<Record<string, string>> = import.meta.glob(
"./packages/*/tsconfig.json",
"./tooling/*/tsconfig.json",
"!**/*.test.*",
"!**/*.d.ts",
"!**/node_modules/**",
],
{ base: "../../../", query: "?raw", import: "default", eager: true }
Expand Down
16 changes: 12 additions & 4 deletions apps/web/src/world-app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Canvas } from "@react-three/fiber";
import { PointerLook } from "@repo/renderer/controls";
import { Effects } from "@repo/renderer/effects";
import { Player } from "@repo/renderer/player";
import { World } from "@repo/renderer/world";
import type { Target } from "@repo/world-generator/interaction";
Expand All @@ -11,6 +12,7 @@ import { EMPTY_VISITS, visit, visitedIn } from "~/map-model";
import { OverviewMap } from "~/overview-map";
import { nextSeed, withSeed } from "~/params";
import { createPoseFeed } from "~/pose-feed";
import { getQuality, setQuality, toggleQuality, useQuality } from "~/quality";
import { SourcePanel } from "~/source-panel";
import { sourceView } from "~/source-view";
import { useAreaWorld, zoneProgress } from "~/use-area-world";
Expand Down Expand Up @@ -43,6 +45,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
const [mapOpen, setMapOpen] = useState(false);
const [worldMapOpen, setWorldMapOpen] = useState(false);
const [visits, setVisits] = useState(EMPTY_VISITS);
const quality = useQuality();
// Generated once per seed, then grown an area at a time: it never
// regenerates while the player moves.
const generated = useAreaWorld(seed, worldOptions);
Expand Down Expand Up @@ -78,10 +81,11 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
}
}, [target, ensurePortal, generated.world]);

// N: next seed (the only thing that regenerates the world). Backspace and
// R navigate, E shows the source and M the local map, only while walking,
// so a free pointer leaves them alone. Tab shows the world map while
// walking and, like Esc once the pointer is free, closes it (`map-keys`).
// N: next seed (the only thing that regenerates the world) and Q: the
// rendering quality, in any pointer state. Backspace and R navigate, E
// shows the source and M the local map, only while walking, so a free
// pointer leaves them alone. Tab shows the world map while walking and,
// like Esc once the pointer is free, closes it (`map-keys`).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Ctrl/Cmd+R and friends belong to the browser.
Expand All @@ -104,6 +108,8 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
}
if (event.code === "KeyN") {
setSeed(nextSeed);
} else if (event.code === "KeyQ") {
setQuality(toggleQuality(getQuality()));
} else if (locked && event.code === "Backspace") {
event.preventDefault();
back();
Expand Down Expand Up @@ -184,6 +190,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
onMove={feed.publish}
/>
<PointerLook />
<Effects quality={quality} />
</Canvas>
)}
{locked && mapOpen && generated.world !== null && (
Expand All @@ -206,6 +213,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
lastMs={generated.lastMs}
preset={initial.preset}
label={label}
quality={quality}
place={place}
breadcrumb={breadcrumbOf(
codeGraph,
Expand Down
6 changes: 4 additions & 2 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
"compilerOptions": {
// `vite/client` declares the `*.css` (and other asset) modules that
// `import "./globals.css"` relies on. Without it TypeScript 7 rejects the
// side-effect import with TS2882.
"types": ["vite/client"],
// side-effect import with TS2882. `@repo/renderer/n8ao` declares the
// untyped `n8ao` module the renderer's effects import: an ambient
// declaration has to be in this program too, and `types` is how.
"types": ["vite/client", "@repo/renderer/n8ao"],
// `~/` is the only way up the tree: oxlint bans `../` imports. Vite and
// Vitest read this via `resolve.tsconfigPaths`.
"paths": { "~/*": ["./src/*"] }
Expand Down
12 changes: 12 additions & 0 deletions packages/renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ React Three Fiber components, player and lights for a built world.

```tsx
import { PointerLook } from "@repo/renderer/controls";
import { Effects } from "@repo/renderer/effects";
import { Player } from "@repo/renderer/player";
import { World } from "@repo/renderer/world";

<Canvas shadows="percentage">
<World world={areaWorld} />
<Player world={areaWorld} enabled={locked} onRoomChange={setRoomId} />
<PointerLook />
<Effects quality="high" />
</Canvas>;
```

Expand Down Expand Up @@ -49,3 +51,13 @@ shaders compile once (and again only for a material an area brings first),
and a single static shadow map that re-renders only
when the shadow light hops to another fixture or an area joins. Every light buzzes with a
phase fixed by its fixture's position, so neighbours never flicker in step.

The wallpaper, carpet and ceiling tile are generated on the fly
(`textures.ts`): each is a seeded, periodic height field (`height-field.ts`)
that gives a colour map, a normal map and one occlusion-roughness map, so
nothing is fetched and the maps tile without a seam. `Effects` sets the
frame's look at either quality, AgX tone mapping at one exposure; at `high`
it adds ambient occlusion (n8ao), bloom off the fixtures, grain, a vignette
and a touch of lens fringing through `@react-three/postprocessing`, tone
mapped last. The haze past the fog is a sphere around the camera rather than
the clear colour, so it is tone mapped like the walls at both qualities.
Loading
Loading