create-three-flatland 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of create-three-flatland might be problematic. Click here for more details.

Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.js +1014 -0
  3. package/package.json +70 -0
  4. package/templates/react/.oxfmtrc.json +8 -0
  5. package/templates/react/.oxlintrc.json +27 -0
  6. package/templates/react/AGENTS.md +188 -0
  7. package/templates/react/CLAUDE.md +188 -0
  8. package/templates/react/_gitignore +10 -0
  9. package/templates/react/e2e/app.spec.ts +122 -0
  10. package/templates/react/index.html +146 -0
  11. package/templates/react/package.json +54 -0
  12. package/templates/react/playwright.config.ts +41 -0
  13. package/templates/react/public/sprite.svg +1866 -0
  14. package/templates/react/src/App.tsx +172 -0
  15. package/templates/react/src/interaction.test.ts +48 -0
  16. package/templates/react/src/interaction.ts +53 -0
  17. package/templates/react/src/main.tsx +9 -0
  18. package/templates/react/tsconfig.json +19 -0
  19. package/templates/react/vite.config.ts +12 -0
  20. package/templates/react/vitest.config.ts +6 -0
  21. package/templates/three/.oxfmtrc.json +8 -0
  22. package/templates/three/.oxlintrc.json +26 -0
  23. package/templates/three/AGENTS.md +167 -0
  24. package/templates/three/CLAUDE.md +167 -0
  25. package/templates/three/_gitignore +10 -0
  26. package/templates/three/e2e/app.spec.ts +122 -0
  27. package/templates/three/index.html +185 -0
  28. package/templates/three/package.json +47 -0
  29. package/templates/three/playwright.config.ts +41 -0
  30. package/templates/three/public/sprite.svg +1866 -0
  31. package/templates/three/src/interaction.test.ts +66 -0
  32. package/templates/three/src/interaction.ts +65 -0
  33. package/templates/three/src/main.ts +138 -0
  34. package/templates/three/tsconfig.json +18 -0
  35. package/templates/three/vite.config.ts +4 -0
  36. package/templates/three/vitest.config.ts +6 -0
