Spaces:
Sleeping
Sleeping
Download engine.js from victor/mini-world: direct link, hf CLI and curl.
- Browser
- Download file 7.16 kB
-
https://huggingface.co/spaces/victor/mini-world/resolve/main/engine.js
- Command line
-
hf download hf://spaces/victor/mini-world/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/victor/mini-world/resolve/main/engine.js
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); | |
| } | |
| } | |