feat: foilage

This commit is contained in:
Dan Finch 2026-08-04 23:03:55 +02:00
parent 67cd54fe33
commit 6fb46573da
9 changed files with 270 additions and 19 deletions

View file

@ -69,7 +69,9 @@ rules live in `.agents/rules/*.md`.
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob; (procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
`Tree.build` appends into shared trunk + foliage meshes), `Boulder` `Tree.build` appends into shared trunk + foliage meshes), `Boulder`
(procedural low-poly rock: a squashed, jittered, part-buried sphere; (procedural low-poly rock: a squashed, jittered, part-buried sphere;
`Boulder.build` appends into a shared mesh). `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
samples a 2x2 color-atlas texture, drawn double-sided).
- `app/` — browser glue. - `app/` — browser glue.
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes - `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes
@ -77,12 +79,13 @@ rules live in `.agents/rules/*.md`.
- `level.ts` — builds the playground: a flat stone-floored room (three thick - `level.ts` — builds the playground: a flat stone-floored room (three thick
walls via `slab`, north side open) always drawn, in the center of a big grassy walls via `slab`, north side open) always drawn, in the center of a big grassy
`Terrain` world (~20x across). Props are placed first (`placeTrees` / `Terrain` world (~20x across). Props are placed first (`placeTrees` /
`placeBoulders` → instance lists + colliders; `TREE_/BOULDER_COUNT`/`_SEED`/ `placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
`_REACH`), then `buildChunks` bakes terrain + props into a `CHUNK_GRID` x `TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`), then `buildChunks` bakes
`CHUNK_GRID` grid of `Chunk`s (each = per-texture meshes grass/bark/leaf/needle/ terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each =
rock + a tight AABB) that `main` frustum-culls. `Aabb` colliders (walls, crate, per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that
grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, `main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it (drawn double-sided). `Aabb` colliders (walls, crate, grown trunks, big
boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
stays clean over the terrain skirt that laps under the room edges. Room surfaces stays clean over the terrain skirt that laps under the room edges. Room surfaces
are single flat quads -- no subdivision needed since texturing is are single flat quads -- no subdivision needed since texturing is
perspective-correct. perspective-correct.
@ -94,9 +97,10 @@ rules live in `.agents/rules/*.md`.
`#fps` meter div (styled inline). `#fps` meter div (styled inline).
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/wall/crate/npc` PNGs - `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc`
(`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = tree PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` =
trunk/oak/spruce, `rock` = boulders). Swap for real art anytime; tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas).
Swap for real art anytime;
filenames are the contract. filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot. - `server/` — Bun server stub. `shared/` — isomorphic slot.
@ -106,7 +110,8 @@ rules live in `.agents/rules/*.md`.
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`, `Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock, then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
bark, leaf, needle (backface-culled) → `Sprite.billboard(npc)` (double-sided) → bark, leaf, needle (backface-culled) + flowers (double-sided) →
`Sprite.billboard(npc)` (double-sided) →
`Framebuffer.quantize``present` (integer-scale, letterboxed blit; `Framebuffer.quantize``present` (integer-scale, letterboxed blit;
`imageSmoothingEnabled` follows `upscaleFilter`). `imageSmoothingEnabled` follows `upscaleFilter`).
@ -190,7 +195,9 @@ canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble.
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
never cracks) into one shared rock mesh, sunk partway into the ground. never cracks) into one shared rock mesh, sunk partway into the ground.
`scatterBoulders` sizes them small→big (biased small) and drops colliders on the `scatterBoulders` sizes them small→big (biased small) and drops colliders on the
big ones. big ones. `Bush` (leaf-blob clusters) and `Flower` (stem + colored bloom, atlas
UVs) are the same again — ground detail scattered near the play area, no
colliders; add a new prop type by cloning the pattern (generator + scatter).
## Controls ## Controls

View file

@ -2,6 +2,7 @@ import type { Texture } from "../engine/render/Texture"
import barkUrl from "../assets/bark.png" import barkUrl from "../assets/bark.png"
import crateUrl from "../assets/crate.png" import crateUrl from "../assets/crate.png"
import floorUrl from "../assets/floor.png" import floorUrl from "../assets/floor.png"
import flowerUrl from "../assets/flower.png"
import grassUrl from "../assets/grass.png" import grassUrl from "../assets/grass.png"
import leafUrl from "../assets/leaf.png" import leafUrl from "../assets/leaf.png"
import needleUrl from "../assets/needle.png" import needleUrl from "../assets/needle.png"
@ -16,6 +17,7 @@ export type Textures = {
leaf: Texture leaf: Texture
needle: Texture needle: Texture
rock: Texture rock: Texture
flower: Texture
wall: Texture wall: Texture
crate: Texture crate: Texture
npc: Texture npc: Texture
@ -23,18 +25,19 @@ export type Textures = {
/** Load every game texture up front. Call once before starting the loop. */ /** Load every game texture up front. Call once before starting the loop. */
export async function loadTextures(): Promise<Textures> { export async function loadTextures(): Promise<Textures> {
const [floor, grass, bark, leaf, needle, rock, wall, crate, npc] = await Promise.all([ const [floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc] = await Promise.all([
loadTexture(floorUrl), loadTexture(floorUrl),
loadTexture(grassUrl), loadTexture(grassUrl),
loadTexture(barkUrl), loadTexture(barkUrl),
loadTexture(leafUrl), loadTexture(leafUrl),
loadTexture(needleUrl), loadTexture(needleUrl),
loadTexture(rockUrl), loadTexture(rockUrl),
loadTexture(flowerUrl),
loadTexture(wallUrl), loadTexture(wallUrl),
loadTexture(crateUrl), loadTexture(crateUrl),
loadTexture(npcUrl), loadTexture(npcUrl),
]) ])
return { floor, grass, bark, leaf, needle, rock, wall, crate, npc } return { floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc }
} }
function loadTexture(url: string): Promise<Texture> { function loadTexture(url: string): Promise<Texture> {

View file

@ -2,6 +2,8 @@ import { Color } from "../engine/render/Color"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { STRIDE, type Mesh } from "../engine/scene/Mesh" import { STRIDE, type Mesh } from "../engine/scene/Mesh"
import { Boulder } from "../engine/scene/Boulder" import { Boulder } from "../engine/scene/Boulder"
import { Bush } from "../engine/scene/Bush"
import { Flower, type FlowerColor } from "../engine/scene/Flower"
import { Terrain } from "../engine/scene/Terrain" import { Terrain } from "../engine/scene/Terrain"
import { Tree } from "../engine/scene/Tree" import { Tree } from "../engine/scene/Tree"
@ -35,6 +37,8 @@ export type Chunk = {
leaf: Mesh leaf: Mesh
needle: Mesh needle: Mesh
rock: Mesh rock: Mesh
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
flowers: Mesh
} }
/** The playground: a flat-floored room dropped into the center of a big open /** The playground: a flat-floored room dropped into the center of a big open
@ -91,6 +95,16 @@ const BOULDER_COUNT = 70
const BOULDER_SEED = 0xB0142 const BOULDER_SEED = 0xB0142
const BOULDER_REACH = 0.7 const BOULDER_REACH = 0.7
/** Bushes + flowers: ground detail, kept to the nearer band since they're small
* and fog/size hides them far out. Flowers roll white/red/yellow. */
const BUSH_COUNT = 140
const BUSH_SEED = 0xB554
const BUSH_REACH = 0.35
const FLOWER_COUNT = 340
const FLOWER_SEED = 0xF10E
const FLOWER_REACH = 0.3
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x /** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter * `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
* (less drawn off-screen) but cost more per-cell tests + bounds; this is the * (less drawn off-screen) but cost more per-cell tests + bounds; this is the
@ -175,15 +189,18 @@ export function buildLevel(): Level {
// frustum-cullable spatial chunks. // frustum-cullable spatial chunks.
const trees = placeTrees(colliders) const trees = placeTrees(colliders)
const boulders = placeBoulders(colliders) const boulders = placeBoulders(colliders)
const chunks = buildChunks(trees, boulders) const bushes = placeBushes()
const flowers = placeFlowers()
const chunks = buildChunks(trees, boulders, bushes, flowers)
return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky } return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky }
} }
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial /** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
* chunks. Each prop lands in the cell holding its base; the cell's bounds are * chunks. Each prop lands in the cell holding its base; the cell's bounds are
* grown to the real geometry so overhanging canopies never get culled early. */ * grown to the real geometry so overhanging canopies never get culled early.
function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] { * Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
const cell = (TERRAIN.outer * 2) / CHUNK_GRID const cell = (TERRAIN.outer * 2) / CHUNK_GRID
const chunks: Chunk[] = [] const chunks: Chunk[] = []
for (let ci = 0; ci < CHUNK_GRID; ci++) { for (let ci = 0; ci < CHUNK_GRID; ci++) {
@ -197,6 +214,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
const leaf = mesh() const leaf = mesh()
const needle = mesh() const needle = mesh()
const rock = mesh() const rock = mesh()
const flowerMesh = mesh()
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) { for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) { if (inCell(tree.position, x0, z0, x1, z1)) {
@ -208,11 +226,21 @@ function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
Boulder.build(boulder, rock) Boulder.build(boulder, rock)
} }
} }
const b = bounds([grass, bark, leaf, needle, rock]) for (const bush of bushes) {
if (inCell(bush.position, x0, z0, x1, z1)) {
Bush.build(bush, leaf)
}
}
for (const flower of flowers) {
if (inCell(flower.position, x0, z0, x1, z1)) {
Flower.build(flower, flowerMesh)
}
}
const b = bounds([grass, bark, leaf, needle, rock, flowerMesh])
if (b === null) { if (b === null) {
continue continue
} }
chunks.push({ ...b, grass, bark, leaf, needle, rock }) chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh })
} }
} }
return chunks return chunks
@ -302,6 +330,43 @@ function placeBoulders(colliders: Aabb[]): Boulder[] {
return boulders return boulders
} }
/** Scatter bushes on the grass near the play area (no colliders -- walk through). */
function placeBushes(): Bush[] {
const rand = mulberry(BUSH_SEED)
const maxDist = TERRAIN.outer * BUSH_REACH
const bushes: Bush[] = []
for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
continue
}
bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 })
}
return bushes
}
/** Scatter small flowers on the grass near the play area, colors rolled. */
function placeFlowers(): Flower[] {
const rand = mulberry(FLOWER_SEED)
const maxDist = TERRAIN.outer * FLOWER_REACH
const flowers: Flower[] = []
for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) {
continue
}
const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0]
flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 })
}
return flowers
}
/** Deterministic 0..1 generator (mulberry32) for tree placement. */ /** Deterministic 0..1 generator (mulberry32) for tree placement. */
function mulberry(seed: number): () => number { function mulberry(seed: number): () => number {
let a = seed >>> 0 let a = seed >>> 0

View file

@ -128,6 +128,7 @@ async function main(): Promise<void> {
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true) Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true)
Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true) Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true)
Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true) Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true)
Rasterizer.draw(fb, c.flowers, textures.flower, viewProj, config)
} }
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config) Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
Framebuffer.quantize(fb, config) Framebuffer.quantize(fb, config)

