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,66 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { approach, SPRITE_SCALE, targetScale, toPointerNdc } 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('approach', () => {
20
+ it('moves toward the target by the easing fraction', () => {
21
+ expect(approach(100, 200, 0.25)).toBe(125)
22
+ })
23
+
24
+ it('converges without ever overshooting', () => {
25
+ let value: number = SPRITE_SCALE.idle
26
+ for (let i = 0; i < 120; i++) value = approach(value, SPRITE_SCALE.hover)
27
+ expect(value).toBeGreaterThan(SPRITE_SCALE.idle)
28
+ expect(value).toBeLessThanOrEqual(SPRITE_SCALE.hover)
29
+ expect(value).toBeCloseTo(SPRITE_SCALE.hover, 5)
30
+ })
31
+
32
+ it('clamps the easing fraction so it cannot overshoot and oscillate', () => {
33
+ expect(approach(0, 100, 5)).toBe(100)
34
+ expect(approach(0, 100, -1)).toBe(0)
35
+ })
36
+
37
+ it('is a no-op once it has arrived', () => {
38
+ expect(approach(150, 150)).toBe(150)
39
+ })
40
+ })
41
+
42
+ describe('toPointerNdc', () => {
43
+ const rect = { left: 0, top: 0, width: 800, height: 600 }
44
+
45
+ it('maps the centre to the origin', () => {
46
+ expect(toPointerNdc(400, 300, rect)).toEqual({ x: 0, y: 0 })
47
+ })
48
+
49
+ it('flips the Y axis — client Y grows down, NDC Y grows up', () => {
50
+ expect(toPointerNdc(0, 0, rect)).toEqual({ x: -1, y: 1 })
51
+ expect(toPointerNdc(800, 600, rect)).toEqual({ x: 1, y: -1 })
52
+ })
53
+
54
+ it('accounts for a canvas that is not at the page origin', () => {
55
+ expect(toPointerNdc(140, 90, { left: 40, top: 40, width: 200, height: 100 })).toEqual({ x: 0, y: 0 })
56
+ })
57
+
58
+ it('returns the origin instead of NaN for a zero-size rect', () => {
59
+ // A canvas queried before layout reports 0×0. Dividing by that would hand
60
+ // the raycaster NaN, which silently disables every hit test.
61
+ const ndc = toPointerNdc(10, 10, { left: 0, top: 0, width: 0, height: 0 })
62
+ expect(Number.isNaN(ndc.x)).toBe(false)
63
+ expect(Number.isNaN(ndc.y)).toBe(false)
64
+ expect(ndc).toEqual({ x: 0, y: 0 })
65
+ })
66
+ })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure scene logic, lifted out of the render loop.
3
+ *
4
+ * The animation and pointer maths live here rather than inline in `main.ts` for
5
+ * one reason: a function that takes numbers and returns numbers can be tested
6
+ * without a GPU, a canvas, or a browser. `src/interaction.test.ts` is the whole
7
+ * payoff — and it is the pattern to keep reaching for as this project grows.
8
+ * Anything that does not touch three.js objects belongs in a module like this.
9
+ */
10
+
11
+ /** Sprite edge length, in world units, for each interaction state. */
12
+ export const SPRITE_SCALE = {
13
+ idle: 150,
14
+ hover: 170,
15
+ press: 130,
16
+ } as const
17
+
18
+ /** How far the sprite closes on its target scale each frame, as a fraction. */
19
+ export const SCALE_EASING = 0.15
20
+
21
+ export interface PointerState {
22
+ hovered: boolean
23
+ pressed: boolean
24
+ }
25
+
26
+ /** The scale the sprite is easing toward. Press wins over hover. */
27
+ export function targetScale(state: PointerState): number {
28
+ if (state.pressed) return SPRITE_SCALE.press
29
+ if (state.hovered) return SPRITE_SCALE.hover
30
+ return SPRITE_SCALE.idle
31
+ }
32
+
33
+ /**
34
+ * Frame-rate-naive exponential ease — the standard `a += (b - a) * t` move.
35
+ * `t` is clamped to [0, 1] so a bad easing value can never overshoot the target
36
+ * and oscillate, which reads on screen as a sprite that jitters instead of settling.
37
+ */
38
+ export function approach(current: number, target: number, t: number = SCALE_EASING): number {
39
+ const k = Math.min(1, Math.max(0, t))
40
+ return current + (target - current) * k
41
+ }
42
+
43
+ /** The subset of DOMRect this module needs — so tests don't have to build one. */
44
+ export interface ViewportRect {
45
+ left: number
46
+ top: number
47
+ width: number
48
+ height: number
49
+ }
50
+
51
+ /**
52
+ * Client (CSS pixel) coordinates → normalized device coordinates for `Raycaster`.
53
+ *
54
+ * Two things are easy to get wrong here. The Y axis flips: NDC is +1 at the TOP
55
+ * of the viewport while client Y grows downward. And a canvas queried before
56
+ * layout reports a zero-size rect, which would divide by zero and hand the
57
+ * raycaster NaN — silently killing every hit test rather than failing loudly.
58
+ */
59
+ export function toPointerNdc(clientX: number, clientY: number, rect: ViewportRect): { x: number; y: number } {
60
+ if (rect.width === 0 || rect.height === 0) return { x: 0, y: 0 }
61
+ return {
62
+ x: ((clientX - rect.left) / rect.width) * 2 - 1,
63
+ y: -((clientY - rect.top) / rect.height) * 2 + 1,
64
+ }
65
+ }
@@ -0,0 +1,138 @@
1
+ import { WebGPURenderer } from 'three/webgpu'
2
+ import { Color, Raycaster, Vector2 } from 'three'
3
+ import { Flatland, Sprite2D, TextureLoader } from 'three-flatland'
4
+ // Pure scene maths, extracted so it can be unit-tested without a GPU.
5
+ // See src/interaction.test.ts — `npm run test`.
6
+ import { approach, SPRITE_SCALE, targetScale, toPointerNdc } from './interaction'
7
+
8
+ /* HMR teardown state — without this, every dev save stacks another
9
+ * renderer + animation loop. Dev-only: `import.meta.hot` is undefined in prod. */
10
+ let rafId = 0
11
+ let activeRenderer: WebGPURenderer | null = null
12
+ let activeFlatland: Flatland | null = null
13
+ /* Every listener registered this run, so HMR can remove them all. Anonymous
14
+ * callbacks can't be removed, so each dev save would otherwise stack another
15
+ * closure over an already-disposed renderer and call setSize on it. */
16
+ const listeners: Array<() => void> = []
17
+
18
+ function on<T extends EventTarget>(target: T, type: string, handler: EventListener): void {
19
+ target.addEventListener(type, handler)
20
+ listeners.push(() => target.removeEventListener(type, handler))
21
+ }
22
+
23
+ /** Fade the loading overlay out, then remove it. Instant under reduced motion. */
24
+ function dismissLoader(): void {
25
+ const loader = document.querySelector<HTMLElement>('#loader')
26
+ if (!loader) return
27
+ const done = () => loader.remove()
28
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return done()
29
+ loader.addEventListener('transitionend', done, { once: true })
30
+ // Fallback: if the transition never fires (tab hidden, reduced-motion race).
31
+ setTimeout(done, 600)
32
+ loader.dataset.loaded = 'true'
33
+ }
34
+
35
+ async function main() {
36
+ const container = document.querySelector<HTMLDivElement>('#app')!
37
+
38
+ // Flatland is the front door: it owns the orthographic camera,
39
+ // sprite batching, resize, and disposal.
40
+ const flatland = new Flatland({ viewSize: 400, clearColor: 0x16191e })
41
+
42
+ // Always WebGPURenderer — it selects the backend itself (WebGPU where
43
+ // supported, WebGL2 fallback where not). Never construct WebGLRenderer.
44
+ const renderer = new WebGPURenderer({ antialias: false })
45
+ activeRenderer = renderer
46
+ activeFlatland = flatland
47
+ container.appendChild(renderer.domElement)
48
+
49
+ await renderer.init()
50
+ const texture = await TextureLoader.load(`${import.meta.env.BASE_URL}sprite.svg`)
51
+
52
+ // Renderer + texture ready — fade the loading overlay out, then drop it.
53
+ dismissLoader()
54
+
55
+ const sprite = new Sprite2D({ texture, anchor: [0.5, 0.5] })
56
+ sprite.scale.set(SPRITE_SCALE.idle, SPRITE_SCALE.idle, 1)
57
+ flatland.add(sprite)
58
+
59
+ // Pointer interactivity — a standard three.js Raycaster. Sprite2D
60
+ // implements raycast() (see hitTestMode for radius/bounds/alpha/none).
61
+ const raycaster = new Raycaster()
62
+ const pointer = new Vector2()
63
+ let hovered = false
64
+ let pressed = false
65
+
66
+ on(renderer.domElement, 'pointermove', ((event: PointerEvent) => {
67
+ const ndc = toPointerNdc(event.clientX, event.clientY, renderer.domElement.getBoundingClientRect())
68
+ pointer.set(ndc.x, ndc.y)
69
+ raycaster.setFromCamera(pointer, flatland.camera)
70
+ hovered = raycaster.intersectObject(sprite).length > 0
71
+ }) as EventListener)
72
+ on(renderer.domElement, 'pointerdown', () => {
73
+ pressed = hovered
74
+ })
75
+ on(window, 'pointerup', () => {
76
+ pressed = false
77
+ })
78
+
79
+ // Fullscreen. `data-fullscreen` drives the icon swap from CSS, and it is set
80
+ // from `fullscreenchange` rather than on click so it stays correct when the
81
+ // browser exits on its own. Safari does not wire Esc to exitFullscreen.
82
+ const fullscreenBtn = document.querySelector('#fullscreen')
83
+ if (fullscreenBtn) {
84
+ const syncFullscreen = () => {
85
+ const active = document.fullscreenElement !== null
86
+ fullscreenBtn.setAttribute('data-fullscreen', String(active))
87
+ fullscreenBtn.setAttribute('aria-label', active ? 'Exit fullscreen' : 'Enter fullscreen')
88
+ }
89
+ on(fullscreenBtn, 'click', () => {
90
+ if (document.fullscreenElement) void document.exitFullscreen()
91
+ else void container.requestFullscreen()
92
+ })
93
+ on(document, 'fullscreenchange', syncFullscreen)
94
+ on(document, 'keydown', ((event: KeyboardEvent) => {
95
+ if (event.key === 'Escape' && document.fullscreenElement) void document.exitFullscreen()
96
+ }) as EventListener)
97
+ syncFullscreen()
98
+ }
99
+
100
+ const resize = () => {
101
+ flatland.resize(container.clientWidth, container.clientHeight)
102
+ renderer.setSize(container.clientWidth, container.clientHeight)
103
+ }
104
+ on(window, 'resize', resize)
105
+ resize()
106
+
107
+ const idleTint = new Color(0xffffff)
108
+ const hoverTint = new Color(0x47cca9)
109
+
110
+ function animate() {
111
+ rafId = requestAnimationFrame(animate)
112
+ sprite.rotation.z += 0.005
113
+ const next = approach(sprite.scale.x, targetScale({ hovered, pressed }))
114
+ sprite.scale.set(next, next, 1)
115
+ sprite.tint.lerp(hovered ? hoverTint : idleTint, 0.15)
116
+ flatland.render(renderer)
117
+ }
118
+ animate()
119
+ }
120
+
121
+ void main()
122
+
123
+ if (import.meta.hot) {
124
+ import.meta.hot.dispose(() => {
125
+ if (rafId) {
126
+ cancelAnimationFrame(rafId)
127
+ rafId = 0
128
+ }
129
+ for (const off of listeners.splice(0)) off()
130
+ activeFlatland?.dispose()
131
+ activeFlatland = null
132
+ if (activeRenderer) {
133
+ activeRenderer.dispose?.()
134
+ activeRenderer.domElement.remove()
135
+ activeRenderer = null
136
+ }
137
+ })
138
+ }
@@ -0,0 +1,18 @@
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
+ "types": ["vite/client"]
16
+ },
17
+ "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
18
+ }
@@ -0,0 +1,4 @@
1
+ import { defineConfig } from 'vite'
2
+
3
+ export default defineConfig({
4
+ })
@@ -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
+ })