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
21 changes: 18 additions & 3 deletions .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@
- `GraphicsProfile` (interface): interface GraphicsProfile — Serializable renderer budget selected by the player's graphics tier.
- `resolveGraphicsProfile` (function): function resolveGraphicsProfile(quality: GraphicsQuality, overrides?: Partial<GraphicsProfile>): GraphicsProfile — Resolve one tier and apply game-authored field and post-stage overrides.

## @jgengine/core/settings/graphicsSettings

- `GRAPHICS_POST_STAGES` (const): const GRAPHICS_POST_STAGES: readonly GraphicsPostStage[] — Ordered stage list used to build settings rows.
- `GRAPHICS_POST_STAGE_LABELS` (const): const GRAPHICS_POST_STAGE_LABELS: Record<GraphicsPostStage, string> — Player-facing labels for each post stage.
- `GraphicsPostStage` (type): type GraphicsPostStage = keyof GraphicsProfile["postStages"] — The post-processing stages a player can toggle individually on top of the quality tier.
- `GraphicsProfileOverrides` (type): type GraphicsProfileOverrides = Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>> — Per-tier profile overrides a game authors with `defineGame({ graphics })`.
- `GraphicsSettingsState` (interface): interface GraphicsSettingsState — The resolved player graphics choice: the tier plus the profile after per-stage and render-scale overrides.
- `RENDER_SCALE_MAX` (const): const RENDER_SCALE_MAX: 2 — Highest player render scale.
- `RENDER_SCALE_MIN` (const): const RENDER_SCALE_MIN: 0.5 — Lowest player render scale; the value is the device-pixel-ratio cap handed to the canvas.
- `RENDER_SCALE_STEP` (const): const RENDER_SCALE_STEP: 0.05 — Render-scale slider increment.
- `applyGraphicsQuality` (function): function applyGraphicsQuality(store: Pick<SettingsStore, "set">, quality: GraphicsQuality, overrides?: GraphicsProfileOverrides): GraphicsProfile — Select a quality tier and re-apply its render scale and stage defaults, so picking a preset resets the individual overrides instead of leaving stale toggles behind.
- `graphicsPostStageSettingId` (function): function graphicsPostStageSettingId(stage: GraphicsPostStage): string — Setting id that stores one stage toggle (`graphics.post.<stage>`).
- `readGraphicsQuality` (function): function readGraphicsQuality(store: Pick<SettingsStore, "get">): GraphicsQuality — The stored quality tier, falling back to the default when the value is missing or unknown.
- `readGraphicsSettings` (function): function readGraphicsSettings(store: Pick<SettingsStore, "get">, overrides?: GraphicsProfileOverrides): GraphicsSettingsState — Resolve the player's graphics state from a settings store. The tier profile (with the game's overrides) supplies every default; a stored render scale or stage toggle replaces its field.

## @jgengine/core/settings/settingsModel