BIN
assets/flower.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -94,6 +94,7 @@ export namespace RenderConfig {
colorDepth: 8, colorDepth: 8,
dither: 0, dither: 0,
vertexSnap: 0, vertexSnap: 0,
perspectiveCorrect: 1,
textureFilter: "linear", textureFilter: "linear",
lighting: "flat", lighting: "flat",
fog: null, fog: null,

76
engine/scene/Bush.ts Normal file
View file

@ -0,0 +1,76 @@
import type { Vec3 } from "../math/Vec3"
import { STRIDE, type Mesh } from "./Mesh"
const TAU = Math.PI * 2
/** A low shrub: a tight cluster of small leafy blobs sitting on the ground.
* Textured with the same leaf sheet as oak canopies, so it batches into the
* chunk's foliage mesh. `size` is the overall spread; `seed` the per-bush wobble. */
export type Bush = {
position: Vec3
size: number
seed: number
}
/** Low-poly bush geometry, same faceted flat-shaded style as the trees. A few
* overlapping jittered spheres read as a rounded shrub; blobs are closed and
* wound outward, so backface culling is safe. `build` appends into a shared
* (leaf-textured) mesh. */
export namespace Bush {
export function build(bush: Bush, mesh: Mesh): void {
const rand = rng(bush.seed)
// A handful of smaller overlapping lumps reads as a soft shrub; one big
// sphere reads as a boulder.
const blobs = 3 + Math.floor(rand() * 3)
const r = bush.size * 0.42
for (let i = 0; i < blobs; i++) {
const angle = rand() * TAU
const dist = i === 0 ? 0 : bush.size * 0.5 * rand()
const cx = bush.position.x + Math.cos(angle) * dist
const cz = bush.position.z + Math.sin(angle) * dist
const cy = bush.position.y + r * (0.5 + rand() * 0.4)
blob(mesh, cx, cy, cz, r * (0.55 + rand() * 0.3), rand)
}
}
/** A small lumpy low-poly sphere, wound outward (matches the oak canopy blob). */
function blob(mesh: Mesh, cx: number, cy: number, cz: number, radius: number, rand: () => number): void {
const seg = 6
const rings = 4
const start = mesh.verts.length / STRIDE
for (let r = 0; r <= rings; r++) {
const phi = (r / rings) * Math.PI
const cyv = Math.cos(phi)
const crv = Math.sin(phi)
const scale = radius * (0.9 + rand() * 0.18)
for (let s = 0; s <= seg; s++) {
const theta = (s / seg) * TAU
mesh.verts.push(
cx + crv * Math.cos(theta) * scale,
cy + cyv * scale,
cz + crv * Math.sin(theta) * scale,
(s / seg) * 2,
(r / rings) * 2,
)
}
}
const row = seg + 1
for (let r = 0; r < rings; r++) {
for (let s = 0; s < seg; s++) {
const p = start + r * row + s
mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
}
}
}
/** Deterministic 0..1 generator (mulberry32) seeded per bush. */
function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
}

79
engine/scene/Flower.ts Normal file
View file

@ -0,0 +1,79 @@
import type { Vec3 } from "../math/Vec3"
import { Mesh } from "./Mesh"
const TAU = Math.PI * 2
/** Flower bloom color, indexing a region of the `flower` texture atlas. */
export type FlowerColor = "white" | "red" | "yellow"
/** A single small flower: a thin crossed-quad stem plus a shallow fan of petals.
* Tiny, so it is drawn double-sided (no backface cull) and carries no collider.
* `size` is roughly its height; `seed` jitters the petals. */
export type Flower = {
position: Vec3
color: FlowerColor
size: number
seed: number
}
/**
* Low-poly flower geometry. The `flower` texture is a 2x2 color atlas -- green
* (stem) plus white / red / yellow blooms -- and every vertex samples the flat
* center of one region, so a flower is solid-colored with no per-flower texture
* or draw call. `build` appends into one shared flower mesh.
*/
export namespace Flower {
/** uv center of each bloom color's atlas region (tile units). */
const BLOOM_UV: Record<FlowerColor, [number, number]> = {
white: [0.75, 0.25],
red: [0.25, 0.75],
yellow: [0.75, 0.75],
}
/** uv center of the green stem region. */
const STEM_U = 0.25
const STEM_V = 0.25
export function build(flower: Flower, mesh: Mesh): void {
const rand = rng(flower.seed)
const p = flower.position
const height = flower.size * (0.8 + rand() * 0.4)
const bloomY = p.y + height
const w = flower.size * 0.04
// Stem: two thin crossed quads so it reads from any angle.
stem(mesh, p.x, p.y, p.z, bloomY, w, 0)
stem(mesh, p.x, p.y, p.z, bloomY, 0, w)
// Bloom: a shallow fan of petals, center raised a touch so it domes.
const [bu, bv] = BLOOM_UV[flower.color]
const rad = flower.size * 0.38
const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv)
const ring = center + 1
const petals = 5
for (let i = 0; i <= petals; i++) {
const angle = (i / petals) * TAU + rand() * 0.4
Mesh.push(mesh, p.x + Math.cos(angle) * rad, bloomY, p.z + Math.sin(angle) * rad, bu, bv)
}
for (let i = 0; i < petals; i++) {
mesh.indices.push(center, ring + i, ring + i + 1)
}
}
/** A thin vertical quad from the ground to `y1`, width along (dx, dz). */
function stem(mesh: Mesh, x: number, y0: number, z: number, y1: number, dx: number, dz: number): void {
const a = Mesh.push(mesh, x - dx, y0, z - dz, STEM_U, STEM_V)
const b = Mesh.push(mesh, x + dx, y0, z + dz, STEM_U, STEM_V)
const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V)
const d = Mesh.push(mesh, x - dx, y1, z - dz, STEM_U, STEM_V)
mesh.indices.push(a, b, c, a, c, d)
}
/** Deterministic 0..1 generator (mulberry32) seeded per flower. */
function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
}

