explodorama / files / src / render / decor.ts
decor.ts
5f066960 2026-10-26 Pavel Safronov | 1 | import { drawTile, rgb, vec2, type Color } from 'littlejsengine' |
| 2 | import { stream } from '../sim/rng' | |
| 3 | import type { GameState, Vec } from '../sim/types' | |
| 4 | import { toWorld } from './board' | |
| 5 | import { SPLATS, sprite } from './sprites' | |
| 6 | ||
| 7 | /** | |
| 8 | * A worn dungeon (user request, 2026-10-25): stains on the floor and the walls from Kenney's Splat | |
| 9 | * Pack — damp puddles, moss, dirt, dust, soot, rust — each level its own mix and amount, from the | |
| 10 | * seed and level, so a level always looks the same and levels differ. Decoration only: not in the | |
| 11 | * sim, drawn faint under everything, so it never reads as a hazard (oil is dark and glossy, PLAN.md | |
| 12 | * §19.3). Heavier along the walls and in corners, as dirt gathers there. | |
| 13 | */ | |
| 14 | interface Stain { | |
| 15 | at: Vec | |
| 16 | /** Offset within the tile, size and turn, in tiles and radians. */ | |
| 17 | dx: number | |
| 18 | dy: number | |
| 19 | size: number | |
| 20 | angle: number | |
| 21 | splat: (typeof SPLATS)[number] | |
| 22 | color: Color | |
| 23 | /** On a wall (rust, moss on stone) rather than the floor. */ | |
| 24 | wall: boolean | |
| 25 | } | |
| 26 | ||
| 27 | /** The looks a level can mix: each a few colours (faint: alpha about 0.15–0.25). */ | |
| 28 | const THEMES: Record<string, Color[]> = { | |
| 29 | damp: [rgb(0.32, 0.42, 0.58, 0.22), rgb(0.4, 0.5, 0.62, 0.18)], | |
| 30 | mossy: [rgb(0.3, 0.52, 0.25, 0.2), rgb(0.42, 0.55, 0.3, 0.16)], | |
| 31 | dirty: [rgb(0.42, 0.32, 0.2, 0.2), rgb(0.55, 0.45, 0.3, 0.16)], | |
| 32 | dusty: [rgb(0.72, 0.64, 0.48, 0.2), rgb(0.62, 0.58, 0.5, 0.16)], | |
| 33 | scorched: [rgb(0.14, 0.13, 0.13, 0.2), rgb(0.25, 0.22, 0.2, 0.16)], | |
| 34 | rusty: [rgb(0.62, 0.32, 0.14, 0.22), rgb(0.5, 0.28, 0.16, 0.18)], | |
| 35 | } | |
| 36 | const THEME_NAMES = Object.keys(THEMES) | |
| 37 | ||
| 38 | /** Off for now (user, 2026-10-25: the splats look too alike, and not the look wanted). */ | |
| 39 | const ENABLED = false | |
| 40 | ||
| 41 | let cached: { key: string; stains: Stain[] } | null = null | |
| 42 | ||
| 43 | /** The level's stains, worked out once per level (and board size). */ | |
| 44 | export function stainsOf(s: GameState): Stain[] { | |
| 45 | const key = `${s.seed}:${s.level}:${s.width}x${s.height}` | |
| 46 | if (cached?.key === key) return cached.stains | |
| 47 | const rng = stream(s.seed, 'decor', s.level) | |
| 48 | // Two looks per level, and how worn it is: from lightly to heavily. | |
| 49 | const first = THEME_NAMES[rng.int(THEME_NAMES.length)]! | |
| 50 | const second = THEME_NAMES[rng.int(THEME_NAMES.length)]! | |
| 51 | const colors = [...THEMES[first]!, ...THEMES[second]!] | |
| 52 | const wear = 0.12 + rng.next() * 0.25 | |
| 53 | const wall = (x: number, y: number) => x < 0 || y < 0 || x >= s.width || y >= s.height || s.cells[y * s.width + x] === 'wall' | |
| 54 | const stains: Stain[] = [] | |
| 55 | for (let y = 0; y < s.height; y++) { | |
| 56 | for (let x = 0; x < s.width; x++) { | |
| 57 | const onWall = wall(x, y) | |
| 58 | // Next to walls more stains gather; walls themselves get a few, smaller. | |
| 59 | const walls = [wall(x - 1, y), wall(x + 1, y), wall(x, y - 1), wall(x, y + 1)].filter(Boolean).length | |
| 60 | const chance = onWall ? wear * 0.35 : wear * (1 + walls * 0.6) | |
| 61 | if (rng.next() >= chance) continue | |
| 62 | stains.push({ | |
| 63 | at: { x, y }, | |
| 64 | dx: (rng.next() - 0.5) * 0.5, | |
| 65 | dy: (rng.next() - 0.5) * 0.5, | |
| 66 | size: onWall ? 0.35 + rng.next() * 0.35 : 0.5 + rng.next() * 0.8, | |
| 67 | angle: rng.next() * Math.PI * 2, | |
| 68 | splat: SPLATS[rng.int(SPLATS.length)]!, | |
| 69 | color: colors[rng.int(colors.length)]!, | |
| 70 | wall: onWall, | |
| 71 | }) | |
| 72 | } | |
| 73 | } | |
| 74 | cached = { key, stains } | |
| 75 | return stains | |
| 76 | } | |
| 77 | ||
| 78 | /** The floor's stains: after the floor, before the walls and everything on the board. */ | |
| 79 | export function drawFloorStains(s: GameState): void { | |
| 80 | if (!ENABLED) return | |
| 81 | for (const st of stainsOf(s)) if (!st.wall) drawStain(s, st) | |
| 82 | } | |
| 83 | ||
| 84 | /** The walls' stains: over the walls, before anything that moves. */ | |
| 85 | export function drawWallStains(s: GameState): void { | |
| 86 | if (!ENABLED) return | |
| 87 | for (const st of stainsOf(s)) if (st.wall) drawStain(s, st) | |
| 88 | } | |
| 89 | ||
| 90 | function drawStain(s: GameState, st: Stain): void { | |
| 91 | drawTile(toWorld(s, st.at).add(vec2(st.dx, st.dy)), vec2(st.size), sprite(st.splat), st.color, st.angle) | |
| 92 | } |