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; } }