import { VIEW, type GameState } from "./engine"; import { OBSTACLES, SPRITES, type ObstacleKind, type SpriteName } from "./sprites"; export interface Palette { canvas: string; ink: string; muted: string; dark: boolean; } export interface Dims { w: number; h: number; } export const PLAYER_X = 56; const OBSTACLE_SPRITE: Record = { standing: "sheepStanding", sleeping: "sheepSleeping", pair: "sheepStanding", stack: "sheepStack" }; const PEBBLE_PERIOD = 960; const PEBBLES = (() => { let seed = 1337; const rand = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296; return Array.from({ length: 48 }, () => ({ d: rand() * PEBBLE_PERIOD, len: 2 + Math.floor(rand() * 7), dy: 4 + Math.floor(rand() * 7) })); })(); const paths = new Map(); const pathFor = (name: SpriteName) => { let path = paths.get(name); if (!path) { path = new Path2D(SPRITES[name].path); paths.set(name, path); } return path; }; const drawSprite = (ctx: CanvasRenderingContext2D, name: SpriteName, x: number, y: number) => { ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.fill(pathFor(name), "evenodd"); ctx.restore(); }; const drawOnGround = (ctx: CanvasRenderingContext2D, name: SpriteName, x: number) => drawSprite(ctx, name, x, VIEW.ground - SPRITES[name].h); const readVar = (styles: CSSStyleDeclaration, name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback; const luminance = (color: string): number | null => { const rgb = color.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/); if (rgb) return (0.2126 * +rgb[1] + 0.7152 * +rgb[2] + 0.0722 * +rgb[3]) / 255; if (!color.startsWith("#")) return null; let hex = color.slice(1); if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c); if (hex.length < 6) return null; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); if ([r, g, b].some(Number.isNaN)) return null; return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; }; export const readPalette = (el: HTMLElement): Palette => { const styles = getComputedStyle(el); const canvas = readVar(styles, "--color-background-canvas", "#fff"); return { canvas, ink: readVar(styles, "--color-foreground-default", "#000"), muted: readVar(styles, "--color-foreground-muted", "#666"), dark: (luminance(canvas) ?? 1) < 0.5 }; }; const drawGround = (ctx: CanvasRenderingContext2D, offset: number, width: number) => { ctx.fillRect(0, VIEW.ground, width, 1); for (const pebble of PEBBLES) { const x = (((pebble.d - offset) % PEBBLE_PERIOD) + PEBBLE_PERIOD) % PEBBLE_PERIOD; if (x > width) continue; ctx.fillRect(Math.round(x), VIEW.ground + pebble.dy, pebble.len, 1); } }; const drawObstacle = (ctx: CanvasRenderingContext2D, kind: ObstacleKind, x: number) => { const sprite = OBSTACLE_SPRITE[kind]; drawOnGround(ctx, sprite, x); if (kind === "pair") drawOnGround(ctx, sprite, x + OBSTACLES.pair.w - SPRITES[sprite].w); }; const playerSprite = (state: GameState): SpriteName => { if (state.phase === "dead") return "runnerDead"; if (!state.player.grounded) return "runnerJump"; return state.player.frame === 1 ? "runnerB" : "runnerA"; }; export const renderFrame = ( ctx: CanvasRenderingContext2D, state: GameState, palette: Palette, dims: Dims ) => { const inverted = state.night && !palette.dark; const sky = inverted ? palette.ink : palette.canvas; const ink = inverted ? palette.canvas : palette.ink; ctx.clearRect(0, 0, dims.w, dims.h); ctx.fillStyle = sky; ctx.fillRect(0, 0, dims.w, dims.h); ctx.fillStyle = ink; if (state.night) { for (const star of state.stars) drawSprite(ctx, "star", star.x, star.y); drawSprite(ctx, "moon", dims.w - 88, 24); } else { for (const cloud of state.clouds) drawSprite(ctx, "cloud", cloud.x, cloud.y); } drawGround(ctx, state.groundOffset, dims.w); for (const obstacle of state.obstacles) drawObstacle(ctx, obstacle.kind, obstacle.x); const sprite = playerSprite(state); drawSprite(ctx, sprite, PLAYER_X, state.player.y - SPRITES[sprite].h); };