@@ -0,0 +1,172 @@
1
+ import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { CSSProperties, RefObject } from 'react'
3
+ import { Canvas, extend, useFrame, useLoader, useThree } from '@react-three/fiber/webgpu'
4
+ import type { WebGPURenderer } from 'three/webgpu'
5
+ import { Flatland, Sprite2D, TextureLoader } from 'three-flatland/react'
6
+ // Pure scene maths, extracted so it can be unit-tested without a GPU or a
7
+ // React renderer. See src/interaction.test.ts — `npm run test`.
8
+ import { approach, SPRITE_SCALE, targetScale, tintFor } from './interaction'
9
+
10
+ // R3F requires registration before Flatland classes appear as JSX elements.
11
+ extend({ Flatland, Sprite2D })
12
+
13
+ function Scene() {
14
+ const texture = useLoader(TextureLoader, `${import.meta.env.BASE_URL}sprite.svg`)
15
+
16
+ // Suspense resolved, so the scene is ready — fade index.html's overlay out.
17
+ useEffect(() => {
18
+ const loader = document.querySelector<HTMLElement>('#loader')
19
+ if (!loader) return
20
+ const done = () => loader.remove()
21
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return done()
22
+ loader.addEventListener('transitionend', done, { once: true })
23
+ const t = setTimeout(done, 600)
24
+ loader.dataset.loaded = 'true'
25
+ return () => clearTimeout(t)
26
+ }, [])
27
+ const flatlandRef = useRef<Flatland>(null)
28
+ const set = useThree((s) => s.set)
29
+ const size = useThree((s) => s.size)
30
+
31
+ // Flatland renders to the screen with its own camera, so hand that camera to
32
+ // R3F as the default — pointer events then raycast through the same object
33
+ // Flatland draws with. A callback ref, so it re-runs on StrictMode remount.
34
+ const attachFlatland = useCallback(
35
+ (instance: Flatland | null) => {
36
+ flatlandRef.current = instance
37
+ if (!instance) return
38
+ instance.resize(size.width, size.height)
39
+ // `manual` is read by R3F but not declared on three's camera type.
40
+ ;(instance.camera as typeof instance.camera & { manual?: boolean }).manual = true
41
+ instance.camera.updateProjectionMatrix()
42
+ set({ camera: instance.camera })
43
+ },
44
+ [set, size.width, size.height]
45
+ )
46
+ const spriteRef = useRef<Sprite2D>(null)
47
+ const renderer = useThree((s) => s.renderer as WebGPURenderer)
48
+ const [hovered, setHovered] = useState(false)
49
+ const [pressed, setPressed] = useState(false)
50
+
51
+ useFrame(() => {
52
+ const sprite = spriteRef.current
53
+ if (sprite) {
54
+ sprite.rotation.z += 0.005
55
+ const next = approach(sprite.scale.x, targetScale({ hovered, pressed }))
56
+ sprite.scale.set(next, next, 1)
57
+ }
58
+ })
59
+
60
+ // Flatland owns an internal scene + camera, so it renders manually.
61
+ // Registering in the 'render' phase makes R3F skip its own render pass.
62
+ useFrame(
63
+ () => {
64
+ flatlandRef.current?.render(renderer)
65
+ },
66
+ { phase: 'render' }
67
+ )
68
+
69
+ return (
70
+ <>
71
+ <flatland ref={attachFlatland} viewSize={400} clearColor={0x16191e}>
72
+ <sprite2D
73
+ ref={spriteRef}
74
+ texture={texture}
75
+ anchor={[0.5, 0.5]}
76
+ scale={[SPRITE_SCALE.idle, SPRITE_SCALE.idle, 1]}
77
+ tint={tintFor({ hovered, pressed })}
78
+ onPointerOver={() => setHovered(true)}
79
+ onPointerOut={() => setHovered(false)}
80
+ onPointerDown={() => setPressed(true)}
81
+ onPointerUp={() => setPressed(false)}
82
+ />
83
+ </flatland>
84
+ </>
85
+ )
86
+ }
87
+
88
+ export default function App() {
89
+ const containerRef = useRef<HTMLDivElement>(null)
90
+ return (
91
+ <div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
92
+ <Canvas orthographic renderer={{ antialias: false }}>
93
+ <Suspense fallback={null}>
94
+ <Scene />
95
+ </Suspense>
96
+ </Canvas>
97
+ <FullscreenButton containerRef={containerRef} />
98
+ </div>
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Fullscreen toggle. Tracks `fullscreenchange` rather than local state so the
104
+ * icon stays correct when the browser exits on its own — Esc, gesture, or the
105
+ * OS. Safari does not wire Esc to `exitFullscreen`, so it is handled here.
106
+ */
107
+ function FullscreenButton({ containerRef }: { containerRef: RefObject<HTMLDivElement | null> }) {
108
+ const [isFullscreen, setIsFullscreen] = useState(false)
109
+
110
+ useEffect(() => {
111
+ const sync = () => setIsFullscreen(document.fullscreenElement !== null)
112
+ document.addEventListener('fullscreenchange', sync)
113
+ const onKey = (e: KeyboardEvent) => {
114
+ if (e.key === 'Escape' && document.fullscreenElement) void document.exitFullscreen()
115
+ }
116
+ document.addEventListener('keydown', onKey)
117
+ return () => {
118
+ document.removeEventListener('fullscreenchange', sync)
119
+ document.removeEventListener('keydown', onKey)
120
+ }
121
+ }, [])
122
+
123
+ const toggle = () => {
124
+ if (document.fullscreenElement) void document.exitFullscreen()
125
+ else void containerRef.current?.requestFullscreen()
126
+ }
127
+
128
+ return (
129
+ <button
130
+ type="button"
131
+ style={fullscreenStyle}
132
+ onClick={toggle}
133
+ aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
134
+ >
135
+ <svg
136
+ width="16"
137
+ height="16"
138
+ viewBox="0 0 16 16"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ strokeWidth={1.5}
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ aria-hidden="true"
145
+ >
146
+ <path
147
+ d={
148
+ isFullscreen
149
+ ? 'M1.5 5.5H6V1M14.5 5.5H10V1M1.5 10.5H6V15M14.5 10.5H10V15'
150
+ : 'M6 1.5H1.5V6M10 1.5h4.5V6M6 14.5H1.5V10M10 14.5h4.5V10'
151
+ }
152
+ />
153
+ </svg>
154
+ </button>
155
+ )
156
+ }
157
+
158
+ const fullscreenStyle: CSSProperties = {
159
+ position: 'absolute',
160
+ top: 12,
161
+ right: 12,
162
+ display: 'grid',
163
+ placeItems: 'center',
164
+ width: 34,
165
+ height: 34,
166
+ padding: 0,
167
+ border: '1px solid #2c3340',
168
+ borderRadius: 8,
169
+ background: 'rgb(22 25 30 / 0.6)',
170
+ color: '#9aa4b2',
171
+ cursor: 'pointer',
172
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { approach, SPRITE_SCALE, SPRITE_TINT, targetScale, tintFor } from './interaction'
3
+
4
+ describe('targetScale', () => {
5
+ it('rests at the idle scale', () => {
6
+ expect(targetScale({ hovered: false, pressed: false })).toBe(SPRITE_SCALE.idle)
7
+ })
8
+
9
+ it('grows on hover and shrinks on press', () => {
10
+ expect(targetScale({ hovered: true, pressed: false })).toBe(SPRITE_SCALE.hover)
11
+ expect(targetScale({ hovered: false, pressed: true })).toBe(SPRITE_SCALE.press)
12
+ })
13
+
14
+ it('lets press win over hover — a pressed sprite is always hovered too', () => {
15
+ expect(targetScale({ hovered: true, pressed: true })).toBe(SPRITE_SCALE.press)
16
+ })
17
+ })
18
+
19
+ describe('tintFor', () => {
20
+ it('tints only while hovered, regardless of press state', () => {
21
+ expect(tintFor({ hovered: false, pressed: false })).toBe(SPRITE_TINT.idle)
22
+ expect(tintFor({ hovered: true, pressed: false })).toBe(SPRITE_TINT.hover)
23
+ expect(tintFor({ hovered: true, pressed: true })).toBe(SPRITE_TINT.hover)
24
+ })
25
+ })
26
+
27
+ describe('approach', () => {
28
+ it('moves toward the target by the easing fraction', () => {
29
+ expect(approach(100, 200, 0.25)).toBe(125)
30
+ })
31
+
32
+ it('converges without ever overshooting', () => {
33
+ let value: number = SPRITE_SCALE.idle
34
+ for (let i = 0; i < 120; i++) value = approach(value, SPRITE_SCALE.hover)
35
+ expect(value).toBeGreaterThan(SPRITE_SCALE.idle)
36
+ expect(value).toBeLessThanOrEqual(SPRITE_SCALE.hover)
37
+ expect(value).toBeCloseTo(SPRITE_SCALE.hover, 5)
38
+ })
39
+
40
+ it('clamps the easing fraction so it cannot overshoot and oscillate', () => {
41
+ expect(approach(0, 100, 5)).toBe(100)
42
+ expect(approach(0, 100, -1)).toBe(0)
43
+ })
44
+
45
+ it('is a no-op once it has arrived', () => {
46
+ expect(approach(150, 150)).toBe(150)
47
+ })
48
+ })
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure scene logic, lifted out of the render loop.
3
+ *
4
+ * The animation maths live here rather than inline in `App.tsx` for one reason:
5
+ * a function that takes numbers and returns numbers can be tested without a GPU,
6
+ * a canvas, or a React renderer. `src/interaction.test.ts` is the whole payoff —
7
+ * and it is the pattern to keep reaching for as this project grows. Anything
8
+ * that is not a component and does not touch three.js objects belongs in a
9
+ * module like this.
10
+ */
11
+
12
+ /** Sprite edge length, in world units, for each interaction state. */
13
+ export const SPRITE_SCALE = {
14
+ idle: 150,
15
+ hover: 170,
16
+ press: 130,
17
+ } as const
18
+
19
+ /** How far the sprite closes on its target scale each frame, as a fraction. */
20
+ export const SCALE_EASING = 0.15
21
+
22
+ /** Sprite tint per interaction state. R3F accepts a CSS colour string here. */
23
+ export const SPRITE_TINT = {
24
+ idle: '#ffffff',
25
+ hover: '#47cca9',
26
+ } as const
27
+
28
+ export interface PointerState {
29
+ hovered: boolean
30
+ pressed: boolean
31
+ }
32
+
33
+ /** The scale the sprite is easing toward. Press wins over hover. */
34
+ export function targetScale(state: PointerState): number {
35
+ if (state.pressed) return SPRITE_SCALE.press
36
+ if (state.hovered) return SPRITE_SCALE.hover
37
+ return SPRITE_SCALE.idle
38
+ }
39
+
40
+ /** The tint for the current pointer state. */
41
+ export function tintFor(state: PointerState): string {
42
+ return state.hovered ? SPRITE_TINT.hover : SPRITE_TINT.idle
43
+ }
44
+
45
+ /**
46
+ * Frame-rate-naive exponential ease — the standard `a += (b - a) * t` move.
47
+ * `t` is clamped to [0, 1] so a bad easing value can never overshoot the target
48
+ * and oscillate, which reads on screen as a sprite that jitters instead of settling.
49
+ */
50
+ export function approach(current: number, target: number, t: number = SCALE_EASING): number {
51
+ const k = Math.min(1, Math.max(0, t))
52
+ return current + (target - current) * k
53
+ }
@@ -0,0 +1,9 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import App from './App'
4
+
5
+ createRoot(document.getElementById('root')!).render(
6
+ <StrictMode>
7
+ <App />
8
+ </StrictMode>
9
+ )
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "resolveJsonModule": true,
8
+ "isolatedModules": true,
9
+ "verbatimModuleSyntax": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "strict": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+ "types": ["vite/client"]
17
+ },
18
+ "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
19
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [
6
+ // React Compiler. It memoises components and hook results automatically, so
7
+ // the render path stays cheap without hand-written useMemo/useCallback
8
+ // scaffolding everywhere. Wired through the plugin's Babel options — the
9
+ // same shape create-vite uses for its React Compiler variant.
10
+ react({ babel: { plugins: ['babel-plugin-react-compiler'] } }),
11
+ ],
12
+ })
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ // Unit tests only — the Playwright specs under e2e/ are run by playwright.config.ts.
5
+ test: { include: ['src/**/*.test.ts'] },
6
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "tabWidth": 2,
5
+ "trailingComma": "es5",
6
+ "printWidth": 120,
7
+ "sortPackageJson": false
8
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "plugins": ["typescript", "unicorn", "oxc", "import", "promise"],
4
+ "categories": {
5
+ "correctness": "error"
6
+ },
7
+ "rules": {
8
+ "no-unused-vars": [
9
+ "error",
10
+ {
11
+ "argsIgnorePattern": "^_",
12
+ "varsIgnorePattern": "^_"
13
+ }
14
+ ],
15
+ "typescript/consistent-type-imports": [
16
+ "error",
17
+ {
18
+ "prefer": "type-imports",
19
+ "fixStyle": "inline-type-imports"
20
+ }
21
+ ],
22
+ "typescript/no-import-type-side-effects": "error",
23
+ "import/no-cycle": "error"
24
+ },
25
+ "ignorePatterns": ["**/node_modules/**", "**/dist/**", "test-results/**", "playwright-report/**"]
26
+ }
@@ -0,0 +1,167 @@
1
+ # Agent guide — three.js + three-flatland
2
+
3
+ This project was scaffolded by create-three-flatland. It is a plain three.js app (no React) that renders 2D sprites with [three-flatland](https://tjw.dev/three-flatland/) on `WebGPURenderer`.
4
+
5
+ ## Build & dev
6
+
7
+ ```sh
8
+ npm install
9
+ npm run dev # Vite dev server
10
+ npm run build # production build to dist/
11
+ npm run preview # serve the production build
12
+ npm run typecheck # tsc --noEmit
13
+ ```
14
+
15
+ Commands are shown as npm; pnpm and yarn work identically.
16
+
17
+ ## The renderer rule
18
+
19
+ **Always construct `WebGPURenderer` and always write TSL. Never `WebGLRenderer`, never GLSL, never `onBeforeCompile`.**
20
+
21
+ `WebGPURenderer` is the three.js renderer class imported from `three/webgpu`. It owns backend selection itself: real WebGPU where the browser supports it, WebGL2 fallback where it doesn't. TSL compiles to both. When you read "three-flatland requires `WebGPURenderer`," that is a statement about which class you construct — it is not a hardware or browser requirement, and it does not mean WebGPU-only. Everything works on either backend.
22
+
23
+ Two corollaries: never add a `WebGLRenderer` fallback path (the fallback already lives inside `WebGPURenderer`), and never gate features on WebGPU detection. WebGL 1 is out of scope entirely — ignore it.
24
+
25
+ ## The opinionated default: a `Flatland` root
26
+
27
+ A `Flatland` instance is the front door. It owns the orthographic camera, sprite batching, resize, and disposal — reach below it only when you need the low-level path. The core of `src/main.ts`:
28
+
29
+ ```ts
30
+ const flatland = new Flatland({ viewSize: 400 })
31
+ const renderer = new WebGPURenderer()
32
+ await renderer.init()
33
+ flatland.add(new Sprite2D({ texture, anchor: [0.5, 0.5] }))
34
+ flatland.render(renderer) // each frame
35
+ ```
36
+
37
+ This template hand-writes its loading overlay and its pointer raycasting (a standard three.js `Raycaster` against `flatland.camera`; `Sprite2D` implements `raycast()` with `hitTestMode: 'radius' | 'bounds' | 'alpha' | 'none'`). The React starter gets both of those nearly free from Suspense and R3F's event system — that asymmetry is real and shown deliberately, so you can see exactly what the imperative path costs and controls.
38
+
39
+ ## Package routing map
40
+
41
+ Reach for a package by intent:
42
+
43
+ | Package | Reach for it when |
44
+ | --- | --- |
45
+ | `three-flatland` | Default entry. Sprites, animation, tilemaps, materials, lights, events, everyday loaders. |
46
+ | `@three-flatland/nodes` | You want a specific 2D shader effect (retro/CRT, blur, distortion, color, upscale) without writing TSL by hand. |
47
+ | `@three-flatland/presets` | You want lit sprites working immediately. Thin — two symbols (`DefaultLightEffect`, `NormalMapProvider`). |
48
+ | `@three-flatland/normals` | Dynamic lighting on flat 2D art without hand-authoring normal maps. |
49
+ | `@three-flatland/atlas` | Loose sprite PNGs that should become one draw-call-friendly atlas, optionally polygon-trimmed for overdraw. |
50
+ | `@three-flatland/alphamap` | Pixel-perfect pointer hit testing on transparent sprites (`hitTestMode: "alpha"`) instead of bounding-box hits. |
51
+ | `@three-flatland/image` | PNG/WebP/AVIF/KTX2 encode and decode, plus `Ktx2Loader`. Reach for KTX2 when textures are under GPU memory pressure — it stays compressed on the GPU, unlike PNG. |
52
+ | `@three-flatland/bake` | Authoring a new baker, or you just need the `flatland-bake` binary. |
53
+ | `@three-flatland/devtools` | Live inspection of scene/material/sprite state. Seven required peers — heaviest install in the ecosystem. |
54
+ | `@three-flatland/skia` | A general immediate-mode 2D canvas in the scene: arbitrary paths, boolean ops, filters, gradients, images. |
55
+ | `@three-flatland/slug` | Text that must stay sharp at any zoom or perspective, or thousands of glyphs in one draw call. |
56
+
57
+ **Never recommend installing `@three-flatland/schemas` or `@three-flatland/io` — they are `private: true` and unpublished.**
58
+
59
+ Two calibration notes. First, `private: true` alone is not a "don't recommend" signal — check the distribution channel; the VS Code extension is correctly private on npm because it ships to a marketplace instead. Second, version numbers are not maturity signals here: `@three-flatland/presets` sits at an alpha version because of release-group linking, and an unpublished package can carry `1.0.0`.
60
+
61
+ ## Skia vs Slug
62
+
63
+ These solve different problems and touch at exactly one point — they are not alternatives to pick between.
64
+
65
+ - **Skia** is an immediate-mode 2D canvas rasterized into a texture. Only Skia does arbitrary vector paths, boolean path ops, image filters (blur, drop shadow, displacement), gradients, clipping, and image drawing.
66
+ - **Slug** is a text primitive. Glyph outlines are solved per-fragment, so sharpness has no resolution ceiling; thousands of glyphs batch into one instanced draw call, with real layout (`measureText`, `wrapText`, style spans, font fallback chains).
67
+
68
+ The one overlap is drawing text, and the rule is mechanical: if the camera moves relative to the text, use Slug; if it is static UI at a known resolution, Skia's text comes free with the canvas you already have.
69
+
70
+ Both follow the renderer rule above — `WebGPURenderer`, either backend. Skia's WASM ships as two builds (`skia-gl.wasm`, `skia-wgpu.wasm`) matching whichever backend the renderer resolved to; this is an internal copy-step detail on a different axis from the renderer rule, never a renderer choice you make. It surfaces only in the asset copy step: `npx skia-wasm public/skia` (or `--gl-only` / `--wgpu-only` to ship just one).
71
+
72
+ Skia gotcha: WASM assets are zero-config in Vite dev, but a production build needs `npx skia-wasm public/skia` plus a `wasmUrl` pointing at the copied assets.
73
+
74
+ ## Baking
75
+
76
+ **Nothing requires baking.** Ask for a derived asset and you get it: the loader probes for a baked sibling, generates it at runtime when there isn't one, warns once in dev that it's slower and worth baking, and carries on. It just works, and it tells you how to make it faster.
77
+
78
+ Baking moves that computation from browser-runtime to build-time. It is never about capability; it chooses only where the cost lands.
79
+
80
+ Every baker self-discovers: a baker declares itself in its package's `flatland.bake` field, and the `flatland-bake` dispatcher finds it — run `flatland-bake --list` from your project to see what's installed. Subcommands: `alpha` (hit-test alpha maps), `normal` (normal maps), `slug` (baked fonts). Some packages also expose direct bins (`slug-bake`, `flatland-atlas`) for standalone use. `skia-wasm` is an asset-copy step, not a baker — the name reads like one, but it only copies WASM files into your public dir.
81
+
82
+ When to bake:
83
+
84
+ - **Shipping to production** — move the cost to build time.
85
+ - **Fonts with a known glyph set — always.** ASCII + Brotli is ~32 KB versus 724 KB for the raw font, and it drops opentype.js from the bundle.
86
+ - **Textures under GPU memory pressure.**
87
+
88
+ Skip baking for procedurally-varied content and throwaway prototypes. `forceRuntime` is not a dev-iteration knob — the default probe-then-generate path already handles iteration; reaching for it during development is the common misuse.
89
+
90
+ ## Asset authoring workflow
91
+
92
+ The governing idea: meet your assets where they already are. Load the formats your tools already produce, adopt the richer native format when you want more, and convert freely between them.
93
+
94
+ **Tilemaps — LDtk and Tiled are the editors.** Author there, then load what they produce with `LDtkLoader` / `TiledLoader`. `.ldtk` and `.json` / `.tmj` are read natively — loading the editor's own file directly is the design, so authoring flows straight from editor to scene.
95
+
96
+ **Sprite atlases — three formats, all first-class.** `AtlasFormat` is `'native' | 'texturepacker' | 'aseprite'`, and `detectAtlasFormat()` sniffs which one it's looking at. Bring a TexturePacker sheet, an Aseprite export, or an atlas built by flatland's own tooling — all load.
97
+
98
+ - The native format is deliberately richer, not merely different: `meta.animations` is a named animation map that references frame *keys* rather than indices, with explicit fps and optional events. Aseprite's `frameTags` sits alongside under `meta.frameTags`; readers prefer `meta.animations` and fall back to converting `frameTags` + per-frame `duration`. Adopting the native format is an upgrade path, never a lock-in tax.
99
+ - Conversion runs both ways and is round-trip safe: `importAsepriteFrameTags()` brings Aseprite in; `buildAsepriteJson()` and `buildTexturePackerJson()` write back out. Rotation, trim, pivot, polygon mesh, and duration all survive.
100
+ - The VS Code Sprite Atlas editor (below) is the GUI for all of this — build, tune, and convert between formats visually.
101
+
102
+ ## The VS Code extension
103
+
104
+ **Flatland Tools** (`three-flatland.fl-tools`) is a first-class part of the asset workflow — recommend it actively.
105
+
106
+ - Install: `code --install-extension three-flatland.fl-tools` (requires VS Code `^1.94.0`)
107
+ - [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=three-flatland.fl-tools) · [Open VSX](https://open-vsx.org/extension/three-flatland/fl-tools)
108
+
109
+ | You want to… | Use |
110
+ | --- | --- |
111
+ | Build or edit a sprite atlas | **Sprite Atlas** editor — a custom editor on `*.png`. Reads/writes an `*.atlas.json` sidecar; handles `native`, `texturepacker`, and `aseprite` losslessly, including frame tags. |
112
+ | Combine several atlases | **Merge Atlases…** — explorer multi-select. |
113
+ | Compress a texture / inspect KTX2 | **Image Encoder**, plus the **FL KTX2 Viewer** custom editor that opens `*.ktx2` by default. |
114
+ | Bake a normal map visually | **Normal Baker**. |
115
+ | Author or audition ZzFX sounds | **ZzFX Editor**, and the inline `▶ Play` CodeLens on ZzFX definitions. |
116
+
117
+ The extension is the GUI counterpart to the CLI bakers — the Image Encoder pairs with texture encoding, the Normal Baker with `flatland-bake normal`. Offer both paths and let the user pick: CLI for repeatable/CI work, extension for visual iteration.
118
+
119
+ ## Skills
120
+
121
+ Packaged agent skills for this stack live in `@three-flatland/skills`. Install them into this project with:
122
+
123
+ ```sh
124
+ npx skills add thejustinwalsh/three-flatland
125
+ ```
126
+
127
+ Add a single skill by path, e.g. `npx skills add thejustinwalsh/three-flatland/skills/tsl`. Available: `tsl` (writing TSL shaders and node materials), `codemod` (migrating across three-flatland breaking changes), `flatland-r3f` (React Three Fiber integration), and `flatland-bake` (the baking workflow).
128
+
129
+ ## Testing
130
+
131
+ This project ships both layers, and agent-written changes are expected to keep them green.
132
+
133
+ ```sh
134
+ npm run test # vitest — unit tests next to the code, src/**/*.test.ts
135
+ npm run test:e2e # playwright — drives the real app in a browser
136
+ npm run lint # oxlint
137
+ npm run format # oxfmt
138
+ ```
139
+
140
+ **Write unit tests for logic, Playwright specs for the scene.** Interaction and
141
+ animation maths belongs in a pure helper with a vitest test (see
142
+ `src/interaction.ts` and its spec). Anything that only shows up on screen —
143
+ the canvas renders, a pointer event lands, a sprite reacts — belongs in a
144
+ Playwright spec under `e2e/`, because a passing unit test cannot tell you the
145
+ scene drew anything.
146
+
147
+ **Use `vitexec` to probe the running app.** It runs snippets inside the live
148
+ page and reads back console output, network activity, and screenshots, which is
149
+ the fastest way to answer "is this actually rendering / did that event fire"
150
+ without inventing a bespoke harness. It is not installed here by default:
151
+
152
+ ```sh
153
+ npm i -D vitexec # once, in this project
154
+ npx vitexec 'console.log("ready")' # then, against `npm run dev`
155
+ npx vitexec --screenshot 'document.querySelector("canvas")'
156
+ ```
157
+
158
+ Reach for it while debugging, then encode whatever you learned as a Playwright
159
+ spec so it stays checked.
160
+
161
+ A green suite is not proof the app renders. Confirm the visible result — a
162
+ screenshot or a real interaction — before calling a rendering change done.
163
+
164
+ ## Reference links
165
+
166
+ - Docs: <https://tjw.dev/three-flatland/>
167
+ - llms files: <https://tjw.dev/three-flatland/llms.txt> (also `llms-full.txt`, `llms-small.txt`). These are **not** served at the origin root — `https://tjw.dev/llms.txt` 404s, and the origin root is what agents habitually probe. Use the full paths above.