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
18 changes: 18 additions & 0 deletions .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,10 @@
- `TalentTree` (function): function TalentTree<TStat extends string = string>({ view: viewProp, nodes, tree, onLearn, icon, label, branchLabel, title = "Talents", variation = "themed", nodeSize = 54, columnGap = 30, tierGap = 46, showPoints = true, className, style, }: TalentTreeProps<TStat>): ReactNode — Drop-in talent / skill-tree widget over the existing progression model (`@jgengine/core/game/talents` + its `talentTreeView` selector). The game passes its node definitions and a live `createTalentTree` instance; the widget lays nodes out by branch column and prerequisite-depth tier, draws SVG prerequisite edges, styles each node learned/available/locked/maxed, shows an icon + rank badge, and calls `onLearn(nodeId)` when the player clicks a node that is currently allocatable. All layout, edge-drawing, and eligibility come from the model/selector — the widget never re-derives topology. Unskinned and HudTheme-token driven; node ids/branches are opaque game data the game supplies icons and labels for.
- `TalentTreeProps` (interface): interface TalentTreeProps<TStat extends string = string> — Props for {@link TalentTree}.

## @jgengine/react/tileLayerPreview

- `TileLayerPreview` (function): function TileLayerPreview({ className }: { className?: string }): React.JSX.Element — Deterministic canvas-backed tile-layer fixture for textured tile presentation evidence.

## @jgengine/react/timerReadout

- `TimerFormat` (type): type TimerFormat = "mm:ss" | "m:ss.d" | "ss.d" — Digital text format for a timer readout.
Expand Down Expand Up @@ -2244,6 +2248,10 @@
- `captureCanvas` (function): function captureCanvas(gl: CaptureRenderer): string | null — Read the current frame to a PNG data URL. Requires the R3F `<Canvas>` to have been created with `gl={{ preserveDrawingBuffer: true }}` (the shell's game canvas already is); returns null if the backing canvas can't be read.
- `downloadImage` (function): function downloadImage(dataUrl: string, filename = "screenshot.png"): void — Trigger a browser download of an image data URL (the photo-mode "save" action).

## @jgengine/shell/render/testFixtures/proceduralTileLayer

- `drawProceduralTileLayer` (function): function drawProceduralTileLayer(canvas: CanvasFixture): TilemapWorldConfig — Draw the deterministic tile atlas used by textured tile-layer captures and tests.

## @jgengine/shell/render/useDisposable

- `Disposable` (interface): interface Disposable — Anything exposing dispose() — three.js geometries, materials, textures, render targets.
Expand Down Expand Up @@ -2819,6 +2827,11 @@
- `SpriteBatchInstance` (interface): interface SpriteBatchInstance — ⚠ undocumented
- `SpriteBatchProps` (interface): interface SpriteBatchProps — ⚠ undocumented

## @jgengine/shell/world/TileLayerRenderer

- `TileLayerRenderer` (function): function TileLayerRenderer({ config, parallax = [1, 1], pixelated = true }: TileLayerRendererProps): React.JSX.Element | null — Renders a tilemap as one instanced quad mesh with atlas UV rectangles.
- `TileLayerRendererProps` (interface): interface TileLayerRendererProps — Inputs for the instanced textured tile-layer renderer.

## @jgengine/shell/world/WorldEntityFrames

- `WorldEntityFrames` (function): function WorldEntityFrames<E extends EntityFrameEntry>({ entries, renderFrame, tickMs = 0, className, anchorTransform, ...layout }: WorldEntityFramesProps<E>): React.JSX.Element — R3F convenience that mounts inside the scene, samples the live camera each frame (throttled by `tickMs`), and renders `@jgengine/react`'s {@link EntityFrames} through a fullscreen `<Html>` overlay. This is the one-line way an R3F game gets overhead enemy nameplates/health bars from its own entity array without a store: pass `entries` + a `renderFrame` that composes the shipped bars. The projection, viewport culling, and stacking come for free.
Expand Down Expand Up @@ -2875,6 +2888,11 @@
- `pingBobOffset` (function): function pingBobOffset(elapsedSeconds: number, amplitude = 0.18, speedHz = 1.1): number — Vertical bob offset (world units) for a hovering ping arrow at `elapsedSeconds`.
- `pingOpacity` (function): function pingOpacity(input: PingOpacityInput): number — Alpha for a world ping in `[0, 1]`: ramps up over `fadeInMs` after it appears and ramps back down over `fadeOutMs` before it expires. Pure math so the lifecycle can be unit-tested without an R3F canvas.

## @jgengine/shell/world/tileLayerInstances

- `TileLayerInstance` (interface): interface TileLayerInstance — One resolved map cell with its world position and atlas UV rectangle.
- `resolveTileLayerInstances` (function): function resolveTileLayerInstances(config: TilemapWorldConfig): readonly TileLayerInstance[] — Resolve map glyphs to atlas frames and normalized UV rectangles for deterministic tests and renderers.

## @jgengine/shell/world/worldBarSamples

- `NameplateSample` (interface): interface NameplateSample — One entity's projected nameplate: screen `x`/`y`, display `name`, health `percent` (or `null` when statless), and world `distance` from the player.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ between (`--json` for structured output).
- Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore.
- WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state.
- Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites.
- Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax.
- Core sprite atlas adapters and deterministic 2D sprite clip playback primitives.
- `src/art-direction.md` scaffold and `check-art-direction` gate for created games.
- Host-authoritative shell sessions now expose each accepted world frame to the local prediction buffer, reconciling and snapping the possessed pose only when drift exceeds its threshold.
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/world/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AvoidZone } from "./geometry";
import type { TerrainPathProfile } from "./pathTerrain";
import type { PlaceWorldFeature } from "./place";
import type { TerraformSnapshot } from "./terraform";
import type { SpriteAtlas } from "../assets/spriteAtlas";
import type { VolumetricCloudsConfig } from "./volumetricClouds";

