Arcane Tycoon — Heroes & Magic theme park tycoon game
Complete browser game inspired by OpenRCT2 with fantasy twist: - Custom roller coaster designer with physics-based ratings + on-ride POV - 10 animated rides, 7 shops, 16 scenery items, path network & guest AI - Heroes guild vs monster invasions (5 classes, XP/gear/bosses) - Magic spell system (8 spells), research tree, economy/marketing/loans - Day-night cycle, weather, park rating, awards, 4 scenarios - Save/load slots + autosave, procedural WebAudio SFX/music - Isometric canvas renderer, minimap, diagnostics overlay - Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
// ============ map.js — tile world, terrain gen, object placement ============
|
||||
import { makeRng } from '../core/util.js';
|
||||
|
||||
export const T_GRASS = 0, T_SAND = 1, T_ROCK = 2, T_WATER = 3;
|
||||
|
||||
export class GameMap {
|
||||
constructor(size = 52) {
|
||||
this.size = size;
|
||||
this.terrain = new Uint8Array(size * size); // terrain type
|
||||
this.pathType = new Uint8Array(size * size); // 0 none, 1 pavement, 2 cobble
|
||||
this.litter = new Float32Array(size * size); // 0..1 litter amount
|
||||
this.vomit = new Float32Array(size * size);
|
||||
// occupancy: null or {kind:'ride'|'shop'|'scenery'|'guild', id, ox, oy} where id indexes state arrays
|
||||
this.objects = new Array(size * size).fill(null);
|
||||
// coaster track occupies cells too: {kind:'track', rideId, pieceIndex}
|
||||
this.beauty = new Float32Array(size * size); // accumulated scenery beauty for rating
|
||||
}
|
||||
|
||||
idx(x, y) { return y * this.size + x; }
|
||||
inBounds(x, y) { return x >= 0 && y >= 0 && x < this.size && y < this.size; }
|
||||
terrainAt(x, y) { return this.inBounds(x, y) ? this.terrain[this.idx(x, y)] : -1; }
|
||||
isPath(x, y) { return this.inBounds(x, y) && this.pathType[this.idx(x, y)] > 0; }
|
||||
isWalkable(x, y) {
|
||||
if (!this.inBounds(x, y)) return false;
|
||||
const i = this.idx(x, y);
|
||||
if (this.pathType[i] > 0) return true;
|
||||
return false;
|
||||
}
|
||||
isBuildable(x, y) {
|
||||
if (!this.inBounds(x, y)) return false;
|
||||
const i = this.idx(x, y);
|
||||
if (this.terrain[i] === T_WATER) return false;
|
||||
return true;
|
||||
}
|
||||
occupied(x, y) {
|
||||
if (!this.inBounds(x, y)) return true;
|
||||
return this.objects[this.idx(x, y)] !== null;
|
||||
}
|
||||
|
||||
setObject(x, y, obj) { this.objects[this.idx(x, y)] = obj; }
|
||||
getObject(x, y) { return this.inBounds(x, y) ? this.objects[this.idx(x, y)] : { kind: 'out' }; }
|
||||
clearObject(x, y) { this.objects[this.idx(x, y)] = null; }
|
||||
|
||||
/** Generate terrain from scenario gen spec */
|
||||
generate(gen, seed) {
|
||||
const rng = makeRng(seed);
|
||||
const n = this.size;
|
||||
// value-noise-ish rolling grass with rock patches
|
||||
const rockNoise = makeRng(seed ^ 0x9e3779b9);
|
||||
const lakeCount = gen.lake ?? 1;
|
||||
const lakes = [];
|
||||
for (let i = 0; i < lakeCount; i++) {
|
||||
lakes.push({ x: 8 + rng() * (n - 20), y: 8 + rng() * (n - 20), r: 4 + rng() * 4 });
|
||||
}
|
||||
// entrance at south edge center
|
||||
this.entranceX = Math.floor(n / 2);
|
||||
this.entranceY = n - 3;
|
||||
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
const i = this.idx(x, y);
|
||||
let t = T_GRASS;
|
||||
// rocky regions
|
||||
if (gen.rocks) {
|
||||
const v = rockNoise();
|
||||
if (v < 0.10 + (gen.rocky ? 0.14 : 0)) t = T_ROCK;
|
||||
} else if (rng() < 0.03) t = T_ROCK;
|
||||
// sand near water
|
||||
for (const L of lakes) {
|
||||
const d = Math.hypot(x - L.x, y - L.y);
|
||||
if (d < L.r) t = T_WATER;
|
||||
else if (d < L.r + 1.6 && gen.sand) t = T_SAND;
|
||||
}
|
||||
this.terrain[i] = t;
|
||||
}
|
||||
}
|
||||
// scatter rocks as scenery later by renderer using deterministic rng
|
||||
this.scatterSeed = seed;
|
||||
}
|
||||
|
||||
/** find a free rect area of w×h buildable & unoccupied tiles near cx,cy */
|
||||
findFreeRect(w, h, cx, cy, maxR = 40) {
|
||||
for (let r = 0; r < maxR; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = cx + dx, y = cy + dy;
|
||||
let ok = true;
|
||||
for (let yy = 0; yy < h && ok; yy++) {
|
||||
for (let xx = 0; xx < w && ok; xx++) {
|
||||
if (!this.isBuildable(x + xx, y + yy) || this.occupied(x + xx, y + yy)) ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
countLitter() {
|
||||
let c = 0;
|
||||
for (let i = 0; i < this.litter.length; i++) if (this.litter[i] > 0.25) c++;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// ============ path.js — BFS pathfinding over path tiles ============
|
||||
const DIRS = [[1, 0], [0, 1], [-1, 0], [0, -1]];
|
||||
|
||||
/**
|
||||
* BFS from (sx,sy) to (tx,ty) over walkable path tiles.
|
||||
* Returns array of [x,y] steps (excluding start, including target) or null.
|
||||
*/
|
||||
export function findPath(map, sx, sy, tx, ty, maxNodes = 6000) {
|
||||
if (!map.isWalkable(sx, sy) || !map.isWalkable(tx, ty)) return null;
|
||||
if (sx === tx && sy === ty) return [];
|
||||
const size = map.size;
|
||||
const prev = new Int32Array(size * size).fill(-1);
|
||||
const visited = new Uint8Array(size * size);
|
||||
const startIdx = sy * size + sx;
|
||||
const queue = [startIdx];
|
||||
visited[startIdx] = 1;
|
||||
let head = 0, nodes = 0;
|
||||
const targetIdx = ty * size + tx;
|
||||
while (head < queue.length && nodes < maxNodes) {
|
||||
const cur = queue[head++];
|
||||
nodes++;
|
||||
if (cur === targetIdx) break;
|
||||
const cx = cur % size, cy = (cur / size) | 0;
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue;
|
||||
const ni = ny * size + nx;
|
||||
if (visited[ni] || !map.isWalkable(nx, ny)) continue;
|
||||
visited[ni] = 1;
|
||||
prev[ni] = cur;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
if (!visited[targetIdx]) return null;
|
||||
const out = [];
|
||||
let cur = targetIdx;
|
||||
while (cur !== startIdx) {
|
||||
out.push([cur % size, (cur / size) | 0]);
|
||||
cur = prev[cur];
|
||||
if (cur < 0) return null;
|
||||
}
|
||||
out.reverse();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** BFS flood to collect all reachable path tiles within radius r of (x,y) */
|
||||
export function reachableWithin(map, x, y, r, outSet) {
|
||||
const size = map.size;
|
||||
const seen = outSet || new Set();
|
||||
const startIdx = y * size + x;
|
||||
if (!map.isWalkable(x, y)) return seen;
|
||||
const q = [[x, y, 0]];
|
||||
seen.add(startIdx);
|
||||
let head = 0;
|
||||
while (head < q.length) {
|
||||
const [cx, cy, d] = q[head++];
|
||||
if (d >= r) continue;
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (!map.isWalkable(nx, ny)) continue;
|
||||
const ni = ny * size + nx;
|
||||
if (seen.has(ni)) continue;
|
||||
seen.add(ni);
|
||||
q.push([nx, ny, d + 1]);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Find nearest tile satisfying predicate via expanding ring search on paths */
|
||||
export function findNearestPathTile(map, x, y, pred, maxR = 30) {
|
||||
for (let r = 0; r <= maxR; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (map.isWalkable(nx, ny) && pred(nx, ny)) return [nx, ny];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Random reachable path tile within radius (for wandering) */
|
||||
export function randomNearbyPath(map, rng, x, y, minR = 3, maxR = 12) {
|
||||
const r = minR + Math.floor(rng() * (maxR - minR));
|
||||
const cands = [];
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const nx = Math.round(x + dx), ny = Math.round(y + dy);
|
||||
if (Math.abs(dx) + Math.abs(dy) > r || Math.abs(dx) + Math.abs(dy) < minR * 0.6) continue;
|
||||
if (map.isWalkable(nx, ny)) cands.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
if (!cands.length) {
|
||||
// fallback: any adjacent path
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const nx = Math.round(x + dx), ny = Math.round(y + dy);
|
||||
if (map.isWalkable(nx, ny)) return [nx, ny];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return cands[Math.floor(rng() * cands.length)];
|
||||
}
|
||||
|
||||
/** Snap a world position to the nearest walkable path tile (searching outward) */
|
||||
export function snapToPath(map, x, y, maxR = 4) {
|
||||
const rx = Math.round(x), ry = Math.round(y);
|
||||
if (map.isWalkable(rx, ry)) return [rx, ry];
|
||||
for (let r = 1; r <= maxR; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||||
if (map.isWalkable(rx + dx, ry + dy)) return [rx + dx, ry + dy];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [Math.min(map.size - 1, Math.max(0, rx)), Math.min(map.size - 1, Math.max(0, ry))];
|
||||
}
|
||||
Reference in New Issue
Block a user