2026-08-04 02:09:54 +02:00
|
|
|
import { deflateSync } from "node:zlib"
|
|
|
|
|
|
|
|
|
|
// Procedurally draw the placeholder textures and write them to /assets as PNGs.
|
|
|
|
|
// One-shot dev tool: `bun run assets`. Swap the output files for real art
|
|
|
|
|
// anytime; the filenames are the contract the game loads.
|
|
|
|
|
|
|
|
|
|
const CRC_TABLE = buildCrcTable()
|
|
|
|
|
|
|
|
|
|
function buildCrcTable(): Uint32Array {
|
|
|
|
|
const table = new Uint32Array(256)
|
|
|
|
|
for (let n = 0; n < 256; n++) {
|
|
|
|
|
let c = n
|
|
|
|
|
for (let k = 0; k < 8; k++) {
|
|
|
|
|
c = (c & 1) === 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1
|
|
|
|
|
}
|
|
|
|
|
table[n] = c >>> 0
|
|
|
|
|
}
|
|
|
|
|
return table
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function crc32(bytes: Uint8Array): number {
|
|
|
|
|
let c = 0xFFFFFFFF
|
|
|
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
|
|
|
c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8)
|
|
|
|
|
}
|
|
|
|
|
return (c ^ 0xFFFFFFFF) >>> 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chunk(type: string, data: Uint8Array): Uint8Array {
|
|
|
|
|
const body = new Uint8Array(4 + data.length)
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
body[i] = type.charCodeAt(i)
|
|
|
|
|
}
|
|
|
|
|
body.set(data, 4)
|
|
|
|
|
const out = new Uint8Array(8 + body.length)
|
|
|
|
|
const view = new DataView(out.buffer)
|
|
|
|
|
view.setUint32(0, data.length)
|
|
|
|
|
out.set(body, 4)
|
|
|
|
|
view.setUint32(4 + body.length, crc32(body))
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function concat(parts: Uint8Array[]): Uint8Array {
|
|
|
|
|
const total = parts.reduce((n, p) => n + p.length, 0)
|
|
|
|
|
const out = new Uint8Array(total)
|
|
|
|
|
let offset = 0
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
out.set(part, offset)
|
|
|
|
|
offset += part.length
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Encode 8-bit RGBA pixels as a PNG byte stream. */
|
|
|
|
|
function encodePng(width: number, height: number, rgba: Uint8Array): Uint8Array {
|
|
|
|
|
const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
|
|
|
|
const ihdr = new Uint8Array(13)
|
|
|
|
|
const view = new DataView(ihdr.buffer)
|
|
|
|
|
view.setUint32(0, width)
|
|
|
|
|
view.setUint32(4, height)
|
|
|
|
|
ihdr[8] = 8 // bit depth
|
|
|
|
|
ihdr[9] = 6 // color type: RGBA
|
|
|
|
|
const stride = width * 4
|
|
|
|
|
const raw = new Uint8Array((stride + 1) * height)
|
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
|
|
|
raw[y * (stride + 1)] = 0 // filter type: none
|
|
|
|
|
raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1)
|
|
|
|
|
}
|
|
|
|
|
const idat = new Uint8Array(deflateSync(raw))
|
|
|
|
|
return concat([signature, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Shade = (x: number, y: number) => [number, number, number, number]
|
|
|
|
|
|
|
|
|
|
function draw(width: number, height: number, shade: Shade): Uint8Array {
|
|
|
|
|
const rgba = new Uint8Array(width * height * 4)
|
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
|
|
|
for (let x = 0; x < width; x++) {
|
|
|
|
|
const [r, g, b, a] = shade(x, y)
|
|
|
|
|
const i = (y * width + x) * 4
|
|
|
|
|
rgba[i] = clamp(r)
|
|
|
|
|
rgba[i + 1] = clamp(g)
|
|
|
|
|
rgba[i + 2] = clamp(b)
|
|
|
|
|
rgba[i + 3] = clamp(a)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return rgba
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clamp(v: number): number {
|
|
|
|
|
return Math.max(0, Math.min(255, Math.round(v)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Deterministic value noise in roughly [-1, 1] from integer coords. */
|
|
|
|
|
function noise(x: number, y: number): number {
|
|
|
|
|
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
|
|
|
|
|
return (n - Math.floor(n)) * 2 - 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Textures ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const floor: Shade = (x, y) => {
|
|
|
|
|
const grout = x % 32 < 2 || y % 32 < 2
|
|
|
|
|
if (grout) {
|
|
|
|
|
return [58, 58, 70, 255]
|
|
|
|
|
}
|
|
|
|
|
const n = noise(x, y) * 14
|
|
|
|
|
return [132 + n, 130 + n, 120 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:51:07 +02:00
|
|
|
// Outdoor ground. Green, busy, with faux vertical blades and soft patches, kept
|
|
|
|
|
// free of strong low-frequency features so it tiles across the terrain without an
|
|
|
|
|
// obvious repeating grid.
|
|
|
|
|
const grass: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 18
|
|
|
|
|
const blade = noise(x, y * 3) * 8
|
|
|
|
|
const patch = noise(Math.floor(x / 8), Math.floor(y / 8)) * 14
|
|
|
|
|
return [56 + n * 0.7 + patch * 0.5, 116 + n + blade + patch, 50 + n * 0.5 + patch * 0.4, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 13:53:59 +02:00
|
|
|
// Tree bark: brown with vertical grain and the odd dark crack.
|
|
|
|
|
const bark: Shade = (x, y) => {
|
|
|
|
|
const grain = Math.sin(x * 0.6 + noise(0, y) * 2) * 10
|
|
|
|
|
const n = noise(x, y) * 14
|
|
|
|
|
const crack = noise(Math.floor(x / 6), y) < -0.45 ? -24 : 0
|
|
|
|
|
return [92 + n + grain + crack, 66 + n * 0.8 + grain * 0.6 + crack, 44 + n * 0.6 + crack, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 13:01:32 +02:00
|
|
|
// Silver birch bark: papery near-white with faint grey mottle and the dark
|
|
|
|
|
// horizontal lenticel dashes that make a birch read as a birch.
|
|
|
|
|
const birch: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 8
|
|
|
|
|
// Dark dashes: short random runs on a few rows, wrapping the trunk horizontally.
|
|
|
|
|
if (y % 8 < 3 && noise(Math.floor(x / 6), Math.floor(y / 8)) > 0.3) {
|
|
|
|
|
return [38 + n, 38 + n, 40 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
const smudge = noise(Math.floor(x / 10), Math.floor(y / 10)) < -0.4 ? -22 : 0
|
|
|
|
|
return [216 + n + smudge, 218 + n + smudge, 214 + n + smudge, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 13:53:59 +02:00
|
|
|
// Oak leaves: mid green, blotchy clumps with brighter highlights.
|
|
|
|
|
const leaf: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 26
|
|
|
|
|
const clump = noise(Math.floor(x / 5), Math.floor(y / 5)) * 22
|
|
|
|
|
const hi = noise(x, y) > 0.6 ? 22 : 0
|
|
|
|
|
return [58 + n * 0.5 + clump * 0.4, 120 + n + clump + hi, 42 + n * 0.4 + clump * 0.3, 255]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spruce needles: darker, cooler blue-green, finer streaky grain.
|
|
|
|
|
const needle: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 18
|
|
|
|
|
const streak = noise(x, y * 2) * 10
|
|
|
|
|
const clump = noise(Math.floor(x / 6), Math.floor(y / 6)) * 12
|
|
|
|
|
return [40 + n * 0.4 + clump * 0.3, 84 + n + streak + clump, 58 + n * 0.5 + clump * 0.4, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 14:02:12 +02:00
|
|
|
// Boulder rock: cool grey, mottled patches, the odd dark crack.
|
|
|
|
|
const rock: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 20
|
|
|
|
|
const patch = noise(Math.floor(x / 7), Math.floor(y / 7)) * 16
|
|
|
|
|
const crack = noise(Math.floor(x / 5) + 3, Math.floor(y / 9)) < -0.5 ? -28 : 0
|
|
|
|
|
const g = 118 + n + patch + crack
|
|
|
|
|
return [g, g + 3, g + 8, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 23:03:55 +02:00
|
|
|
// 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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 02:09:54 +02:00
|
|
|
const wall: Shade = (x, y) => {
|
|
|
|
|
const row = Math.floor(y / 16)
|
|
|
|
|
const bx = (x + (row % 2) * 16) % 32
|
|
|
|
|
const mortar = y % 16 < 2 || bx < 2
|
|
|
|
|
if (mortar) {
|
|
|
|
|
return [48, 44, 44, 255]
|
|
|
|
|
}
|
|
|
|
|
const n = noise(x, y) * 12
|
|
|
|
|
return [150 + n, 72 + n, 56 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const crate: Shade = (x, y) => {
|
|
|
|
|
const edge = x < 3 || x > 60 || y < 3 || y > 60
|
|
|
|
|
const band = (x > 29 && x < 35) || (y > 29 && y < 35)
|
|
|
|
|
const bolt = (x - 8) ** 2 + (y - 8) ** 2 < 6 || (x - 55) ** 2 + (y - 55) ** 2 < 6
|
|
|
|
|
const n = noise(x, y) * 10 + Math.sin(y * 0.5) * 8
|
|
|
|
|
if (bolt) {
|
|
|
|
|
return [60, 46, 26, 255]
|
|
|
|
|
}
|
|
|
|
|
if (edge || band) {
|
|
|
|
|
return [96, 62, 30, 255]
|
|
|
|
|
}
|
|
|
|
|
return [140 + n, 96 + n, 46 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 12:49:13 +02:00
|
|
|
// Frog skin atlas: green mottled skin with a lighter belly on the left (u<0.71),
|
|
|
|
|
// a dark eye tone on the right (mapped by the eye/haunch bumps' UVs).
|
|
|
|
|
const frog: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 10
|
|
|
|
|
if (x < 34) {
|
|
|
|
|
const belly = (y / 48) * 28
|
2026-08-07 13:37:30 +02:00
|
|
|
return [80 + n, 140 + belly + n, 80 + n, 255]
|
2026-08-07 12:49:13 +02:00
|
|
|
}
|
2026-08-07 13:37:30 +02:00
|
|
|
return [46 + n, 72 + n, 50 + n, 255]
|
2026-08-07 12:49:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bee atlas: yellow/black stripe bands down the left (u<0.54, banded by y so the
|
|
|
|
|
// body stripes across its length), a dark head tone in the middle, pale wings on
|
|
|
|
|
// the right.
|
|
|
|
|
const bee: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 8
|
|
|
|
|
if (x < 26) {
|
|
|
|
|
return Math.floor(y / 6) % 2 === 0 ? [250 + n, 206 + n, 42 + n, 255] : [30 + n, 26 + n, 14 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
if (x < 38) {
|
|
|
|
|
return [36 + n, 30 + n, 18 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
return [228 + n, 238 + n, 248 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 13:37:30 +02:00
|
|
|
// Robin atlas: warm brown back/head/tail (left), orange-red breast (mid), a spare
|
|
|
|
|
// pale band, and a dark eye/beak tone (right) -- picked per body part by its UVs.
|
|
|
|
|
const robin: Shade = (x, y) => {
|
|
|
|
|
const n = noise(x, y) * 10
|
|
|
|
|
if (x < 19) {
|
|
|
|
|
return [120 + n, 92 + n, 58 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
if (x < 34) {
|
|
|
|
|
return [214 + n, 98 + n, 48 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
if (x < 41) {
|
|
|
|
|
return [226 + n, 224 + n, 216 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
return [34 + n, 28 + n, 26 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 02:09:54 +02:00
|
|
|
// 48x64, transparent background, a simple round-topped figure with eyes.
|
|
|
|
|
const npc: Shade = (x, y) => {
|
|
|
|
|
const dx = (x - 24) / 17
|
|
|
|
|
const dy = (y - 34) / 24
|
|
|
|
|
const d = dx * dx + dy * dy
|
|
|
|
|
if (d > 1) {
|
|
|
|
|
return [0, 0, 0, 0]
|
|
|
|
|
}
|
|
|
|
|
const outline = d > 0.82
|
|
|
|
|
const eye = (x - 17) ** 2 + (y - 28) ** 2 < 9 || (x - 31) ** 2 + (y - 28) ** 2 < 9
|
|
|
|
|
const pupil = (x - 17) ** 2 + (y - 29) ** 2 < 2 || (x - 31) ** 2 + (y - 29) ** 2 < 2
|
|
|
|
|
if (pupil) {
|
|
|
|
|
return [20, 20, 30, 255]
|
|
|
|
|
}
|
|
|
|
|
if (eye) {
|
|
|
|
|
return [235, 235, 240, 255]
|
|
|
|
|
}
|
|
|
|
|
if (outline) {
|
|
|
|
|
return [30, 70, 50, 255]
|
|
|
|
|
}
|
|
|
|
|
const n = noise(x, y) * 12
|
|
|
|
|
return [70 + n, 165 + n, 110 + n, 255]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Write ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const assets: Array<[string, number, number, Shade]> = [
|
|
|
|
|
["floor", 64, 64, floor],
|
2026-08-04 12:51:07 +02:00
|
|
|
["grass", 64, 64, grass],
|
2026-08-04 13:53:59 +02:00
|
|
|
["bark", 64, 64, bark],
|
2026-08-07 13:01:32 +02:00
|
|
|
["birch", 64, 64, birch],
|
2026-08-04 13:53:59 +02:00
|
|
|
["leaf", 64, 64, leaf],
|
|
|
|
|
["needle", 64, 64, needle],
|
2026-08-04 14:02:12 +02:00
|
|
|
["rock", 64, 64, rock],
|
2026-08-04 23:03:55 +02:00
|
|
|
["flower", 64, 64, flower],
|
2026-08-04 02:09:54 +02:00
|
|
|
["wall", 64, 64, wall],
|
|
|
|
|
["crate", 64, 64, crate],
|
|
|
|
|
["npc", 48, 64, npc],
|
2026-08-07 12:49:13 +02:00
|
|
|
["frog", 48, 48, frog],
|
|
|
|
|
["bee", 48, 48, bee],
|
2026-08-07 13:37:30 +02:00
|
|
|
["robin", 48, 48, robin],
|
2026-08-04 02:09:54 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const [name, w, h, shade] of assets) {
|
|
|
|
|
const png = encodePng(w, h, draw(w, h, shade))
|
|
|
|
|
await Bun.write(`assets/${name}.png`, png)
|
|
|
|
|
console.log(`assets/${name}.png (${w}x${h}, ${png.length} bytes)`)
|
|
|
|
|
}
|