export interface WorldBounds {
Expand Down Expand Up @@ -573,6 +574,10 @@ export interface PlotsWorldConfig extends WorldGridConfig {

export interface TilemapWorldConfig extends WorldGridConfig {
map: string;
/** Atlas-backed tile rendering; cell glyphs map to atlas frame ids. */
tileSet?: { atlas: SpriteAtlas; tiles: Record<string, string> };
/** Render ordering key consumed by the shell tile renderer. */
sortingLayer?: string;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/previewFixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HudThemePreview } from "./hudThemePreview";
import { IconsPreview } from "./iconsPreview";
import { StreetGeometryPreview } from "./streetGeometryPreview";
import { SpriteClipPreview } from "./spriteClipPreview";
import { TileLayerPreview } from "./tileLayerPreview";

/**
* One entry in the engine preview-fixture registry: a deterministic, engine-level
Expand Down Expand Up @@ -56,6 +57,11 @@ export const PREVIEW_FIXTURES: Record<string, PreviewFixture> = {
description: "A procedurally drawn two-frame atlas driven by the core sprite clip player.",
component: SpriteClipPreview,
},
TileLayerPreview: {
name: "TileLayerPreview",
description: "A procedural 3x3 atlas-backed tile layer preview.",
component: TileLayerPreview,
},
};

/** Sorted list of registered fixture names, for discovery/listing. */
Expand Down
36 changes: 36 additions & 0 deletions packages/react/src/tileLayerPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";

/** Deterministic canvas-backed tile-layer fixture for textured tile presentation evidence. */
export function TileLayerPreview({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (canvas === null || context == null) return;
canvas.width = 640;
canvas.height = 360;
context.fillStyle = "#0e1216";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#e8edf2";
context.font = "700 18px system-ui";
context.fillText("TEXTURED TILE LAYER PREVIEW", 32, 42);
context.font = "14px system-ui";
context.fillStyle = "#9aa7b5";
context.fillText("3 × 3 procedural atlas map · one layer", 32, 68);
const colors = { a: "#355070", b: "#6b705c", c: "#3a86a8" };
const map = ["aba", "bcb", "aba"];
const size = 72;
for (let row = 0; row < map.length; row += 1) {
for (let column = 0; column < map[row]!.length; column += 1) {
context.fillStyle = colors[map[row]![column] as keyof typeof colors];
context.fillRect(212 + column * size, 104 + row * size, size - 2, size - 2);
}
}
context.strokeStyle = "#f4c95d";
context.strokeRect(212, 104, size * 3 - 2, size * 3 - 2);
context.fillStyle = "#9aa7b5";
context.font = "12px system-ui";
context.fillText("procedural atlas tiles · floor / wall / water", 32, 344);
}, []);
return <canvas ref={canvasRef} className={className} style={{ imageRendering: "pixelated", width: "100%", height: "100%" }} />;
}
13 changes: 13 additions & 0 deletions packages/shell/src/render/testFixtures/proceduralTileLayer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";

import { resolveTileLayerInstances } from "../../world/tileLayerInstances";
import { drawProceduralTileLayer } from "./proceduralTileLayer";

describe("procedural tile-layer fixture", () => {
test("draws a deterministic atlas and resolves nine cells", () => {
const context = { fillStyle: "", clearRect() {}, fillRect() {} };
const config = drawProceduralTileLayer({ width: 0, height: 0, getContext: () => context, toDataURL: () => "data:image/png;base64,fixture" });
expect(config.tileSet?.atlas.image).toBe("data:image/png;base64,fixture");
expect(resolveTileLayerInstances(config)).toHaveLength(9);
});
});
34 changes: 34 additions & 0 deletions packages/shell/src/render/testFixtures/proceduralTileLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { TilemapWorldConfig } from "@jgengine/core/world/features";

type DrawingContext = Pick<CanvasRenderingContext2D, "clearRect" | "fillRect"> & { fillStyle: string | CanvasGradient | CanvasPattern };
type CanvasFixture = { width: number; height: number; getContext(contextId: "2d"): DrawingContext | null; toDataURL(): string };

/** Draw the deterministic tile atlas used by textured tile-layer captures and tests. */
export function drawProceduralTileLayer(canvas: CanvasFixture): TilemapWorldConfig {
canvas.width = 48;
canvas.height = 16;
const context = canvas.getContext("2d");
if (context === null) throw new Error("A 2D canvas context is required.");
context.clearRect(0, 0, canvas.width, canvas.height);
for (const [x, color] of [[0, "#355070"], [16, "#6b705c"], [32, "#3a86a8"]] as const) {
context.fillStyle = color;
context.fillRect(x, 0, 16, 16);
}
return {
map: "aba\nbcb\naba",
cellSize: 1,
tileSet: {
atlas: {
image: canvas.toDataURL(),
size: [48, 16],
frames: {
floor: { x: 0, y: 0, w: 16, h: 16 },
wall: { x: 16, y: 0, w: 16, h: 16 },
water: { x: 32, y: 0, w: 16, h: 16 },
},
animations: {},
},
tiles: { a: "floor", b: "wall", c: "water" },
},
};
}
4 changes: 4 additions & 0 deletions packages/shell/src/world/GridWorldScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { WorldFeature } from "@jgengine/core/world/features";
import { resolveGridInstances } from "@jgengine/core/world/gridInstances";

import { useDisposable } from "../render/useDisposable";
import { TileLayerRenderer } from "./TileLayerRenderer";

export interface GridWorldSceneProps {
feature: WorldFeature;
Expand All @@ -24,6 +25,9 @@ function isGridWorldFeature(feature: WorldFeature): feature is GridWorldFeature
* `cells`, following the same direct-buffer pattern as `InstancedBodies`.
*/
export function GridWorldScene({ feature }: GridWorldSceneProps) {
if (feature.kind === "tilemap" && feature.tileSet !== undefined) {
return <TileLayerRenderer config={feature} />;
}
const instances = useMemo(
() => (isGridWorldFeature(feature) ? resolveGridInstances(feature) : []),
[feature],
Expand Down
28 changes: 28 additions & 0 deletions packages/shell/src/world/TileLayerRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";

import { resolveTileLayerInstances } from "./tileLayerInstances";

describe("resolveTileLayerInstances", () => {
test("resolves a 3x3 map to atlas frame UV rectangles", () => {
const instances = resolveTileLayerInstances({
map: "aba\nbcb\naba",
tileSet: {
atlas: {
image: "data:image/png;base64,procedural",
size: [16, 16],
frames: {
floor: { x: 0, y: 0, w: 8, h: 8 },
wall: { x: 8, y: 0, w: 8, h: 8 },
water: { x: 0, y: 8, w: 8, h: 8 },
},
animations: {},
},
tiles: { a: "floor", b: "wall", c: "water" },
},
});

expect(instances).toHaveLength(9);
expect(instances[0]).toMatchObject({ x: -1, z: -1, frame: "floor", uvOffset: [0, 0.5], uvScale: [0.5, 0.5] });
expect(instances[4]).toMatchObject({ x: 0, z: 0, frame: "water", uvOffset: [0, 0], uvScale: [0.5, 0.5] });
});
});
76 changes: 76 additions & 0 deletions packages/shell/src/world/TileLayerRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useFrame, useLoader, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";

import type { TilemapWorldConfig } from "@jgengine/core/world/features";
import { sortingOrder } from "@jgengine/core/render/sprite2d";

import { useDisposable } from "../render/useDisposable";

import { resolveTileLayerInstances } from "./tileLayerInstances";

/** Inputs for the instanced textured tile-layer renderer. */
export interface TileLayerRendererProps {
config: TilemapWorldConfig;
parallax?: readonly [number, number];
pixelated?: boolean;
}

/** Renders a tilemap as one instanced quad mesh with atlas UV rectangles. */
export function TileLayerRenderer({ config, parallax = [1, 1], pixelated = true }: TileLayerRendererProps) {
const tileSet = config.tileSet;
const { camera } = useThree();
const texture = useLoader(THREE.TextureLoader, tileSet?.atlas.image ?? "");
const instances = useMemo(() => resolveTileLayerInstances(config), [config]);
const meshRef = useRef<THREE.InstancedMesh>(null);
const geometry = useDisposable(() => new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), []);
const uvOffset = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 2), 2), [instances.length]);
const uvScale = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 2), 2), [instances.length]);
const material = useDisposable(() => {
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, alphaTest: 0.08, depthWrite: false });
mat.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace("#include <common>", "#include <common>\nattribute vec2 tileUvOffset;\nattribute vec2 tileUvScale;").replace("#include <uv_vertex>", "#include <uv_vertex>\n#ifdef USE_MAP\n vMapUv = vMapUv * tileUvScale + tileUvOffset;\n#endif");
};
mat.customProgramCacheKey = () => "jgengine-tile-layer";
return mat;
}, [texture]);

