591 lines
18 KiB
TypeScript
591 lines
18 KiB
TypeScript
import { System } from '../core/System.ts';
|
|
import { Palette } from '../core/Palette.ts';
|
|
import { SpriteLibrary } from '../core/SpriteLibrary.ts';
|
|
import {
|
|
ComponentType,
|
|
SystemName,
|
|
AnimationState,
|
|
VFXType,
|
|
EntityType,
|
|
} from '../core/Constants.ts';
|
|
import type { Entity } from '../core/Entity.ts';
|
|
import type { Engine } from '../core/Engine.ts';
|
|
import type { Position } from '../components/Position.ts';
|
|
import type { Sprite } from '../components/Sprite.ts';
|
|
import type { Health } from '../components/Health.ts';
|
|
import type { Velocity } from '../components/Velocity.ts';
|
|
import type { Combat } from '../components/Combat.ts';
|
|
import type { Stealth } from '../components/Stealth.ts';
|
|
import type { Evolution } from '../components/Evolution.ts';
|
|
import type { Absorbable } from '../components/Absorbable.ts';
|
|
import type { VFXSystem } from './VFXSystem.ts';
|
|
import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
|
|
|
|
/**
|
|
* System responsible for rendering all game elements, including background, map, entities, and VFX.
|
|
*/
|
|
export class RenderSystem extends System {
|
|
ctx: CanvasRenderingContext2D;
|
|
|
|
/**
|
|
* @param engine - The game engine instance
|
|
*/
|
|
constructor(engine: Engine) {
|
|
super(SystemName.RENDER);
|
|
this.requiredComponents = [ComponentType.POSITION, ComponentType.SPRITE];
|
|
this.priority = 100;
|
|
this.engine = engine;
|
|
this.ctx = engine.ctx;
|
|
}
|
|
|
|
/**
|
|
* Execute the rendering pipeline: clear, draw background, map, entities, and effects.
|
|
* @param deltaTime - Time elapsed since last frame in seconds
|
|
* @param _entities - Filtered active entities
|
|
*/
|
|
process(deltaTime: number, _entities: Entity[]): void {
|
|
this.engine.clear();
|
|
|
|
this.drawBackground();
|
|
this.drawMap();
|
|
|
|
const allEntities = this.engine.entities;
|
|
allEntities.forEach((entity) => {
|
|
const health = entity.getComponent<Health>(ComponentType.HEALTH);
|
|
const evolution = entity.getComponent<Evolution>(ComponentType.EVOLUTION);
|
|
|
|
if (!entity.active) {
|
|
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
|
|
if (health && health.isDead() && absorbable && !absorbable.absorbed) {
|
|
this.drawEntity(entity, deltaTime, true);
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (health && health.isDead() && !evolution) {
|
|
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
|
|
if (!absorbable || absorbable.absorbed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.drawEntity(entity, deltaTime);
|
|
});
|
|
|
|
this.drawSkillEffects();
|
|
this.drawVFX();
|
|
}
|
|
|
|
/**
|
|
* Draw the cave background with dithered patterns.
|
|
*/
|
|
drawBackground(): void {
|
|
const ctx = this.ctx;
|
|
const width = this.engine.canvas.width;
|
|
const height = this.engine.canvas.height;
|
|
|
|
ctx.fillStyle = Palette.VOID;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
ctx.fillStyle = Palette.DARKER_BLUE;
|
|
for (let i = 0; i < 20; i++) {
|
|
const x = Math.floor((i * 70 + Math.sin(i) * 30) % width);
|
|
const y = Math.floor((i * 50 + Math.cos(i) * 40) % height);
|
|
const size = Math.floor(25 + (i % 4) * 15);
|
|
|
|
ctx.fillRect(x, y, size, size);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw the static tile-based map walls and highlights.
|
|
*/
|
|
drawMap(): void {
|
|
const tileMap = this.engine.tileMap;
|
|
if (!tileMap) return;
|
|
|
|
const ctx = this.ctx;
|
|
const tileSize = tileMap.tileSize;
|
|
|
|
ctx.fillStyle = Palette.DARK_BLUE;
|
|
|
|
for (let r = 0; r < tileMap.rows; r++) {
|
|
for (let c = 0; c < tileMap.cols; c++) {
|
|
if (tileMap.getTile(c, r) === 1) {
|
|
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
|
|
|
|
ctx.fillStyle = Palette.ROYAL_BLUE;
|
|
ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2);
|
|
ctx.fillStyle = Palette.DARK_BLUE;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw an individual entity, including its pixel-art sprite, health bar, and indicators.
|
|
* @param entity - The entity to render
|
|
* @param deltaTime - Time elapsed
|
|
* @param isDeadFade - Whether to apply death fade effect
|
|
*/
|
|
drawEntity(entity: Entity, deltaTime: number, isDeadFade = false): void {
|
|
const position = entity.getComponent<Position>(ComponentType.POSITION);
|
|
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
|
|
const health = entity.getComponent<Health>(ComponentType.HEALTH);
|
|
|
|
if (!position || !sprite) return;
|
|
|
|
this.ctx.save();
|
|
|
|
const drawX = Math.floor(position.x);
|
|
const drawY = Math.floor(position.y);
|
|
|
|
let alpha = sprite.alpha;
|
|
if (isDeadFade && health && health.isDead()) {
|
|
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
|
|
if (absorbable && !absorbable.absorbed) {
|
|
const deathTime = entity.deathTime || Date.now();
|
|
const timeSinceDeath = (Date.now() - deathTime) / 1000;
|
|
const fadeTime = 3.0;
|
|
alpha = Math.max(0.3, 1.0 - timeSinceDeath / fadeTime);
|
|
}
|
|
}
|
|
this.ctx.globalAlpha = alpha;
|
|
this.ctx.translate(drawX, drawY + (sprite.yOffset || 0));
|
|
this.ctx.scale(sprite.scale, sprite.scale);
|
|
|
|
if (sprite.shape === EntityType.SLIME) {
|
|
sprite.animationTime += deltaTime;
|
|
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
|
|
}
|
|
|
|
let drawColor = sprite.color;
|
|
if (sprite.shape === EntityType.SLIME) drawColor = Palette.CYAN;
|
|
|
|
this.ctx.fillStyle = drawColor;
|
|
|
|
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
|
|
if (velocity) {
|
|
const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1;
|
|
sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE;
|
|
}
|
|
|
|
let spriteData = SpriteLibrary[sprite.shape as string];
|
|
if (!spriteData) {
|
|
spriteData = SpriteLibrary[EntityType.SLIME];
|
|
}
|
|
|
|
let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE];
|
|
|
|
if (!frames || !Array.isArray(frames)) {
|
|
// Fallback to default slime animation if data structure is unexpected
|
|
frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE];
|
|
}
|
|
|
|
if (!health || !health.isDead()) {
|
|
sprite.animationTime += deltaTime;
|
|
}
|
|
const currentFrameIdx =
|
|
Math.floor(sprite.animationTime * sprite.animationSpeed) % frames.length;
|
|
const grid = frames[currentFrameIdx];
|
|
|
|
if (!grid || !grid.length) {
|
|
this.ctx.restore();
|
|
return;
|
|
}
|
|
|
|
const rows = grid.length;
|
|
const cols = grid[0].length;
|
|
|
|
const pixelW = sprite.width / cols;
|
|
const pixelH = sprite.height / rows;
|
|
|
|
const offsetX = -sprite.width / 2;
|
|
const offsetY = -sprite.height / 2;
|
|
|
|
const isFlipped = Math.cos(position.rotation) < 0;
|
|
|
|
this.ctx.save();
|
|
if (isFlipped) {
|
|
this.ctx.scale(-1, 1);
|
|
}
|
|
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
const value = grid[r][c];
|
|
if (value === 0) continue;
|
|
|
|
if (value === 1) {
|
|
this.ctx.fillStyle = drawColor;
|
|
} else if (value === 2) {
|
|
this.ctx.fillStyle = Palette.WHITE;
|
|
} else if (value === 3) {
|
|
this.ctx.fillStyle = Palette.DARKER_BLUE;
|
|
}
|
|
|
|
this.ctx.fillRect(
|
|
offsetX + c * pixelW,
|
|
offsetY + r * pixelH,
|
|
Math.ceil(pixelW),
|
|
Math.ceil(pixelH)
|
|
);
|
|
}
|
|
}
|
|
this.ctx.restore();
|
|
|
|
if (health && health.maxHp > 0 && !health.isProjectile) {
|
|
this.drawHealthBar(health, sprite);
|
|
}
|
|
|
|
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
|
|
if (combat && combat.isAttacking) {
|
|
this.ctx.save();
|
|
this.ctx.rotate(position.rotation);
|
|
this.drawAttackIndicator(combat, entity);
|
|
this.ctx.restore();
|
|
}
|
|
|
|
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
|
|
if (stealth && stealth.isStealthed) {
|
|
this.drawStealthIndicator(stealth, sprite);
|
|
}
|
|
|
|
const evolution = entity.getComponent<Evolution>(ComponentType.EVOLUTION);
|
|
if (evolution) {
|
|
if (evolution.mutationEffects.glowingBody) {
|
|
this.drawGlowEffect(sprite);
|
|
}
|
|
if (evolution.mutationEffects.electricSkin) {
|
|
if (Math.random() < 0.2) {
|
|
this.ctx.fillStyle = Palette.CYAN;
|
|
const sparkX = Math.floor((Math.random() - 0.5) * sprite.width);
|
|
const sparkY = Math.floor((Math.random() - 0.5) * sprite.height);
|
|
this.ctx.fillRect(sparkX, sparkY, 2, 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw all active visual effects particles.
|
|
*/
|
|
drawVFX(): void {
|
|
const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
|
|
| VFXSystem
|
|
| undefined;
|
|
if (!vfxSystem) return;
|
|
|
|
const ctx = this.ctx;
|
|
const particles = vfxSystem.getParticles();
|
|
|
|
particles.forEach((p) => {
|
|
ctx.fillStyle = p.color;
|
|
ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8;
|
|
|
|
const x = Math.floor(p.x);
|
|
const y = Math.floor(p.y);
|
|
const size = Math.floor(p.size);
|
|
|
|
ctx.fillRect(x - size / 2, y - size / 2, size, size);
|
|
});
|
|
|
|
ctx.globalAlpha = 1.0;
|
|
}
|
|
|
|
/**
|
|
* Draw a health bar above an entity.
|
|
*/
|
|
drawHealthBar(health: Health, sprite: Sprite): void {
|
|
const ctx = this.ctx;
|
|
const barWidth = Math.floor(sprite.width * 1.2);
|
|
const barHeight = 2;
|
|
const yOffset = Math.floor(sprite.height / 2 + 3);
|
|
|
|
const startX = -Math.floor(barWidth / 2);
|
|
const startY = -yOffset;
|
|
|
|
ctx.fillStyle = Palette.DARK_BLUE;
|
|
ctx.fillRect(startX, startY, barWidth, barHeight);
|
|
|
|
const healthPercent = Math.max(0, health.hp / health.maxHp);
|
|
const fillWidth = Math.floor(barWidth * healthPercent);
|
|
|
|
if (healthPercent > 0.5) {
|
|
ctx.fillStyle = Palette.CYAN;
|
|
} else if (healthPercent > 0.25) {
|
|
ctx.fillStyle = Palette.SKY_BLUE;
|
|
} else {
|
|
ctx.fillStyle = Math.floor(Date.now() / 200) % 2 === 0 ? Palette.WHITE : Palette.ROYAL_BLUE;
|
|
}
|
|
|
|
ctx.fillRect(startX, startY, fillWidth, barHeight);
|
|
}
|
|
|
|
/**
|
|
* Draw an animation indicating a melee attack.
|
|
*/
|
|
drawAttackIndicator(combat: Combat, entity: Entity): void {
|
|
const ctx = this.ctx;
|
|
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
|
|
if (!sprite) return;
|
|
|
|
const t = 1.0 - combat.attackCooldown / 0.3;
|
|
const alpha = Math.sin(Math.PI * t);
|
|
const range = combat.attackRange;
|
|
|
|
ctx.save();
|
|
|
|
if (sprite.shape === EntityType.SLIME) {
|
|
ctx.strokeStyle = Palette.CYAN;
|
|
ctx.lineWidth = 3;
|
|
ctx.lineCap = 'round';
|
|
ctx.globalAlpha = alpha;
|
|
|
|
const length = range * Math.sin(Math.PI * t);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(length, 0);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = Palette.WHITE;
|
|
ctx.beginPath();
|
|
ctx.arc(length, 0, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else if (sprite.shape === EntityType.BEAST) {
|
|
ctx.strokeStyle = Palette.WHITE;
|
|
ctx.lineWidth = 2;
|
|
ctx.globalAlpha = alpha;
|
|
|
|
const radius = range;
|
|
const angleRange = Math.PI * 0.6;
|
|
const start = -angleRange / 2 + t * angleRange;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, radius, start - 0.5, start + 0.5);
|
|
ctx.stroke();
|
|
} else if (sprite.shape === EntityType.HUMANOID) {
|
|
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
|
|
ctx.lineWidth = 4;
|
|
|
|
const radius = range;
|
|
const sweep = Math.PI * 0.8;
|
|
const startAngle = -sweep / 2;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, radius, startAngle, startAngle + sweep * t);
|
|
ctx.stroke();
|
|
} else {
|
|
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
|
const size = 15 * t;
|
|
ctx.beginPath();
|
|
ctx.arc(10, 0, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw an indicator circle around a stealthed entity.
|
|
*/
|
|
drawStealthIndicator(stealth: Stealth, sprite: Sprite): void {
|
|
const ctx = this.ctx;
|
|
const radius = Math.max(sprite.width, sprite.height) / 2 + 5;
|
|
|
|
ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
if (stealth.visibility > 0.3) {
|
|
ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`;
|
|
ctx.beginPath();
|
|
ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw a glowing effect around an entity with bioluminescence.
|
|
*/
|
|
drawGlowEffect(sprite: Sprite): void {
|
|
const ctx = this.ctx;
|
|
const time = Date.now() * 0.001; // Time in seconds for pulsing
|
|
const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8
|
|
const baseRadius = Math.max(sprite.width, sprite.height) / 2;
|
|
const glowRadius = baseRadius + 4 + pulse * 2;
|
|
|
|
// Create radial gradient for soft glow
|
|
const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius);
|
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`);
|
|
gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`);
|
|
gradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
|
|
|
|
// Draw multiple layers for a softer glow effect
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'screen';
|
|
|
|
// Outer glow layer
|
|
ctx.fillStyle = gradient;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Inner bright core
|
|
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6);
|
|
innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`);
|
|
innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
|
|
ctx.fillStyle = innerGradient;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, baseRadius * 0.6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw active skill effects (cones, impacts, etc.).
|
|
*/
|
|
drawSkillEffects(): void {
|
|
const skillEffectSystem = this.engine.systems.find(
|
|
(s) => s.name === SystemName.SKILL_EFFECT
|
|
) as SkillEffectSystem | undefined;
|
|
if (!skillEffectSystem) return;
|
|
|
|
const effects = skillEffectSystem.getEffects();
|
|
const ctx = this.ctx;
|
|
|
|
effects.forEach((effect) => {
|
|
ctx.save();
|
|
|
|
switch (effect.type) {
|
|
case 'fire_breath':
|
|
this.drawFireBreath(ctx, effect);
|
|
break;
|
|
case 'pounce':
|
|
this.drawPounce(ctx, effect);
|
|
break;
|
|
case 'pounce_impact':
|
|
this.drawPounceImpact(ctx, effect);
|
|
break;
|
|
}
|
|
|
|
ctx.restore();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Draw a fire breath cone effect.
|
|
*/
|
|
drawFireBreath(ctx: CanvasRenderingContext2D, effect: SkillEffect): void {
|
|
if (!effect.x || !effect.y || !effect.angle || !effect.range || !effect.coneAngle) return;
|
|
const progress = Math.min(1.0, effect.time / effect.lifetime);
|
|
const alpha = Math.max(0, 1.0 - progress);
|
|
|
|
ctx.translate(effect.x, effect.y);
|
|
ctx.rotate(effect.angle);
|
|
|
|
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
|
|
gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`);
|
|
gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`);
|
|
gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`);
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const angle = (Math.random() - 0.5) * effect.coneAngle;
|
|
const dist = Math.random() * effect.range * progress;
|
|
const x = Math.cos(angle) * dist;
|
|
const y = Math.sin(angle) * dist;
|
|
const size = 3 + Math.random() * 5;
|
|
|
|
ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw a pounce dash effect with trails.
|
|
*/
|
|
drawPounce(ctx: CanvasRenderingContext2D, effect: SkillEffect): void {
|
|
if (!effect.startX || !effect.startY || !effect.angle) return;
|
|
const progress = Math.min(1.0, effect.time / effect.lifetime);
|
|
|
|
let currentX: number, currentY: number;
|
|
if (effect.caster) {
|
|
const pos = effect.caster.getComponent<Position>(ComponentType.POSITION);
|
|
if (pos) {
|
|
currentX = pos.x;
|
|
currentY = pos.y;
|
|
} else {
|
|
return;
|
|
}
|
|
} else {
|
|
currentX = effect.startX + Math.cos(effect.angle) * (effect.speed || 400) * effect.time;
|
|
currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time;
|
|
}
|
|
|
|
ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress));
|
|
ctx.fillStyle = Palette.VOID;
|
|
ctx.beginPath();
|
|
ctx.ellipse(effect.startX, effect.startY, 10, 5, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const alpha = Math.max(0, 0.8 * (1.0 - progress));
|
|
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
|
|
ctx.lineWidth = 2;
|
|
ctx.lineCap = 'round';
|
|
ctx.beginPath();
|
|
ctx.moveTo(effect.startX, effect.startY);
|
|
ctx.lineTo(currentX, currentY);
|
|
ctx.stroke();
|
|
|
|
const ringSize = progress * 40;
|
|
ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(effect.startX, effect.startY, ringSize, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
/**
|
|
* Draw an impact ring and particles from a pounce landing.
|
|
*/
|
|
drawPounceImpact(ctx: CanvasRenderingContext2D, effect: SkillEffect): void {
|
|
if (!effect.x || !effect.y) return;
|
|
const progress = Math.min(1.0, effect.time / effect.lifetime);
|
|
const alpha = Math.max(0, 1.0 - progress);
|
|
const size = Math.max(0, 30 * (1 - progress));
|
|
|
|
if (size > 0 && alpha > 0) {
|
|
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
const angle = (i / 8) * Math.PI * 2;
|
|
const dist = size * 0.7;
|
|
const x = effect.x + Math.cos(angle) * dist;
|
|
const y = effect.y + Math.sin(angle) * dist;
|
|
|
|
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
}
|