shellquest
Version:
Terminal-based procedurally generated dungeon crawler
938 lines (830 loc) • 29.1 kB
text/typescript
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);
}
}
}