useEffect(() => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.magFilter = pixelated ? THREE.NearestFilter : THREE.LinearFilter;
texture.minFilter = pixelated ? THREE.NearestFilter : THREE.LinearMipmapLinearFilter;
texture.needsUpdate = true;
}, [texture, pixelated]);

useEffect(() => {
geometry.setAttribute("tileUvOffset", uvOffset);
geometry.setAttribute("tileUvScale", uvScale);
}, [geometry, uvOffset, uvScale]);

const matrix = useMemo(() => new THREE.Matrix4(), []);
useFrame(() => {
const mesh = meshRef.current;
if (mesh === null) return;
mesh.position.set(-camera.position.x * (1 - parallax[0]), 0, camera.position.z * (1 - parallax[1]));
});
useEffect(() => {
const mesh = meshRef.current;
if (mesh === null) return;
mesh.count = instances.length;
const array = mesh.instanceMatrix.array as Float32Array;
const offset = uvOffset.array as Float32Array;
const scale = uvScale.array as Float32Array;
for (let i = 0; i < instances.length; i += 1) {
const instance = instances[i]!;
matrix.makeTranslation(instance.x, 0, instance.z).toArray(array, i * 16);
offset.set(instance.uvOffset, i * 2);
scale.set(instance.uvScale, i * 2);
}
mesh.instanceMatrix.needsUpdate = true;
uvOffset.needsUpdate = true;
uvScale.needsUpdate = true;
}, [instances, matrix, uvOffset, uvScale]);

