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,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
|
+
}
|