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 .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2107,6 +2107,7 @@
- `DEFAULT_DAY_SKY_BOTTOM` (const): const DEFAULT_DAY_SKY_BOTTOM: "#e3f4ff" — ⚠ undocumented
- `DEFAULT_DAY_SKY_TOP` (const): const DEFAULT_DAY_SKY_TOP: "#3fa4f2" — ⚠ undocumented
- `DEFAULT_DAY_SUN_INTENSITY` (const): const DEFAULT_DAY_SUN_INTENSITY: 1 — ⚠ undocumented
- `DEFAULT_SUN_ELEVATION_DEG` (const): const DEFAULT_SUN_ELEVATION_DEG: number — The engine arc's noon height: `atan(1 / SUN_DEPTH_RATIO)`, ~68 degrees.
- `DaylightCycleConfig` (interface): interface DaylightCycleConfig — ⚠ undocumented
- `DaylightState` (interface): interface DaylightState — ⚠ undocumented
- `SKY_PRESET_DAY_FRACTION` (const): const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number> — ⚠ undocumented
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/jgengine-world/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3036,6 +3036,7 @@
- `RoadEnvironmentDescriptor` (type): type RoadEnvironmentDescriptor = { kind: "road" } & Required< Pick<RoadEnvironmentConfig, "path" | "width" | "color" | "markings" | "markingColor" | "elevation"> > & { /** Resolved sidewalk band, or `false` when the road has none. */ sidewalk: { width: number; color: string } | false; } — Resolved road descriptor produced by {@link road} and rendered by the shell environment scene.
- `SkyEnvironmentConfig` (interface): interface SkyEnvironmentConfig — ⚠ undocumented
- `SkyEnvironmentDescriptor` (type): type SkyEnvironmentDescriptor = { kind: "sky" } & Required< Pick<SkyEnvironmentConfig, "preset" | "timeOfDay"> > & Omit<SkyEnvironmentConfig, "preset" | "timeOfDay"> — ⚠ undocumented
- `SkySunConfig` (interface): interface SkySunConfig — Where the sun sits, as a compass bearing and a height above the horizon, both in degrees. `azimuth` 0 points toward -Z (north), 90 toward +X (east); `elevation` 90 is straight overhead.
- `SnowEnvironmentConfig` (interface): interface SnowEnvironmentConfig — ⚠ undocumented
- `SnowEnvironmentDescriptor` (type): type SnowEnvironmentDescriptor = { kind: "snow" } & Required< Pick<SnowEnvironmentConfig, "area" | "density" | "speed" | "flakeSize" | "drift" | "wind" | "color" | "opacity"> > — ⚠ undocumented
- `StructureEnvironmentDescriptor` (type): type StructureEnvironmentDescriptor = BuildingEnvironmentDescriptor — ⚠ undocumented
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/jgengine/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@
- `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
- `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches.

## @jgengine/core/devtools/warnOnce

- `warnOnce` (function): function warnOnce(site: string, message: string): void — Logs a dev-mode `console.warn` once per `site`, and never in a production build. Reach for it where a permissive config would otherwise fail silently — a clipped sky dome, an ignored tint.

## @jgengine/core/meta/changelog

- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/jgengine/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p

- `defineStore` (function) · `import { defineStore } from "@jgengine/shell/gameKit"`

## warn-once — emit a dev-only warning a single time per call site

- `warnOnce` (function) · `import { warnOnce } from "@jgengine/core/devtools/warnOnce"`

## world-chunk-key — map world positions to persisted chunk keys at a declared cell size

- `chunkKeyOf` (function) · `import { chunkKeyOf } from "@jgengine/core/runtime/worldChunks"`
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@ between (`--json` for structured output).

## [Unreleased]

### Added

- `sky({ sun: { azimuth, elevation } })` places the dome sun and the sky-owned sun light by compass bearing and height; under `timeOfDay` the day arc swings around that noon position. Unset, the shell derives it from the first authored `lighting.directional` light so the dome glow agrees with the shadows. Editor lighting panel gains bearing/height sliders (`sunAzimuth`/`sunElevation` on the scene document).
- `warnOnce` in `@jgengine/core/devtools/warnOnce`: dev-only, once-per-site console warnings. Used for a `sky.radius` at or past the camera far plane (the dome depth-clips to black) and for `zenithColor`/`horizonColor` under `timeOfDay` (they only set the noon keyframe).

### Fixed

- Time-of-day and biome-driven sky domes now draw the sun disc and glow, tracking the sun through the day; previously only the fixed-preset dome had one.

