Add initial project files including HTML, CSS, and JavaScript for Finger Chooser app
This commit is contained in:
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Chrome against localhost",
|
||||||
|
"url": "http://localhost:8080",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
css/style.css
Normal file
33
css/style.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* Mobile-first styles */
|
||||||
|
html,body,#stage{height:100%;margin:0;padding:0}
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;background:#111;color:#fff}
|
||||||
|
#stage{position:relative;overflow:hidden;touch-action:none}
|
||||||
|
#overlay{position:absolute;left:12px;right:12px;top:18px;text-align:center}
|
||||||
|
#overlay h1{margin:0;font-size:20px;letter-spacing:0.5px}
|
||||||
|
.hint{opacity:.7;font-size:13px}
|
||||||
|
|
||||||
|
.finger{position:absolute;pointer-events:none;width:88px;height:88px;border-radius:50%;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;font-weight:600;color:#fff;background:rgba(255,255,255,0.06);border:3px solid rgba(255,255,255,0.12);backdrop-filter: blur(6px)}
|
||||||
|
.finger .label{font-size:18px}
|
||||||
|
.finger.highlight{border-color:#ffd54f;box-shadow:0 0 24px rgba(255,213,79,0.18)}
|
||||||
|
.finger.winner{background:linear-gradient(135deg,#66ff99,#00b894);border-color:#00a86b;color:#00311a;box-shadow:0 8px 30px rgba(0,168,107,0.28);transform:translate(-50%,-50%) scale(1.05)}
|
||||||
|
.finger.lost{opacity:0;transform:translate(-50%,-50%) scale(0.6);transition:all 350ms ease}
|
||||||
|
.finger{transition:all 120ms linear}
|
||||||
|
|
||||||
|
@media (min-width:600px){
|
||||||
|
.finger{width:72px;height:72px}
|
||||||
|
.finger .label{font-size:16px}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse{animation:pulse 1200ms infinite}
|
||||||
|
@keyframes pulse{0%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.08)}100%{transform:translate(-50%,-50%) scale(1)}}
|
||||||
|
|
||||||
|
.top-center-hint{position:absolute;left:50%;transform:translateX(-50%);top:8px;font-size:12px;opacity:.9}
|
||||||
|
|
||||||
|
/* Spinner visuals (subtle ring around centroid while selecting) */
|
||||||
|
#spinner{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;transform-origin:0 0}
|
||||||
|
#spinner .ring{position:absolute;left:0;top:0;width:0;height:0;border-radius:50%;border:2px dashed rgba(255,255,255,0.06);box-shadow:0 2px 12px rgba(0,0,0,0.35)}
|
||||||
|
#spinner .pointer{position:absolute;left:50%;top:0;width:12px;height:12px;margin-left:-6px;background:#ffd54f;border-radius:50%;box-shadow:0 6px 18px rgba(255,213,79,0.18)}
|
||||||
|
|
||||||
|
/* Transition helpers */
|
||||||
|
.finger{transition:transform 130ms linear, opacity 220ms ease, border-color 160ms ease}
|
||||||
|
|
||||||
39
index.html
Normal file
39
index.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||||
|
<title>Finger Chooser — Wer bleibt übrig?</title>
|
||||||
|
<meta name="description" content="Finger Chooser: Lege 2 oder mehr Finger auf das Display. Ein Finger wird per Zufall ausgewählt. Optimiert für Mobile.">
|
||||||
|
<meta name="robots" content="index,follow">
|
||||||
|
<link rel="canonical" href="/index.html">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="Finger Chooser — Wer bleibt übrig?">
|
||||||
|
<meta property="og:description" content="Lege mehrere Finger auf das Display und lass einen zufällig auswählen. Optimiert für Mobile.">
|
||||||
|
<meta name="theme-color" content="#111111">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@context":"https://schema.org","@type":"WebApplication","name":"Finger Chooser","description":"Lege mehrere Finger auf das Display und ein Finger wird zufällig ausgewählt.","applicationCategory":"Game"}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="stage">
|
||||||
|
<div id="overlay">
|
||||||
|
<h1>Finger Chooser</h1>
|
||||||
|
<p>Lege 2 oder mehr Finger auf das Display — einer wird zufällig ausgewählt.</p>
|
||||||
|
<p class="hint">Hebe alle Finger an, um neu zu starten.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
<noscript>
|
||||||
|
<style>
|
||||||
|
#overlay{opacity:1;background:transparent}
|
||||||
|
</style>
|
||||||
|
<div style="padding:12px;text-align:center;background:#222;color:#fff">Bitte aktiviere JavaScript, um die Anwendung zu nutzen.</div>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
176
js/app.js
Normal file
176
js/app.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const touches = new Map(); // id -> {el,x,y}
|
||||||
|
let selecting = false;
|
||||||
|
let selectionDone = false;
|
||||||
|
|
||||||
|
function createFinger(t){
|
||||||
|
const id = (t.identifier !== undefined && t.identifier !== null) ? String(t.identifier) : ('m'+Date.now());
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'finger';
|
||||||
|
el.setAttribute('data-id', id);
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'label';
|
||||||
|
label.textContent = '1';
|
||||||
|
el.appendChild(label);
|
||||||
|
el.style.left = t.clientX + 'px';
|
||||||
|
el.style.top = t.clientY + 'px';
|
||||||
|
stage.appendChild(el);
|
||||||
|
touches.set(id, {el: el, x: t.clientX, y: t.clientY});
|
||||||
|
updateLabels();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFinger(t){
|
||||||
|
const id = (t.identifier !== undefined && t.identifier !== null) ? String(t.identifier) : 'm';
|
||||||
|
const entry = touches.get(id);
|
||||||
|
if(entry){ entry.x = t.clientX; entry.y = t.clientY; entry.el.style.left = t.clientX + 'px'; entry.el.style.top = t.clientY + 'px'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFingerById(id){
|
||||||
|
const key = String(id);
|
||||||
|
const entry = touches.get(key);
|
||||||
|
if(entry){ entry.el.remove(); touches.delete(key); updateLabels(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLabels(){
|
||||||
|
let i=1;
|
||||||
|
for(const entry of touches.values()){
|
||||||
|
const lbl = entry.el.querySelector('.label');
|
||||||
|
if(lbl) lbl.textContent = String(i++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeOrderedItems(){
|
||||||
|
const arr = Array.from(touches.entries()).map(([id,entry])=>({id,el:entry.el,x:entry.x,y:entry.y}));
|
||||||
|
const cx = arr.reduce((s,a)=>s+a.x,0)/arr.length;
|
||||||
|
const cy = arr.reduce((s,a)=>s+a.y,0)/arr.length;
|
||||||
|
arr.forEach(a=>{
|
||||||
|
const ang = Math.atan2(a.y-cy,a.x-cx) * 180/Math.PI;
|
||||||
|
a.angle = (ang+360)%360;
|
||||||
|
a.dist = Math.hypot(a.x-cx,a.y-cy);
|
||||||
|
});
|
||||||
|
arr.sort((a,b)=>a.angle-b.angle);
|
||||||
|
return {items:arr, center:{x:cx,y:cy}};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeOrderedItems(){
|
||||||
|
const arr = Array.from(touches.entries()).map(([id,entry])=>({id,el:entry.el,x:entry.x,y:entry.y}));
|
||||||
|
const cx = arr.reduce((s,a)=>s+a.x,0)/arr.length;
|
||||||
|
const cy = arr.reduce((s,a)=>s+a.y,0)/arr.length;
|
||||||
|
arr.forEach(a=>{
|
||||||
|
const ang = Math.atan2(a.y-cy,a.x-cx) * 180/Math.PI; // -180..180
|
||||||
|
a.angle = (ang+360)%360; // 0..360
|
||||||
|
a.dist = Math.hypot(a.x-cx,a.y-cy);
|
||||||
|
});
|
||||||
|
arr.sort((a,b)=>a.angle-b.angle);
|
||||||
|
return {items:arr, center:{x:cx,y:cy}};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSelectionIfNeeded(){
|
||||||
|
if(selecting || selectionDone) return;
|
||||||
|
if(touches.size < 1) return; // require at least 1 finger
|
||||||
|
selecting = true;
|
||||||
|
|
||||||
|
const {items} = computeOrderedItems();
|
||||||
|
const els = items.map(i=>i.el);
|
||||||
|
const n = els.length;
|
||||||
|
|
||||||
|
// If only one finger, immediately mark it as winner (short visual)
|
||||||
|
if(n === 1){
|
||||||
|
const el = els[0];
|
||||||
|
el.classList.add('winner','pulse');
|
||||||
|
selecting = false;
|
||||||
|
selectionDone = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotations = Math.floor(Math.random()*3)+3; // 3-5 rotations
|
||||||
|
const winnerIndex = Math.floor(Math.random()*n);
|
||||||
|
const totalSteps = rotations * n + winnerIndex;
|
||||||
|
|
||||||
|
let cumulative = 0;
|
||||||
|
let current = 0;
|
||||||
|
for(let step=0; step<=totalSteps; step++){
|
||||||
|
const t = step/totalSteps;
|
||||||
|
const delay = 40 + Math.pow(t,2)*520; // ease-out timing
|
||||||
|
cumulative += delay;
|
||||||
|
const idx = current % n;
|
||||||
|
setTimeout(()=>{
|
||||||
|
els.forEach((el,i)=>{
|
||||||
|
if(i===idx) el.classList.add('highlight'); else el.classList.remove('highlight');
|
||||||
|
});
|
||||||
|
}, cumulative);
|
||||||
|
current++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(()=>{
|
||||||
|
els.forEach((el,i)=>{
|
||||||
|
if(i===winnerIndex){ el.classList.remove('highlight'); el.classList.add('winner','pulse'); }
|
||||||
|
else { el.classList.remove('highlight'); el.classList.add('lost'); }
|
||||||
|
});
|
||||||
|
selecting = false;
|
||||||
|
selectionDone = true;
|
||||||
|
}, cumulative + 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSelection(){
|
||||||
|
selecting = false;
|
||||||
|
selectionDone = false;
|
||||||
|
for(const entry of touches.values()){ entry.el.classList.remove('highlight','lost','winner','pulse'); entry.el.style.opacity = '1'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWhenAllUp(){
|
||||||
|
if(touches.size===0){
|
||||||
|
setTimeout(()=>{
|
||||||
|
selecting=false; selectionDone=false;
|
||||||
|
const nodes = Array.from(stage.querySelectorAll('.finger'));
|
||||||
|
nodes.forEach(node=>node.remove());
|
||||||
|
touches.clear();
|
||||||
|
},160);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch handlers
|
||||||
|
stage.addEventListener('touchstart', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const changed = Array.from(e.changedTouches || []);
|
||||||
|
changed.forEach(t=>createFinger(t));
|
||||||
|
cancelSelection();
|
||||||
|
startSelectionIfNeeded();
|
||||||
|
}, {passive:false});
|
||||||
|
stage.addEventListener('touchmove', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const changed = Array.from(e.changedTouches || []);
|
||||||
|
changed.forEach(t=>updateFinger(t));
|
||||||
|
}, {passive:false});
|
||||||
|
stage.addEventListener('touchend', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const changed = Array.from(e.changedTouches || []);
|
||||||
|
changed.forEach(t=>removeFingerById(t.identifier));
|
||||||
|
if(!selectionDone) cancelSelection();
|
||||||
|
if(touches.size===0) resetWhenAllUp();
|
||||||
|
}, {passive:false});
|
||||||
|
stage.addEventListener('touchcancel', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const changed = Array.from(e.changedTouches || []);
|
||||||
|
changed.forEach(t=>removeFingerById(t.identifier));
|
||||||
|
if(!selectionDone) cancelSelection();
|
||||||
|
if(touches.size===0) resetWhenAllUp();
|
||||||
|
}, {passive:false});
|
||||||
|
|
||||||
|
// Support mouse for desktop testing
|
||||||
|
let mouseDown = false;
|
||||||
|
const mouseId = 'mouse';
|
||||||
|
stage.addEventListener('mousedown', function(e){
|
||||||
|
mouseDown = true;
|
||||||
|
createFinger({identifier:mouseId, clientX:e.clientX, clientY:e.clientY});
|
||||||
|
cancelSelection();
|
||||||
|
startSelectionIfNeeded();
|
||||||
|
});
|
||||||
|
window.addEventListener('mousemove', function(e){ if(!mouseDown) return; updateFinger({identifier:mouseId, clientX:e.clientX, clientY:e.clientY}); });
|
||||||
|
window.addEventListener('mouseup', function(e){ if(!mouseDown) return; mouseDown=false; removeFingerById(mouseId); if(!selectionDone) cancelSelection(); if(touches.size===0) resetWhenAllUp(); });
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user