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