UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

938 lines (830 loc) 29.1 kB
import {RGBA} from '@opentui/core'; import {Entity} from './Entity.ts'; import {type CollisionEntity, CollisionSystem} from '../collision/CollisionSystem.ts'; import type {ZombieAttackGame} from '../ZombieAttackGame.ts'; import {Assets, getPalette, getPixels} from '../../../assets/assets.ts'; import type {PixelCanvas} from '../drawing/pixelCanvas.ts'; import type {GameLevel} from '../level/types.ts'; import {TILE_SIZE} from '../TileMap.ts'; import {SPELL_LIGHT_CONFIGS} from '../lighting/LightingSystem.ts'; export type SpellType = 'fireball' | 'iceblast' | 'lightning' | 'poison'; const SPELL_ANIMATION_MAP: Record<SpellType, string> = { fireball: 'fire-spell-1', iceblast: 'ice-spell-1', // TODO: Add when available lightning: 'lightning-spell-1', // TODO: Add when available poison: 'poison-spell-1', // TODO: Add when available }; interface SpellConfig { speed: number; damage: number; lifetime: number; animationFps: number; colors: { core: RGBA; innerGlow: RGBA; outerGlow: RGBA; trail: RGBA; spark: RGBA; }; particleCount: number; trailLength: number; coreSize: number; glowSize: number; } const SPELL_CONFIGS: Record<SpellType, SpellConfig> = { fireball: { speed: 3.5, damage: 25, lifetime: 2000, animationFps: 6, colors: { core: RGBA.fromInts(255, 255, 200, 255), innerGlow: RGBA.fromInts(255, 200, 100, 200), outerGlow: RGBA.fromInts(255, 100, 0, 100), trail: RGBA.fromInts(255, 80, 0, 80), spark: RGBA.fromInts(255, 255, 100, 255), }, particleCount: 25, trailLength: 20, coreSize: 3, glowSize: 8, }, iceblast: { speed: 2.8, damage: 20, lifetime: 2500, animationFps: 6, colors: { core: RGBA.fromInts(200, 230, 255, 255), innerGlow: RGBA.fromInts(150, 200, 255, 200), outerGlow: RGBA.fromInts(100, 150, 255, 100), trail: RGBA.fromInts(180, 220, 255, 80), spark: RGBA.fromInts(230, 250, 255, 255), }, particleCount: 20, trailLength: 25, coreSize: 3, glowSize: 7, }, lightning: { speed: 5.0, damage: 30, lifetime: 1500, animationFps: 8, colors: { core: RGBA.fromInts(255, 255, 255, 255), innerGlow: RGBA.fromInts(200, 200, 255, 220), outerGlow: RGBA.fromInts(150, 150, 255, 120), trail: RGBA.fromInts(180, 180, 255, 100), spark: RGBA.fromInts(255, 255, 255, 255), }, particleCount: 30, trailLength: 15, coreSize: 2, glowSize: 10, }, poison: { speed: 2.0, damage: 15, lifetime: 3000, animationFps: 4, colors: { core: RGBA.fromInts(150, 255, 150, 255), innerGlow: RGBA.fromInts(100, 220, 100, 200), outerGlow: RGBA.fromInts(50, 180, 50, 100), trail: RGBA.fromInts(80, 200, 80, 80), spark: RGBA.fromInts(180, 255, 180, 255), }, particleCount: 35, trailLength: 30, coreSize: 4, glowSize: 6, }, }; interface Particle { x: number; y: number; vx: number; vy: number; life: number; maxLife: number; size: number; color: RGBA; type: 'spark' | 'trail' | 'glow' | 'ember' | 'crystal' | 'bolt' | 'bubble'; rotation?: number; rotationSpeed?: number; gravity?: number; drag?: number; } interface TrailPoint { x: number; y: number; age: number; size: number; } export class SpellProjectile extends Entity implements CollisionEntity { private spellType: SpellType; private config: SpellConfig; private velocityX: number; private velocityY: number; private lifetime: number; private age: number = 0; // Advanced particle systems private particles: Particle[] = []; private trail: TrailPoint[] = []; private lastParticleSpawn: number = 0; private animationTime: number = 0; // Sprite animation properties private currentFrame: number = 0; private frameTime: number = 0; private animationAsset: any = null; // Core effect properties private corePulse: number = 0; private coreRotation: number = 0; private energyField: number = 0; // Lightning-specific private lightningBolts: Array<{points: Array<{x: number; y: number}>; life: number}> = []; private lastLightningSpawn: number = 0; // Light source tracking private lightId: string; private static nextLightId: number = 0; // Collision properties collisionOffsetX: number = 4; collisionOffsetY: number = 4; collisionWidth: number = 8; collisionHeight: number = 8; collisionType: 'projectile' = 'projectile'; solid: boolean = false; constructor( game: ZombieAttackGame, x: number, y: number, directionX: number, directionY: number, spellType: SpellType, ) { super(game); this.x = x; this.y = y; this.width = 16; this.height = 16; this.tileName = ''; this.spellType = spellType; this.config = SPELL_CONFIGS[spellType]; this.lifetime = this.config.lifetime; this.layer = 3; // Normalize direction const length = Math.sqrt(directionX * directionX + directionY * directionY); if (length > 0) { directionX /= length; directionY /= length; } this.velocityX = directionX * this.config.speed; this.velocityY = directionY * this.config.speed; // Load animation asset const animationName = SPELL_ANIMATION_MAP[spellType]; if (animationName && Assets[animationName]) { this.animationAsset = Assets[animationName]; } // Initialize advanced particle systems this.initializeParticles(); // Register as a light source this.lightId = `spell-${SpellProjectile.nextLightId++}`; this.registerLightSource(); } private registerLightSource(): void { const lightConfig = SPELL_LIGHT_CONFIGS[this.spellType]; if (!lightConfig) return; const lighting = this.game.getDungeonLighting(); if (lighting) { lighting.getLightingSystem().addLight({ id: this.lightId, x: this.x + 8, y: this.y + 8, ...lightConfig, }); } } private updateLightPosition(): void { const lighting = this.game.getDungeonLighting(); if (lighting) { lighting.getLightingSystem().updateLightPosition(this.lightId, this.x + 8, this.y + 8); } } private removeLightSource(): void { const lighting = this.game.getDungeonLighting(); if (lighting) { lighting.getLightingSystem().removeLight(this.lightId); } } render(ctx: PixelCanvas) { const centerX = 8; const centerY = 8; ctx.save(); ctx.translate(centerX, centerY); // Draw sprite animation if available if (this.animationAsset && this.animationAsset.frames) { // Ensure coordinates are properly aligned for sprite rendering this.drawSpriteAnimation(ctx); } else { // Fallback to procedural effects if no sprite available // Draw outer glow this.drawRadialGlow( ctx, centerX, centerY, this.config.glowSize + this.corePulse * 2, this.config.colors.outerGlow, 0.3, ); // Draw inner glow this.drawRadialGlow( ctx, centerX, centerY, this.config.glowSize * 0.6 + this.corePulse, this.config.colors.innerGlow, 0.6, ); // Draw energy core with rotation effect this.drawEnergyCore(ctx, centerX, centerY); // Draw spell-specific effects if (this.spellType === 'fireball') { this.drawFireEffect(ctx); } else if (this.spellType === 'iceblast') { this.drawIceEffect(ctx); } else if (this.spellType === 'poison') { this.drawPoisonEffect(ctx); } } // Always draw trail and particles for enhanced effects this.drawTrail(ctx); // Draw lightning bolts for lightning spell if (this.spellType === 'lightning') { this.drawLightningBolts(ctx); } // Draw particles this.drawParticles(ctx); ctx.restore(); } private initializeParticles(): void { // Create initial particle burst for (let i = 0; i < this.config.particleCount; i++) { const angle = (i / this.config.particleCount) * Math.PI * 2; const speed = Math.random() * 1.5 + 0.5; // Determine particle type based on spell let particleType: Particle['type'] = 'spark'; if (this.spellType === 'fireball') { particleType = Math.random() < 0.3 ? 'ember' : 'spark'; } else if (this.spellType === 'iceblast') { particleType = Math.random() < 0.4 ? 'crystal' : 'spark'; } else if (this.spellType === 'lightning') { particleType = 'bolt'; } else if (this.spellType === 'poison') { particleType = Math.random() < 0.5 ? 'bubble' : 'spark'; } this.particles.push({ x: Math.random() * 4 - 2, y: Math.random() * 4 - 2, vx: Math.cos(angle) * speed * 0.3, vy: Math.sin(angle) * speed * 0.3, life: 1.0, maxLife: 1.0, size: Math.random() * 2 + 1, color: this.config.colors.spark, type: particleType, rotation: Math.random() * Math.PI * 2, rotationSpeed: (Math.random() - 0.5) * 0.2, gravity: this.spellType === 'fireball' ? -0.02 : 0, drag: 0.98, }); } } update(deltaTime: number, level: GameLevel, collisionSystem?: CollisionSystem): void { this.age += deltaTime; this.animationTime += deltaTime; this.corePulse = Math.sin(this.animationTime * 0.008) * 0.5 + 0.5; this.coreRotation += deltaTime * 0.003; this.energyField = (this.energyField + deltaTime * 0.01) % (Math.PI * 2); // Update sprite animation if (this.animationAsset && this.animationAsset.frames) { this.frameTime += deltaTime; const frameDelay = 1000 / this.config.animationFps; if (this.frameTime >= frameDelay) { this.currentFrame = (this.currentFrame + 1) % this.animationAsset.frames.length; this.frameTime = 0; } } // Check if lifetime expired if (this.age >= this.lifetime) { this.destroy(level, collisionSystem); return; } // Move projectile const oldX = this.x; const oldY = this.y; this.x += this.velocityX * (deltaTime / 16.67); this.y += this.velocityY * (deltaTime / 16.67); // Update light source position this.updateLightPosition(); // Update trail this.updateTrail(oldX + 8, oldY + 8); // Check collision with walls const tileX = Math.floor((this.x + 8) / TILE_SIZE); const tileY = Math.floor((this.y + 8) / TILE_SIZE); if (level.isSolid(tileX, tileY)) { this.createImpactEffect(); this.destroy(level, collisionSystem); return; } // Check collision with enemies if (collisionSystem) { const collisions = collisionSystem.getPotentialCollisions(this); for (const entity of collisions) { if (entity.collisionType === 'enemy') { const dx = Math.abs(entity.x + 8 - (this.x + 8)); const dy = Math.abs(entity.y + 8 - (this.y + 8)); if (dx < 12 && dy < 12) { if ('takeDamage' in entity && typeof entity.takeDamage === 'function') { (entity as any).takeDamage(this.config.damage); } this.createImpactEffect(); this.destroy(level, collisionSystem); return; } } } } // Update particles this.updateParticles(deltaTime); // Spawn new particles periodically if (this.animationTime - this.lastParticleSpawn > 50) { this.spawnTrailParticles(); this.lastParticleSpawn = this.animationTime; } // Update lightning bolts for lightning spell if (this.spellType === 'lightning' && this.animationTime - this.lastLightningSpawn > 100) { this.spawnLightningBolt(); this.lastLightningSpawn = this.animationTime; } this.updateLightningBolts(deltaTime); } private updateTrail(x: number, y: number): void { // Add new trail point this.trail.unshift({ x: x, y: y, age: 0, size: this.config.coreSize, }); // Update and remove old trail points for (let i = this.trail.length - 1; i >= 0; i--) { this.trail[i].age += 1; if (this.trail[i].age > this.config.trailLength) { this.trail.splice(i, 1); } } } private updateParticles(deltaTime: number): void { const dt = deltaTime / 16.67; for (let i = this.particles.length - 1; i >= 0; i--) { const particle = this.particles[i]; // Update position particle.x += particle.vx * dt; particle.y += particle.vy * dt; // Apply physics if (particle.gravity) { particle.vy += particle.gravity * dt; } particle.vx *= particle.drag || 1; particle.vy *= particle.drag || 1; // Update rotation if (particle.rotationSpeed) { particle.rotation = (particle.rotation || 0) + particle.rotationSpeed * dt; } // Fade out particle.life -= dt * 0.02; // Special behaviors by type if (particle.type === 'bubble' && Math.random() < 0.02) { // Bubbles occasionally pop particle.life = 0; this.spawnBubblePop(particle.x, particle.y); } else if (particle.type === 'ember' && particle.life < 0.3) { // Embers turn to smoke particle.color = RGBA.fromInts(80, 80, 80, Math.floor(particle.life * 255)); particle.vy -= 0.05; // Float up } // Remove dead particles if (particle.life <= 0) { this.particles.splice(i, 1); } } } private spawnTrailParticles(): void { const numParticles = this.spellType === 'lightning' ? 5 : 3; for (let i = 0; i < numParticles; i++) { if (this.particles.length >= this.config.particleCount * 3) break; const angle = Math.random() * Math.PI * 2; const speed = Math.random() * 0.8 + 0.2; const offset = Math.random() * 3; let particleType: Particle['type'] = 'trail'; if (this.spellType === 'fireball' && Math.random() < 0.3) { particleType = 'ember'; } else if (this.spellType === 'iceblast' && Math.random() < 0.2) { particleType = 'crystal'; } this.particles.push({ x: Math.cos(angle) * offset, y: Math.sin(angle) * offset, vx: Math.cos(angle) * speed - this.velocityX * 0.1, vy: Math.sin(angle) * speed - this.velocityY * 0.1, life: 0.8, maxLife: 0.8, size: Math.random() * 1.5 + 0.5, color: this.config.colors.trail, type: particleType, rotation: Math.random() * Math.PI * 2, rotationSpeed: (Math.random() - 0.5) * 0.1, gravity: particleType === 'ember' ? -0.01 : 0, drag: 0.95, }); } } private spawnBubblePop(x: number, y: number): void { for (let i = 0; i < 4; i++) { const angle = (i / 4) * Math.PI * 2; this.particles.push({ x: x, y: y, vx: Math.cos(angle) * 0.5, vy: Math.sin(angle) * 0.5, life: 0.3, maxLife: 0.3, size: 0.5, color: RGBA.fromInts(150, 255, 150, 180), type: 'spark', drag: 0.9, }); } } private spawnLightningBolt(): void { if (this.lightningBolts.length >= 3) { this.lightningBolts.shift(); } const points: Array<{x: number; y: number}> = []; const segments = 8; for (let i = 0; i <= segments; i++) { const t = i / segments; const baseX = -this.velocityX * 2 * t; const baseY = -this.velocityY * 2 * t; const offset = Math.sin(t * Math.PI) * 3; const jitter = (Math.random() - 0.5) * 4; points.push({ x: baseX + Math.random() * jitter, y: baseY + Math.random() * jitter, }); } this.lightningBolts.push({ points: points, life: 1.0, }); } private updateLightningBolts(deltaTime: number): void { const dt = deltaTime / 16.67; for (let i = this.lightningBolts.length - 1; i >= 0; i--) { this.lightningBolts[i].life -= dt * 0.05; if (this.lightningBolts[i].life <= 0) { this.lightningBolts.splice(i, 1); } } } private createImpactEffect(): void { // Create massive burst of particles on impact const impactParticles = this.config.particleCount * 4; for (let i = 0; i < impactParticles; i++) { const angle = (i / impactParticles) * Math.PI * 2 + Math.random() * 0.2; const speed = Math.random() * 3 + 2; const size = Math.random() * 3 + 1; let particleType: Particle['type'] = 'spark'; if (this.spellType === 'fireball') { particleType = Math.random() < 0.5 ? 'ember' : 'spark'; } else if (this.spellType === 'iceblast') { particleType = 'crystal'; } else if (this.spellType === 'poison') { particleType = 'bubble'; } this.particles.push({ x: 0, y: 0, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1.0, maxLife: 1.0, size: size, color: i % 2 === 0 ? this.config.colors.innerGlow : this.config.colors.spark, type: particleType, rotation: Math.random() * Math.PI * 2, rotationSpeed: (Math.random() - 0.5) * 0.3, gravity: this.spellType === 'fireball' ? -0.03 : 0, drag: 0.92, }); } // Create shockwave ring for (let i = 0; i < 16; i++) { const angle = (i / 16) * Math.PI * 2; this.particles.push({ x: Math.cos(angle) * 2, y: Math.sin(angle) * 2, vx: Math.cos(angle) * 4, vy: Math.sin(angle) * 4, life: 0.5, maxLife: 0.5, size: 2, color: this.config.colors.outerGlow, type: 'glow', drag: 0.85, }); } } private drawTrail(ctx: PixelCanvas): void { for (let i = 0; i < this.trail.length - 1; i++) { const point = this.trail[i]; const nextPoint = this.trail[i + 1]; const alpha = (1 - point.age / this.config.trailLength) * 0.5; if (alpha <= 0) continue; // Draw line between trail points const steps = 10; for (let s = 0; s <= steps; s++) { const t = s / steps; const x = point.x + (nextPoint.x - point.x) * t; const y = point.y + (nextPoint.y - point.y) * t; const size = point.size * (1 - point.age / this.config.trailLength); // Draw trail glow for (let py = -size; py <= size; py++) { for (let px = -size; px <= size; px++) { const dist = Math.sqrt(px * px + py * py); if (dist <= size) { const intensity = (1 - dist / size) * alpha; const color = RGBA.fromInts( this.config.colors.trail.r, this.config.colors.trail.g, this.config.colors.trail.b, Math.floor(intensity * 255), ); ctx.setPixel(Math.floor(x + px), Math.floor(y + py), color); } } } } } } private drawLightningBolts(ctx: PixelCanvas): void { for (const bolt of this.lightningBolts) { const alpha = bolt.life; for (let i = 0; i < bolt.points.length - 1; i++) { const p1 = bolt.points[i]; const p2 = bolt.points[i + 1]; // Draw line segment const dx = p2.x - p1.x; const dy = p2.y - p1.y; const steps = Math.max(Math.abs(dx), Math.abs(dy)); for (let s = 0; s <= steps; s++) { const t = steps > 0 ? s / steps : 0; const x = p1.x + dx * t; const y = p1.y + dy * t; // Core bolt ctx.setPixel(x, y, RGBA.fromInts(255, 255, 255, Math.floor(alpha * 255))); // Glow around bolt const glowSize = 2; for (let gy = -glowSize; gy <= glowSize; gy++) { for (let gx = -glowSize; gx <= glowSize; gx++) { const dist = Math.sqrt(gx * gx + gy * gy); if (dist <= glowSize && dist > 0) { const glowIntensity = (1 - dist / glowSize) * alpha * 0.3; ctx.setPixel( x + gx, y + gy, RGBA.fromInts(200, 200, 255, Math.floor(glowIntensity * 255)), ); } } } } } } } private drawParticles(ctx: PixelCanvas): void { for (const particle of this.particles) { const x = particle.x; const y = particle.y; const alpha = particle.life / particle.maxLife; if (particle.type === 'crystal') { // Draw ice crystal shape const points = 6; for (let i = 0; i < points; i++) { const angle = (i / points) * Math.PI * 2 + (particle.rotation || 0); const px = Math.cos(angle) * particle.size; const py = Math.sin(angle) * particle.size; // Draw lines from center to points const steps = Math.floor(particle.size * 2); for (let s = 0; s <= steps; s++) { const t = s / steps; ctx.setPixel( Math.floor(x + px * t), Math.floor(y + py * t), RGBA.fromInts( particle.color.r, particle.color.g, particle.color.b, Math.floor(alpha * particle.color.a), ), ); } } } else if (particle.type === 'bubble') { // Draw bubble outline const radius = particle.size; for (let angle = 0; angle < Math.PI * 2; angle += 0.2) { const px = Math.cos(angle) * radius; const py = Math.sin(angle) * radius; ctx.setPixel( Math.floor(x + px), Math.floor(y + py), RGBA.fromInts( particle.color.r, particle.color.g, particle.color.b, Math.floor(alpha * particle.color.a * 0.8), ), ); } // Bubble highlight ctx.setPixel( Math.floor(x - radius * 0.3), Math.floor(y - radius * 0.3), RGBA.fromInts(255, 255, 255, Math.floor(alpha * 100)), ); } else { // Default particle (spark, ember, etc) const size = Math.ceil(particle.size); for (let py = -size; py <= size; py++) { for (let px = -size; px <= size; px++) { const dist = Math.sqrt(px * px + py * py); if (dist <= particle.size) { const intensity = (1 - dist / particle.size) * alpha; ctx.setPixel( Math.floor(x + px), Math.floor(y + py), RGBA.fromInts( particle.color.r, particle.color.g, particle.color.b, Math.floor(intensity * particle.color.a), ), ); } } } } } } private drawRadialGlow( ctx: PixelCanvas, centerX: number, centerY: number, radius: number, color: RGBA, intensity: number, ): void { const r = Math.ceil(radius); for (let y = -r; y <= r; y++) { for (let x = -r; x <= r; x++) { const dist = Math.sqrt(x * x + y * y); if (dist <= radius) { const falloff = 1 - dist / radius; const alpha = falloff * falloff * intensity; ctx.setPixel( Math.floor(centerX + x), Math.floor(centerY + y), RGBA.fromInts(color.r, color.g, color.b, Math.floor(alpha * color.a)), ); } } } } private drawEnergyCore(ctx: PixelCanvas, centerX: number, centerY: number): void { const coreRadius = this.config.coreSize + this.corePulse; // Draw rotating energy field for (let angle = 0; angle < Math.PI * 2; angle += 0.1) { const rotatedAngle = angle + this.coreRotation; const fieldRadius = coreRadius + Math.sin(angle * 3 + this.energyField) * 2; const x = Math.cos(rotatedAngle) * fieldRadius; const y = Math.sin(rotatedAngle) * fieldRadius; ctx.setPixel( Math.floor(centerX + x), Math.floor(centerY + y), RGBA.fromInts( this.config.colors.core.r, this.config.colors.core.g, this.config.colors.core.b, 180, ), ); } // Draw solid core for (let y = -coreRadius; y <= coreRadius; y++) { for (let x = -coreRadius; x <= coreRadius; x++) { const dist = Math.sqrt(x * x + y * y); if (dist <= coreRadius) { // Create gradient from center const gradient = 1 - (dist / coreRadius) * 0.3; ctx.setPixel( Math.floor(centerX + x), Math.floor(centerY + y), RGBA.fromInts( Math.min(255, this.config.colors.core.r * gradient), Math.min(255, this.config.colors.core.g * gradient), Math.min(255, this.config.colors.core.b * gradient), 255, ), ); } } } // Draw bright center point ctx.setPixel(Math.floor(centerX), Math.floor(centerY), RGBA.fromInts(255, 255, 255, 255)); } private drawFireEffect(ctx: PixelCanvas): void { // Draw flame tongues for (let i = 0; i < 3; i++) { const angle = (i / 3) * Math.PI * 2 + this.animationTime * 0.002; const length = 4 + Math.sin(this.animationTime * 0.01 + i) * 2; for (let d = 0; d < length; d++) { const x = Math.cos(angle) * d; const y = Math.sin(angle) * d - d * 0.3; // Flames rise const intensity = 1 - d / length; ctx.setPixel(x, y, RGBA.fromInts(255, 200, 100, Math.floor(intensity * 150))); } } } private drawIceEffect(ctx: PixelCanvas): void { // Draw ice spikes const spikes = 6; for (let i = 0; i < spikes; i++) { const angle = (i / spikes) * Math.PI * 2 + this.coreRotation * 0.5; const length = 3 + Math.sin(this.animationTime * 0.005 + i) * 1; for (let d = 0; d < length; d++) { const x = Math.cos(angle) * d; const y = Math.sin(angle) * d; const intensity = 1 - d / length; ctx.setPixel(x, y, RGBA.fromInts(200, 230, 255, Math.floor(intensity * 200))); // Add sparkles if (d === Math.floor(length - 1) && Math.random() < 0.3) { ctx.setPixel( x + (Math.random() - 0.5) * 2, y + (Math.random() - 0.5) * 2, RGBA.fromInts(255, 255, 255, 255), ); } } } } private drawSpriteAnimation(ctx: PixelCanvas): void { if (!this.animationAsset || !this.animationAsset.frames) return; const palette = getPalette(this.animationAsset, this.currentFrame); const pixels = getPixels(this.animationAsset, this.currentFrame); const width = this.animationAsset.width; const height = this.animationAsset.height; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const pixelIndex = y * width + x; const paletteIndex = pixels[pixelIndex]; const [r, g, b, a] = palette[paletteIndex]; // Skip transparent pixels if (a > 0) { ctx.setPixel(x, y, RGBA.fromInts(r, g, b, a)); } } } } private drawPoisonEffect(ctx: PixelCanvas): void { // Draw toxic vapor swirls for (let i = 0; i < 4; i++) { const angle = this.animationTime * 0.001 + (i / 4) * Math.PI * 2; const radius = 3 + Math.sin(this.animationTime * 0.008 + i) * 2; for (let a = 0; a < Math.PI * 2; a += 0.3) { const x = Math.cos(a + angle) * radius; const y = Math.sin(a + angle) * radius; const wobble = Math.sin(a * 3 + this.animationTime * 0.01) * 1; ctx.setPixel(x + wobble, y, RGBA.fromInts(100, 200, 100, 100)); } } } private destroy(level: GameLevel, collisionSystem?: CollisionSystem): void { this.removeLightSource(); level.removeEntity(this); if (collisionSystem) { collisionSystem.removeEntity(this); } } }