540 lines
20 KiB
JavaScript
540 lines
20 KiB
JavaScript
(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 = '×';
|
||
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);
|
||
}
|
||
});
|
||
})();
|