View file

@ -151,6 +151,24 @@ const rock: Shade = (x, y) => {
return [g, g + 3, g + 8, 255] return [g, g + 3, g + 8, 255]
} }
// Flower color atlas (2x2): green stem (top-left), white / red / yellow blooms.
// Geometry samples each region's flat center, so a flower is one solid color.
const flower: Shade = (x, y) => {
const n = noise(x, y) * 8
const left = x < 32
const top = y < 32
if (top && left) {
return [66 + n, 128 + n, 58 + n, 255]
}
if (top) {
return [238 + n, 240 + n, 245 + n, 255]
}
if (left) {
return [202 + n, 52 + n, 58 + n, 255]
}
return [240 + n, 208 + n, 72 + n, 255]
}
const wall: Shade = (x, y) => { const wall: Shade = (x, y) => {
const row = Math.floor(y / 16) const row = Math.floor(y / 16)
const bx = (x + (row % 2) * 16) % 32 const bx = (x + (row % 2) * 16) % 32
@ -209,6 +227,7 @@ const assets: Array<[string, number, number, Shade]> = [
["leaf", 64, 64, leaf], ["leaf", 64, 64, leaf],
["needle", 64, 64, needle], ["needle", 64, 64, needle],
["rock", 64, 64, rock], ["rock", 64, 64, rock],
["flower", 64, 64, flower],
["wall", 64, 64, wall], ["wall", 64, 64, wall],
["crate", 64, 64, crate], ["crate", 64, 64, crate],
["npc", 48, 64, npc], ["npc", 48, 64, npc],