- `BUILT_IN_SETTING_CATEGORIES` (const): const BUILT_IN_SETTING_CATEGORIES: readonly BuiltInSettingCategory[] — ⚠ undocumented
Expand All @@ -87,7 +102,7 @@
- `GameSettingsConfig` (interface): interface GameSettingsConfig — ⚠ undocumented
- `GraphicsQuality` (type): type GraphicsQuality = "low" | "medium" | "high" — ⚠ undocumented
- `SETTINGS_STORAGE_PREFIX` (const): const SETTINGS_STORAGE_PREFIX: "jgengine:setting:" — ⚠ undocumented
- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly touchStyle: "controls.touchStyle"; readonly touchJoystick: "controls.touchJoystick"; } — ⚠ undocumented
- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly graphicsRenderScale: "graphics.renderScale"; readonly graphicsPostAo: "graphics.post.ao"; rea… — ⚠ undocumented
- `SettingCategory` (type): type SettingCategory = BuiltInSettingCategory | (string & {}) — Built-in category ids keep autocomplete; any other string makes a fresh category.
- `SettingCategoryDef` (interface): interface SettingCategoryDef — Declares or relabels/reorders a category tab; use it for a custom category or to reshape the built-ins.
- `SettingKind` (type): type SettingKind = "slider" | "toggle" | "select" — ⚠ undocumented
Expand Down Expand Up @@ -192,7 +207,7 @@
- `RadialSlice` (interface): interface RadialSlice — One wedge of a radial menu — geometry for rendering a slice. Angles are radians from "up" (−Y), clockwise.
- `RadialVectorOptions` (interface): interface RadialVectorOptions extends RadialArc — Options for {@link radialIndexFromVector}.
- `ResolvedAction` (interface): interface ResolvedAction — A resolved action view model — availability computed once from cooldown, cost, `disabled`, and any caller reasons. `enabled` is the single truth a renderer gates interaction on; `reasons` is the ordered explanation (cooldown, then unmet costs, then caller reasons, then a generic disable).
- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly touchStyle: "controls.touchStyle"; readonly touchJoystick: "controls.touchJoystick"; } — ⚠ undocumented
- `SETTING_IDS` (const): const SETTING_IDS: { readonly masterVolume: "sound.master"; readonly graphicsQuality: "graphics.quality"; readonly graphicsShadows: "graphics.shadows"; readonly graphicsUiScale: "graphics.uiScale"; readonly graphicsRenderScale: "graphics.renderScale"; readonly graphicsPostAo: "graphics.post.ao"; rea… — ⚠ undocumented
- `STUDIO_STAGE_POST` (const): const STUDIO_STAGE_POST: PostProcessingConfig — A cinematic "product shot" post preset — the full chain on (contact-AO, soft bloom, a warm film grade with vignette + a touch of grain + chromatic aberration). Meant for a `StudioStage` where a single parametric asset is framed on a backdrop, so every studio reads shipped, not intern-tier. DoF is left off by default (it needs a per-scene focus distance); set `dof` to enable it.
- `ScreenMarkerLayout` (interface): interface ScreenMarkerLayout — Where and how to draw one marker, from {@link layoutScreenMarker}.
- `ScreenMarkerOptions` (interface): interface ScreenMarkerOptions — Options for {@link layoutScreenMarker}.
Expand Down Expand Up @@ -2395,7 +2410,7 @@

- `AudioSettingsBridge` (function): function AudioSettingsBridge({ store, engine, buses, }: { store: SettingsStore; engine: AudioEngine; buses: Record<string, AudioBusDef> | undefined; }): null — ⚠ undocumented
- `TOUCH_STYLE_AUTO` (const): const TOUCH_STYLE_AUTO: "auto" — Sentinel Controls value meaning "defer to the game's suggested touch skin".
- `useGraphicsSettings` (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>>): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } — ⚠ undocumented
- `useGraphicsSettings` (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: GraphicsProfileOverrides): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } — ⚠ undocumented
- `useSettingsRevision` (function): function useSettingsRevision(store: SettingsStore): number — ⚠ undocumented

## @jgengine/shell/settings/settingsController
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ between (`--json` for structured output).

### Added

- The built-in Graphics settings tab now exposes render scale plus ambient occlusion, bloom, depth-of-field, and SMAA toggles alongside the quality tier; picking a tier re-applies its defaults. `@jgengine/core/settings/graphicsSettings` (`readGraphicsSettings`, `applyGraphicsQuality`, `GRAPHICS_POST_STAGES`) resolves the stored choices onto the `GraphicsProfile` the shell renders with, and `SETTING_IDS` gains `graphics.renderScale` and `graphics.post.*`. Every game gets the rows in its existing settings menu with no wiring (#1688).

- Building palettes take textured surfaces: a `BuildingPalette` part (and `BuildingKitPart.material`) may be `{ color?, maps?, repeat?, roughness?, metalness? }` with `maps` straight from `buildMaterialCatalog(...).resolve(id)!.maps`, not only a hex colour. Generated facade boxes and kit models tile the PBR maps per slot; unbound kinds keep their flat colour. `buildingSurfaceColor` / `resolveBuildingSurface` read either form.

### Fixed
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/settings/graphicsSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";

import { DEFAULT_GRAPHICS_PROFILES } from "./graphicsProfile";
import {
applyGraphicsQuality,
graphicsPostStageSettingId,
readGraphicsQuality,
readGraphicsSettings,
RENDER_SCALE_MAX,
} from "./graphicsSettings";
import { createSettingsStore, SETTING_IDS } from "./settingsModel";

function memStorage() {
const map = new Map<string, string>();
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
};
}

