Skip to content
Back to Examples

2D Lighting

Dynamic sprite lighting with draggable point lights.

import { WebGPURenderer } from 'three/webgpu'
import { Vector2 } from 'three'
import {
Flatland,
Light2D,
AnimatedSprite2D,
TileMap2D,
SpriteSheetLoader,
LDtkLoader,
Layers,
type TileMapData,
type TileMapObject,
type AnimationSetDefinition,
} from 'three-flatland'
import {
DefaultLightEffect,
NormalMapProvider,
} from '@three-flatland/presets'
import { createPane } from '@three-flatland/devtools'
// ============================================
// CONSTANTS
// ============================================
const VIEW_SIZE = 640
const TILE_PX = 16
/**
* Vertical ortho view size that frames the whole map in the current
* canvas. `scale` is screen-px per world-unit on the binding axis: when
* the viewport can show the map at ≥ 1× we snap to a whole number so the
* pixel art stays crisp; when it's smaller than the map (scale < 1) we
* keep the fractional value so the scene shrinks to fit instead of
* clamping to 1× and cropping. Scale is uniform, so `canvasH / scale`
* also fits the width.
*/
function fitViewSize(canvasW: number, canvasH: number, mapW: number, mapH: number): number {
if (canvasW <= 0 || canvasH <= 0 || mapW <= 0 || mapH <= 0) return VIEW_SIZE
let scale = Math.min(canvasW / mapW, canvasH / mapH)
if (scale >= 1) scale = Math.floor(scale)
return canvasH / scale
}
const TILE_SCALE = 2
const KNIGHT_SCALE = TILE_PX * TILE_SCALE * 2
const SLIME_SCALE = TILE_PX * TILE_SCALE
const WALL_TILE = 24
// Hero movement speed (world u/s) + click-to-walk tuning.
const HERO_SPEED = 70
// Distance at which click-target navigation "arrives" — smaller than
// the hero sprite to avoid overshoot jitter.
const HERO_ARRIVE_RADIUS = 4
// Click radius used to decide if a click intended a torch vs. a
// bare-floor walk target. 1.25 tile-widths covers sloppy aim.
const TORCH_CLICK_RADIUS = TILE_PX * TILE_SCALE * 1.25
// ─── Slime behavior tuning ──────────────────────────────────────────
const SLIME_EXCITE_RADIUS = KNIGHT_SCALE * 1.5
const SLIME_SPEED_WANDER = 14
const SLIME_SPEED_EXCITED = 32
const SLIME_STAMINA_DRAIN_WANDER = 0.05
const SLIME_STAMINA_DRAIN_EXCITED = 0.25
const SLIME_STAMINA_RECOVER = 0.3
const SLIME_STAMINA_RESUME = 0.6
const SLIME_HOP_MIN_WANDER = 0.5
const SLIME_HOP_MAX_WANDER = 0.8
const SLIME_PAUSE_MIN_WANDER = 0.4
const SLIME_PAUSE_MAX_WANDER = 0.8
const SLIME_HOP_MIN_EXCITED = 0.3
const SLIME_HOP_MAX_EXCITED = 0.5
const SLIME_PAUSE_MIN_EXCITED = 0.1
const SLIME_PAUSE_MAX_EXCITED = 0.25
// ============================================
// ANIMATION SETS
// ============================================
const knightAnimations: AnimationSetDefinition = {
fps: 8,
animations: {
idle: { frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'], fps: 6, loop: true },
run: {
frames: Array.from({ length: 16 }, (_, i) => `run_${i}`),
fps: 16,
loop: true,
},
},
}
const slimeAnimations: AnimationSetDefinition = {
fps: 8,
animations: {
idle: {
frames: Array.from({ length: 8 }, (_, i) => `idle_${i}`),
fps: 6,
loop: true,
},
walk: {
frames: Array.from({ length: 8 }, (_, i) => `walk_${i}`),
fps: 10,
loop: true,
},
},
}
// ============================================
// MAP DATA EXTRACTION
// ============================================
function extractObjectsByType(mapData: TileMapData, type: string): TileMapObject[] {
const results: TileMapObject[] = []
for (const layer of mapData.objectLayers) {
for (const obj of layer.objects) {
if (obj.type === type) results.push(obj)
}
}
return results
}
function mapToWorld(obj: TileMapObject, mapData: TileMapData, scale: number): [number, number] {
const mapH = mapData.height * mapData.tileHeight
const cx = (obj.x + obj.width / 2) * scale
const cy = (mapH - obj.y - obj.height / 2) * scale
const offsetX = (mapData.width * mapData.tileWidth * scale) / 2
const offsetY = (mapH * scale) / 2
return [cx - offsetX, cy - offsetY]
}
// ============================================
// SLIME STATE
// ============================================
interface SlimeState {
pos: Vector2
vel: Vector2
sprite: AnimatedSprite2D | null
light: Light2D | null
stamina: number
state: 'rest' | 'wander' | 'excited'
hopPhase: 'hop' | 'pause'
hopTimer: number
animation: 'idle' | 'walk'
drainBias: number
}
function newSlime(mapHalfW: number, mapHalfH: number): SlimeState {
// Full-tile wall inset (TILE_PX * TILE_SCALE = 32) keeps the
// slime's tight body clear of the wall art.
const wallInset = TILE_PX * TILE_SCALE
const entityHalf = SLIME_SCALE / 2
const mx = mapHalfW - wallInset - entityHalf
const my = mapHalfH - wallInset - entityHalf
const stamina = Math.random()
const state: SlimeState['state'] = stamina < 0.4 ? 'rest' : 'wander'
const hopPhase: SlimeState['hopPhase'] = Math.random() < 0.5 ? 'hop' : 'pause'
return {
pos: new Vector2((Math.random() * 2 - 1) * mx, (Math.random() * 2 - 1) * my),
vel: new Vector2(),
sprite: null,
light: null,
stamina,
state,
hopPhase,
hopTimer: Math.random() * 0.5,
animation: state === 'rest' || hopPhase === 'pause' ? 'idle' : 'walk',
drainBias: 0.85 + Math.random() * 0.3,
}
}
// ============================================
// MAIN
// ============================================
async function main() {
// ─── Renderer ───────────────────────────────────────────────────
const renderer = new WebGPURenderer({ antialias: false })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(1)
renderer.domElement.style.imageRendering = 'pixelated'
document.body.appendChild(renderer.domElement)
await renderer.init()
// ─── Flatland ───────────────────────────────────────────────────
const flatland = new Flatland({
viewSize: VIEW_SIZE,
clearColor: 0x06060c,
aspect: window.innerWidth / window.innerHeight,
})
flatland.resize(window.innerWidth, window.innerHeight)
// ─── Lighting ───────────────────────────────────────────────────
const lightEffect = new DefaultLightEffect()
flatland.setLighting(lightEffect)
// Per-category quota: cap how many slime lights any single tile may
// accumulate before falling back to compensation. Keeps hero/torch
// lights from being crowded out in dense slime clusters.
lightEffect.forwardPlus.setFillQuota('slime', 4)
// ─── Assets ─────────────────────────────────────────────────────
const [knightSheet, slimeSheet, mapData] = await Promise.all([
SpriteSheetLoader.load('./sprites/knight.json', { normals: true }),
SpriteSheetLoader.load('./sprites/slime.json', { normals: true }),
LDtkLoader.load('./maps/dungeon.ldtk', undefined, { normals: true }),
])
const mapHalfW = (mapData.width * mapData.tileWidth * TILE_SCALE) / 2
const mapHalfH = (mapData.height * mapData.tileHeight * TILE_SCALE) / 2
// Now that the map's world size is known, frame it to the canvas.
const refitView = () => {
flatland.viewSize = fitViewSize(
window.innerWidth,
window.innerHeight,
mapHalfW * 2,
mapHalfH * 2
)
flatland.resize(window.innerWidth, window.innerHeight)
}
refitView()
// ─── Tilemap ────────────────────────────────────────────────────
const tilemap = new TileMap2D({ data: mapData })
tilemap.scale.set(TILE_SCALE, TILE_SCALE, 1)
tilemap.position.set(-mapHalfW, -mapHalfH, -100)
const tilemapNormals = new NormalMapProvider()
tilemapNormals.normalMap = mapData.tilesets[0]?.normalMap ?? null
tilemap.addEffect(tilemapNormals)
flatland.add(tilemap)
// torch_switch tiles hold a torch Light2D at their center — treating
// them as shadow casters would self-shadow their own light. They remain
// collision for the hero (handled separately), just not occluders.
tilemap.markOccluders(['collision'])
// ─── Light positions from object layers ─────────────────────────
const fixedLightPositions: Array<[number, number]> =
extractObjectsByType(mapData, 'light').map((obj) => mapToWorld(obj, mapData, TILE_SCALE))
const switchPositions: Array<[number, number]> =
extractObjectsByType(mapData, 'torch_switch').map((obj) => mapToWorld(obj, mapData, TILE_SCALE))
// ─── Lights ─────────────────────────────────────────────────────
const ambientLight = new Light2D({
type: 'ambient',
color: 0x5544aa,
intensity: 0.6,
})
flatland.add(ambientLight)
// Wall torches (fixed) — warm orange. Hero lights (importance: 10).
const torchLights: Light2D[] = []
const torchEnabled: boolean[] = []
for (let i = 0; i < fixedLightPositions.length; i++) {
const [x, y] = fixedLightPositions[i]!
const light = new Light2D({
type: 'point',
color: 0xff6600,
intensity: 1.6,
distance: 140,
decay: 2,
importance: 10,
position: [x, y],
})
flatland.add(light)
torchLights.push(light)
torchEnabled.push(true)
}
// Switchable torches — cool amber, 0.8 intensity multiplier.
for (let i = 0; i < switchPositions.length; i++) {
const [x, y] = switchPositions[i]!
const light = new Light2D({
type: 'point',
color: 0xffcc44,
intensity: 1.6 * 0.8,
distance: 140 * 0.7,
decay: 2,
importance: 10,
position: [x, y],
})
flatland.add(light)
torchLights.push(light)
torchEnabled.push(true)
}
// ─── Hero ───────────────────────────────────────────────────────
const hero = new AnimatedSprite2D({
spriteSheet: knightSheet,
animationSet: knightAnimations,
animation: 'idle',
layer: Layers.ENTITIES + 1,
})
hero.scale.set(KNIGHT_SCALE, KNIGHT_SCALE, 1)
hero.lit = true
hero.castsShadow = true
const heroNormals = new NormalMapProvider()
heroNormals.normalMap = knightSheet.normalMap ?? null
hero.addEffect(heroNormals)
flatland.add(hero)
// Spawn hero one tile +X off the first fixed torch so the map opens
// already lit around the player.
const heroPos = new Vector2(0, 0)
if (fixedLightPositions.length > 0) {
const [tx, ty] = fixedLightPositions[0]!
heroPos.set(tx + TILE_PX * TILE_SCALE, ty)
}
hero.position.set(heroPos.x, heroPos.y, 0)
const heroKeys = { up: false, down: false, left: false, right: false }
let heroAnim: 'idle' | 'run' = 'idle'
const heroFacing = new Vector2(1, 0)
let heroMoveTarget: Vector2 | null = null
let heroTargetTorchIdx: number | null = null
// ─── Slimes ─────────────────────────────────────────────────────
const slimes: SlimeState[] = []
function addSlime(): void {
const s = newSlime(mapHalfW, mapHalfH)
const sprite = new AnimatedSprite2D({
spriteSheet: slimeSheet,
animationSet: slimeAnimations,
animation: s.animation,
anchor: [0.5, 0.5],
layer: Layers.ENTITIES,
})
sprite.scale.set(SLIME_SCALE, SLIME_SCALE, 1)
sprite.lit = true
// No castsShadow — the slime IS a light source. Marking it as an
// occluder would self-shadow its own light.
const slimeNormals = new NormalMapProvider()
slimeNormals.normalMap = slimeSheet.normalMap ?? null
sprite.addEffect(slimeNormals)
// Stagger animation cursor so slimes don't lock-step on first frame.
const frames = slimeAnimations.animations[s.animation]!.frames.length
sprite.play(s.animation, { startFrame: Math.floor(Math.random() * frames) })
flatland.add(sprite)
const light = new Light2D({
type: 'point',
color: 0x33ff66,
intensity: 0.25,
distance: 40,
decay: 2,
castsShadow: false,
category: 'slime',
})
flatland.add(light)
s.sprite = sprite
s.light = light
slimes.push(s)
}
function removeSlime(): void {
const s = slimes.pop()
if (!s) return
if (s.sprite) flatland.remove(s.sprite)
if (s.light) flatland.remove(s.light)
}
function setSlimeCount(count: number): void {
while (slimes.length < count) addSlime()
while (slimes.length > count) removeSlime()
}
// ─── Tweakpane params ───────────────────────────────────────────
// `paused` and `stationary` are NOT exposed in the pane — they exist
// only so the `__captureScene` / `__endCapture` console helpers can
// freeze the scene during recording. If you want them back on the
// UI, add `pane.addBinding(params, 'paused')` etc. below.
// paused — full freeze: rawDelta zeroed, no animation, no motion
// stationary — animations and torch flicker keep ticking, but
// entities don't move. Used by the synchronized
// pair-capture recorder so two takes share identical
// entity positions.
const params = {
paused: false,
stationary: false,
lightingEnabled: true,
bands: 4,
pixelSize: 4,
ambient: 0.6,
lightHeight: 0.75,
glowRadius: 0,
glowIntensity: 0.6,
rimIntensity: 0,
shadowStrength: 0.8,
shadowBias: 0.5,
shadowStartOffsetScale: 1,
shadowMaxDistance: 300,
shadowPixelSize: 4,
torchIntensity: 1.8,
torchDistance: 140,
slimeCount: 5,
slimeLights: true,
slimeQuota: 4,
}
setSlimeCount(params.slimeCount)
// ─── Push initial uniforms to lighting ──────────────────────────
const lightingUniforms = lightEffect as unknown as {
bands: number
pixelSize: number
lightHeight: number
glowRadius: number
glowIntensity: number
rimIntensity: number
shadowStrength: number
shadowBias: number
shadowStartOffsetScale: number
shadowMaxDistance: number
shadowPixelSize: number
}
const lightingConstants = lightEffect as unknown as {
bandsEnabled: boolean
pixelSnapEnabled: boolean
shadowPixelSnapEnabled: boolean
glowEnabled: boolean
rimEnabled: boolean
}
function pushUniforms(): void {
lightingUniforms.bands = params.bands
lightingUniforms.pixelSize = params.pixelSize
lightingUniforms.lightHeight = params.lightHeight
lightingUniforms.glowRadius = params.glowRadius
lightingUniforms.glowIntensity = params.glowIntensity
lightingUniforms.rimIntensity = params.rimIntensity
lightingUniforms.shadowStrength = params.shadowStrength
lightingUniforms.shadowBias = params.shadowBias
lightingUniforms.shadowStartOffsetScale = params.shadowStartOffsetScale
lightingUniforms.shadowMaxDistance = params.shadowMaxDistance
lightingUniforms.shadowPixelSize = params.shadowPixelSize
}
// Track previous compile-time toggle state so we only push values
// when the boolean actually flips — each setter triggers a shader
// rebuild with a dev warning, so don't set the same value twice.
let prevBandsEnabled = params.bands > 0
let prevPixelSnapEnabled = params.pixelSize > 0
let prevShadowPixelSnapEnabled = params.shadowPixelSize > 0
let prevGlowEnabled = params.glowRadius > 0
let prevRimEnabled = params.rimIntensity > 0
function pushConstants(): void {
const bandsEnabled = params.bands > 0
const pixelSnapEnabled = params.pixelSize > 0
const shadowPixelSnapEnabled = params.shadowPixelSize > 0
const glowEnabled = params.glowRadius > 0
const rimEnabled = params.rimIntensity > 0
if (bandsEnabled !== prevBandsEnabled) {
lightingConstants.bandsEnabled = bandsEnabled
prevBandsEnabled = bandsEnabled
}
if (pixelSnapEnabled !== prevPixelSnapEnabled) {
lightingConstants.pixelSnapEnabled = pixelSnapEnabled
prevPixelSnapEnabled = pixelSnapEnabled
}
if (shadowPixelSnapEnabled !== prevShadowPixelSnapEnabled) {
lightingConstants.shadowPixelSnapEnabled = shadowPixelSnapEnabled
prevShadowPixelSnapEnabled = shadowPixelSnapEnabled
}
if (glowEnabled !== prevGlowEnabled) {
lightingConstants.glowEnabled = glowEnabled
prevGlowEnabled = glowEnabled
}
if (rimEnabled !== prevRimEnabled) {
lightingConstants.rimEnabled = rimEnabled
prevRimEnabled = rimEnabled
}
}
// Initial constants push — set baseline once. Subsequent pane edits
// route through `pushConstants` which only fires on transitions.
lightingConstants.bandsEnabled = prevBandsEnabled
lightingConstants.pixelSnapEnabled = prevPixelSnapEnabled
lightingConstants.shadowPixelSnapEnabled = prevShadowPixelSnapEnabled
lightingConstants.glowEnabled = prevGlowEnabled
lightingConstants.rimEnabled = prevRimEnabled
pushUniforms()
// ─── Tweakpane UI ───────────────────────────────────────────────
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
const lightFolder = pane.addFolder({ title: 'Lighting', expanded: true })
lightFolder.addBinding(params, 'lightingEnabled', { label: 'enabled' })
.on('change', () => {
flatland.setLighting(params.lightingEnabled ? lightEffect : null)
})
lightFolder.addBinding(params, 'bands', { min: 0, max: 8, step: 1 })
.on('change', () => { pushUniforms(); pushConstants() })
lightFolder.addBinding(params, 'pixelSize', { min: 0, max: 8, step: 1 })
.on('change', () => { pushUniforms(); pushConstants() })
lightFolder.addBinding(params, 'ambient', { min: 0, max: 3, step: 0.05 })
.on('change', () => { ambientLight.intensity = params.ambient })
lightFolder.addBinding(params, 'lightHeight', { min: 0, max: 2, step: 0.05 })
.on('change', () => pushUniforms())
lightFolder.addBinding(params, 'glowRadius', { min: 0, max: 2, step: 0.05 })
.on('change', () => { pushUniforms(); pushConstants() })
lightFolder.addBinding(params, 'glowIntensity', { min: 0, max: 2, step: 0.05 })
.on('change', () => pushUniforms())
lightFolder.addBinding(params, 'rimIntensity', { min: 0, max: 2, step: 0.05 })
.on('change', () => { pushUniforms(); pushConstants() })
const shadowFolder = pane.addFolder({ title: 'Shadows', expanded: false })
shadowFolder.addBinding(params, 'shadowStrength', { min: 0, max: 1, step: 0.05, label: 'strength' })
.on('change', () => pushUniforms())
shadowFolder.addBinding(params, 'shadowBias', { min: 0, max: 2, step: 0.05, label: 'bias' })
.on('change', () => pushUniforms())
shadowFolder.addBinding(params, 'shadowStartOffsetScale', { min: 0, max: 3, step: 0.05, label: 'startOffsetScale' })
.on('change', () => pushUniforms())
shadowFolder.addBinding(params, 'shadowMaxDistance', { min: 0, max: 600, step: 10, label: 'maxDistance' })
.on('change', () => pushUniforms())
shadowFolder.addBinding(params, 'shadowPixelSize', { min: 0, max: 8, step: 1, label: 'pixelSize' })
.on('change', () => { pushUniforms(); pushConstants() })
const torchFolder = pane.addFolder({ title: 'Torches', expanded: false })
torchFolder.addBinding(params, 'torchIntensity', { min: 0, max: 3, step: 0.05, label: 'intensity' })
torchFolder.addBinding(params, 'torchDistance', { min: 40, max: 400, step: 10, label: 'distance' })
const slimeFolder = pane.addFolder({ title: 'Slimes', expanded: false })
slimeFolder.addBinding(params, 'slimeCount', { min: 0, max: 1000, step: 1, label: 'count' })
.on('change', (ev) => setSlimeCount(ev.value))
slimeFolder.addBinding(params, 'slimeLights', { label: 'lights' })
slimeFolder.addBinding(params, 'slimeQuota', { min: 0, max: 16, step: 1, label: 'quota' })
.on('change', (ev) => lightEffect.forwardPlus.setFillQuota('slime', ev.value))
// ─── Input ──────────────────────────────────────────────────────
function keymap(e: KeyboardEvent): keyof typeof heroKeys | null {
switch (e.code) {
case 'KeyW':
case 'ArrowUp':
return 'up'
case 'KeyS':
case 'ArrowDown':
return 'down'
case 'KeyA':
case 'ArrowLeft':
return 'left'
case 'KeyD':
case 'ArrowRight':
return 'right'
default:
return null
}
}
function tryActivateTorch(): void {
const activationRadius = TILE_PX * TILE_SCALE * 2.5
const facingThreshold = 0.3 // ~72° cone
const switchStart = fixedLightPositions.length
let bestIdx = -1
let bestDist = Infinity
for (let i = 0; i < switchPositions.length; i++) {
const [sx, sy] = switchPositions[i]!
const dx = sx - heroPos.x
const dy = sy - heroPos.y
const dist = Math.hypot(dx, dy)
if (dist > activationRadius) continue
if (dist > 1) {
const dot = (dx / dist) * heroFacing.x + (dy / dist) * heroFacing.y
if (dot < facingThreshold) continue
}
if (dist < bestDist) { bestDist = dist; bestIdx = i }
}
if (bestIdx < 0) return
torchEnabled[switchStart + bestIdx] = !torchEnabled[switchStart + bestIdx]
}
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
tryActivateTorch()
e.preventDefault()
return
}
const k = keymap(e)
if (k) {
heroKeys[k] = true
// Keyboard input cancels in-flight click-to-walk path.
heroMoveTarget = null
heroTargetTorchIdx = null
e.preventDefault()
}
})
window.addEventListener('keyup', (e) => {
const k = keymap(e)
if (k) { heroKeys[k] = false; e.preventDefault() }
})
renderer.domElement.addEventListener('click', (e) => {
const rect = renderer.domElement.getBoundingClientRect()
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1
const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
const aspect = rect.width / rect.height
const vs = flatland.viewSize
const worldX = (ndcX * vs * aspect) / 2
const worldY = (ndcY * vs) / 2
let snapX = worldX
let snapY = worldY
let torchIdx: number | null = null
let bestDistSq = TORCH_CLICK_RADIUS * TORCH_CLICK_RADIUS
for (let i = 0; i < switchPositions.length; i++) {
const [sx, sy] = switchPositions[i]!
const dx = sx - worldX
const dy = sy - worldY
const d2 = dx * dx + dy * dy
if (d2 < bestDistSq) {
bestDistSq = d2
torchIdx = i
// Stand one sprite-width off the torch toward the current
// hero position so we don't fully occlude the light glyph.
const off = TILE_PX * TILE_SCALE
const toHeroX = heroPos.x - sx
const toHeroY = heroPos.y - sy
const thLen = Math.hypot(toHeroX, toHeroY) || 1
snapX = sx + (toHeroX / thLen) * off
snapY = sy + (toHeroY / thLen) * off
}
}
heroMoveTarget = new Vector2(snapX, snapY)
heroTargetTorchIdx = torchIdx
})
// ─── Resize ─────────────────────────────────────────────────────
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight)
refitView()
})
// ─── Render loop ────────────────────────────────────────────────
let lastTime = performance.now()
let flickerT = 0
function animate(): void {
const now = performance.now()
const rawDelta = Math.min(0.1, (now - lastTime) / 1000)
lastTime = now
// Two deltas:
// `animDelta` — sprite animation cursors + torch flicker. Zero only
// when paused.
// `delta` — entity *position* updates (hero walk, slime hop,
// stamina). Zero when paused OR stationary.
// Most call sites care about position, so the motion variant keeps the
// shorter name `delta` to minimize diff churn.
const animDelta = params.paused ? 0 : rawDelta
const delta = params.paused || params.stationary ? 0 : rawDelta
flickerT += animDelta
// ── Torch flicker ────────────────────────────────────────────
const wallCount = fixedLightPositions.length
for (let i = 0; i < torchLights.length; i++) {
const torch = torchLights[i]!
torch.enabled = torchEnabled[i] ?? true
const isWall = i < wallCount
const intensityMul = isWall ? 1.6 : 0.8
const distanceMul = isWall ? 1.0 : 0.7
torch.distance = params.torchDistance * distanceMul
torch.intensity =
params.torchIntensity *
intensityMul *
(1 + Math.sin(flickerT * (15 + i * 2)) * 0.1 + Math.sin(flickerT * (23 + i * 3)) * 0.05)
}
// ── Hero movement: keyboard wins, else click-to-walk ─────────
const k = heroKeys
const hvx = (k.right ? 1 : 0) - (k.left ? 1 : 0)
const hvy = (k.up ? 1 : 0) - (k.down ? 1 : 0)
let moveX = 0
let moveY = 0
let moving = false
let facingX = heroFacing.x
let facingY = heroFacing.y
if (hvx !== 0 || hvy !== 0) {
const len = Math.hypot(hvx, hvy)
facingX = hvx / len
facingY = hvy / len
moveX = facingX * HERO_SPEED * delta
moveY = facingY * HERO_SPEED * delta
moving = true
} else if (heroMoveTarget !== null) {
const tgt = heroMoveTarget
const dx = tgt.x - heroPos.x
const dy = tgt.y - heroPos.y
const dist = Math.hypot(dx, dy)
if (dist <= HERO_ARRIVE_RADIUS) {
// Arrived. If target carried a torch toggle, flip it now.
if (heroTargetTorchIdx !== null) {
const idx = heroTargetTorchIdx
const switchStart = fixedLightPositions.length
torchEnabled[switchStart + idx] = !torchEnabled[switchStart + idx]
}
heroMoveTarget = null
heroTargetTorchIdx = null
} else {
facingX = dx / dist
facingY = dy / dist
const step = Math.min(HERO_SPEED * delta, dist)
moveX = facingX * step
moveY = facingY * step
moving = true
}
}
if (moving) {
heroFacing.set(facingX, facingY)
const prevX = heroPos.x
const prevY = heroPos.y
heroPos.x += moveX
heroPos.y += moveY
const mx = mapHalfW - WALL_TILE - KNIGHT_SCALE / 2
const my = mapHalfH - WALL_TILE - KNIGHT_SCALE / 2
heroPos.x = Math.max(-mx, Math.min(mx, heroPos.x))
heroPos.y = Math.max(-my, Math.min(my, heroPos.y))
// Wall-stop: if a click-target walk hit a wall, the clamp eats
// most of the intended step. Cancel navigation so the hero
// doesn't run in place against an edge.
if (heroMoveTarget !== null) {
const expected = Math.hypot(moveX, moveY)
const actual = Math.hypot(heroPos.x - prevX, heroPos.y - prevY)
if (expected > 0 && actual < expected * 0.5) {
heroMoveTarget = null
heroTargetTorchIdx = null
}
}
}
hero.position.set(heroPos.x, heroPos.y, 0)
hero.zIndex = -Math.floor(heroPos.y)
if (moving && heroAnim !== 'run') { hero.play('run'); heroAnim = 'run' }
else if (!moving && heroAnim !== 'idle') { hero.play('idle'); heroAnim = 'idle' }
if (Math.abs(facingX) > 0.01) hero.flipX = facingX < 0
hero.update(animDelta * 1000)
// ── Slimes ───────────────────────────────────────────────────
const exciteRadiusSq = SLIME_EXCITE_RADIUS * SLIME_EXCITE_RADIUS
const slimeWallInset = TILE_PX * TILE_SCALE
const slimeBoundX = mapHalfW - slimeWallInset - SLIME_SCALE / 2
const slimeBoundY = mapHalfH - slimeWallInset - SLIME_SCALE / 2
for (let i = 0; i < slimes.length; i++) {
const s = slimes[i]!
// Proximity check (squared-distance, no sqrt).
const dx = heroPos.x - s.pos.x
const dy = heroPos.y - s.pos.y
const knightNear = dx * dx + dy * dy < exciteRadiusSq
// State transitions.
if (s.stamina <= 0) {
s.state = 'rest'
} else if (s.state === 'rest') {
if (s.stamina >= SLIME_STAMINA_RESUME) {
s.state = knightNear ? 'excited' : 'wander'
s.hopPhase = 'pause'
s.hopTimer = 0.2 + Math.random() * 0.2
s.vel.set(0, 0)
}
} else {
s.state = knightNear ? 'excited' : 'wander'
}
// Movement: rest vs. hop/pause rhythm.
if (s.state === 'rest') {
s.vel.set(0, 0)
s.stamina = Math.min(1, s.stamina + SLIME_STAMINA_RECOVER * s.drainBias * delta)
} else {
s.hopTimer -= delta
if (s.hopTimer <= 0) {
if (s.hopPhase === 'hop') {
s.hopPhase = 'pause'
s.hopTimer = s.state === 'excited'
? SLIME_PAUSE_MIN_EXCITED + Math.random() * (SLIME_PAUSE_MAX_EXCITED - SLIME_PAUSE_MIN_EXCITED)
: SLIME_PAUSE_MIN_WANDER + Math.random() * (SLIME_PAUSE_MAX_WANDER - SLIME_PAUSE_MIN_WANDER)
s.vel.set(0, 0)
} else {
s.hopPhase = 'hop'
s.hopTimer = s.state === 'excited'
? SLIME_HOP_MIN_EXCITED + Math.random() * (SLIME_HOP_MAX_EXCITED - SLIME_HOP_MIN_EXCITED)
: SLIME_HOP_MIN_WANDER + Math.random() * (SLIME_HOP_MAX_WANDER - SLIME_HOP_MIN_WANDER)
const angle = Math.random() * Math.PI * 2
const speed = s.state === 'excited' ? SLIME_SPEED_EXCITED : SLIME_SPEED_WANDER
s.vel.set(Math.cos(angle) * speed, Math.sin(angle) * speed)
}
}
s.pos.x += s.vel.x * delta
s.pos.y += s.vel.y * delta
if (s.pos.x > slimeBoundX) { s.pos.x = slimeBoundX; s.vel.x = -Math.abs(s.vel.x) }
if (s.pos.x < -slimeBoundX) { s.pos.x = -slimeBoundX; s.vel.x = Math.abs(s.vel.x) }
if (s.pos.y > slimeBoundY) { s.pos.y = slimeBoundY; s.vel.y = -Math.abs(s.vel.y) }
if (s.pos.y < -slimeBoundY) { s.pos.y = -slimeBoundY; s.vel.y = Math.abs(s.vel.y) }
if (s.hopPhase === 'hop') {
const drain = s.state === 'excited' ? SLIME_STAMINA_DRAIN_EXCITED : SLIME_STAMINA_DRAIN_WANDER
s.stamina = Math.max(0, s.stamina - drain * s.drainBias * delta)
}
}
// Animation + transform.
if (s.sprite) {
const wantAnim: 'idle' | 'walk' =
s.state !== 'rest' && s.hopPhase === 'hop' ? 'walk' : 'idle'
if (wantAnim !== s.animation) {
s.sprite.play(wantAnim)
s.animation = wantAnim
}
s.sprite.position.set(s.pos.x, s.pos.y, 0)
s.sprite.zIndex = -Math.floor(s.pos.y)
if (Math.abs(s.vel.x) > 1) s.sprite.flipX = s.vel.x < 0
s.sprite.update(animDelta * 1000)
}
// Steady glow — intensity reflects state.
if (s.light) {
s.light.enabled = params.slimeLights
s.light.position.set(s.pos.x, s.pos.y, 0)
s.light.intensity = s.state === 'excited' ? 0.35
: s.state === 'rest' ? 0.2
: 0.28
}
}
flatland.render(renderer)
updateDevtools()
}
renderer.setAnimationLoop(animate)
// ─── Single-shot scene recorder ──────────────────────────────────
//
// Console-callable:
// await window.__captureScene('lighting-on', 3000)
// await window.__captureScene('lighting-off', 3000)
//
// Records the *current* visual state. Two files land in Downloads:
// <name>.webm — durationMs of canvas video
// <name>-poster.jpg — first-frame still
//
// Manual workflow:
// 1. Set up scene 1 via Tweakpane (lighting on, ambient .8, etc).
// 2. Run `await __captureScene('lighting-on', 3000)` from the console.
// 3. Wait for both files to land in Downloads.
// 4. Toggle Tweakpane to scene 2 state (lighting off, etc).
// 5. Run `await __captureScene('lighting-off', 3000)`.
// 6. Drop the four files into docs/public/diagrams/.
//
// To get matching animation phase across captures, both calls reset
// every sprite to frame 0 of `idle` and zero the torch flicker. They
// also force `stationary = true` AND LEAVE IT ON so entities don't
// drift while you set up the next scene state in Tweakpane between
// captures. Use `__endCapture()` (or just uncheck the Stationary box)
// when you're done with a capture session — the demo resumes normal
// entity motion. Lighting state is never touched.
;(window as Window & {
__captureScene?: (name: string, durationMs?: number) => Promise<void>
__endCapture?: () => void
}).__captureScene = async function captureScene(name: string, durationMs = 3000): Promise<void> {
if (!name || typeof name !== 'string') {
console.error('[captureScene] usage: __captureScene("lighting-on", 3000)')
return
}
// Always pause = false (rendering must continue), always stationary
// = true (entities frozen, animations still play). We do NOT restore
// these on exit — successive captures stay aligned. The state lives
// only in `params`; there are no Tweakpane checkboxes for it.
params.paused = false
params.stationary = true
const mainCanvas = renderer.domElement as HTMLCanvasElement
function pickMimeType(): string {
const candidates = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4',
]
for (const m of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(m)) return m
}
return ''
}
function resetAnimations(): void {
hero.play('idle', { startFrame: 0 })
heroAnim = 'idle'
for (const s of slimes) {
if (s.sprite) {
s.sprite.play('idle', { startFrame: 0 })
s.animation = 'idle'
}
s.hopPhase = 'pause'
s.hopTimer = 0.5
s.vel.set(0, 0)
}
flickerT = 0
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function capturePoster(filename: string): Promise<void> {
// Snapshot the current canvas to a JPG for the <Compare> poster
// (paints instantly while the WebM streams in).
const dataUrl = mainCanvas.toDataURL('image/jpeg', 0.9)
const blob = await (await fetch(dataUrl)).blob()
downloadBlob(blob, filename)
}
async function recordVideo(filename: string): Promise<void> {
const stream = mainCanvas.captureStream(60)
const mimeType = pickMimeType()
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
const chunks: Blob[] = []
recorder.ondataavailable = (e: BlobEvent) => {
if (e.data && e.data.size > 0) chunks.push(e.data)
}
return new Promise<void>((resolve) => {
recorder.onstop = () => {
const blob = new Blob(chunks, { type: mimeType || 'video/webm' })
downloadBlob(blob, filename)
resolve()
}
recorder.start()
setTimeout(() => recorder.stop(), durationMs)
})
}
// Reset to frame-0 of idle for every animated sprite so back-to-back
// captures share the same animation phase. Wait one frame so the reset
// takes visual effect before we snapshot the poster.
resetAnimations()
await new Promise((r) => requestAnimationFrame(r))
console.log(`[captureScene] poster + ${durationMs}ms video → ${name}.webm + ${name}-poster.jpg`)
// Capture the poster from the very first frame of recording so the
// poster matches what the WebM starts with.
await capturePoster(`${name}-poster.jpg`)
await recordVideo(`${name}.webm`)
// Stationary stays ON — set up the next scene and call again. Use
// window.__endCapture() to resume normal entity motion when done.
console.log(
`[captureScene] done — ${name}.webm + ${name}-poster.jpg in Downloads. ` +
`Stationary remains ON; call __endCapture() to resume motion.`,
)
}
// Resume normal entity motion after a capture session.
;(window as Window & { __endCapture?: () => void }).__endCapture = function endCapture(): void {
params.stationary = false
console.log('[endCapture] motion resumed')
}
}
main()
import { Suspense, useRef, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { Canvas, extend, useFrame, useLoader, useThree } from '@react-three/fiber/webgpu'
import type { WebGPURenderer } from 'three/webgpu'
import {
Vector2,
type OrthographicCamera as ThreeOrthographicCamera,
} from 'three'
import {
Flatland,
Light2D,
Sprite2D,
AnimatedSprite2D,
SpriteGroup,
TileMap2D,
SpriteSheetLoader,
LDtkLoader,
Layers,
attachLighting,
attachEffect,
type AnimationSetDefinition,
} from 'three-flatland/react'
import {
DefaultLightEffect,
NormalMapProvider,
} from '@three-flatland/presets'
import '@three-flatland/presets/react'
import { usePane, usePaneFolder, usePaneInput } from '@three-flatland/devtools/react'
extend({
Flatland,
Sprite2D,
AnimatedSprite2D,
SpriteGroup,
TileMap2D,
Light2D,
DefaultLightEffect,
NormalMapProvider,
})
// ============================================
// CONSTANTS
// ============================================
const VIEW_SIZE = 640
const TILE_PX = 16
/**
* Vertical ortho view size that frames the whole map in the current
* canvas. `scale` is screen-px per world-unit on the binding axis: when
* the viewport can show the map at ≥ 1× we snap to a whole number so the
* pixel art stays crisp; when it's smaller than the map (scale < 1) we
* keep the fractional value so the scene shrinks to fit instead of
* clamping to 1× and cropping. Scale is uniform, so `canvasH / scale`
* also fits the width.
*/
function fitViewSize(canvasW: number, canvasH: number, mapW: number, mapH: number): number {
if (canvasW <= 0 || canvasH <= 0 || mapW <= 0 || mapH <= 0) return VIEW_SIZE
let scale = Math.min(canvasW / mapW, canvasH / mapH)
if (scale >= 1) scale = Math.floor(scale)
return canvasH / scale
}
const TILE_SCALE = 2
const KNIGHT_SCALE = TILE_PX * TILE_SCALE * 2
const SLIME_SCALE = TILE_PX * TILE_SCALE
const WALL_TILE = 24
// Hero movement speed (world u/s) + click-to-walk tuning.
const HERO_SPEED = 70
// Distance at which click-target navigation "arrives" — smaller than
// the hero sprite to avoid overshoot jitter.
const HERO_ARRIVE_RADIUS = 4
// Click radius used to decide if a click intended a torch vs. a
// bare-floor walk target. 1.25 tile-widths covers sloppy aim.
const TORCH_CLICK_RADIUS = TILE_PX * TILE_SCALE * 1.25
// ─── Slime behavior tuning ──────────────────────────────────────────
// World-distance beyond which a slime ignores nearby knights. ~1.5
// knight-widths keeps excitement local without making slimes skittish
// from across the room.
const SLIME_EXCITE_RADIUS = KNIGHT_SCALE * 1.5
const SLIME_SPEED_WANDER = 14 // world units / s — slow crawl
const SLIME_SPEED_EXCITED = 32 // ~2.3× — visibly agitated
// Stamina drain rates — higher = shorter burst before needing rest.
// Applied only during the `hop` sub-phase; pauses hold stamina flat.
// Tuned so wandering slimes hop around for a good long stretch before
// collapsing, and excited slimes burn out comparatively quickly.
const SLIME_STAMINA_DRAIN_WANDER = 0.05
const SLIME_STAMINA_DRAIN_EXCITED = 0.25
// Recovery rate while resting. 0.3/s → ~3 s from empty to full refill.
const SLIME_STAMINA_RECOVER = 0.3
// Minimum stamina before a resting slime starts wandering again. A
// soft threshold (not 1.0) prevents "rest → move one frame → rest"
// oscillation when the slime is bumping the map edge.
const SLIME_STAMINA_RESUME = 0.6
// Hop/pause rhythm — slimes don't move continuously. They launch in a
// direction for a short hop, then settle and pick a new direction.
// Excited slimes hop a touch longer but pause far less.
const SLIME_HOP_MIN_WANDER = 0.5
const SLIME_HOP_MAX_WANDER = 0.8
const SLIME_PAUSE_MIN_WANDER = 0.4
const SLIME_PAUSE_MAX_WANDER = 0.8
const SLIME_HOP_MIN_EXCITED = 0.3
const SLIME_HOP_MAX_EXCITED = 0.5
const SLIME_PAUSE_MIN_EXCITED = 0.1
const SLIME_PAUSE_MAX_EXCITED = 0.25
// ============================================
// ANIMATION
// ============================================
const knightAnimations: AnimationSetDefinition = {
fps: 8,
animations: {
idle: { frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'], fps: 6, loop: true },
run: {
frames: Array.from({ length: 16 }, (_, i) => `run_${i}`),
fps: 16,
loop: true,
},
},
}
// Slime sheet is a strict 8×5 grid of 24×24 frames — each row is one
// animation. The demo only plays `idle` (resting) and `walk` (wander /
// excited). `walk` runs faster when the slime is excited to hint at the
// agitation without needing a dedicated animation track.
const slimeAnimations: AnimationSetDefinition = {
fps: 8,
animations: {
idle: {
frames: Array.from({ length: 8 }, (_, i) => `idle_${i}`),
fps: 6,
loop: true,
},
walk: {
frames: Array.from({ length: 8 }, (_, i) => `walk_${i}`),
fps: 10,
loop: true,
},
},
}
// ============================================
// ORTHO CAMERA
// ============================================
function OrthoCamera({ viewSize }: { viewSize: number }) {
const set = useThree((s) => s.set)
const size = useThree((s) => s.size)
const camRef = useRef<ThreeOrthographicCamera | null>(null)
const aspect = size.width / size.height
// Re-derive the frustum whenever the fit view size or aspect changes —
// a ref callback fires only on mount, so resize/zoom updates would be
// missed without this effect.
useLayoutEffect(() => {
const cam = camRef.current
if (!cam) return
cam.left = (-viewSize * aspect) / 2
cam.right = (viewSize * aspect) / 2
cam.top = viewSize / 2
cam.bottom = -viewSize / 2
cam.updateProjectionMatrix()
set({ camera: cam })
}, [viewSize, aspect, set])
return (
<orthographicCamera
ref={camRef}
position={[0, 0, 100]}
near={0.1}
far={1000}
manual
/>
)
}
// ============================================
// TILEMAP
// ============================================
// ============================================
// MAP DATA EXTRACTION
// ============================================
import type { TileMapData, TileMapObject } from 'three-flatland/react'
function extractObjectsByType(mapData: TileMapData, type: string): TileMapObject[] {
const results: TileMapObject[] = []
for (const layer of mapData.objectLayers) {
for (const obj of layer.objects) {
if (obj.type === type) results.push(obj)
}
}
return results
}
function mapToWorld(obj: TileMapObject, mapData: TileMapData, scale: number): [number, number] {
const mapH = mapData.height * mapData.tileHeight
const cx = (obj.x + obj.width / 2) * scale
const cy = (mapH - obj.y - obj.height / 2) * scale
const offsetX = (mapData.width * mapData.tileWidth * scale) / 2
const offsetY = (mapH * scale) / 2
return [cx - offsetX, cy - offsetY]
}
// ============================================
// WANDERERS
// ============================================
interface Wanderer {
pos: Vector2
vel: Vector2
retargetTimer: number
}
function newWanderer(halfW: number, halfH: number): Wanderer {
return {
pos: new Vector2(
(Math.random() - 0.5) * halfW * 0.6,
(Math.random() - 0.5) * halfH * 0.6
),
vel: new Vector2(),
retargetTimer: Math.random() * 2,
}
}
/**
* Uniform spawn anywhere inside the playable map interior (the square
* inside the wall tiles, shrunk by `entityHalf` so the sprite's centre
* never overlaps a wall on frame 0). Used for slime spawns so the
* group scatters across the whole dungeon rather than clumping at the
* hero's starting spot.
*
* `wallInset` is the collision thickness of the outer wall ring. Pass
* `TILE_PX * TILE_SCALE` (full wall tile) for tight-bodied sprites
* like slimes; pass the smaller `WALL_TILE` fudge for sprites whose
* art is designed to overlap the wall a bit (e.g. the hero).
*/
function newInteriorWanderer(halfW: number, halfH: number, entityHalf: number, wallInset: number): Wanderer {
const mx = halfW - wallInset - entityHalf
const my = halfH - wallInset - entityHalf
return {
pos: new Vector2(
(Math.random() * 2 - 1) * mx,
(Math.random() * 2 - 1) * my,
),
vel: new Vector2(),
retargetTimer: Math.random() * 2,
}
}
function updateWanderer(w: Wanderer, delta: number, speed: number, halfW: number, halfH: number, entityRadius = 0): void {
w.retargetTimer -= delta
if (w.retargetTimer <= 0) {
const a = Math.random() * Math.PI * 2
w.vel.set(Math.cos(a) * speed, Math.sin(a) * speed)
w.retargetTimer = 1 + Math.random() * 2
}
w.pos.x += w.vel.x * delta
w.pos.y += w.vel.y * delta
const mx = halfW - WALL_TILE - entityRadius
const my = halfH - WALL_TILE - entityRadius
if (w.pos.x > mx) { w.pos.x = mx; w.vel.x = -Math.abs(w.vel.x) }
if (w.pos.x < -mx) { w.pos.x = -mx; w.vel.x = Math.abs(w.vel.x) }
if (w.pos.y > my) { w.pos.y = my; w.vel.y = -Math.abs(w.vel.y) }
if (w.pos.y < -my) { w.pos.y = -my; w.vel.y = Math.abs(w.vel.y) }
}
// ============================================
// SCENE
// ============================================
interface SceneProps {
paused: boolean
lightingEnabled: boolean
bands: number
shadowStrength: number
shadowBias: number
shadowStartOffsetScale: number
shadowMaxDistance: number
shadowPixelSize: number
pixelSize: number
ambient: number
slimeCount: number
slimeLights: boolean
slimeQuota: number
torchIntensity: number
torchDistance: number
lightHeight: number
glowRadius: number
glowIntensity: number
rimIntensity: number
// Compile-time toggles. Each flip triggers a shader rebuild on
// the LightEffect so the dead branches actually leave the graph.
bandsEnabled: boolean
pixelSnapEnabled: boolean
shadowPixelSnapEnabled: boolean
glowEnabled: boolean
rimEnabled: boolean
}
function FlatlandScene(props: SceneProps) {
const knightSheet = useLoader(SpriteSheetLoader, './sprites/knight.json', (l) => {
l.normals = true
})
const slimeSheet = useLoader(SpriteSheetLoader, './sprites/slime.json', (l) => {
l.normals = true
})
const mapData = useLoader(LDtkLoader, './maps/dungeon.ldtk', (l) => {
l.normals = true
})
const gl = useThree((s) => s.gl)
const size = useThree((s) => s.size)
const flatlandRef = useRef<Flatland>(null)
const tilemapRef = useRef<TileMap2D>(null)
const defaultLightRef = useRef<InstanceType<typeof DefaultLightEffect>>(null)
const torchLightRefs = useRef<(Light2D | null)[]>([])
const [torchEnabled, setTorchEnabled] = useState<boolean[]>([])
const flickerTimer = useRef(0)
const mapHalfW = (mapData.width * mapData.tileWidth * TILE_SCALE) / 2
const mapHalfH = (mapData.height * mapData.tileHeight * TILE_SCALE) / 2
// Frame the whole map for the current canvas (integer scale when
// upscaling, fractional when the viewport is smaller than the map).
const viewSize = useMemo(
() => fitViewSize(size.width, size.height, mapHalfW * 2, mapHalfH * 2),
[size.width, size.height, mapHalfW, mapHalfH]
)
// Click-to-walk reads this inside a listener that doesn't re-bind on
// every resize — keep a live ref so world mapping uses the current view.
const viewSizeRef = useRef(viewSize)
viewSizeRef.current = viewSize
const fixedLightPositions = useMemo(() =>
extractObjectsByType(mapData, 'light').map(obj => mapToWorld(obj, mapData, TILE_SCALE)),
[mapData])
const switchPositions = useMemo(() =>
extractObjectsByType(mapData, 'torch_switch').map(obj => mapToWorld(obj, mapData, TILE_SCALE)),
[mapData])
const allTorchPositions = useMemo(() =>
[...fixedLightPositions, ...switchPositions],
[fixedLightPositions, switchPositions])
useEffect(() => {
setTorchEnabled(allTorchPositions.map(() => true))
}, [allTorchPositions.length])
const heroRef = useRef<AnimatedSprite2D | null>(null)
const heroPos = useRef(new Vector2(0, 0))
const heroKeys = useRef({ up: false, down: false, left: false, right: false })
const heroAnim = useRef<'idle' | 'run'>('idle')
const heroFacing = useRef(new Vector2(1, 0))
/**
* Diablo-style click-to-walk target. `null` when no click target is
* active (keyboard-only control). When set, the hero path-walks
* toward it each frame. Keyboard input cancels the target so player
* intent always wins.
*/
const heroMoveTarget = useRef<Vector2 | null>(null)
/**
* When the click target is a torch switch, we queue its index here
* so the hero can toggle it on arrival. `switchStart + idx` indexes
* into `torchEnabled`, matching the existing space-key logic.
*/
const heroTargetTorchIdx = useRef<number | null>(null)
/** Once-only flag so hero placement only runs after map data lands. */
const heroSpawnedRef = useRef(false)
// Slimes run a three-state behavior: rest (recover stamina), wander
// (default ambling), and excited (sprinting when a knight is nearby).
// Within wander/excited the slime alternates between `hop` (brief
// directional burst) and `pause` (stand still, pick a direction for
// the next hop) sub-phases — very slime-like rhythm. Stamina only
// drains during the hop sub-phase. `drainBias` is a per-slime ±10%
// multiplier on drain/recovery rates so otherwise-identical slimes
// drift apart in phase over time — without this they'd synchronize
// into a single collective heartbeat.
const slimesRef = useRef<Array<{
anim: Wanderer
sprite: AnimatedSprite2D | null
light: Light2D | null
stamina: number
state: 'rest' | 'wander' | 'excited'
hopPhase: 'hop' | 'pause'
hopTimer: number
animation: 'idle' | 'walk'
drainBias: number
}>>([])
// Spawn hero near the first fixed torch so the map starts lit around
// the player. Falls back to origin if the map has no torches (shouldn't
// happen with the dungeon LDtk but keeps the guard cheap).
if (!heroSpawnedRef.current && fixedLightPositions.length > 0) {
const [tx, ty] = fixedLightPositions[0]!
// Offset one tile along +X so the hero isn't physically on top of
// the torch sprite — reads better visually.
heroPos.current.set(tx + TILE_PX * TILE_SCALE, ty)
heroSpawnedRef.current = true
}
if (slimesRef.current.length !== props.slimeCount) {
while (slimesRef.current.length < props.slimeCount) {
// Spread starting stamina across the full range AND randomly
// drop some spawns straight into `rest` so the group never
// shares a single collective cycle phase. drainBias (±10%)
// ensures that even slimes that happen to align drift apart
// over time from the accumulated rate difference.
const stamina = Math.random()
const state = stamina < 0.4 ? 'rest' : 'wander'
// Random initial hop phase + leftover timer so wandering slimes
// don't all burst out of the gate in unison either.
const hopPhase = Math.random() < 0.5 ? 'hop' : 'pause'
slimesRef.current.push({
// Full-tile wall inset (TILE_PX * TILE_SCALE = 32) keeps the
// slime's tight body clear of the wall art. The hero uses the
// smaller WALL_TILE fudge because its frame has transparent
// padding that can visually overlap the wall without clipping.
anim: newInteriorWanderer(
mapHalfW,
mapHalfH,
SLIME_SCALE / 2,
TILE_PX * TILE_SCALE,
),
sprite: null,
light: null,
stamina,
state,
hopPhase,
hopTimer: Math.random() * 0.5,
animation: state === 'rest' || hopPhase === 'pause' ? 'idle' : 'walk',
drainBias: 0.85 + Math.random() * 0.3,
})
}
if (slimesRef.current.length > props.slimeCount) slimesRef.current.length = props.slimeCount
}
// Push uniform values each frame via refs — effect instance updates
// for *uniform* fields are zero-cost `.value =` writes on the
// underlying TSL uniform nodes. The compile-time toggles
// (`*Enabled`) below are different: assigning them re-runs the
// LightEffect's `_buildLightFn` and triggers a shader recompile.
useEffect(() => {
const e = defaultLightRef.current as unknown as {
bands: number
shadowStrength: number
shadowBias: number
shadowStartOffsetScale: number
shadowMaxDistance: number
shadowPixelSize: number
pixelSize: number
lightHeight: number
glowRadius: number
glowIntensity: number
rimIntensity: number
} | null
if (!e) return
e.bands = props.bands
e.shadowStrength = props.shadowStrength
e.shadowBias = props.shadowBias
e.shadowStartOffsetScale = props.shadowStartOffsetScale
e.shadowMaxDistance = props.shadowMaxDistance
e.shadowPixelSize = props.shadowPixelSize
e.pixelSize = props.pixelSize
e.lightHeight = props.lightHeight
e.glowRadius = props.glowRadius
e.glowIntensity = props.glowIntensity
e.rimIntensity = props.rimIntensity
}, [
props.bands,
props.shadowStrength,
props.shadowBias,
props.shadowStartOffsetScale,
props.shadowMaxDistance,
props.shadowPixelSize,
props.pixelSize,
props.lightHeight,
props.glowRadius,
props.glowIntensity,
props.rimIntensity,
])
// Compile-time toggle pushes — separate from the uniform pushes
// above so a uniform tweak never accidentally bumps a constant.
// Each setter call here triggers `_rebuildLightFn` if the value
// actually changed (early-out on identity).
useEffect(() => {
const e = defaultLightRef.current as unknown as {
bandsEnabled: boolean
pixelSnapEnabled: boolean
shadowPixelSnapEnabled: boolean
glowEnabled: boolean
rimEnabled: boolean
} | null
if (!e) return
e.bandsEnabled = props.bandsEnabled
e.pixelSnapEnabled = props.pixelSnapEnabled
e.shadowPixelSnapEnabled = props.shadowPixelSnapEnabled
e.glowEnabled = props.glowEnabled
e.rimEnabled = props.rimEnabled
}, [
props.bandsEnabled,
props.pixelSnapEnabled,
props.shadowPixelSnapEnabled,
props.glowEnabled,
props.rimEnabled,
])
useEffect(() => {
// torch_switch tiles hold a torch Light2D at their center — treating
// them as shadow casters would self-shadow their own light. They remain
// collision for the hero (handled separately), just not occluders.
tilemapRef.current?.markOccluders(['collision'])
}, [mapData])
useEffect(() => {
const fl = flatlandRef.current
if (!fl) return
fl.viewSize = viewSize
fl.resize(size.width, size.height)
}, [size.width, size.height, viewSize])
// Hero input
useEffect(() => {
const keymap = (e: KeyboardEvent): keyof typeof heroKeys.current | null => {
switch (e.code) {
case 'KeyW':
case 'ArrowUp':
return 'up'
case 'KeyS':
case 'ArrowDown':
return 'down'
case 'KeyA':
case 'ArrowLeft':
return 'left'
case 'KeyD':
case 'ArrowRight':
return 'right'
default:
return null
}
}
const tryActivateTorch = () => {
const hero = heroPos.current
const facing = heroFacing.current
const activationRadius = TILE_PX * TILE_SCALE * 2.5
const facingThreshold = 0.3 // ~72° cone — plenty of slop
const switchStart = fixedLightPositions.length
let bestIdx = -1
let bestDist = Infinity
for (let i = 0; i < switchPositions.length; i++) {
const [sx, sy] = switchPositions[i]!
const dx = sx - hero.x
const dy = sy - hero.y
const dist = Math.hypot(dx, dy)
if (dist > activationRadius) continue
if (dist > 1) {
const dot = (dx / dist) * facing.x + (dy / dist) * facing.y
if (dot < facingThreshold) continue
}
if (dist < bestDist) { bestDist = dist; bestIdx = i }
}
if (bestIdx < 0) return
setTorchEnabled(prev => {
const next = [...prev]
next[switchStart + bestIdx] = !next[switchStart + bestIdx]
return next
})
}
const down = (e: KeyboardEvent) => {
if (e.code === 'Space') {
tryActivateTorch()
e.preventDefault()
return
}
const k = keymap(e)
if (k) {
heroKeys.current[k] = true
// Keyboard input cancels any in-flight click-to-walk path —
// player intent beats queued navigation.
heroMoveTarget.current = null
heroTargetTorchIdx.current = null
e.preventDefault()
}
}
const up = (e: KeyboardEvent) => {
const k = keymap(e)
if (k) { heroKeys.current[k] = false; e.preventDefault() }
}
const canvas = (gl as unknown as { domElement: HTMLCanvasElement }).domElement
const click = (e: MouseEvent) => {
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1
const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
const aspect = rect.width / rect.height
const vs = viewSizeRef.current
const worldX = ndcX * (vs * aspect) / 2
const worldY = ndcY * vs / 2
// Diablo-style click-to-walk. If the click landed near a torch
// switch, queue that switch's index so the hero toggles it on
// arrival. Otherwise it's a bare-floor move target.
let snapX = worldX
let snapY = worldY
let torchIdx: number | null = null
let bestDistSq = TORCH_CLICK_RADIUS * TORCH_CLICK_RADIUS
for (let i = 0; i < switchPositions.length; i++) {
const [sx, sy] = switchPositions[i]!
const dx = sx - worldX
const dy = sy - worldY
const d2 = dx * dx + dy * dy
if (d2 < bestDistSq) {
bestDistSq = d2
torchIdx = i
// Stand one sprite-width off the torch so the hero's own
// body doesn't fully occlude the light glyph.
const dist = Math.sqrt(d2) || 1
const off = TILE_PX * TILE_SCALE
// Toward the current hero — so we approach from the nearer
// side rather than teleporting around the torch.
const toHeroX = heroPos.current.x - sx
const toHeroY = heroPos.current.y - sy
const thLen = Math.hypot(toHeroX, toHeroY) || 1
snapX = sx + (toHeroX / thLen) * off
snapY = sy + (toHeroY / thLen) * off
void dist
}
}
heroMoveTarget.current = new Vector2(snapX, snapY)
heroTargetTorchIdx.current = torchIdx
}
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
canvas.addEventListener('click', click)
return () => {
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
canvas.removeEventListener('click', click)
}
}, [gl, fixedLightPositions.length, switchPositions])
useFrame((_, rawDelta) => {
// When paused, freeze the simulation. Rendering still happens, so the
// canvas continues to update — useful for capturing comparison
// screenshots on identical entity positions.
if (props.paused) return
const delta = rawDelta
flickerTimer.current += delta
const t = flickerTimer.current
const wallCount = fixedLightPositions.length
for (let i = 0; i < torchLightRefs.current.length; i++) {
const torch = torchLightRefs.current[i]
if (!torch) continue
torch.enabled = torchEnabled[i] ?? true
const isWall = i < wallCount
const intensityMul = isWall ? 1.6 : 0.8
const distanceMul = isWall ? 1.0 : 0.7
torch.distance = props.torchDistance * distanceMul
torch.intensity =
props.torchIntensity *
intensityMul *
(1 + Math.sin(t * (15 + i * 2)) * 0.1 + Math.sin(t * (23 + i * 3)) * 0.05)
}
// ── Hero movement: keyboard wins, else click-to-walk ──────
const k = heroKeys.current
const hvx = (k.right ? 1 : 0) - (k.left ? 1 : 0)
const hvy = (k.up ? 1 : 0) - (k.down ? 1 : 0)
let moveX = 0
let moveY = 0
let moving = false
let facingX = heroFacing.current.x
let facingY = heroFacing.current.y
if (hvx !== 0 || hvy !== 0) {
const len = Math.hypot(hvx, hvy)
facingX = hvx / len
facingY = hvy / len
moveX = facingX * HERO_SPEED * delta
moveY = facingY * HERO_SPEED * delta
moving = true
} else if (heroMoveTarget.current !== null) {
const tgt = heroMoveTarget.current
const dx = tgt.x - heroPos.current.x
const dy = tgt.y - heroPos.current.y
const dist = Math.hypot(dx, dy)
if (dist <= HERO_ARRIVE_RADIUS) {
// Arrived. If the target carried a torch toggle, flip it now.
// Defer the setState off the frame — examples/react/CLAUDE.md
// forbids setState in useFrame because it triggers a synchronous
// mid-frame re-render. The microtask runs after useFrame returns,
// letting React's automatic batching schedule a normal render.
if (heroTargetTorchIdx.current !== null) {
const idx = heroTargetTorchIdx.current
const switchStart = fixedLightPositions.length
queueMicrotask(() => {
setTorchEnabled((prev) => {
const next = [...prev]
next[switchStart + idx] = !next[switchStart + idx]
return next
})
})
}
heroMoveTarget.current = null
heroTargetTorchIdx.current = null
} else {
facingX = dx / dist
facingY = dy / dist
// Don't overshoot the target: cap travel to remaining distance.
const step = Math.min(HERO_SPEED * delta, dist)
moveX = facingX * step
moveY = facingY * step
moving = true
}
}
if (moving) {
heroFacing.current.set(facingX, facingY)
const prevX = heroPos.current.x
const prevY = heroPos.current.y
heroPos.current.x += moveX
heroPos.current.y += moveY
const mx = mapHalfW - WALL_TILE - KNIGHT_SCALE / 2
const my = mapHalfH - WALL_TILE - KNIGHT_SCALE / 2
heroPos.current.x = Math.max(-mx, Math.min(mx, heroPos.current.x))
heroPos.current.y = Math.max(-my, Math.min(my, heroPos.current.y))
// Wall-stop: if a click-target walk hit a wall this frame, the
// clamp will have eaten most of the intended step. Detect that
// and cancel the navigation so the hero doesn't "run in place"
// against the edge. Keyboard paths never set a target so this
// only affects click-to-walk.
if (heroMoveTarget.current !== null) {
const expected = Math.hypot(moveX, moveY)
const actual = Math.hypot(heroPos.current.x - prevX, heroPos.current.y - prevY)
// Allow ~half the intended step before declaring a stall — a
// glancing wall contact (hero sliding along an edge) shouldn't
// cancel the walk if the tangential component still progresses.
if (expected > 0 && actual < expected * 0.5) {
heroMoveTarget.current = null
heroTargetTorchIdx.current = null
}
}
}
if (heroRef.current) {
heroRef.current.position.set(heroPos.current.x, heroPos.current.y, 0)
heroRef.current.zIndex = -Math.floor(heroPos.current.y)
if (moving && heroAnim.current !== 'run') {
heroRef.current.play('run')
heroAnim.current = 'run'
} else if (!moving && heroAnim.current !== 'idle') {
heroRef.current.play('idle')
heroAnim.current = 'idle'
}
if (Math.abs(facingX) > 0.01) heroRef.current.flipX = facingX < 0
heroRef.current.update(delta * 1000)
}
// Build a flat list of "predator" positions (hero + knight NPCs)
// once per frame; each slime samples it for proximity. O(slimes ×
// predators) = ~N distance tests — just the hero now.
const predatorPositions: Array<{ x: number; y: number }> = [
{ x: heroPos.current.x, y: heroPos.current.y },
]
const exciteRadiusSq = SLIME_EXCITE_RADIUS * SLIME_EXCITE_RADIUS
// Slimes use the full wall-tile thickness (TILE_PX * TILE_SCALE)
// for collision instead of the looser WALL_TILE fudge the hero
// gets away with. Without this, the tight slime body visually
// punches into the wall art by ~8 world units on impact.
const slimeWallInset = TILE_PX * TILE_SCALE
const slimeBoundX = mapHalfW - slimeWallInset - SLIME_SCALE / 2
const slimeBoundY = mapHalfH - slimeWallInset - SLIME_SCALE / 2
for (let i = 0; i < slimesRef.current.length; i++) {
const s = slimesRef.current[i]!
// ── Proximity check ────────────────────────────────────────
// Squared-distance compare avoids the sqrt that `Math.hypot`
// would cost per predator.
let knightNear = false
for (const p of predatorPositions) {
const dx = p.x - s.anim.pos.x
const dy = p.y - s.anim.pos.y
if (dx * dx + dy * dy < exciteRadiusSq) {
knightNear = true
break
}
}
// ── State transitions ──────────────────────────────────────
// Forced rest when stamina depletes — overrides knight proximity
// so a winded slime can't stay excited even if harassed.
if (s.stamina <= 0) {
s.state = 'rest'
} else if (s.state === 'rest') {
if (s.stamina >= SLIME_STAMINA_RESUME) {
s.state = knightNear ? 'excited' : 'wander'
// Entering wander/excited from rest — snap into a pause so
// the slime pre-roll-surveys before hopping. Feels more
// natural than teleporting straight into motion.
s.hopPhase = 'pause'
s.hopTimer = 0.2 + Math.random() * 0.2
s.anim.vel.x = 0
s.anim.vel.y = 0
}
} else {
s.state = knightNear ? 'excited' : 'wander'
}
// ── Movement: rest vs. hop/pause rhythm ────────────────────
if (s.state === 'rest') {
s.anim.vel.x = 0
s.anim.vel.y = 0
s.stamina = Math.min(
1,
s.stamina + SLIME_STAMINA_RECOVER * s.drainBias * delta,
)
} else {
// Advance the hop/pause timer and flip phases when it expires.
s.hopTimer -= delta
if (s.hopTimer <= 0) {
if (s.hopPhase === 'hop') {
// Hop done — settle into a pause.
s.hopPhase = 'pause'
s.hopTimer = s.state === 'excited'
? SLIME_PAUSE_MIN_EXCITED + Math.random() * (SLIME_PAUSE_MAX_EXCITED - SLIME_PAUSE_MIN_EXCITED)
: SLIME_PAUSE_MIN_WANDER + Math.random() * (SLIME_PAUSE_MAX_WANDER - SLIME_PAUSE_MIN_WANDER)
s.anim.vel.x = 0
s.anim.vel.y = 0
} else {
// Pause done — launch into a new hop in a random direction.
s.hopPhase = 'hop'
s.hopTimer = s.state === 'excited'
? SLIME_HOP_MIN_EXCITED + Math.random() * (SLIME_HOP_MAX_EXCITED - SLIME_HOP_MIN_EXCITED)
: SLIME_HOP_MIN_WANDER + Math.random() * (SLIME_HOP_MAX_WANDER - SLIME_HOP_MIN_WANDER)
const angle = Math.random() * Math.PI * 2
const speed = s.state === 'excited' ? SLIME_SPEED_EXCITED : SLIME_SPEED_WANDER
s.anim.vel.x = Math.cos(angle) * speed
s.anim.vel.y = Math.sin(angle) * speed
}
}
// Apply velocity (only non-zero during hop phase) + wall bounce.
// Bypasses `updateWanderer` because that function continuously
// retargets its own velocity; we drive vel explicitly here.
s.anim.pos.x += s.anim.vel.x * delta
s.anim.pos.y += s.anim.vel.y * delta
if (s.anim.pos.x > slimeBoundX) { s.anim.pos.x = slimeBoundX; s.anim.vel.x = -Math.abs(s.anim.vel.x) }
if (s.anim.pos.x < -slimeBoundX) { s.anim.pos.x = -slimeBoundX; s.anim.vel.x = Math.abs(s.anim.vel.x) }
if (s.anim.pos.y > slimeBoundY) { s.anim.pos.y = slimeBoundY; s.anim.vel.y = -Math.abs(s.anim.vel.y) }
if (s.anim.pos.y < -slimeBoundY) { s.anim.pos.y = -slimeBoundY; s.anim.vel.y = Math.abs(s.anim.vel.y) }
// Drain stamina only during active hops — pauses hold the
// value steady so the slime's total movement endurance is
// determined by hop-time alone.
if (s.hopPhase === 'hop') {
const drain = s.state === 'excited'
? SLIME_STAMINA_DRAIN_EXCITED
: SLIME_STAMINA_DRAIN_WANDER
s.stamina = Math.max(0, s.stamina - drain * s.drainBias * delta)
}
}
// ── Animation + transform ──────────────────────────────────
if (s.sprite) {
// Walk while actively hopping, idle otherwise (rest OR pause
// between hops). Animation changes drive `.play()` only on
// transition — not every frame.
const wantAnim: 'idle' | 'walk' =
s.state !== 'rest' && s.hopPhase === 'hop' ? 'walk' : 'idle'
if (wantAnim !== s.animation) {
s.sprite.play(wantAnim)
s.animation = wantAnim
}
s.sprite.position.set(s.anim.pos.x, s.anim.pos.y, 0)
s.sprite.zIndex = -Math.floor(s.anim.pos.y)
if (Math.abs(s.anim.vel.x) > 1) s.sprite.flipX = s.anim.vel.x < 0
s.sprite.update(delta * 1000)
}
// ── Steady glow ────────────────────────────────────────────
// Slimes glow steadily — no flicker. Intensity shifts with state
// so operators can read the state at a glance without HUD text.
// `slimeLights` pane toggle disables the light entirely without
// tearing the Light2D instance down (zero cost when disabled —
// Forward+ culls disabled lights before per-tile upload).
if (s.light) {
s.light.enabled = props.slimeLights
s.light.position.set(s.anim.pos.x, s.anim.pos.y, 0)
s.light.intensity = s.state === 'excited' ? 0.35
: s.state === 'rest' ? 0.2
: 0.28
}
}
})
useFrame(() => {
flatlandRef.current?.render(gl as unknown as WebGPURenderer)
}, { phase: 'render' })
return (
<>
<OrthoCamera viewSize={viewSize} />
<flatland ref={flatlandRef} viewSize={viewSize} clearColor={0x06060c}>
{props.lightingEnabled && (
<defaultLightEffect
ref={defaultLightRef}
attach={attachLighting}
bands={props.bands}
shadowStrength={props.shadowStrength}
shadowBias={props.shadowBias}
shadowStartOffsetScale={props.shadowStartOffsetScale}
shadowMaxDistance={props.shadowMaxDistance}
shadowPixelSize={props.shadowPixelSize}
pixelSize={props.pixelSize}
categoryQuotas={{ slime: props.slimeQuota }}
/>
)}
{/* Floor + walls. Tileset's baked normalMap (synthesized by
LDtkLoader from per-tile `tileDir` / `tileCap*` custom data)
drives directional lighting — walls tilt toward their visible
face, floors stay flat. */}
<tileMap2D ref={tilemapRef} data={mapData} scale={[TILE_SCALE, TILE_SCALE, 1]} position={[-mapHalfW, -mapHalfH, -100]}>
<normalMapProvider attach={attachEffect} normalMap={mapData.tilesets[0]?.normalMap ?? null} />
</tileMap2D>
{/* Ambient — purple-tinted dungeon atmosphere */}
<light2D lightType="ambient" color={0x5544aa} intensity={props.ambient} />
{/* Wall torches (fixed) — warm orange */}
{fixedLightPositions.map((pos, i) => (
<light2D
key={`wall-torch-${i}`}
ref={(el) => { torchLightRefs.current[i] = el }}
lightType="point"
position={[pos[0], pos[1], 0]}
color={0xff6600}
intensity={props.torchIntensity}
distance={props.torchDistance}
decay={2}
importance={10}
/>
))}
{/* Toggle torches (switchable) — cool amber */}
{switchPositions.map((pos, i) => (
<light2D
key={`switch-torch-${i}`}
ref={(el) => { torchLightRefs.current[fixedLightPositions.length + i] = el }}
lightType="point"
position={[pos[0], pos[1], 0]}
color={0xffcc44}
intensity={props.torchIntensity * 0.8}
distance={props.torchDistance * 0.7}
decay={2}
importance={10}
/>
))}
{/* Hero — rendered on a layer ABOVE slimes (ENTITIES + 1) so
the knight sorts on top when they overlap. Slimes share a
sheet/material with each other, hero uses a different one,
so they can't collapse into the same batch regardless of
layer — bumping the layer is purely a visual z-order hint. */}
<animatedSprite2D
ref={(el) => { heroRef.current = el }}
texture={knightSheet.texture}
spriteSheet={knightSheet}
animationSet={knightAnimations}
animation="idle"
position={[0, 0, 0]}
scale={[KNIGHT_SCALE, KNIGHT_SCALE, 1]}
castsShadow
lit
layer={Layers.ENTITIES + 1}
>
<normalMapProvider attach={attachEffect} normalMap={knightSheet.normalMap ?? null} />
</animatedSprite2D>
{/* Slimes + per-slime lights. Real sprite-sheet now; the
loader-baked normal atlas lights each frame consistently.
`castsShadow` omitted — the slime IS a light source (attached
Light2D at its center). Marking it as an occluder would
self-shadow its own light. */}
{slimesRef.current.map((s, i) => (
<animatedSprite2D
key={`slime-${i}`}
ref={(el) => {
// Stagger the animation cursor once on first mount so
// each slime's walk/idle cycle starts at a random frame
// instead of every slime playing frame 0 in lockstep.
const firstMount = el !== null && s.sprite === null
s.sprite = el
if (firstMount && el !== null) {
const frames = slimeAnimations.animations[s.animation]!.frames.length
el.play(s.animation, { startFrame: Math.floor(Math.random() * frames) })
}
}}
texture={slimeSheet.texture}
spriteSheet={slimeSheet}
animationSet={slimeAnimations}
animation={s.animation}
scale={[SLIME_SCALE, SLIME_SCALE, 1]}
anchor={[0.5, 0.5]}
lit
layer={Layers.ENTITIES}
>
<normalMapProvider attach={attachEffect} normalMap={slimeSheet.normalMap ?? null} />
</animatedSprite2D>
))}
{slimesRef.current.map((s, i) => (
<light2D
key={`slime-light-${i}`}
ref={(el) => { s.light = el }}
lightType="point"
color={0x33ff66}
intensity={0.25}
distance={40}
decay={2}
castsShadow={false}
category="slime"
/>
))}
</flatland>
</>
)
}
// ============================================
// APP
// ============================================
export default function App() {
const { pane } = usePane()
// ── Lighting ─────────────────────────────────────────────────────
// Sliders here split into two flavors: live uniforms (no rebuild
// cost on change — ambient, lightHeight, glowIntensity) and
// compile-time gates derived from value > 0 (bands, pixelSize,
// glowRadius, rimIntensity). The gates trigger a shader rebuild
// only when crossing the 0/N boundary; tuning the slider above 0
// updates the in-shader uniform at zero rebuild cost.
const [paused] = usePaneInput(pane, 'pause', false)
const light = usePaneFolder(pane, 'Lighting', { expanded: true })
const [lightingEnabled] = usePaneInput(light, 'enabled', true)
const [bands] = usePaneInput(light, 'bands', 4, { min: 0, max: 8, step: 1 })
const [pixelSize] = usePaneInput(light, 'pixelSize', 4, { min: 0, max: 8, step: 1 })
const [ambient] = usePaneInput(light, 'ambient', 0.6, { min: 0, max: 3, step: 0.05 })
// lightHeight: the universal +Z component added to every light's
// direction. Higher values make flat surfaces (floors, wall caps)
// read as more "top-lit" — classic 2.5D look. Lower values push the
// light toward a side-lit feel where tilted faces dominate.
const [lightHeight] = usePaneInput(light, 'lightHeight', 0.75, { min: 0, max: 2, step: 0.05 })
const [glowRadius] = usePaneInput(light, 'glowRadius', 0, { min: 0, max: 2, step: 0.05 })
const [glowIntensity] = usePaneInput(light, 'glowIntensity', 0.6, { min: 0, max: 2, step: 0.05 })
const [rimIntensity] = usePaneInput(light, 'rimIntensity', 0, { min: 0, max: 2, step: 0.05 })
const shadows = usePaneFolder(pane, 'Shadows')
const [shadowStrength] = usePaneInput(shadows, 'strength', 0.8, { min: 0, max: 1, step: 0.05 })
const [shadowBias] = usePaneInput(shadows, 'bias', 0.5, { min: 0, max: 2, step: 0.05 })
const [shadowStartOffsetScale] = usePaneInput(shadows, 'startOffsetScale', 1, { min: 0, max: 3, step: 0.05 })
const [shadowMaxDistance] = usePaneInput(shadows, 'maxDistance', 300, { min: 0, max: 600, step: 10 })
const [shadowPixelSize] = usePaneInput(shadows, 'pixelSize', 4, { min: 0, max: 8, step: 1 })
const torches = usePaneFolder(pane, 'Torches')
const [torchIntensity] = usePaneInput(torches, 'intensity', 1.8, { min: 0, max: 3, step: 0.05 })
const [torchDistance] = usePaneInput(torches, 'distance', 140, { min: 40, max: 400, step: 10 })
const slimes = usePaneFolder(pane, 'Slimes')
const [slimeCount] = usePaneInput(slimes, 'count', 5, { min: 0, max: 1000, step: 1 })
const [slimeLights] = usePaneInput(slimes, 'lights', true)
// Per-tile quota for the "slime" fill bucket. Default 2 keeps hero
// lights uncontested in dense scenes; bumping to 4–8 reduces the
// tile-checkerboard artifact in 1000-slime clusters at the cost of
// a few more shader iterations per fragment in saturated tiles.
const [slimeQuota] = usePaneInput(slimes, 'quota', 4, { min: 0, max: 16, step: 1 })
// Compile-time gates derived from slider values. The boolean is
// what drives the shader rebuild — when the slider crosses 0/N,
// the gate flips and the LightEffect's `_rebuildLightFn` fires.
// While the slider stays above 0, the numeric value flows through
// as a live uniform with no rebuild cost.
const bandsEnabled = bands > 0
const pixelSnapEnabled = pixelSize > 0
const shadowPixelSnapEnabled = shadowPixelSize > 0
const glowEnabled = glowRadius > 0
const rimEnabled = rimIntensity > 0
return (
<Canvas renderer={{ antialias: false }}>
<color attach="background" args={['#06060c']} />
<Suspense fallback={null}>
<FlatlandScene
paused={paused}
lightingEnabled={lightingEnabled}
bands={bands}
shadowStrength={shadowStrength}
shadowBias={shadowBias}
shadowStartOffsetScale={shadowStartOffsetScale}
shadowMaxDistance={shadowMaxDistance}
shadowPixelSize={shadowPixelSize}
pixelSize={pixelSize}
ambient={ambient}
slimeCount={slimeCount}
slimeLights={slimeLights}
slimeQuota={slimeQuota}
torchIntensity={torchIntensity}
torchDistance={torchDistance}
lightHeight={lightHeight}
glowRadius={glowRadius}
glowIntensity={glowIntensity}
rimIntensity={rimIntensity}
bandsEnabled={bandsEnabled}
pixelSnapEnabled={pixelSnapEnabled}
shadowPixelSnapEnabled={shadowPixelSnapEnabled}
glowEnabled={glowEnabled}
rimEnabled={rimEnabled}
/>
</Suspense>
</Canvas>
)
}

This example demonstrates dynamic 2D lighting using DefaultLightEffect (Forward+ tiled culling), two flickering point lights with draggable indicators, and animated knight sprites:

ControlAction
Torch 1 / 2Toggle point lights on/off
DragMove point lights (touch or mouse)

The scene uses DefaultLightEffect (Forward+ tiled culling) for efficient per-tile light culling:

import { Flatland, Light2D, Sprite2D } from 'three-flatland'
import { DefaultLightEffect } from '@three-flatland/presets'
const defaultEffect = new DefaultLightEffect()
const flatland = new Flatland({ viewSize: 300, clearColor: 0x0a0a12 })
flatland.setLighting(defaultEffect)
// Two colored torches
const torch1 = new Light2D({
type: 'point',
position: [-80, 50],
color: 0xff6600,
intensity: 1.2,
distance: 150,
decay: 2,
})
const torch2 = new Light2D({
type: 'point',
position: [80, 50],
color: 0xffaa00,
intensity: 1.0,
distance: 150,
decay: 2,
})
flatland.add(torch1)
flatland.add(torch2)
// Dim ambient so unlighted areas aren't pure black
flatland.add(new Light2D({
type: 'ambient',
color: 0x111122,
intensity: 0.15,
}))

All sprites receive lighting by default when a LightEffect is active. Set lit: false to opt out (e.g., for UI elements):

const knight = new Sprite2D({
texture: knightSheet.texture,
frame: knightSheet.getFrame('idle_0'),
})
flatland.add(knight)
// UI indicator — always full brightness
const indicator = new Sprite2D({ texture: circleTexture, lit: false })

In React, use attachLighting to wire the LightEffect to Flatland, attachEffect for normal providers, and light2D JSX elements for lights:

import { extend } from '@react-three/fiber/webgpu'
import {
Flatland, Light2D, Sprite2D, attachLighting, attachEffect,
} from 'three-flatland/react'
import { DefaultLightEffect, NormalMapProvider } from '@three-flatland/presets'
import '@three-flatland/presets/react'
extend({ Flatland, Sprite2D, Light2D, DefaultLightEffect, NormalMapProvider })
<flatland ref={flatlandRef} viewSize={300} clearColor={0x0a0a12}>
<defaultLightEffect attach={attachLighting} />
<light2D lightType="point" position={[-80, 50, 0]}
color={0xff6600} intensity={1.2} distance={150} decay={2} />
<light2D lightType="point" position={[80, 50, 0]}
color={0xffaa00} intensity={1.0} distance={150} decay={2} />
<light2D lightType="ambient" color={0x111122} intensity={0.15} />
<sprite2D texture={tex}>
<normalMapProvider attach={attachEffect} />
</sprite2D>
</flatland>