/** * engine.js — Single-player grid world for the browser game. * Pure ES module, zero dependencies. */ // ─── Constants ─────────────────────────────────────────────────────────────── export const EMOJIS = { tree: '🌳', rock: '🪨', blank: '⬜', diamond: '💎', agent: '🤖', }; export const DIRECTIONS = { up: { dx: 0, dy: -1 }, down: { dx: 0, dy: 1 }, left: { dx: -1, dy: 0 }, right: { dx: 1, dy: 0 }, }; export const DEFAULTS = { WORLD_WIDTH: 600, WORLD_HEIGHT: 600, DIAMOND_COUNT: 500, VIEWPORT_RADIUS: 6, LOOP_DELAY_MS: 500, TERRAIN_SCALE: 0.1, TERRAIN_THRESHOLDS: [ { emoji: '🌳', threshold: 0.4 }, { emoji: '🪨', threshold: 0.5 }, ], }; // ─── Seedable PRNG (mulberry32) — used when a seed is passed; otherwise Math.random ── function mulberry32(a) { return function () { let t = (a += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ─── Perlin Noise ──────────────────────────────────────────────────────────── function makePerlin(rng = Math.random) { const p = Array.from({ length: 256 }, () => Math.floor(rng() * 256)); const perm = [...p, ...p]; const fade = t => t * t * t * (t * (t * 6 - 15) + 10); const lerp = (t, a, b) => a + t * (b - a); const grad = (h, x, y) => { const u = (h & 3) < 2 ? x : y, v = (h & 3) < 2 ? y : x; return (h & 1 ? -u : u) + (h & 2 ? -2 * v : 2 * v); }; return (x, y) => { const X = Math.floor(x) & 255, Y = Math.floor(y) & 255; x -= Math.floor(x); y -= Math.floor(y); const u = fade(x), v = fade(y); const A = perm[X] + Y, B = perm[X + 1] + Y; return lerp(v, lerp(u, grad(perm[A], x, y), grad(perm[B], x - 1, y)), lerp(u, grad(perm[A + 1], x, y - 1), grad(perm[B + 1], x - 1, y - 1)) ); }; } // ─── World Generation ──────────────────────────────────────────────────────── function generateWorld( width = DEFAULTS.WORLD_WIDTH, height = DEFAULTS.WORLD_HEIGHT, thresholds = DEFAULTS.TERRAIN_THRESHOLDS, scale = DEFAULTS.TERRAIN_SCALE, rng = Math.random, ) { const perlin = makePerlin(rng); const tiles = new Array(width * height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const n = (perlin(x * scale, y * scale) + 1) / 2; let tile = EMOJIS.blank; for (const { emoji, threshold } of thresholds) { if (n < threshold) { tile = emoji; break; } } tiles[y * width + x] = tile; } } return { width, height, tiles }; } function getTile(world, x, y) { if (x < 0 || y < 0 || x >= world.width || y >= world.height) return EMOJIS.rock; return world.tiles[y * world.width + x]; } function isPassable(world, x, y) { if (x < 0 || y < 0 || x >= world.width || y >= world.height) return false; return world.tiles[y * world.width + x] === EMOJIS.blank; } function findRandomEmpty(world, occupied = [], maxAttempts = 1000, rng = Math.random) { for (let i = 0; i < maxAttempts; i++) { const x = Math.floor(rng() * world.width); const y = Math.floor(rng() * world.height); if (isPassable(world, x, y) && !occupied.some(p => p.x === x && p.y === y)) { return { x, y }; } } throw new Error('Could not find empty cell'); } // ─── Viewport Rendering ────────────────────────────────────────────────────── function renderViewport(world, objects, viewerAgent, radius = DEFAULTS.VIEWPORT_RADIUS) { let out = ''; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const wx = viewerAgent.x + dx, wy = viewerAgent.y + dy; if (wx === viewerAgent.x && wy === viewerAgent.y) { out += viewerAgent.emoji; continue; } const obj = objects.find(o => o.x === wx && o.y === wy); if (obj) { out += obj.emoji; continue; } out += getTile(world, wx, wy); } out += '\n'; } return out; } // ─── SinglePlayerGame ──────────────────────────────────────────────────────── export class SinglePlayerGame { constructor(opts = {}) { const { width = DEFAULTS.WORLD_WIDTH, height = DEFAULTS.WORLD_HEIGHT, diamondCount = DEFAULTS.DIAMOND_COUNT, seed = null, // null → Math.random; integer → deterministic mulberry32 } = typeof opts === 'object' ? opts : { width: opts }; const rng = seed != null ? mulberry32(seed) : Math.random; this.world = generateWorld(width, height, undefined, undefined, rng); this.objects = []; this.agent = { x: 0, y: 0, emoji: EMOJIS.agent }; this.inventory = []; this.stats = { moves: 0, diamonds: 0, picks: 0 }; for (let i = 0; i < diamondCount; i++) { const pos = findRandomEmpty(this.world, this.objects, 1000, rng); this.objects.push({ x: pos.x, y: pos.y, emoji: EMOJIS.diamond }); } const agentPos = findRandomEmpty(this.world, this.objects, 1000, rng); this.agent.x = agentPos.x; this.agent.y = agentPos.y; } getViewport(radius = DEFAULTS.VIEWPORT_RADIUS) { return renderViewport(this.world, this.objects, this.agent, radius); } move(direction) { const d = DIRECTIONS[direction]; if (!d) throw new Error('Invalid direction'); const nx = this.agent.x + d.dx, ny = this.agent.y + d.dy; if (!isPassable(this.world, nx, ny)) throw new Error('Cannot move into obstacle'); // Disallow moving onto a pickable object — the agent must use `pick` instead. // The error string names the right tool so the model can self-correct next turn. const blocker = this.objects.find(o => o.x === nx && o.y === ny); if (blocker) { throw new Error(`Cannot move onto ${blocker.emoji} — use pick ${direction} instead`); } this.agent.x = nx; this.agent.y = ny; this.stats.moves++; return { x: nx, y: ny }; } pick(direction) { const d = DIRECTIONS[direction]; if (!d) throw new Error('Invalid direction'); const tx = this.agent.x + d.dx, ty = this.agent.y + d.dy; const idx = this.objects.findIndex(o => o.x === tx && o.y === ty); if (idx === -1) throw new Error('No object in that direction'); const obj = this.objects.splice(idx, 1)[0]; this.inventory.push(obj.emoji); this.stats.picks++; if (obj.emoji === EMOJIS.diamond) this.stats.diamonds++; return { pickedUp: obj.emoji }; } executeAction(action) { if (action.action === 'move') return this.move(action.detail); if (action.action === 'pick') return this.pick(action.detail); } }