- Separate SDK publication validation from external Games quality checks while retaining the full local and main-branch gates.

- Correct foot-IK endpoint recomputation after hip rotation; verify reachable targets and clamping without changing bone lengths.
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/devtools/warnOnce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { isProductionEnvironment } from "./saveEndpoint";

const warned = new Set<string>();

/**
* Logs a dev-mode `console.warn` once per `site`, and never in a production build. Reach for it
* where a permissive config would otherwise fail silently — a clipped sky dome, an ignored tint.
* @capability warn-once emit a dev-only warning a single time per call site
*/
export function warnOnce(site: string, message: string): void {
if (warned.has(site) || isProductionEnvironment()) return;
warned.add(site);
const host = globalThis as { console?: { warn?: (text: string) => void } };
host.console?.warn?.(message);
}

/** Clears the once-per-site record so a test can observe the warning again. @internal */
export function resetWarnOnce(): void {
warned.clear();
}
4 changes: 3 additions & 1 deletion packages/core/src/editor/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function cloneEnvironment(environment: EditorEnvironment): EditorEnvironment {
...(environment.zenithColor === undefined ? {} : { zenithColor: environment.zenithColor }),
...(environment.sunIntensity === undefined ? {} : { sunIntensity: environment.sunIntensity }),
...(environment.ambientIntensity === undefined ? {} : { ambientIntensity: environment.ambientIntensity }),
...(environment.sunAzimuth === undefined ? {} : { sunAzimuth: environment.sunAzimuth }),
...(environment.sunElevation === undefined ? {} : { sunElevation: environment.sunElevation }),
...(environment.fog === undefined ? {} : { fog: { ...environment.fog } }),
};
}
Expand Down Expand Up @@ -1143,7 +1145,7 @@ function decodeEnvironment(
failed = true;
}
}
for (const key of ["sunIntensity", "ambientIntensity"] as const) {
for (const key of ["sunIntensity", "ambientIntensity", "sunAzimuth", "sunElevation"] as const) {
if (value[key] === undefined) continue;
if (typeof value[key] === "number" && Number.isFinite(value[key])) env[key] = value[key] as number;
else {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/editor/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,8 @@ describe("document.environment (#1110 lighting)", () => {
timeOfDay: true,
sunIntensity: 0.7,
ambientIntensity: 0.4,
sunAzimuth: 129,
sunElevation: 29,
horizonColor: "#ff8a5c",
zenithColor: "#1a2b4a",
fog: { color: "#ffb37a", near: 40, far: 220 },
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/editor/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,15 @@ describe("lakebedFromWaterVolumes", () => {
preset: "night",
fog: { near: 50, far: 200, color: "#001122" },
sunIntensity: 0.2,
sunAzimuth: 129,
sunElevation: 29,
},
};
expect(skyFromDocument(doc)).toEqual({
preset: "night",
fog: { near: 50, far: 200, color: "#001122" },
sunIntensity: 0.2,
sun: { azimuth: 129, elevation: 29 },
});
expect(environmentContentFromDocument(doc).sky).toEqual(skyFromDocument(doc));
});
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/editor/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export function skyConfigFromEnvironment(env: EditorEnvironment): SkyEnvironment
...(env.zenithColor === undefined ? {} : { zenithColor: env.zenithColor }),
...(env.sunIntensity === undefined ? {} : { sunIntensity: env.sunIntensity }),
...(env.ambientIntensity === undefined ? {} : { ambientIntensity: env.ambientIntensity }),
...(env.sunAzimuth === undefined && env.sunElevation === undefined
? {}
: {
sun: {
...(env.sunAzimuth === undefined ? {} : { azimuth: env.sunAzimuth }),
...(env.sunElevation === undefined ? {} : { elevation: env.sunElevation }),
},
}),
...(env.fog === undefined ? {} : { fog: { ...env.fog } }),
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export interface EditorEnvironment {
zenithColor?: string;
sunIntensity?: number;
ambientIntensity?: number;
/** Sun compass bearing in degrees (0 = -Z, 90 = +X); runtime `SkySunConfig.azimuth`. */
sunAzimuth?: number;
/** Sun height above the horizon in degrees; runtime `SkySunConfig.elevation`. */
sunElevation?: number;
fog?: EditorFogConfig;
}

Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/world/features.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, spyOn, test } from "bun:test";

import { resetWarnOnce } from "../devtools/warnOnce";

import {
biomes,
building,
Expand Down Expand Up @@ -208,6 +210,7 @@ describe("world features", () => {
radius: 2600,
hazeStrength: 0.62,
sunGlowStrength: 0.6,
sun: { azimuth: 129, elevation: 29 },
fog: { color: "#ffb37a", near: 40, far: 220 },
});
expect(descriptor).toEqual({
Expand All @@ -218,6 +221,7 @@ describe("world features", () => {
zenithColor: "#1a2b4a",
sunIntensity: 0.7,
ambientIntensity: 0.4,
sun: { azimuth: 129, elevation: 29 },
radius: 2600,
hazeStrength: 0.62,
sunGlowStrength: 0.6,
Expand All @@ -228,6 +232,23 @@ describe("world features", () => {
expect(world).toEqual({ kind: "environment", sky: descriptor });
});

test("sky warns once when authored tints ride a time-of-day crossfade", () => {
resetWarnOnce();
const warn = console.warn;
const messages: string[] = [];
console.warn = (text: string) => {
messages.push(text);
};
try {
sky({ timeOfDay: true, zenithColor: "#3fa4f2" });
sky({ timeOfDay: true, horizonColor: "#e3f4ff" });
sky({ timeOfDay: false, zenithColor: "#3fa4f2" });
} finally {
console.warn = warn;
}
expect(messages.filter((m) => m.includes("noon keyframe"))).toHaveLength(1);
});

test("terrain carries flatten masks", () => {
const descriptor = terrain({
height: 4,
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/world/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PlaceWorldFeature } from "./place";
import type { TerraformSnapshot } from "./terraform";
import type { SpriteAtlas } from "../assets/spriteAtlas";
import type { VolumetricCloudsConfig } from "./volumetricClouds";
import { warnOnce } from "../devtools/warnOnce";

export interface WorldBounds {
w: number;
Expand Down Expand Up @@ -316,16 +317,39 @@ export interface OceanEnvironmentConfig {
color?: string;
}

/**
* Where the sun sits, as a compass bearing and a height above the horizon, both in degrees.
* `azimuth` 0 points toward -Z (north), 90 toward +X (east); `elevation` 90 is straight overhead.
*/
export interface SkySunConfig {
/** Compass bearing of the sun in degrees; 0 = -Z, 90 = +X. Default 0. */
azimuth?: number;
/** Height above the horizon in degrees; 0 sits on the horizon, 90 is overhead. Default 68. */
elevation?: number;
}

export interface SkyEnvironmentConfig {
/** Fixed look used when `timeOfDay` is off (or no clock is available); default "day". */
preset?: "day" | "dusk" | "night";
/** Drive sun/sky from the world clock's `calendar().dayFraction` instead of the fixed `preset`. */
timeOfDay?: boolean;
/**
* Horizon tint at noon. Under `timeOfDay` this only sets the noon keyframe; dawn, dusk, and night
* still crossfade through the engine presets, so an authored tint is visible for part of the day.
*/
horizonColor?: string;
/** Zenith tint at noon; same `timeOfDay` crossfade caveat as `horizonColor`. */
zenithColor?: string;
sunIntensity?: number;
ambientIntensity?: number;
/** Sky-dome sphere radius in world units. Enlarge for a playfield sited far from the origin so the viewer never exits the dome. Default 260. */
/**
* Sun placement for the dome glow and the sky-owned sun light. With a fixed `preset` this is the
* sun's exact direction; under `timeOfDay` it is where the sun stands at noon and the day arc
* swings around that bearing. Unset with an authored `lighting.directional` light, the shell takes
* the first directional light's direction so the dome sun agrees with the shadows.
*/
sun?: SkySunConfig;
/** Sky-dome sphere radius in world units. Enlarge for a playfield sited far from the origin so the viewer never exits the dome. Must stay below the camera far plane or the dome depth-clips to black. Default 260. */
radius?: number;
/** Horizon haze-band strength: 0 removes the dusty band, ~1 makes it heavy. Default 0.5. */
hazeStrength?: number;
Expand Down Expand Up @@ -728,6 +752,12 @@ export function terrain(config: TerrainEnvironmentConfig = {}): TerrainEnvironme
}

export function sky(config: SkyEnvironmentConfig = {}): SkyEnvironmentDescriptor {
if (config.timeOfDay === true && (config.zenithColor !== undefined || config.horizonColor !== undefined)) {
warnOnce(
"sky-timeofday-tint",
"[jgengine] sky(): zenithColor/horizonColor only set the noon keyframe under timeOfDay; dawn, dusk, and night crossfade through the engine presets. Drop timeOfDay for a fixed tint.",
);
}
return withOptional(
{
kind: "sky" as const,
Expand All @@ -739,6 +769,7 @@ export function sky(config: SkyEnvironmentConfig = {}): SkyEnvironmentDescriptor
...(config.zenithColor === undefined ? {} : { zenithColor: config.zenithColor }),
...(config.sunIntensity === undefined ? {} : { sunIntensity: config.sunIntensity }),
...(config.ambientIntensity === undefined ? {} : { ambientIntensity: config.ambientIntensity }),
...(config.sun === undefined ? {} : { sun: { ...config.sun } }),
...(config.radius === undefined ? {} : { radius: config.radius }),
...(config.hazeStrength === undefined ? {} : { hazeStrength: config.hazeStrength }),
...(config.sunGlowStrength === undefined ? {} : { sunGlowStrength: config.sunGlowStrength }),
Expand Down
22 changes: 22 additions & 0 deletions packages/editor/src/LightingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const DISPLAY_DEFAULTS = {
timeOfDay: false,
sunIntensity: 1,
ambientIntensity: 0.45,
sunAzimuth: 0,
sunElevation: 68,
fogNear: 80,
fogFar: 400,
fogColor: "",
Expand All @@ -30,6 +32,8 @@ function readDisplay(env: EditorEnvironment | undefined) {
timeOfDay: env?.timeOfDay ?? DISPLAY_DEFAULTS.timeOfDay,
sunIntensity: env?.sunIntensity ?? DISPLAY_DEFAULTS.sunIntensity,
ambientIntensity: env?.ambientIntensity ?? DISPLAY_DEFAULTS.ambientIntensity,
sunAzimuth: env?.sunAzimuth ?? DISPLAY_DEFAULTS.sunAzimuth,
sunElevation: env?.sunElevation ?? DISPLAY_DEFAULTS.sunElevation,
fogNear: env?.fog?.near ?? DISPLAY_DEFAULTS.fogNear,
fogFar: env?.fog?.far ?? DISPLAY_DEFAULTS.fogFar,
fogColor: env?.fog?.color ?? DISPLAY_DEFAULTS.fogColor,
Expand Down Expand Up @@ -192,6 +196,24 @@ export function LightingPanel({ session }: { session: EditorSession }) {
onChange={(value) => patch({ ambientIntensity: value }, "env:ambient")}
format={(v) => v.toFixed(2)}
/>
<SliderRow
label="bearing"
value={display.sunAzimuth}
min={0}
max={360}
step={1}
onChange={(value) => patch({ sunAzimuth: value }, "env:sunAzimuth")}
format={(v) => `${Math.round(v)}°`}
/>
<SliderRow
label="height"
value={display.sunElevation}
min={0}
max={90}
step={1}
onChange={(value) => patch({ sunElevation: value }, "env:sunElevation")}
format={(v) => `${Math.round(v)}°`}
/>
</section>

<section className="space-y-1.5 border-t border-white/[0.05] pt-2">
Expand Down
13 changes: 11 additions & 2 deletions packages/shell/src/Shell3dPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from "./camera";
import { CullingProvider } from "./visibility/CullingProvider";
import { SkyDaylight, TimeOfDayDaylight } from "./environment";
import { bearingFromDirection } from "./environment/daylightCycle";
import { resolveSkyLightOwnership, skyEmitsLights } from "./environment/skyLightingPolicy";
import { EnvironmentScene } from "./environment/EnvironmentScene";
import { PointerProbe } from "./pointer/PointerProbe";
Expand Down Expand Up @@ -252,9 +253,17 @@ export function Shell3dPresentation({
const cinematicLook = (playable.look ?? "cinematic") !== "flat";
const backdrop = resolvedLook.backdrop;
const backdropSky = backdrop?.sky !== undefined ? resolveSkyDescriptor(backdrop.sky) : undefined;
const effectiveSky = backdropSky ?? worldSky;
const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined);
const authoredSky = backdropSky ?? worldSky;
const lighting = resolvedLook.lighting;
const sunFromLighting = lighting?.directional?.[0]?.position;
const effectiveSky = useMemo(
() =>
authoredSky === undefined || authoredSky.sun !== undefined || authoredSky.timeOfDay || sunFromLighting === undefined
? authoredSky
: { ...authoredSky, sun: bearingFromDirection(sunFromLighting) },
[authoredSky, sunFromLighting],
);
const backgroundColor = backdrop?.background ?? (effectiveSky === undefined ? DEFAULT_BACKGROUND_COLOR : undefined);
const orthographic = playable.camera?.projection === "orthographic";

const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
Expand Down
Loading
Loading