Felladrin's picture
Add the simulator files
17fd0c2 verified
import { randomRange, SpatialGrid, TAU, distance } from "./utils.js";
const RESOURCE_FOOD = 0;
const RESOURCE_ENERGY = 1;
let nextResourceId = 1;
class Resource {
constructor(x, y, type, amount) {
this.id = nextResourceId++;
this.x = x;
this.y = y;
this.type = type;
this.amount = amount;
this.maxAmount = amount;
this.regenRate = type === RESOURCE_FOOD ? 0.3 : 0.15;
this.pulsePhase = Math.random() * TAU;
this.alive = true;
}
update(dt, regenMultiplier) {
if (this.amount < this.maxAmount) {
this.amount = Math.min(
this.maxAmount,
this.amount + this.regenRate * regenMultiplier * dt,
);
}
this.pulsePhase += dt * 2;
}
consume(amount) {
const taken = Math.min(this.amount, amount);
this.amount -= taken;
if (this.amount <= 0.01) {
this.alive = false;
}
return taken;
}
}
class WorldEvent {
constructor(type, x, y, radius, duration, intensity) {
this.type = type;
this.x = x;
this.y = y;
this.radius = radius;
this.duration = duration;
this.elapsed = 0;
this.intensity = intensity;
this.active = true;
}
update(dt) {
this.elapsed += dt;
if (this.elapsed >= this.duration) {
this.active = false;
}
}
get progress() {
return this.elapsed / this.duration;
}
get currentIntensity() {
const t = this.progress;
if (t < 0.1) return this.intensity * (t / 0.1);
if (t > 0.8) return this.intensity * ((1 - t) / 0.2);
return this.intensity;
}
}
export const EVENT_TYPES = {
bloom: { name: "Resource Bloom", color: "#00ffaa", icon: "B" },
drought: { name: "Drought", color: "#ff8844", icon: "D" },
storm: { name: "Energy Storm", color: "#ffee44", icon: "S" },
catastrophe: { name: "Catastrophe", color: "#ff3366", icon: "!" },
abundance: { name: "Abundance", color: "#00ff88", icon: "A" },
};
export class World {
constructor(width, height) {
this.width = width;
this.height = height;
this.resources = [];
this.events = [];
this.resourceGrid = new SpatialGrid(width, height, 80);
this.eventTimer = 0;
this.eventInterval = randomRange(15, 25);
this.totalTime = 0;
this.regenMultiplier = 1.0;
this.memory = {
totalBlooms: 0,
totalDroughts: 0,
totalCatastrophes: 0,
recentEvents: [],
};
this._initResources();
}
_initResources() {
const foodCount = Math.floor((this.width * this.height) / 8000);
for (let i = 0; i < foodCount; i++) {
this.resources.push(
new Resource(
randomRange(20, this.width - 20),
randomRange(20, this.height - 20),
RESOURCE_FOOD,
randomRange(15, 40),
),
);
}
const energyCount = Math.floor(foodCount * 0.3);
for (let i = 0; i < energyCount; i++) {
this.resources.push(
new Resource(
randomRange(20, this.width - 20),
randomRange(20, this.height - 20),
RESOURCE_ENERGY,
randomRange(25, 60),
),
);
}
}
update(dt) {
this.totalTime += dt;
for (let i = this.resources.length - 1; i >= 0; i--) {
const r = this.resources[i];
r.update(dt, this.regenMultiplier);
if (!r.alive) {
this.resources.splice(i, 1);
}
}
const targetFood = Math.floor((this.width * this.height) / 8000);
if (this.resources.length < targetFood * 0.6) {
const count = Math.min(3, targetFood - this.resources.length);
for (let i = 0; i < count; i++) {
this.resources.push(
new Resource(
randomRange(20, this.width - 20),
randomRange(20, this.height - 20),
Math.random() < 0.75 ? RESOURCE_FOOD : RESOURCE_ENERGY,
randomRange(15, 40),
),
);
}
}
this.resourceGrid.clear();
for (const r of this.resources) {
this.resourceGrid.insert(r, r.x, r.y);
}
for (let i = this.events.length - 1; i >= 0; i--) {
this.events[i].update(dt);
if (!this.events[i].active) {
this.events.splice(i, 1);
}
}
this.eventTimer += dt;
if (this.eventTimer >= this.eventInterval) {
this.eventTimer = 0;
this.eventInterval = randomRange(12, 22);
this._triggerRandomEvent();
}
this._updateMemoryEffects();
}
_triggerRandomEvent() {
const types = Object.keys(EVENT_TYPES);
const weights = [1, 1, 1, 0.3, 1];
if (this.memory.totalDroughts > this.memory.totalBlooms) {
weights[0] += 1;
}
if (this.memory.totalCatastrophes > 2) {
weights[3] *= 0.3;
weights[4] += 1;
}
const totalWeight = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * totalWeight;
let typeIdx = 0;
for (let i = 0; i < weights.length; i++) {
r -= weights[i];
if (r <= 0) {
typeIdx = i;
break;
}
}
const type = types[typeIdx];
const x = randomRange(100, this.width - 100);
const y = randomRange(100, this.height - 100);
const radius = randomRange(120, 250);
const duration = randomRange(8, 18);
const event = new WorldEvent(type, x, y, radius, duration, 1.0);
this.events.push(event);
this._applyEventEffects(event);
this.memory.recentEvents.push({ type, time: this.totalTime });
if (this.memory.recentEvents.length > 20) this.memory.recentEvents.shift();
if (type === "bloom" || type === "abundance") this.memory.totalBlooms++;
if (type === "drought") this.memory.totalDroughts++;
if (type === "catastrophe") this.memory.totalCatastrophes++;
return event;
}
_applyEventEffects(event) {
switch (event.type) {
case "bloom": {
for (let i = 0; i < 8; i++) {
const angle = Math.random() * TAU;
const dist = Math.random() * event.radius;
const rx = event.x + Math.cos(angle) * dist;
const ry = event.y + Math.sin(angle) * dist;
if (
rx > 10 &&
rx < this.width - 10 &&
ry > 10 &&
ry < this.height - 10
) {
this.resources.push(
new Resource(rx, ry, RESOURCE_FOOD, randomRange(20, 50)),
);
}
}
break;
}
case "drought": {
for (const r of this.resources) {
if (distance(r.x, r.y, event.x, event.y) < event.radius) {
r.amount *= 0.5;
}
}
break;
}
case "storm": {
for (let i = 0; i < 5; i++) {
const angle = Math.random() * TAU;
const dist = Math.random() * event.radius;
const rx = event.x + Math.cos(angle) * dist;
const ry = event.y + Math.sin(angle) * dist;
if (
rx > 10 &&
rx < this.width - 10 &&
ry > 10 &&
ry < this.height - 10
) {
this.resources.push(
new Resource(rx, ry, RESOURCE_ENERGY, randomRange(30, 70)),
);
}
}
break;
}
case "abundance": {
this.regenMultiplier = 2.0;
setTimeout(() => {
this.regenMultiplier = 1.0;
}, event.duration * 1000);
break;
}
}
}
_updateMemoryEffects() {
if (this.memory.totalCatastrophes > 3) {
this.regenMultiplier = Math.max(this.regenMultiplier, 1.2);
}
}
getResourcesNear(x, y, radius) {
return this.resourceGrid.query(x, y, radius);
}
getEventsAt(x, y) {
const affecting = [];
for (const e of this.events) {
if (distance(x, y, e.x, e.y) < e.radius) {
affecting.push(e);
}
}
return affecting;
}
getCatastropheDamage(x, y) {
let dmg = 0;
for (const e of this.events) {
if (e.type === "catastrophe") {
const d = distance(x, y, e.x, e.y);
if (d < e.radius) {
dmg += e.currentIntensity * (1 - d / e.radius) * 0.5;
}
}
}
return dmg;
}
render(ctx) {
if (
!this._bgCache ||
this._bgCache.width !== this.width ||
this._bgCache.height !== this.height
) {
this._bgCache = document.createElement("canvas");
this._bgCache.width = this.width;
this._bgCache.height = this.height;
const bgCtx = this._bgCache.getContext("2d");
const spacing = 35;
bgCtx.fillStyle = "rgba(80, 160, 220, 0.025)";
for (let x = spacing; x < this.width; x += spacing) {
for (let y = spacing; y < this.height; y += spacing) {
bgCtx.fillRect(x, y, 1.2, 1.2);
}
}
bgCtx.strokeStyle = "rgba(60, 120, 180, 0.015)";
bgCtx.lineWidth = 0.5;
for (let x = spacing; x < this.width; x += spacing * 4) {
bgCtx.beginPath();
bgCtx.moveTo(x, 0);
bgCtx.lineTo(x, this.height);
bgCtx.stroke();
}
for (let y = spacing; y < this.height; y += spacing * 4) {
bgCtx.beginPath();
bgCtx.moveTo(0, y);
bgCtx.lineTo(this.width, y);
bgCtx.stroke();
}
}
ctx.drawImage(this._bgCache, 0, 0);
for (const e of this.events) {
const info = EVENT_TYPES[e.type];
const intensity = e.currentIntensity;
const rgb = this._hexToRgb(info.color);
const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.radius);
grad.addColorStop(
0,
`rgba(${rgb.r},${rgb.g},${rgb.b},${0.08 * intensity})`,
);
grad.addColorStop(
0.7,
`rgba(${rgb.r},${rgb.g},${rgb.b},${0.03 * intensity})`,
);
grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius, 0, TAU);
ctx.fill();
const ringPulse = 0.5 + 0.5 * Math.sin(this.totalTime * 2);
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${0.15 * intensity * ringPulse})`;
ctx.lineWidth = 1.5;
ctx.setLineDash([8, 8]);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${0.3 * intensity})`;
ctx.font = "10px Courier New";
ctx.textAlign = "center";
ctx.fillText(info.name.toUpperCase(), e.x, e.y - e.radius + 15);
ctx.textAlign = "start";
}
if (!this._foodGlow) {
this._foodGlow = this._makeGlowSprite(0, 255, 170, 20);
this._energyGlow = this._makeGlowSprite(255, 238, 68, 20);
}
for (const r of this.resources) {
const pulse = 0.6 + 0.4 * Math.sin(r.pulsePhase);
const sizeRatio = r.amount / r.maxAmount;
const baseRadius = 3 + sizeRatio * 4;
const radius = baseRadius * (0.85 + 0.15 * pulse);
const sprite =
r.type === RESOURCE_FOOD ? this._foodGlow : this._energyGlow;
const gs = sprite.width;
ctx.globalAlpha = 0.4 + 0.4 * sizeRatio;
ctx.drawImage(sprite, r.x - gs / 2, r.y - gs / 2);
ctx.globalAlpha = 1;
if (r.type === RESOURCE_FOOD) {
ctx.fillStyle = `rgba(0,255,170,${0.5 + 0.4 * sizeRatio})`;
} else {
ctx.fillStyle = `rgba(255,238,68,${0.5 + 0.4 * sizeRatio})`;
}
ctx.beginPath();
ctx.arc(r.x, r.y, radius, 0, TAU);
ctx.fill();
}
}
_makeGlowSprite(r, g, b, size) {
const canvas = document.createElement("canvas");
const s = size * 3;
canvas.width = s;
canvas.height = s;
const ctx = canvas.getContext("2d");
const grad = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
grad.addColorStop(0, `rgba(${r},${g},${b},0.3)`);
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.08)`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, s, s);
return canvas;
}
_hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
toJSON() {
return {
width: this.width,
height: this.height,
totalTime: this.totalTime,
memory: { ...this.memory },
resources: this.resources.map((r) => ({
x: r.x,
y: r.y,
type: r.type,
amount: r.amount,
maxAmount: r.maxAmount,
})),
};
}
static fromJSON(data) {
const w = new World(data.width, data.height);
w.resources = [];
w.totalTime = data.totalTime || 0;
w.memory = data.memory || w.memory;
for (const rd of data.resources) {
const r = new Resource(rd.x, rd.y, rd.type, rd.maxAmount);
r.amount = rd.amount;
w.resources.push(r);
}
return w;
}
}