mini-world / engine.js
victor's picture
victor HF Staff
chore: boot Space paused during experiment phase
327ab12
Raw History Blame Contribute Delete
7.16 kB
/**
* 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);
}
}