import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { Canvas, useFrame, useLoader, useThree, extend } from '@react-three/fiber/webgpu'
import { Vector2, Raycaster, Plane, Vector3, type OrthographicCamera } from 'three'
} from 'three-flatland/react'
import { DevtoolsProvider, usePane } from '@three-flatland/devtools/react'
import type { Pane } from 'tweakpane'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
// Extend R3F with our custom classes
extend({ SpriteGroup, Sprite2D, Sprite2DMaterial })
// Letterboxed orthographic camera that fits viewWidth × viewHeight in the canvas
function FitOrthoCamera({ viewWidth, viewHeight }: { viewWidth: number; viewHeight: number }) {
const set = useThree((s) => s.set)
const size = useThree((s) => s.size)
const aspect = size.width / size.height
const viewAspect = viewWidth / viewHeight
ref={(cam: OrthographicCamera | null) => {
if (aspect > viewAspect) {
// Window wider — fit to height
cam.bottom = -viewHeight / 2
cam.left = (-viewHeight * aspect) / 2
cam.right = (viewHeight * aspect) / 2
// Window taller — fit to width
cam.left = -viewWidth / 2
cam.right = viewWidth / 2
cam.top = viewWidth / aspect / 2
cam.bottom = -viewWidth / aspect / 2
cam.updateProjectionMatrix()
const ASSET_BASE = './assets/'
// Grass tilemap UV size (32x32 tiles in 640x256 texture)
const TILE_UV_SIZE = { width: 32 / 640, height: 32 / 256 }
function getGrassTileUV(x: number, y: number) {
const maxX = GRID_WIDTH - 1
const maxY = GRID_HEIGHT - 1
let sliceCol = x === 0 ? 0 : x === maxX ? SLICE_TILES - 1 : 1 + ((x - 1) % (SLICE_TILES - 2))
let sliceRow = y === 0 ? 0 : y === maxY ? SLICE_TILES - 1 : 1 + ((y - 1) % (SLICE_TILES - 2))
const flippedSliceRow = SLICE_TILES - 1 - sliceRow
x: sliceCol * TILE_UV_SIZE.width,
y: 1 - (flippedSliceRow + 1) * TILE_UV_SIZE.height,
// Building definitions — frame names refer to the shared sprites atlas
// (sprites.png + sprites.atlas.json). All buildings load from one
// texture → one material → one SpriteBatch, so per-sprite zIndex
// Y-sort works across building types.
// Two paths for the same content, by design:
// `frame` → atlas frame in sprites.png — used by the GAME RENDER so
// all buildings share one material and batch together
// `texture` → individual PNG — used by the PICKER UI `<img>` element
// (atlas frames packed without padding bleed when scaled
// via CSS background-size; HTML img tags don't benefit
// from atlasing anyway since there's nothing to batch)
// Render dimensions = atlas sourceSize so manual scale and Sprite2D's
// auto-sizing (setFrame → updateSize on first frame) agree. Without
// this, hover sprite (reuses one Sprite2D across building switches, so
// updateSize fires only the first time) diverges from placed sprites
// (one Sprite2D per placement, updateSize always fires on init).
{ name: 'house', frame: 'house', texture: 'buildings/House_Blue.png', width: 108, height: 148, shadowScale: 1.2 },
{ name: 'tower', frame: 'tower_0', texture: 'buildings/Tower_Blue.png', width: 114, height: 183, shadowScale: 1.0 },
{ name: 'tree', frame: 'tree_0', texture: 'deco/Tree.png', width: 111, height: 174, shadowScale: 1.5 },
// Grid positions for ground tiles (computed once)
const GROUND_POSITIONS = Array.from({ length: GRID_HEIGHT * GRID_WIDTH }, (_, i) => ({
y: Math.floor(i / GRID_WIDTH),
// ============================================
// DECLARATIVE COMPONENTS
// ============================================
interface GroundTileProps {
material: Sprite2DMaterial
function GroundTile({ gridX, gridY, material, gridOffsetX, gridOffsetY }: GroundTileProps) {
const uv = getGrassTileUV(gridX, gridY)
const frame: SpriteFrame = {
width: TILE_UV_SIZE.width,
height: TILE_UV_SIZE.height,
position={[gridOffsetX + gridX * TILE_SIZE, gridOffsetY + gridY * TILE_SIZE, 0]}
interface EntitySpritesProps {
spritesMaterial: Sprite2DMaterial
spritesSheet: SpriteSheet
shadowMaterial: Sprite2DMaterial
function EntitySprites({ entity, spritesMaterial, spritesSheet, shadowMaterial, gridOffsetX, gridOffsetY }: EntitySpritesProps) {
const building = BUILDINGS[entity.buildingIndex]!
const posX = gridOffsetX + entity.gridX * TILE_SIZE
const posY = gridOffsetY + entity.gridY * TILE_SIZE
const frame = spritesSheet.getFrame(building.frame)
material={shadowMaterial}
position={[posX, posY - TILE_SIZE * 0.3, 0]}
scale={[TILE_SIZE * building.shadowScale, TILE_SIZE * building.shadowScale * 0.5, 1]}
{/* Building — shared spritesMaterial so all building types batch
together → cross-entity zIndex Y-sort works */}
material={spritesMaterial}
position={[posX, posY + building.height / 2 - TILE_SIZE / 2, 0]}
scale={[building.width, building.height, 1]}
zIndex={-Math.floor(posY)}
interface HoverPreviewProps {
position: [number, number, number]
material: Sprite2DMaterial
spritesSheet: SpriteSheet
building: typeof BUILDINGS[number]
function HoverPreview({ visible, position, material, spritesSheet, building }: HoverPreviewProps) {
const frame: SpriteFrame = spritesSheet.getFrame(building.frame)
scale={[building.width, building.height, 1]}
layer={Layers.FOREGROUND}
// ============================================
// ============================================
function StatsMonitor({ pane, spriteStats }: { pane: Pane; spriteStats: RenderStats }) {
const statsObjRef = useRef({ sprites: 0, batches: 0 })
const folderRef = useRef<ReturnType<Pane['addFolder']> | null>(null)
const statsFolder = pane.addFolder({ title: 'Batching', expanded: false })
statsFolder.addBinding(statsObjRef.current, 'sprites', { readonly: true, format: (v: number) => v.toFixed(0) })
statsFolder.addBinding(statsObjRef.current, 'batches', { readonly: true, format: (v: number) => v.toFixed(0) })
folderRef.current = statsFolder
// Update stats values each frame via useFrame is not possible here (outside Canvas),
// so we update on each render
statsObjRef.current.sprites = spriteStats.spriteCount
statsObjRef.current.batches = spriteStats.batchCount
interface VillageSceneProps {
onPlaceBuilding: (gridX: number, gridY: number) => void
onStats: (stats: RenderStats) => void
function VillageScene({ entities, selectedBuilding, onPlaceBuilding, onStats }: VillageSceneProps) {
const { camera, gl } = useThree()
// Load textures (presets are automatically applied - NearestFilter + SRGBColorSpace)
const grassTex = useLoader(TextureLoader, ASSET_BASE + 'terrain/Tilemap_Flat.png')
const shadowTex = useLoader(TextureLoader, ASSET_BASE + 'terrain/Shadows.png')
const spritesSheet = useLoader(SpriteSheetLoader, ASSET_BASE + 'buildings/sprites.atlas.json')
// Create materials (stable - each texture is a stable reference)
const grassMaterial = useMemo(() => new Sprite2DMaterial({ map: grassTex }), [grassTex])
const shadowMaterial = useMemo(() => new Sprite2DMaterial({ map: shadowTex }), [shadowTex])
// ONE material for ALL buildings + trees → ONE batch → cross-entity
// zIndex Y-sort works correctly.
const spritesMaterial = useMemo(
() => new Sprite2DMaterial({ map: spritesSheet.texture }),
const gridOffsetX = (-GRID_WIDTH * TILE_SIZE) / 2 + TILE_SIZE / 2
const gridOffsetY = (-GRID_HEIGHT * TILE_SIZE) / 2 + TILE_SIZE / 2
const occupiedCells = useMemo(() => {
const cells = new Set<string>()
entities.forEach((e) => cells.add(`${e.gridX},${e.gridY}`))
const [hoverGrid, setHoverGrid] = useState<{ x: number; y: number } | null>(null)
const hoverGridRef = useRef(hoverGrid)
hoverGridRef.current = hoverGrid
const hoverVisible = hoverGrid !== null && !occupiedCells.has(`${hoverGrid.x},${hoverGrid.y}`)
const raycaster = useMemo(() => new Raycaster(), [])
const groundPlane = useMemo(() => new Plane(new Vector3(0, 0, 1), 0), [])
const screenToGrid = useCallback(
(clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const mouse = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(mouse, camera)
const worldPos = new Vector3()
raycaster.ray.intersectPlane(groundPlane, worldPos)
const gx = Math.floor((worldPos.x - gridOffsetX + TILE_SIZE / 2) / TILE_SIZE)
const gy = Math.floor((worldPos.y - gridOffsetY + TILE_SIZE / 2) / TILE_SIZE)
if (gx >= 0 && gx < GRID_WIDTH && gy >= 0 && gy < GRID_HEIGHT) {
[camera, gl, raycaster, groundPlane, gridOffsetX, gridOffsetY]
// Mouse events - use useEffect for proper cleanup
const canvas = gl.domElement
const onMouseMove = (e: MouseEvent) => setHoverGrid(screenToGrid(e.clientX, e.clientY))
const onMouseLeave = () => setHoverGrid(null)
const grid = hoverGridRef.current
if (grid && !occupiedCells.has(`${grid.x},${grid.y}`)) {
onPlaceBuilding(grid.x, grid.y)
canvas.addEventListener('mousemove', onMouseMove)
canvas.addEventListener('mouseleave', onMouseLeave)
canvas.addEventListener('click', onClick)
canvas.removeEventListener('mousemove', onMouseMove)
canvas.removeEventListener('mouseleave', onMouseLeave)
canvas.removeEventListener('click', onClick)
}, [gl, screenToGrid, occupiedCells, onPlaceBuilding])
// SpriteGroup ref for stats
const spriteGroupRef = useRef<SpriteGroup>(null)
// Surface SpriteGroup batching stats to the parent each frame
if (spriteGroupRef.current) {
onStats(spriteGroupRef.current.stats)
}, { priority: -Infinity })
const hoverPosition: [number, number, number] = hoverGrid
gridOffsetX + hoverGrid.x * TILE_SIZE,
gridOffsetY + hoverGrid.y * TILE_SIZE + BUILDINGS[selectedBuilding]!.height / 2 - TILE_SIZE / 2,
<spriteGroup ref={spriteGroupRef}>
{GROUND_POSITIONS.map(({ x, y }) => (
gridOffsetX={gridOffsetX}
gridOffsetY={gridOffsetY}
{entities.map((entity) => (
spritesMaterial={spritesMaterial}
spritesSheet={spritesSheet}
shadowMaterial={shadowMaterial}
gridOffsetX={gridOffsetX}
gridOffsetY={gridOffsetY}
{/* Hover preview - NOT batched, renders separately with high renderOrder */}
material={spritesMaterial}
spritesSheet={spritesSheet}
building={BUILDINGS[selectedBuilding]!}
// ============================================
// ============================================
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.7)',
} as React.CSSProperties,
button: (selected: boolean) =>
border: `2px solid ${selected ? '#4a9eff' : 'transparent'}`,
backgroundColor: selected ? 'rgba(74, 158, 255, 0.2)' : 'rgba(255, 255, 255, 0.1)',
transition: 'all 0.15s ease',
}) as React.CSSProperties,
transform: 'translateX(-50%)',
color: 'rgba(0, 0, 0, 0.5)',
} as React.CSSProperties,
// ============================================
// ============================================
const INITIAL_ENTITIES: PlacedEntity[] = [
{ id: 1, buildingIndex: 0, gridX: 2, gridY: 3 },
{ id: 2, buildingIndex: 0, gridX: 5, gridY: 5 },
{ id: 3, buildingIndex: 1, gridX: 8, gridY: 2 },
{ id: 4, buildingIndex: 2, gridX: 1, gridY: 6 },
{ id: 5, buildingIndex: 2, gridX: 10, gridY: 4 },
{ id: 6, buildingIndex: 2, gridX: 7, gridY: 6 },
export default function App() {
const [entities, setEntities] = useState<PlacedEntity[]>(INITIAL_ENTITIES)
const [selectedBuilding, setSelectedBuilding] = useState(0)
const [spriteStats, setSpriteStats] = useState<RenderStats>({ spriteCount: 0, batchCount: 0, visibleSprites: 0 })
const { pane } = usePane()
const viewWidth = TILE_SIZE * (GRID_WIDTH + 2)
const viewHeight = TILE_SIZE * (GRID_HEIGHT + 4)
const handlePlaceBuilding = useCallback(
(gridX: number, gridY: number) => {
if (prev.some((e) => e.gridX === gridX && e.gridY === gridY)) return prev
// Derive next ID from existing entities to survive HMR
const nextId = Math.max(0, ...prev.map((e) => e.id)) + 1
return [...prev, { id: nextId, buildingIndex: selectedBuilding, gridX, gridY }]
style={{ background: "#16191e" }}
renderer={{ antialias: false }}
gl.domElement.style.imageRendering = 'pixelated'
{/* L1 + L2 — gem-tinted clear color + lit radial gradient. */}
<GemBackground gem={GEM} />
<FitOrthoCamera viewWidth={viewWidth} viewHeight={viewHeight} />
<DevtoolsProvider name="batch-demo" />
selectedBuilding={selectedBuilding}
onPlaceBuilding={handlePlaceBuilding}
<StatsMonitor pane={pane} spriteStats={spriteStats} />
{/* TODO: migrate game UI to three-flatland events */}
{BUILDINGS.map((building, index) => {
const isTree = building.name === 'tree'
style={styles.button(index === selectedBuilding)}
onClick={() => setSelectedBuilding(index)}
src={`${ASSET_BASE}${building.texture}`}
? { position: 'absolute', inset: 0, width: '400%', height: '300%', maxWidth: 'none', objectFit: 'cover', objectPosition: '0 0', pointerEvents: 'none' }
: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }
<div style={styles.credits}>
<a href="https://pixelfrog-assets.itch.io/tiny-swords" target="_blank" style={{ color: 'rgba(0, 0, 0, 0.6)' }}>