<!doctype html>
<html lang="it">
<title>Particle Face+Hands Flow (MediaPipe)</title>
<style>
:root{
--bg:#070A12;
--panel:rgba(255,255,255,.08);
--panel2:rgba(255,255,255,.12);
--text:rgba(255,255,255,.92);
--muted:rgba(255,255,255,.65);
--accent:#7CF7FF;
--danger:#FF4FD8;
}
html,body{height:100%;margin:0;background:var(--bg);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;}
#app{position:fixed;inset:0;overflow:hidden;}
canvas{display:block;}
#intro{
position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:radial-gradient(1200px 800px at 50% 40%, rgba(124,247,255,.08), transparent 55%),
radial-gradient(1000px 700px at 60% 60%, rgba(255,79,216,.07), transparent 60%),
var(--bg);
z-index:50;
}
.card{
width:min(560px,92vw);
padding:28px 26px;
border-radius:18px;
background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.12);
box-shadow:0 14px 50px rgba(0,0,0,.45);
color:var(--text);
}
.title{font-size:20px;font-weight:700;letter-spacing:.2px;margin:0 0 10px;}
.desc{margin:0 0 18px;color:var(--muted);line-height:1.35}
.btn{
display:inline-flex;align-items:center;gap:10px;
padding:12px 16px;border-radius:12px;
border:1px solid rgba(255,255,255,.16);
background:rgba(255,255,255,.10);
color:var(--text);cursor:pointer;
font-weight:650;
user-select:none;
transition:transform .05s ease, background .15s ease;
}
.btn:hover{background:rgba(255,255,255,.14)}
.btn:active{transform:translateY(1px)}
.pill{
display:inline-block;margin-left:8px;padding:2px 8px;border-radius:999px;
font-size:12px;color:rgba(255,255,255,.8);border:1px solid rgba(255,255,255,.14);
background:rgba(255,255,255,.06);
}
/* Overlay UI */
#hud{position:fixed;inset:0;pointer-events:none;z-index:10;}
.panel{
pointer-events:auto;
position:absolute;
background:var(--panel);
border:1px solid rgba(255,255,255,.12);
border-radius:14px;
color:var(--text);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:0 10px 32px rgba(0,0,0,.35);
}
#controls{
top:14px;left:14px;padding:10px;display:flex;gap:10px;align-items:center;
}
.toggle{
display:flex;align-items:center;gap:8px;
padding:10px 12px;border-radius:12px;
border:1px solid rgba(255,255,255,.14);
background:rgba(255,255,255,.08);
cursor:pointer;
user-select:none;
font-weight:650;
font-size:13px;
}
.toggle small{display:block;font-weight:600;color:var(--muted);margin-top:2px}
.dot{
width:10px;height:10px;border-radius:999px;background:rgba(255,255,255,.28);
box-shadow:0 0 0 2px rgba(0,0,0,.15) inset;
}
.dot.on{background:var(--accent); box-shadow:0 0 18px rgba(124,247,255,.35);}
#status{
top:14px;right:14px;padding:10px 12px;min-width:210px;display:flex;flex-direction:column;gap:4px;
}
#status .line1{font-weight:750;font-size:13px}
#status .line2{color:var(--muted);font-size:12px}
#shortcuts{
right:14px;bottom:14px;padding:10px 12px;max-width:min(320px,92vw);
display:flex;flex-direction:column;gap:6px;
}
.kbdrow{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:12px}
kbd{
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;
font-size:12px;color:rgba(255,255,255,.85);
padding:2px 8px;border-radius:8px;
border:1px solid rgba(255,255,255,.16);
background:rgba(0,0,0,.25);
}
/* Camera preview */
#previewWrap{
position:absolute;
top:14px;left:50%;transform:translateX(-50%);
width:256px;height:144px;
border-radius:16px;
overflow:hidden;
background:rgba(0,0,0,.35);
border:1px solid rgba(255,255,255,.14);
box-shadow:0 16px 42px rgba(0,0,0,.45);
pointer-events:none;
}
#previewVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);}
#previewOverlay{position:absolute;inset:0;width:100%;height:100%;}
#themeTag{
position:absolute;left:50%;transform:translateX(-50%);
top:168px;
padding:6px 10px;border-radius:999px;
background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.12);
color:rgba(255,255,255,.86);
font-size:12px;font-weight:700;
pointer-events:none;
}
</style>
</head>
<body>
<div id="app">
<canvas id="fx"></canvas>
<div id="hud">
<div id="previewWrap">
<video id="previewVideo" playsinline muted></video>
<canvas id="previewOverlay" width="256" height="144"></canvas>
</div>
<div id="themeTag" class="panel" style="border-radius:999px;padding:6px 10px;">Tema: <span id="themeName">Arcobaleno</span></div>
<div id="controls" class="panel">
<div id="modeBtn" class="toggle" title="Spazio: Attrazione/Repulsione">
<span class="dot on" id="modeDot"></span>
<div>
<div id="modeLabel">Attrazione</div>
<small>Spazio</small>
</div>
</div>
<div id="camBtn" class="toggle" title="V: Attiva/Disattiva fotocamera">
<span class="dot on" id="camDot"></span>
<div>
<div id="camLabel">Camera ON</div>
<small>V</small>
</div>
</div>
</div>
<div id="status" class="panel">
<div class="line1" id="status1">Caricamento…</div>
<div class="line2" id="status2">Inizializzo MediaPipe</div>
</div>
<div id="shortcuts" class="panel">
<div style="font-weight:750;font-size:13px">Scorciatoie</div>
<div class="kbdrow"><span>Modalità</span><span><kbd>Spazio</kbd></span></div>
<div class="kbdrow"><span>Camera</span><span><kbd>V</kbd></span></div>
<div class="kbdrow"><span>Tema (pugno)</span><span><kbd>Gesto</kbd></span></div>
</div>
</div>
<div id="intro">
<div class="card">
<h1 class="title">Particelle in tempo reale: volto + mani</h1>
<p class="desc">
Migliaia di particelle scorrono come un liquido per formare la tua faccia e le tue mani.
Premi <b>Abilita fotocamera</b>, poi mostra le mani davanti alla camera.
<span class="pill">MediaPipe Hands + FaceMesh</span>
</p>
<button id="startBtn" class="btn">Abilita fotocamera</button>
<p class="desc" style="margin-top:14px;font-size:12px;">
Nota: serve un server locale (non aprire il file direttamente).
</p>
</div>
</div>
</div>
<script type="module">
import { Hands } from "https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js";
import { FaceMesh } from "https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js";
import { Camera } from "https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js";
// ---------- DOM ----------
const fx = document.getElementById("fx");
const fxc = fx.getContext("2d", { alpha: true });
const intro = document.getElementById("intro");
const startBtn = document.getElementById("startBtn");
const previewVideo = document.getElementById("previewVideo");
const previewOverlay = document.getElementById("previewOverlay");
const poc = previewOverlay.getContext("2d");
const status1 = document.getElementById("status1");
const status2 = document.getElementById("status2");
const modeBtn = document.getElementById("modeBtn");
const modeDot = document.getElementById("modeDot");
const modeLabel = document.getElementById("modeLabel");
const camBtn = document.getElementById("camBtn");
const camDot = document.getElementById("camDot");
const camLabel = document.getElementById("camLabel");
const themeName = document.getElementById("themeName");
// ---------- Canvas sizing ----------
function resize() {
const dpr = Math.min(2, window.devicePixelRatio || 1);
fx.width = Math.floor(innerWidth * dpr);
fx.height = Math.floor(innerHeight * dpr);
fx.style.width = innerWidth + "px";
fx.style.height = innerHeight + "px";
fxc.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener("resize", resize);
resize();
// ---------- Themes ----------
const THEMES = [
{ name: "Arcobaleno", mode: "hsv", base: 0, sat: 1.0, lum: 0.62 },
{ name: "Fuoco", mode: "fire", base: 18, sat: 1.0, lum: 0.62 },
{ name: "Oceano", mode: "ocean", base: 190, sat: 0.95,lum: 0.60 },
{ name: "Galassia", mode: "galaxy",base: 270, sat: 0.95,lum: 0.62 },
{ name: "Matrice", mode: "matrix",base: 120, sat: 0.95,lum: 0.55 },
];
let themeIndex = 0;
function hsl(h,s,l,a=1){ return `hsla(${(h%360+360)%360},${Math.round(s*100)}%,${Math.round(l*100)}%,${a})`; }
function particleColor(t, speed01, seed01){
const th = THEMES[themeIndex];
if (th.mode === "hsv") return hsl(th.base + seed01*360, th.sat, th.lum + 0.1*speed01, 1);
if (th.mode === "fire") return hsl(th.base + seed01*55, 1.0, 0.48 + 0.25*speed01, 1);
if (th.mode === "ocean") return hsl(th.base + seed01*45, 0.95,0.40 + 0.25*speed01, 1);
if (th.mode === "galaxy")return hsl(th.base + seed01*70, 0.90,0.45 + 0.28*speed01, 1);
if (th.mode === "matrix")return hsl(th.base + seed01*18, 0.90,0.40 + 0.22*speed01, 1);
return hsl(200,1,.6,1);
}
function setTheme(idx){
themeIndex = (idx + THEMES.length) % THEMES.length;
themeName.textContent = THEMES[themeIndex].name;
}
setTheme(0);
// ---------- Particle system ----------
const GOLDEN = 0.61803398875;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const P_MIN = 8000, P_MAX = 15000;
const P_COUNT = 12000; // puoi cambiare tra 8000..15000
const px = new Float32Array(P_COUNT);
const py = new Float32Array(P_COUNT);
const vx = new Float32Array(P_COUNT);
const vy = new Float32Array(P_COUNT);
const seed = new Float32Array(P_COUNT);
const tgt = new Uint32Array(P_COUNT);
function rand01(i){
// deterministic-ish seed
const x = Math.sin(i*12.9898) * 43758.5453;
return x - Math.floor(x);
}
function initParticles(){
for(let i=0;i<P_COUNT;i++){
px[i] = innerWidth * rand01(i*3+1);
py[i] = innerHeight* rand01(i*3+2);
vx[i] = (rand01(i*3+3)-0.5)*0.4;
vy[i] = (rand01(i*3+4)-0.5)*0.4;
seed[i] = rand01(i*7+9);
tgt[i] = 0;
}
}
initParticles();
let repel = false; // false=attrazione, true=repulsione
function setMode(isRepel){
repel = isRepel;
modeLabel.textContent = repel ? "Repulsione" : "Attrazione";
modeDot.classList.toggle("on", !repel); // on = attrazione (accent)
}
setMode(false);
// trails: draw translucent rect each frame
function fadeTrails(){
fxc.fillStyle = "rgba(5,7,14,0.14)";
fxc.fillRect(0,0,innerWidth,innerHeight);
}
// ---------- Targets (hands + face) ----------
// target: {x,y,w} in screen space (pixels) where w = "spread" (tight vs wide)
let targets = [];
let haveHands = false;
let haveFace = false;
// Hand connections (MediaPipe)
const HAND_CONN = [
[0,1],[1,2],[2,3],[3,4], // thumb
[0,5],[5,6],[6,7],[7,8], // index
[0,9],[9,10],[10,11],[11,12],// middle
[0,13],[13,14],[14,15],[15,16],// ring
[0,17],[17,18],[18,19],[19,20],// pinky
[5,9],[9,13],[13,17] // palm
];
// Face contour sets (approx) for colored outlines (useful subset)
// Note: we still use all 468 points for particle targets.
const FACE_OVAL = [10,338,297,332,284,251,389,356,454,323,361,288,397,365,379,378,400,377,152,148,176,149,150,136,172,58,132,93,234,127,162,21,54,103,67,109];
const LIPS = [61,146,91,181,84,17,314,405,321,375,291,308,324,318,402,317,14,87,178,88,95,78];
const LEFT_EYE = [33,7,163,144,145,153,154,155,133,173,157,158,159,160,161,246];
const RIGHT_EYE = [263,249,390,373,374,380,381,382,362,398,384,385,386,387,388,466];
function toScreen(xNorm,yNorm){
return { x: (1-xNorm) * innerWidth, y: yNorm * innerHeight }; // mirror like selfie
}
function toPreview(xNorm,yNorm){
// preview is also mirrored via video CSS, but we draw overlay directly mirrored
return { x: (1-xNorm) * 256, y: yNorm * 144 };
}
function addCluster(cx, cy, count, spreadPx){
// Golden-angle disc for organic, uniform-ish distribution around a point
for(let i=0;i<count;i++){
const r = spreadPx * Math.sqrt((i + 0.5) / count);
const a = i * GOLDEN_ANGLE;
targets.push({
x: cx + Math.cos(a)*r,
y: cy + Math.sin(a)*r,
w: Math.max(1.0, spreadPx * 0.35)
});
}
}
function addSegmentSamples(ax,ay,bx,by, samples, spreadA, spreadB){
// sample along segment using golden ratio fractional stepping
for(let i=0;i<samples;i++){
const t = (i * GOLDEN) % 1; // golden section stepping
const x = ax + (bx-ax)*t;
const y = ay + (by-ay)*t;
const spread = spreadA + (spreadB - spreadA)*t;
addCluster(x,y, 1, spread);
}
}
function buildHandTargets(handLandmarks){
// taper: tips tight, segments medium, palm wide
// landmark indices: wrist=0, tips: 4,8,12,16,20
const lm = handLandmarks;
// Palm area clusters (wider)
const palmIdx = [0,5,9,13,17];
for (const idx of palmIdx){
const s = toScreen(lm[idx].x, lm[idx].y);
addCluster(s.x, s.y, 6, 12); // wider
}
// Wrist (a bit tighter than palm)
{
const s = toScreen(lm[0].x, lm[0].y);
addCluster(s.x, s.y, 6, 8);
}
// Bones (medium) + tips (tight)
for (const [a,b] of HAND_CONN){
const A = toScreen(lm[a].x, lm[a].y);
const B = toScreen(lm[b].x, lm[b].y);
const isTip = (b===4||b===8||b===12||b===16||b===20);
const spreadA = 7;
const spreadB = isTip ? 2.2 : 5.2;
const len = Math.hypot(B.x-A.x, B.y-A.y);
const samples = Math.max(2, Math.min(10, Math.round(len/18)));
addSegmentSamples(A.x,A.y,B.x,B.y, samples, spreadA, spreadB);
if (isTip){
addCluster(B.x, B.y, 10, 2.0); // very tight fingertip clustering
}
}
}
// Face depth accent: exaggerate z around nose/cheeks/orbits
const FACE_BOOST = new Map([
[1, 2.5], // nose tip-ish
[4, 2.0], // nose ridge-ish
[168,1.6], // mid-face
[234,1.8], // left cheek-ish
[454,1.8], // right cheek-ish
[33, 1.6], // left eye corner
[263,1.6], // right eye corner
[159,1.7], // left upper lid
[386,1.7], // right upper lid
]);
function buildFaceTargets(faceLandmarks){
const lm = faceLandmarks;
// Use all 468 points, but keep clustering extremely tight (1–2 px)
for(let i=0;i<lm.length;i++){
const p = lm[i];
const s = toScreen(p.x, p.y);
// parallax-ish boost: push points away from center by z
const cx = innerWidth*0.5, cy = innerHeight*0.5;
const dx = s.x - cx, dy = s.y - cy;
const w = FACE_BOOST.get(i) ?? 1.0;
const z = (p.z ?? 0) * -1; // z is usually negative towards camera
const k = 120 * w; // strength
const bx = s.x + dx * z * 0.22 * w;
const by = s.y + dy * z * 0.22 * w;
targets.push({ x: bx, y: by, w: 1.4 }); // tight mesh
}
}
function rebuildTargets(handsResults, faceResults){
targets.length = 0;
haveHands = false;
haveFace = false;
// Hands
if (handsResults?.multiHandLandmarks?.length){
haveHands = true;
for(const hand of handsResults.multiHandLandmarks){
buildHandTargets(hand);
}
}
// Face
if (faceResults?.multiFaceLandmarks?.length){
haveFace = true;
// only first face
buildFaceTargets(faceResults.multiFaceLandmarks[0]);
}
// Ensure at least some targets to avoid NaNs (float around center)
if (targets.length === 0){
const cx = innerWidth*0.5, cy = innerHeight*0.5;
for(let i=0;i<300;i++){
const r = 120*Math.sqrt((i+0.5)/300);
const a = i*GOLDEN_ANGLE;
targets.push({x:cx+Math.cos(a)*r,y:cy+Math.sin(a)*r,w:18});
}
}
// Assign particles uniformly across targets
const T = targets.length >>> 0;
const offset = (performance.now() * 0.001 * 97) >>> 0;
for(let i=0;i<P_COUNT;i++){
tgt[i] = (i + offset) % T;
}
}
// ---------- Drawing preview overlay ----------
function drawHandOverlayPreview(handLandmarks){
// Draw skeleton with cyan + pink alternating
const lm = handLandmarks;
const cyan = "rgba(124,247,255,0.95)";
const pink = "rgba(255,79,216,0.90)";
poc.lineWidth = 2.0;
poc.lineCap = "round";
poc.lineJoin = "round";
for(let i=0;i<HAND_CONN.length;i++){
const [a,b] = HAND_CONN[i];
const A = toPreview(lm[a].x,lm[a].y);
const B = toPreview(lm[b].x,lm[b].y);
poc.strokeStyle = (i%2===0) ? cyan : pink;
poc.beginPath();
poc.moveTo(A.x,A.y);
poc.lineTo(B.x,B.y);
poc.stroke();
}
// Points (bigger at wrist + tips)
const tips = new Set([4,8,12,16,20,0]);
for(let i=0;i<lm.length;i++){
const p = toPreview(lm[i].x,lm[i].y);
const big = tips.has(i);
poc.fillStyle = big ? "rgba(255,255,255,0.92)" : "rgba(255,255,255,0.55)";
poc.beginPath();
poc.arc(p.x,p.y, big ? 3.6 : 2.0, 0, Math.PI*2);
poc.fill();
}
}
function strokeLoop(indices, color){
if (!indices || indices.length<2) return;
poc.strokeStyle = color;
poc.lineWidth = 2.0;
poc.beginPath();
for(let i=0;i<indices.length;i++){
const idx = indices[i];
const p = currentFaceLm ? toPreview(currentFaceLm[idx].x, currentFaceLm[idx].y) : null;
if(!p) continue;
if(i===0) poc.moveTo(p.x,p.y); else poc.lineTo(p.x,p.y);
}
poc.closePath();
poc.stroke();
}
let currentFaceLm = null;
function drawFaceOverlayPreview(faceLandmarks){
currentFaceLm = faceLandmarks;
// light mesh points (not full triangulation — keeps it fast in preview)
poc.fillStyle = "rgba(255,255,255,0.28)";
for(let i=0;i<faceLandmarks.length;i+=2){
const p = toPreview(faceLandmarks[i].x, faceLandmarks[i].y);
poc.fillRect(p.x, p.y, 1, 1);
}
// feature outlines
strokeLoop(LEFT_EYE, "rgba(104,255,225,0.90)"); // aqua green
strokeLoop(RIGHT_EYE, "rgba(104,255,225,0.90)");
strokeLoop(LIPS, "rgba(255,79,216,0.92)"); // pink
strokeLoop(FACE_OVAL, "rgba(124,247,255,0.90)"); // cyan
}
function updateStatus(){
if (!ready){
status1.textContent = "Caricamento…";
status2.textContent = "Inizializzo MediaPipe";
return;
}
if (!camOn){
status1.textContent = "Camera disattivata";
status2.textContent = "Premi V per riattivare";
return;
}
if (haveFace || haveHands){
const parts = [];
if (haveFace) parts.push("volto");
if (haveHands) parts.push("mani");
status1.textContent = "Rilevamento attivo";
status2.textContent = "Vedo: " + parts.join(" + ");
} else {
status1.textContent = "In attesa…";
status2.textContent = "Mostra le mani (o il volto) alla camera";
}
}
// ---------- Fist gesture (theme cycle) ----------
function dist(a,b){
const dx=a.x-b.x, dy=a.y-b.y;
return Math.hypot(dx,dy);
}
function isFist(lm){
// heuristic: for index/middle/ring/pinky, tip closer to wrist than MCP by a margin
const wrist = lm[0];
const pairs = [
{ tip: 8, mcp: 5 },
{ tip: 12, mcp: 9 },
{ tip: 16, mcp: 13},
{ tip: 20, mcp: 17},
];
let curled = 0;
for (const p of pairs){
const dt = dist(lm[p.tip], wrist);
const dm = dist(lm[p.mcp], wrist);
if (dt < dm * 0.88) curled++;
}
// thumb optional (varies a lot), so just require 3/4 curled
return curled >= 3;
}
let lastFistTime = 0;
// ---------- MediaPipe wiring ----------
let ready = false;
let camOn = false;
let camera = null;
let lastHandsResults = null;
let lastFaceResults = null;
const hands = new Hands({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
hands.setOptions({
maxNumHands: 2,
modelComplexity: 1,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.6,
selfieMode: true
});
const faceMesh = new FaceMesh({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
});
faceMesh.setOptions({
maxNumFaces: 1,
refineLandmarks: true,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.6,
selfieMode: true
});
hands.onResults((res)=>{
lastHandsResults = res;
// theme cycle on fist gesture
if (res?.multiHandLandmarks?.length){
for (const hand of res.multiHandLandmarks){
if (isFist(hand) && (performance.now() - lastFistTime) > 650){
lastFistTime = performance.now();
setTheme(themeIndex + 1);
break;
}
}
}
});
faceMesh.onResults((res)=>{
lastFaceResults = res;
});
async function startCamera(){
status1.textContent = "Caricamento…";
status2.textContent = "Richiesta accesso fotocamera";
// attach video
previewVideo.setAttribute("autoplay","");
previewVideo.setAttribute("playsinline","");
previewVideo.muted = true;
camera = new Camera(previewVideo, {
onFrame: async () => {
if (!camOn) return;
// Send same frame to both models (serial to avoid overload)
await hands.send({ image: previewVideo });
await faceMesh.send({ image: previewVideo });
// rebuild targets from latest results
rebuildTargets(lastHandsResults, lastFaceResults);
// draw preview overlay (mirrored feed is in CSS; overlay is drawn mirrored via x->1-x mapping)
poc.clearRect(0,0,256,144);
if (lastFaceResults?.multiFaceLandmarks?.length){
drawFaceOverlayPreview(lastFaceResults.multiFaceLandmarks[0]);
}
if (lastHandsResults?.multiHandLandmarks?.length){
for (const hand of lastHandsResults.multiHandLandmarks){
drawHandOverlayPreview(hand);
}
}
updateStatus();
},
width: 640,
height: 360
});
camOn = true;
camDot.classList.add("on");
camLabel.textContent = "Camera ON";
await camera.start();
}
async function stopCamera(){
camOn = false;
camDot.classList.remove("on");
camLabel.textContent = "Camera OFF";
updateStatus();
// Best-effort stop tracks
try{
const stream = previewVideo.srcObject;
if (stream && stream.getTracks){
for (const t of stream.getTracks()) t.stop();
}
previewVideo.srcObject = null;
}catch(e){}
try{ camera?.stop(); }catch(e){}
camera = null;
}
// ---------- Simulation step ----------
function stepParticles(){
const T = targets.length;
if (!T) return;
// physics params
const attract = repel ? -1 : 1;
const pull = 0.020 * attract; // attraction/repulsion strength
const damp = 0.985; // viscous damping (fluid-ish)
const jitter = 0.08; // micro turbulence
const maxSpeed = 3.4;
for(let i=0;i<P_COUNT;i++){
const t = targets[tgt[i] % T];
const dx = (t.x - px[i]);
const dy = (t.y - py[i]);
// weight by tightness: face targets (w~1.4) pull tighter; palm wider softer
const w = t.w;
const inv = 1.0 / (Math.hypot(dx,dy) + 10*w);
// swirl term to make it look liquid
const swx = -dy * inv * 0.65;
const swy = dx * inv * 0.65;
vx[i] = (vx[i] + dx * inv * pull * (14/w) + swx*0.012 + (seed[i]-0.5)*jitter*0.03) * damp;
vy[i] = (vy[i] + dy * inv * pull * (14/w) + swy*0.012 + (seed[i]-0.5)*jitter*0.03) * damp;
// cap speed
const sp = Math.hypot(vx[i], vy[i]);
if (sp > maxSpeed){
const s = maxSpeed / (sp + 1e-6);
vx[i] *= s; vy[i] *= s;
}
px[i] += vx[i];
py[i] += vy[i];
// wrap edges (keeps density stable)
if (px[i] < -20) px[i] = innerWidth + 20;
else if (px[i] > innerWidth+20) px[i] = -20;
if (py[i] < -20) py[i] = innerHeight + 20;
else if (py[i] > innerHeight+20) py[i] = -20;
}
}
function drawParticles(){
// draw points with soft glow via multiple passes (cheap-ish)
// we avoid per-particle shadowBlur (slow); instead draw small circles + additive-ish
fxc.globalCompositeOperation = "lighter";
for(let i=0;i<P_COUNT;i++){
const sp = Math.min(1, Math.hypot(vx[i],vy[i]) / 3.2);
fxc.fillStyle = particleColor(THEMES[themeIndex], sp, seed[i]);
// radius: face points tighter; general use small
const r = 1.15 + sp*0.9;
fxc.beginPath();
fxc.arc(px[i], py[i], r, 0, Math.PI*2);
fxc.fill();
}
fxc.globalCompositeOperation = "source-over";
}
function loop(){
fadeTrails();
stepParticles();
drawParticles();
requestAnimationFrame(loop);
}
// ---------- UI interactions ----------
modeBtn.addEventListener("click", ()=> setMode(!repel));
camBtn.addEventListener("click", async ()=>{
if (!ready) return;
if (camOn) await stopCamera();
else await startCamera();
});
addEventListener("keydown", async (e)=>{
if (e.code === "Space"){
e.preventDefault();
setMode(!repel);
}
if (e.key.toLowerCase() === "v"){
if (!ready) return;
if (camOn) await stopCamera();
else await startCamera();
}
});
// ---------- Start ----------
startBtn.addEventListener("click", async ()=>{
try{
intro.style.display = "none";
status1.textContent = "Caricamento…";
status2.textContent = "Carico modelli e avvio camera";
ready = true;
updateStatus();
await startCamera();
loop();
}catch(err){
console.error(err);
intro.style.display = "flex";
status1.textContent = "Errore";
status2.textContent = "Impossibile avviare la fotocamera (permessi / https / server locale).";
ready = false;
camOn = false;
}
});
// boot status
updateStatus();
</script>
</body>
</html>