describe("graphicsSettings", () => {
test("empty store resolves to the default tier profile", () => {
const store = createSettingsStore(memStorage());
const state = readGraphicsSettings(store);
expect(state.quality).toBe("high");
expect(state.profile).toEqual(DEFAULT_GRAPHICS_PROFILES.high);
});

test("unknown stored quality falls back to the default", () => {
const store = createSettingsStore(memStorage());
store.set(SETTING_IDS.graphicsQuality, "ultra");
expect(readGraphicsQuality(store)).toBe("high");
});

test("stored stage toggles and render scale override the tier", () => {
const store = createSettingsStore(memStorage());
store.set(SETTING_IDS.graphicsQuality, "medium");
store.set(graphicsPostStageSettingId("ao"), false);
store.set(SETTING_IDS.graphicsRenderScale, 0.75);
const state = readGraphicsSettings(store);
expect(state.profile.postStages).toEqual({ ao: false, bloom: true, dof: true, smaa: true });
expect(state.profile.renderScale).toBe(0.75);
expect(state.profile.shadowMapSize).toBe(1024);
});

test("render scale is clamped to the slider bounds", () => {
const store = createSettingsStore(memStorage());
store.set(SETTING_IDS.graphicsRenderScale, 9);
expect(readGraphicsSettings(store).profile.renderScale).toBe(RENDER_SCALE_MAX);
});

test("game overrides feed the tier defaults", () => {
const store = createSettingsStore(memStorage());
store.set(SETTING_IDS.graphicsQuality, "low");
const state = readGraphicsSettings(store, { low: { renderScale: 0.8, postStages: { bloom: false } } });
expect(state.profile.renderScale).toBe(0.8);
expect(state.profile.postStages.bloom).toBe(false);
});

test("applying a tier resets stage and render-scale overrides", () => {
const store = createSettingsStore(memStorage());
store.set(graphicsPostStageSettingId("dof"), false);
store.set(SETTING_IDS.graphicsRenderScale, 0.5);
applyGraphicsQuality(store, "low");
const state = readGraphicsSettings(store);
expect(state.quality).toBe("low");
expect(state.profile).toEqual(DEFAULT_GRAPHICS_PROFILES.low);
expect(store.get(graphicsPostStageSettingId("dof"), true)).toBe(false);
});
});
101 changes: 101 additions & 0 deletions packages/core/src/settings/graphicsSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { resolveGraphicsProfile, type GraphicsProfile } from "./graphicsProfile";
import {
DEFAULT_GRAPHICS_QUALITY,
GRAPHICS_QUALITY_OPTIONS,
SETTING_IDS,
type GraphicsQuality,
type SettingsStore,
} from "./settingsModel";

/** The post-processing stages a player can toggle individually on top of the quality tier. */
export type GraphicsPostStage = keyof GraphicsProfile["postStages"];

/** Ordered stage list used to build settings rows. */
export const GRAPHICS_POST_STAGES: readonly GraphicsPostStage[] = ["ao", "bloom", "dof", "smaa"];

/** Player-facing labels for each post stage. */
export const GRAPHICS_POST_STAGE_LABELS: Record<GraphicsPostStage, string> = {
ao: "Ambient occlusion",
bloom: "Bloom",
dof: "Depth of field",
smaa: "Anti-aliasing (SMAA)",
};

/** Setting id that stores one stage toggle (`graphics.post.<stage>`). */
export function graphicsPostStageSettingId(stage: GraphicsPostStage): string {
return `graphics.post.${stage}`;
}

/** Lowest player render scale; the value is the device-pixel-ratio cap handed to the canvas. */
export const RENDER_SCALE_MIN = 0.5;
/** Highest player render scale. */
export const RENDER_SCALE_MAX = 2;
/** Render-scale slider increment. */
export const RENDER_SCALE_STEP = 0.05;

/** Per-tier profile overrides a game authors with `defineGame({ graphics })`. */
export type GraphicsProfileOverrides = Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>>;

/** The resolved player graphics choice: the tier plus the profile after per-stage and render-scale overrides. */
export interface GraphicsSettingsState {
quality: GraphicsQuality;
profile: GraphicsProfile;
}

const QUALITY_VALUES = new Set(GRAPHICS_QUALITY_OPTIONS.map((option) => option.value));

function isGraphicsQuality(value: unknown): value is GraphicsQuality {
return typeof value === "string" && QUALITY_VALUES.has(value);
}

function clampRenderScale(value: number): number {
return Math.min(RENDER_SCALE_MAX, Math.max(RENDER_SCALE_MIN, value));
}

