From 6a9d4ab0a57c43f10bfaa5d3228b5fdab85e5141 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:02:54 -0400 Subject: [PATCH] feat-add-textured-tile-layers --- .claude/skills/jgengine-ui/api.md | 18 +++++ CHANGELOG.md | 1 + packages/core/src/world/features.ts | 5 ++ packages/react/src/previewFixtures.ts | 6 ++ packages/react/src/tileLayerPreview.tsx | 36 +++++++++ .../testFixtures/proceduralTileLayer.test.ts | 13 ++++ .../testFixtures/proceduralTileLayer.ts | 34 +++++++++ packages/shell/src/world/GridWorldScene.tsx | 4 + .../shell/src/world/TileLayerRenderer.test.ts | 28 +++++++ .../shell/src/world/TileLayerRenderer.tsx | 76 +++++++++++++++++++ .../shell/src/world/tileLayerInstances.ts | 36 +++++++++ scripts/export-manifest.json | 4 + 12 files changed, 261 insertions(+) create mode 100644 packages/react/src/tileLayerPreview.tsx create mode 100644 packages/shell/src/render/testFixtures/proceduralTileLayer.test.ts create mode 100644 packages/shell/src/render/testFixtures/proceduralTileLayer.ts create mode 100644 packages/shell/src/world/TileLayerRenderer.test.ts create mode 100644 packages/shell/src/world/TileLayerRenderer.tsx create mode 100644 packages/shell/src/world/tileLayerInstances.ts diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 59258c3d5..f0b2e9607 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -1656,6 +1656,10 @@ - `TalentTree` (function): function TalentTree({ view: viewProp, nodes, tree, onLearn, icon, label, branchLabel, title = "Talents", variation = "themed", nodeSize = 54, columnGap = 30, tierGap = 46, showPoints = true, className, style, }: TalentTreeProps): 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 — 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. @@ -2244,6 +2248,10 @@ - `captureCanvas` (function): function captureCanvas(gl: CaptureRenderer): string | null — Read the current frame to a PNG data URL. Requires the R3F `` 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. @@ -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({ entries, renderFrame, tickMs = 0, className, anchorTransform, ...layout }: WorldEntityFramesProps): 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 `` 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. @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b37fa97..c102e3802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/world/features.ts b/packages/core/src/world/features.ts index b1bb53872..b500d7479 100644 --- a/packages/core/src/world/features.ts +++ b/packages/core/src/world/features.ts @@ -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 { @@ -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 }; + /** Render ordering key consumed by the shell tile renderer. */ + sortingLayer?: string; } /** diff --git a/packages/react/src/previewFixtures.ts b/packages/react/src/previewFixtures.ts index 13b0a1713..df3b74bf8 100644 --- a/packages/react/src/previewFixtures.ts +++ b/packages/react/src/previewFixtures.ts @@ -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 @@ -56,6 +57,11 @@ export const PREVIEW_FIXTURES: Record = { 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. */ diff --git a/packages/react/src/tileLayerPreview.tsx b/packages/react/src/tileLayerPreview.tsx new file mode 100644 index 000000000..9a3f2df5f --- /dev/null +++ b/packages/react/src/tileLayerPreview.tsx @@ -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(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 ; +} diff --git a/packages/shell/src/render/testFixtures/proceduralTileLayer.test.ts b/packages/shell/src/render/testFixtures/proceduralTileLayer.test.ts new file mode 100644 index 000000000..a5ab31623 --- /dev/null +++ b/packages/shell/src/render/testFixtures/proceduralTileLayer.test.ts @@ -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); + }); +}); diff --git a/packages/shell/src/render/testFixtures/proceduralTileLayer.ts b/packages/shell/src/render/testFixtures/proceduralTileLayer.ts new file mode 100644 index 000000000..cd08e53c9 --- /dev/null +++ b/packages/shell/src/render/testFixtures/proceduralTileLayer.ts @@ -0,0 +1,34 @@ +import type { TilemapWorldConfig } from "@jgengine/core/world/features"; + +type DrawingContext = Pick & { 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" }, + }, + }; +} diff --git a/packages/shell/src/world/GridWorldScene.tsx b/packages/shell/src/world/GridWorldScene.tsx index 0eac64f21..8afb11d1d 100644 --- a/packages/shell/src/world/GridWorldScene.tsx +++ b/packages/shell/src/world/GridWorldScene.tsx @@ -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; @@ -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 ; + } const instances = useMemo( () => (isGridWorldFeature(feature) ? resolveGridInstances(feature) : []), [feature], diff --git a/packages/shell/src/world/TileLayerRenderer.test.ts b/packages/shell/src/world/TileLayerRenderer.test.ts new file mode 100644 index 000000000..15e8b64ab --- /dev/null +++ b/packages/shell/src/world/TileLayerRenderer.test.ts @@ -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] }); + }); +}); diff --git a/packages/shell/src/world/TileLayerRenderer.tsx b/packages/shell/src/world/TileLayerRenderer.tsx new file mode 100644 index 000000000..c8479721a --- /dev/null +++ b/packages/shell/src/world/TileLayerRenderer.tsx @@ -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(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 ", "#include \nattribute vec2 tileUvOffset;\nattribute vec2 tileUvScale;").replace("#include ", "#include \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 ; +} diff --git a/packages/shell/src/world/tileLayerInstances.ts b/packages/shell/src/world/tileLayerInstances.ts new file mode 100644 index 000000000..1d917e268 --- /dev/null +++ b/packages/shell/src/world/tileLayerInstances.ts @@ -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; +} diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 3c0591dbf..5792255e7 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -652,6 +652,7 @@ "./store", "./streetGeometryPreview", "./talentTree", + "./tileLayerPreview", "./timerReadout", "./useDebouncedCommit", "./voice", @@ -791,6 +792,7 @@ "./render/resolveModel", "./render/sceneCapture", "./render/testFixtures/proceduralSpriteAtlas", + "./render/testFixtures/proceduralTileLayer", "./render/useDisposable", "./render/useEntityRenderCues", "./render/useModelAnimation", @@ -878,6 +880,7 @@ "./world/PlaceScene", "./world/RetainedVfx", "./world/SpriteBatch", + "./world/TileLayerRenderer", "./world/WorldEntityFrames", "./world/WorldHud", "./world/WorldItems", @@ -889,6 +892,7 @@ "./world/floatTextStyle", "./world/pingPulse", "./world/telegraphPulse", + "./world/tileLayerInstances", "./world/worldBarSamples", "./worldSky", "./worldSync"