<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Вороной из салютов — сверху гуще</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
display: block;
width: min(88vw, 88vh);
height: min(88vw, 88vh);
border-radius: 50%;
box-shadow: 0 0 30px cyan;
background: #000;
cursor: crosshair;
}
</style>
</head>
<body>
<canvas id="c" width="400" height="400"></canvas>
<script>
// ============================================================
// CANVAS
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 400, H = 400;
const CX = W / 2, CY = H / 2;
const RADIUS = W / 2;
// ============================================================
// ПАРАМЕТРЫ
// ============================================================
const GRAVITY = 0.06;
const FRICTION = 0.985;
const TRAIL_ALPHA = 0.10;
const EXPLOSION_SPEED = 3.0;
const SPARK_COUNT_SMALL = 25;
const SPARK_COUNT_MEDIUM = 45;
const SPARK_COUNT_BIG = 80;
const SPARK_DECAY = 0.0035;
const SPARK_WIDTH = 1;
const NUM_POINTS = 12;
let points = [];
// ============================================================
// СОСТОЯНИЕ
// ============================================================
let sparks = [];
let time = 0;
// ============================================================
// HSL → RGB
// ============================================================
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
let c = (1 - Math.abs(2 * l - 1)) * s;
let x = c * (1 - Math.abs(((h / 60) % 2) - 1));
let m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return {
R: Math.round((r + m) * 255),
G: Math.round((g + m) * 255),
B: Math.round((b + m) * 255)
};
}
function inCircle(x, y, margin = 0) {
let dx = x - CX;
let dy = y - CY;
let r = RADIUS - margin;
return dx * dx + dy * dy <= r * r;
}
// ============================================================
// ГРАДИЕНТ ПЛОТНОСТИ: СВЕРХУ ГУЩЕ, СНИЗУ РЕЖЕ
// Возвращает множитель 0.15 .. 1.0
// y = 0 (верх) → 1.0 (максимум залпов)
// y = H (низ) → 0.15 (минимум залпов)
// ============================================================
function densityFactor(y) {
// Нормируем y в 0..1
let t = Math.max(0, Math.min(1, y / H));
// Вверху t=0 → 1.0, внизу t=1 → 0.15
return 1.0 - t * 0.85;
}
// ============================================================
// ИНИЦИАЛИЗАЦИЯ ЯЧЕЕК
// ============================================================
function initVoronoiPoints() {
points = [];
for (let i = 0; i < NUM_POINTS; i++) {
let angle = (i / NUM_POINTS) * Math.PI * 2;
let r = RADIUS * (0.35 + Math.random() * 0.45);
let x = CX + Math.cos(angle) * r + (Math.random() - 0.5) * 40;
let y = CY + Math.sin(angle) * r + (Math.random() - 0.5) * 40;
if (!inCircle(x, y, 20)) {
x = CX + Math.cos(angle) * r;
y = CY + Math.sin(angle) * r;
}
points.push({
x, y,
hue: (i / NUM_POINTS) * 360
});
}
}
function ownerOf(x, y) {
let best = 0, bestD = Infinity;
for (let i = 0; i < points.length; i++) {
let dx = x - points[i].x;
let dy = y - points[i].y;
let d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
function distanceToBorder(x, y) {
let d1 = Infinity, d2 = Infinity;
for (let i = 0; i < points.length; i++) {
let dx = x - points[i].x;
let dy = y - points[i].y;
let d = dx * dx + dy * dy;
if (d < d1) {
d2 = d1;
d1 = d;
} else if (d < d2) {
d2 = d;
}
}
return Math.sqrt(d2) - Math.sqrt(d1);
}
// ============================================================
// ЗАЛП В ТОЧКЕ
// ============================================================
function launchAt(x, y, hue, countMode) {
if (!inCircle(x, y, 4)) return;
let count;
if (countMode === 'big') count = SPARK_COUNT_BIG;
else if (countMode === 'medium') count = SPARK_COUNT_MEDIUM;
else count = SPARK_COUNT_SMALL;
count = Math.floor(count * (0.8 + Math.random() * 0.4));
let type = Math.random();
let hue2 = (hue + 120 + Math.random() * 120) % 360;
for (let i = 0; i < count; i++) {
let a, speed, h_i;
if (type < 0.6) {
a = Math.random() * Math.PI * 2;
speed = EXPLOSION_SPEED * (0.4 + Math.random() * 0.8);
h_i = hue + (Math.random() - 0.5) * 30;
} else if (type < 0.85) {
a = Math.random() * Math.PI * 2;
speed = EXPLOSION_SPEED * (0.9 + Math.random() * 0.15);
h_i = hue;
} else {
a = Math.random() * Math.PI * 2;
speed = EXPLOSION_SPEED * (0.5 + Math.random() * 0.7);
h_i = (i % 2 === 0) ? hue : hue2;
}
sparks.push({
x, y,
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
hue: h_i,
life: 1.0,
decay: SPARK_DECAY * (0.6 + Math.random() * 0.7),
size: 1.0 + Math.random() * 1.2
});
}
if (countMode === 'big') {
sparks.push({
x, y, vx: 0, vy: 0,
hue: hue,
life: 1.0, decay: 0.10,
size: 8, flash: true
});
}
}
// ============================================================
// ГЕНЕРАЦИЯ УЗОРА
// ============================================================
let scanAngle = 0;
let scanRadius = 0;
function scanAndLaunch() {
time++;
// === 1) ЗАЛПЫ ИЗ ЦЕНТРОВ ЯЧЕЕК ===
// Каждый кадр проходим по всем ячейкам и с вероятностью,
// зависящей от Y-координаты, запускаем залп.
for (let i = 0; i < points.length; i++) {
let p = points[i];
let df = densityFactor(p.y);
// Верхние ячейки стреляют почти каждый кадр,
// нижние — редко
if (Math.random() < df * 0.35) {
launchAt(p.x, p.y, p.hue, df > 0.6 ? 'big' : 'medium');
}
}
// === 2) ДОПОЛНИТЕЛЬНЫЕ ЗАЛПЫ СВЕРХУ ===
// Дополнительный поток залпов в верхней половине круга
if (time % 2 === 0) {
// Верхняя половина: y от 0 до CY
for (let k = 0; k < 3; k++) {
let x = CX + (Math.random() - 0.5) * 2 * RADIUS * 0.8;
let y = Math.random() * CY * 0.9;
if (!inCircle(x, y, 20)) continue;
let idx = ownerOf(x, y);
let p = points[idx];
let df = densityFactor(y);
if (Math.random() < df) {
launchAt(x, y, p.hue, 'small');
}
}
}
// === 3) СКАНИРОВАНИЕ ПО СПИРАЛИ ===
for (let step = 0; step < 4; step++) {
scanRadius += 1;
if (scanRadius > RADIUS - 10) {
scanRadius = 10;
scanAngle += Math.PI / 16;
if (scanAngle > Math.PI * 2) scanAngle = 0;
}
let x = CX + Math.cos(scanAngle) * scanRadius;
let y = CY + Math.sin(scanAngle) * scanRadius;
if (!inCircle(x, y, 15)) continue;
let df = densityFactor(y);
let border = distanceToBorder(x, y);
// На границе — залп смешанным цветом
if (border < 8) {
// Даже на границе плотность зависит от Y
if (Math.random() < df) {
let i1 = ownerOf(x, y);
let i2 = -1, bestD = Infinity;
for (let i = 0; i < points.length; i++) {
if (i === i1) continue;
let d = (x - points[i].x) ** 2 + (y - points[i].y) ** 2;
if (d < bestD) { bestD = d; i2 = i; }
}
if (i2 >= 0) {
let h1 = points[i1].hue;
let h2 = points[i2].hue;
let dh = ((h2 - h1 + 540) % 360) - 180;
let hue = (h1 + dh * 0.5 + 360) % 360;
launchAt(x, y, hue, df > 0.6 ? 'medium' : 'small');
}
}
}
// Внутри ячейки, но близко к границе
else if (border < 25 && Math.random() < df * 0.5) {
let i = ownerOf(x, y);
launchAt(x, y, points[i].hue, 'small');
}
}
}
// ============================================================
// ОБНОВЛЕНИЕ
// ============================================================
function update() {
ctx.fillStyle = `rgba(0, 0, 0, ${TRAIL_ALPHA})`;
ctx.fillRect(0, 0, W, H);
scanAndLaunch();
// --- Искры ---
for (let i = sparks.length - 1; i >= 0; i--) {
let s = sparks[i];
if (s.flash) {
if (inCircle(s.x, s.y, -s.size)) {
let c = hslToRgb(s.hue, 1.0, 0.75);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size * s.life, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${c.R},${c.G},${c.B},${s.life * 0.5})`;
ctx.fill();
}
s.life -= s.decay;
if (s.life <= 0) sparks.splice(i, 1);
continue;
}
s.x += s.vx;
s.y += s.vy;
s.vx *= FRICTION;
s.vy *= FRICTION;
s.vy += GRAVITY;
if (inCircle(s.x, s.y, -2)) {
let brightness = 0.55 + s.life * 0.15;
let c = hslToRgb(s.hue, 1.0, brightness);
let px = s.x - s.vx;
let py = s.y - s.vy;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(s.x, s.y);
ctx.strokeStyle = `rgba(${c.R},${c.G},${c.B},${Math.min(s.life, 1)})`;
ctx.lineWidth = s.size * s.life * SPARK_WIDTH;
ctx.lineCap = 'round';
ctx.stroke();
}
s.life -= s.decay;
if (s.life <= 0 || !inCircle(s.x, s.y, -30)) {
sparks.splice(i, 1);
}
}
// Подсветка якорей
for (let p of points) {
let c = hslToRgb(p.hue, 1.0, 0.6);
ctx.beginPath();
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${c.R},${c.G},${c.B},0.9)`;
ctx.fill();
}
}
function loop() {
update();
requestAnimationFrame(loop);
}
// ============================================================
// КЛИК
// ============================================================
canvas.addEventListener('click', (e) => {
let rect = canvas.getBoundingClientRect();
let x = (e.clientX - rect.left) * (W / rect.width);
let y = (e.clientY - rect.top) * (H / rect.height);
if (inCircle(x, y)) {
let idx = ownerOf(x, y);
launchAt(points[idx].x, points[idx].y, points[idx].hue, 'big');
}
});
// ============================================================
// СТАРТ
// ============================================================
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
initVoronoiPoints();
loop();
</script>
</body>
</html>
вот чисто теоретически эта версия вам нравится ?))) если да то надо подумать над экономией… а то этот монстр просто не экономно ест ресурсы…
и главное салюты внедрим)))