/** The stored quality tier, falling back to the default when the value is missing or unknown. */
export function readGraphicsQuality(store: Pick<SettingsStore, "get">): GraphicsQuality {
const raw = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY);
return isGraphicsQuality(raw) ? raw : DEFAULT_GRAPHICS_QUALITY;
}

/**
* Resolve the player's graphics state from a settings store. The tier profile (with the game's
* overrides) supplies every default; a stored render scale or stage toggle replaces its field.
*/
export function readGraphicsSettings(
store: Pick<SettingsStore, "get">,
overrides?: GraphicsProfileOverrides,
): GraphicsSettingsState {
const quality = readGraphicsQuality(store);
const tier = resolveGraphicsProfile(quality, overrides?.[quality]);
const postStages = { ...tier.postStages };
for (const stage of GRAPHICS_POST_STAGES) {
postStages[stage] = store.get(graphicsPostStageSettingId(stage), tier.postStages[stage]);
}
return {
quality,
profile: {
...tier,
renderScale: clampRenderScale(store.get(SETTING_IDS.graphicsRenderScale, tier.renderScale)),
postStages,
},
};
}

/**
* Select a quality tier and re-apply its render scale and stage defaults, so picking a preset
* resets the individual overrides instead of leaving stale toggles behind.
*/
export function applyGraphicsQuality(
store: Pick<SettingsStore, "set">,
quality: GraphicsQuality,
overrides?: GraphicsProfileOverrides,
): GraphicsProfile {
const tier = resolveGraphicsProfile(quality, overrides?.[quality]);
store.set(SETTING_IDS.graphicsQuality, quality);
store.set(SETTING_IDS.graphicsRenderScale, tier.renderScale);
for (const stage of GRAPHICS_POST_STAGES) {
store.set(graphicsPostStageSettingId(stage), tier.postStages[stage]);
}
return tier;
}
5 changes: 5 additions & 0 deletions packages/core/src/settings/settingsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export const SETTING_IDS = {
graphicsQuality: "graphics.quality",
graphicsShadows: "graphics.shadows",
graphicsUiScale: "graphics.uiScale",
graphicsRenderScale: "graphics.renderScale",
graphicsPostAo: "graphics.post.ao",
graphicsPostBloom: "graphics.post.bloom",
graphicsPostDof: "graphics.post.dof",
graphicsPostSmaa: "graphics.post.smaa",
touchStyle: "controls.touchStyle",
touchJoystick: "controls.touchJoystick",
} as const;
Expand Down
1 change: 1 addition & 0 deletions packages/shell/src/Shell3dPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ export function Shell3dPresentation({
categories={settingsConfig.categories ?? []}
hide={hideCategories}
fovEnabled={fovControlEnabled}
graphics={playable.graphics}
hideBindings={settingsConfig.hideBindings ?? []}
touchStyle={
coarsePointer &&
Expand Down
11 changes: 4 additions & 7 deletions packages/shell/src/settings/appliedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
import {
busVolumeSettingId,
DEFAULT_GRAPHICS_QUALITY,
DEFAULT_MASTER_VOLUME,
DEFAULT_UI_SCALE,
GRAPHICS_QUALITY_DPR,
SETTING_IDS,
UI_SCALE_MAX,
UI_SCALE_MIN,
type GraphicsQuality,
type SettingsStore,
} from "@jgengine/core/settings/settingsModel";
import { resolveGraphicsProfile, type GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
import { readGraphicsSettings, type GraphicsProfileOverrides } from "@jgengine/core/settings/graphicsSettings";

import {
DEFAULT_TOUCH_JOYSTICK_VARIANT,
Expand Down Expand Up @@ -61,12 +60,10 @@ export function useSettingsRevision(store: SettingsStore): number {
export function useGraphicsSettings(
store: SettingsStore,
shadowsDefault: boolean,
overrides?: Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>>,
overrides?: GraphicsProfileOverrides,
): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } {
useSettingsRevision(store);
const rawQuality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY) as GraphicsQuality;
const quality: GraphicsQuality = GRAPHICS_QUALITY_DPR[rawQuality] !== undefined ? rawQuality : "high";
const profile = resolveGraphicsProfile(quality, overrides?.[quality]);
const { quality, profile } = readGraphicsSettings(store, overrides);
const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE);
return {
shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault),
Expand Down
Loading
Loading