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.
- package/LICENSE +21 -0
- package/dist/index.js +1014 -0
- package/package.json +70 -0
- package/templates/react/.oxfmtrc.json +8 -0
- package/templates/react/.oxlintrc.json +27 -0
- package/templates/react/AGENTS.md +188 -0
- package/templates/react/CLAUDE.md +188 -0
- package/templates/react/_gitignore +10 -0
- package/templates/react/e2e/app.spec.ts +122 -0
- package/templates/react/index.html +146 -0
- package/templates/react/package.json +54 -0
- package/templates/react/playwright.config.ts +41 -0
- package/templates/react/public/sprite.svg +1866 -0
- package/templates/react/src/App.tsx +172 -0
- package/templates/react/src/interaction.test.ts +48 -0
- package/templates/react/src/interaction.ts +53 -0
- package/templates/react/src/main.tsx +9 -0
- package/templates/react/tsconfig.json +19 -0
- package/templates/react/vite.config.ts +12 -0
- package/templates/react/vitest.config.ts +6 -0
- package/templates/three/.oxfmtrc.json +8 -0
- package/templates/three/.oxlintrc.json +26 -0
- package/templates/three/AGENTS.md +167 -0
- package/templates/three/CLAUDE.md +167 -0
- package/templates/three/_gitignore +10 -0
- package/templates/three/e2e/app.spec.ts +122 -0
- package/templates/three/index.html +185 -0
- package/templates/three/package.json +47 -0
- package/templates/three/playwright.config.ts +41 -0
- package/templates/three/public/sprite.svg +1866 -0
- package/templates/three/src/interaction.test.ts +66 -0
- package/templates/three/src/interaction.ts +65 -0
- package/templates/three/src/main.ts +138 -0
- package/templates/three/tsconfig.json +18 -0
- package/templates/three/vite.config.ts +4 -0
- 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,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,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.
|