eweng.space/assets/vine.js
2026-05-10 20:37:58 +02:00

540 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const CHARS = {
down: ['|', '¦', '¡', 'l', 'I', '1'],
downRight: ['\\', '╲'],
downLeft: ['/', ''],
leaf: ['.', 'o', '*', "'", '"', '·', '°', '`', '¨', '+'],
flower: ['✿', '❀', '✾', '❁', '⚘'],
debris: ['°', '·', '¨', '`', ','],
};
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function charForAngle(angle) {
const deg = angle * 180 / Math.PI;
if (deg >= 60 && deg <= 120) return pick(CHARS.down);
if (deg < 60) return pick(CHARS.downRight);
return pick(CHARS.downLeft);
}
class Vine {
constructor(container) {
this.container = container;
this.width = container.offsetWidth || window.innerWidth;
this.height = container.offsetHeight || window.innerHeight;
this.tips = [];
this.elements = [];
this.flowers = [];
this.frameCount = 0;
this.minStep = 24;
this.flowerCount = 0;
this.startVine();
this.startVine();
this.startVine();
this.startVine();
this.createHelpUI();
this.loop = this.loop.bind(this);
requestAnimationFrame(this.loop);
window.addEventListener('resize', () => {
this.width = container.offsetWidth || window.innerWidth;
this.height = container.offsetHeight || window.innerHeight;
});
// Interactions
this.container.addEventListener('click', (e) => this.handleClick(e));
this.container.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
this.container.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.handleRightClick(e);
});
}
startVine() {
if (this.tips.length >= 12) return;
this.tips.push({
x: 40 + Math.random() * Math.max(0, this.width - 80),
y: -5,
angle: Math.PI / 2 + (Math.random() - 0.5) * 0.25,
hue: Math.random() * 360,
hueSpeed: (Math.random() - 0.5) * 0.3,
generation: 0,
age: 0,
alive: true,
nextBranch: 140 + Math.random() * 140,
speed: 0.7 + Math.random() * 0.3,
distSinceLastChar: 0,
});
}
createHelpUI() {
const btn = document.createElement('button');
btn.id = 'vine-help-btn';
btn.textContent = '?';
this.container.appendChild(btn);
const panel = document.createElement('div');
panel.id = 'vine-help-panel';
panel.style.display = 'none';
const close = document.createElement('button');
close.className = 'close';
close.innerHTML = '&times;';
close.setAttribute('aria-label', 'fermer');
panel.appendChild(close);
const title = document.createElement('h3');
title.innerHTML = this.animText('jardinage');
panel.appendChild(title);
const items = [
{ k: 'clic', a: 'sur tige', d: 'coupe la liane' },
{ k: 'double-clic', a: 'sur tige', d: 'plante une fleur' },
{ k: 'clic droit', a: 'sur tige', d: 'plante une fleur' },
{ k: 'clic', a: 'sur fleur', d: "l'attrape" },
{ k: 'clic', a: 'sur fond', d: 'dévie la racine' },
];
const ul = document.createElement('ul');
items.forEach((it) => {
const li = document.createElement('li');
li.innerHTML =
'<kbd>' + it.k + '</kbd> ' +
it.a + ' <span style="opacity:0.5">→</span> ' + it.d;
ul.appendChild(li);
});
panel.appendChild(ul);
this.container.appendChild(panel);
const counter = document.createElement('div');
counter.id = 'vine-flower-counter';
counter.textContent = '0';
this.counterEl = counter;
this.container.appendChild(counter);
btn.addEventListener('click', (e) => {
e.stopPropagation();
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
});
close.addEventListener('click', (e) => {
e.stopPropagation();
panel.style.display = 'none';
});
panel.addEventListener('click', (e) => e.stopPropagation());
}
animText(text) {
return text.split('').map((c, i) => {
if (c === ' ') return ' ';
const delayColor = (i * 0.3).toFixed(1);
const delayLevite = (Math.random() * 4).toFixed(2);
const duree = (3 + Math.random() * 3).toFixed(2);
return `<span style="display:inline-block;animation:colorshift 12s ease-in-out infinite,levite ${duree}s ease-in-out infinite;animation-delay:-${delayColor}s,-${delayLevite}s">${c}</span>`;
}).join('');
}
// ===== INTERACTIONS =====
handleClick(e) {
const target = e.target;
// 1. Clic sur une fleur vivante -> attraper
if (target.classList.contains('vine-flower') && !target.classList.contains('falling-flower')) {
this.catchFlower(target);
return;
}
// 2. Clic sur caractère de tige (pas feuille) -> couper
if (target.classList.contains('vine-char') && !target.classList.contains('vine-leaf')) {
this.cutVine(parseFloat(target.style.left), parseFloat(target.style.top));
return;
}
// 3. Clic sur fond vide -> dévier la racine la plus proche
if (target === this.container) {
this.redirectVine(e.offsetX, e.offsetY);
}
}
handleDoubleClick(e) {
const target = e.target;
// Double-clic sur caractère de tige -> planter une fleur
if (target.classList.contains('vine-char')) {
const x = parseFloat(target.style.left);
const y = parseFloat(target.style.top);
this.plantFlower(x, y);
}
}
handleRightClick(e) {
const target = e.target;
// Clic droit sur caractère de tige -> planter une fleur
if (target.classList.contains('vine-char')) {
const x = parseFloat(target.style.left);
const y = parseFloat(target.style.top);
this.plantFlower(x, y);
}
}
findClosestTip(x, y, onlyRoots = false) {
let closest = null;
let closestDist = Infinity;
for (const tip of this.tips) {
if (onlyRoots && tip.generation > 0) continue;
const dx = tip.x - x;
const dy = tip.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < closestDist) {
closestDist = dist;
closest = tip;
}
}
return closest;
}
cutVine(clickX, clickY) {
const tip = this.findClosestTip(clickX, clickY);
if (!tip) return;
// Effet de débris visuels
for (let i = 0; i < 4; i++) {
this.createDebris(clickX + (Math.random() - 0.5) * 20, clickY + (Math.random() - 0.5) * 20, tip.hue);
}
// Créer deux nouvelles lianes qui divergent
const hue = tip.hue;
const hueSpeed = tip.hueSpeed;
this.tips.push({
x: clickX,
y: clickY,
angle: Math.PI / 2 - 0.3 - Math.random() * 0.4,
hue,
hueSpeed: hueSpeed * (0.8 + Math.random() * 0.4),
generation: 0,
age: 0,
alive: true,
nextBranch: 60 + Math.random() * 80,
speed: 0.9 + Math.random() * 0.4,
distSinceLastChar: 0,
});
this.tips.push({
x: clickX,
y: clickY,
angle: Math.PI / 2 + 0.3 + Math.random() * 0.4,
hue,
hueSpeed: hueSpeed * (0.8 + Math.random() * 0.4),
generation: 0,
age: 0,
alive: true,
nextBranch: 60 + Math.random() * 80,
speed: 0.9 + Math.random() * 0.4,
distSinceLastChar: 0,
});
// Tuer l'ancienne liane
tip.alive = false;
}
plantFlower(x, y) {
const tip = this.findClosestTip(x, y);
const hue = tip ? tip.hue : Math.random() * 360;
this.createFlower(x, y, hue);
}
catchFlower(el) {
const idx = this.flowers.findIndex((f) => f.el === el);
if (idx === -1) return;
const flower = this.flowers[idx];
if (flower.dying) return;
flower.dying = true;
flower.el.classList.remove('vine-flower');
flower.el.classList.add('falling-flower');
flower.el.style.animationDuration = '0.6s';
flower.el.style.animationTimingFunction = 'ease-out';
setTimeout(() => {
if (flower.el.parentNode) flower.el.remove();
}, 700);
this.flowers.splice(idx, 1);
this.flowerCount++;
if (this.counterEl) {
this.counterEl.textContent = this.flowerCount;
}
}
redirectVine(clickX, clickY) {
const root = this.findClosestTip(clickX, clickY, true);
if (!root) return;
const dx = clickX - root.x;
const dy = clickY - root.y;
let targetAngle = Math.atan2(Math.abs(dy), dx);
targetAngle = Math.max(0.3, Math.min(Math.PI - 0.3, targetAngle));
// Transition douce : on s'approche progressivement de l'angle cible
const diff = targetAngle - root.angle;
root.angle += diff * 0.4;
root.speed = Math.min(root.speed * 1.3, 2.5);
}
createDebris(x, y, hue) {
const color = this.getColor(hue, y, false);
const el = document.createElement('span');
el.textContent = pick(CHARS.debris);
el.style.cssText =
`position:absolute;left:${x}px;top:${y}px;` +
`font-family:"Courier New",Courier,monospace;` +
`font-size:12px;line-height:1;color:${color};` +
`pointer-events:none;user-select:none;` +
`opacity:0.8;`;
el.style.display = 'inline-block';
this.container.appendChild(el);
this.elements.push({
el,
born: this.frameCount,
maxAge: 60 + Math.floor(Math.random() * 40),
});
}
// ===== LOGIQUE EXISTANTE =====
getColor(hue, y, isFlower) {
const drifted = (hue + y * 0.04 + Math.random() * 6) % 360;
const sat = isFlower ? 68 : 58;
const lit = isFlower ? 74 : 68;
return `hsl(${drifted}, ${sat}%, ${lit}%)`;
}
createChar(x, y, angle, hue, isLeaf) {
const color = this.getColor(hue, y, false);
const el = document.createElement('span');
el.textContent = isLeaf ? pick(CHARS.leaf) : charForAngle(angle);
el.classList.add('vine-char');
if (isLeaf) el.classList.add('vine-leaf');
el.style.cssText =
`position:absolute;left:${x}px;top:${y}px;` +
`font-family:"Courier New",Courier,monospace;` +
`font-size:17px;line-height:1;color:${color};` +
`text-shadow:0 0 3px currentColor;` +
`pointer-events:auto;user-select:none;` +
`opacity:${isLeaf ? 0.55 : 0.85};`;
const scale = 0.9 + Math.random() * 0.12;
const rot = (Math.random() - 0.5) * 5;
el.style.transform = `scale(${scale}) rotate(${rot}deg)`;
el.style.display = 'inline-block';
this.container.appendChild(el);
const lifeRatio = Math.min(1, y / Math.max(1, this.height));
const maxAge = Math.floor(1800 - lifeRatio * 1000 + Math.random() * 300);
this.elements.push({
el,
born: this.frameCount,
maxAge,
});
}
createFlower(x, y, hue) {
if (this.flowers.length >= 8) return;
const color = this.getColor(hue, y, true);
const el = document.createElement('span');
el.textContent = pick(CHARS.flower);
el.classList.add('vine-flower');
el.style.cssText =
`position:absolute;left:${x}px;top:${y}px;` +
`font-family:"Courier New",Courier,monospace;` +
`font-size:18px;line-height:1;color:${color};` +
`text-shadow:0 0 5px currentColor;` +
`pointer-events:auto;user-select:none;` +
`opacity:0.9;display:inline-block;`;
this.container.appendChild(el);
this.flowers.push({
el,
x, y,
life: 30 + Math.random() * 40,
dying: false,
});
}
updateFlowers() {
for (let i = this.flowers.length - 1; i >= 0; i--) {
const f = this.flowers[i];
if (f.dying) continue;
f.life -= 1;
if (f.life <= 0) {
f.dying = true;
f.el.classList.remove('vine-flower');
f.el.classList.add('falling-flower');
setTimeout(() => {
if (f.el.parentNode) f.el.remove();
}, 2600);
this.flowers.splice(i, 1);
}
}
}
ageElements() {
if (this.frameCount % 10 !== 0) return;
const now = this.frameCount;
let pruneCount = 0;
const maxPrune = 18;
for (let i = 0; i < this.elements.length && pruneCount < maxPrune; i++) {
const item = this.elements[i];
const age = now - item.born;
if (age >= item.maxAge) {
if (item.el.classList.contains('vine-leaf') && !item.falling) {
item.falling = true;
item.el.classList.add('falling-leaf');
setTimeout(() => {
if (item.el.parentNode) item.el.remove();
}, 2100);
} else {
item.el.remove();
}
this.elements.splice(i, 1);
i--;
pruneCount++;
} else if (age > item.maxAge - 120 && !item.falling) {
const fade = 1 - (age - (item.maxAge - 120)) / 120;
item.el.style.opacity = Math.max(0, fade * 0.85);
}
}
}
update() {
this.frameCount++;
if (this.frameCount % 3 !== 0) return;
this.updateFlowers();
this.ageElements();
const newTips = [];
for (let i = 0; i < this.tips.length; i++) {
const tip = this.tips[i];
tip.age++;
tip.hue = (tip.hue + tip.hueSpeed) % 360;
if (tip.hue < 0) tip.hue += 360;
tip.angle += (Math.random() - 0.5) * 0.025;
if (tip.angle < 0.3) tip.angle = 0.3;
if (tip.angle > Math.PI - 0.3) tip.angle = Math.PI - 0.3;
const dx = Math.cos(tip.angle);
const dy = Math.sin(tip.angle);
const step = tip.speed * 1.6;
tip.x += dx * step;
tip.y += dy * step;
tip.distSinceLastChar += step;
if (tip.distSinceLastChar >= this.minStep) {
tip.distSinceLastChar = 0;
this.createChar(tip.x, tip.y, tip.angle, tip.hue, false);
}
if (tip.generation > 0 && Math.random() < 0.015) {
const lx = tip.x + (Math.random() - 0.5) * 14;
const ly = tip.y + (Math.random() - 0.5) * 14;
this.createChar(lx, ly, tip.angle, tip.hue, true);
}
if (
tip.age >= tip.nextBranch &&
tip.generation < 2
) {
tip.nextBranch = tip.age + 140 + Math.random() * 160;
const side = Math.random() > 0.5 ? 1 : -1;
const spread = 0.35 + Math.random() * 0.55;
newTips.push({
x: tip.x,
y: tip.y,
angle: tip.angle + side * spread,
hue: tip.hue,
hueSpeed: tip.hueSpeed,
generation: tip.generation + 1,
age: 0,
alive: true,
nextBranch: 120 + Math.random() * 140,
speed: tip.speed * (0.75 + Math.random() * 0.15),
distSinceLastChar: 0,
});
this.createFlower(
tip.x + (Math.random() - 0.5) * 8,
tip.y + (Math.random() - 0.5) * 8,
tip.hue
);
}
if (tip.generation >= 1 && Math.random() < 0.008) {
this.createFlower(
tip.x + (Math.random() - 0.5) * 6,
tip.y + (Math.random() - 0.5) * 6,
tip.hue
);
}
// Branches : meurent quand sortent
if (tip.generation > 0) {
if (
tip.y > this.height + 15 ||
tip.x < -20 ||
tip.x > this.width + 20
) {
tip.alive = false;
}
}
// Racines : respawn immédiat quand touchent le bas
else if (tip.y > this.height + 15) {
tip.y = -5;
tip.x = 40 + Math.random() * Math.max(0, this.width - 80);
tip.angle = Math.PI / 2 + (Math.random() - 0.5) * 0.25;
tip.hue = Math.random() * 360;
tip.hueSpeed = (Math.random() - 0.5) * 0.3;
tip.age = 0;
tip.nextBranch = 140 + Math.random() * 140;
tip.distSinceLastChar = 0;
// Chance de faire apparaître une nouvelle racine (max 6 racines)
if (this.tips.filter((t) => t.generation === 0).length < 6 && Math.random() < 0.35) {
this.startVine();
}
}
}
this.tips = this.tips.filter((t) => t.alive).concat(newTips);
// Respawn de secours si jamais il y a trop peu de tips
if (this.tips.filter((t) => t.generation === 0).length < 3 && Math.random() < 0.5) {
this.startVine();
}
}
loop() {
this.update();
requestAnimationFrame(this.loop);
}
}
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('vine-container');
if (container) {
new Vine(container);
}
});
})();