if (tileSet === undefined || instances.length === 0) return null;
return <instancedMesh ref={meshRef} args={[geometry, material, instances.length]} renderOrder={sortingOrder(["ground", "objects", "effects"], config.sortingLayer ?? "ground", 0)} frustumCulled={false} />;
}
36 changes: 36 additions & 0 deletions packages/shell/src/world/tileLayerInstances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { TilemapWorldConfig } from "@jgengine/core/world/features";

/** One resolved map cell with its world position and atlas UV rectangle. */
export interface TileLayerInstance {
x: number;
z: number;
frame: string;
uvOffset: readonly [number, number];
uvScale: readonly [number, number];
}

/** Resolve map glyphs to atlas frames and normalized UV rectangles for deterministic tests and renderers. */
export function resolveTileLayerInstances(config: TilemapWorldConfig): readonly TileLayerInstance[] {
if (config.tileSet === undefined) return [];
const { atlas, tiles } = config.tileSet;
const rows = config.map.replace(/\r\n/g, "\n").split("\n").filter((row) => row.length > 0);
const width = Math.max(0, ...rows.map((row) => row.length));
const instances: TileLayerInstance[] = [];
for (let row = 0; row < rows.length; row += 1) {
const line = rows[row]!.padEnd(width, " ");
for (let column = 0; column < width; column += 1) {
const frame = tiles[line[column]!];
if (frame === undefined) continue;
const rect = atlas.frames[frame];
if (rect === undefined) throw new Error(`tileSet frame "${frame}" is missing from the atlas`);
instances.push({
x: column - (width - 1) / 2,
z: row - (rows.length - 1) / 2,
frame,
uvOffset: [rect.x / atlas.size[0], 1 - (rect.y + rect.h) / atlas.size[1]],
uvScale: [rect.w / atlas.size[0], rect.h / atlas.size[1]],
});
}
}
return instances;
}
4 changes: 4 additions & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@
"./store",
"./streetGeometryPreview",
"./talentTree",
"./tileLayerPreview",
"./timerReadout",
"./useDebouncedCommit",
"./voice",
Expand Down Expand Up @@ -791,6 +792,7 @@
"./render/resolveModel",
"./render/sceneCapture",
"./render/testFixtures/proceduralSpriteAtlas",
"./render/testFixtures/proceduralTileLayer",
"./render/useDisposable",
"./render/useEntityRenderCues",
"./render/useModelAnimation",
Expand Down Expand Up @@ -878,6 +880,7 @@
"./world/PlaceScene",
"./world/RetainedVfx",
"./world/SpriteBatch",
"./world/TileLayerRenderer",
"./world/WorldEntityFrames",
"./world/WorldHud",
"./world/WorldItems",
Expand All @@ -889,6 +892,7 @@
"./world/floatTextStyle",
"./world/pingPulse",
"./world/telegraphPulse",
"./world/tileLayerInstances",
"./world/worldBarSamples",
"./worldSky",
"./worldSync"
Expand Down
Loading