1836 lines
92 KiB
HTML
Executable File
1836 lines
92 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<meta
|
||
name="description"
|
||
content="Application d'horloge d'echecs avec modes tournoi, historique et timers de cube."
|
||
/>
|
||
<title>Chess Clock</title>
|
||
<link rel="stylesheet" href="./src/features/chess-clock/chessClock.css" />
|
||
<script>
|
||
window.addEventListener("error", function (event) {
|
||
let panel = document.getElementById("boot-status");
|
||
if (!panel) {
|
||
panel = document.createElement("div");
|
||
panel.id = "boot-status";
|
||
document.body.prepend(panel);
|
||
}
|
||
panel.style.display = "block";
|
||
panel.style.position = "fixed";
|
||
panel.style.inset = "0";
|
||
panel.style.background = "#060a0e";
|
||
panel.style.zIndex = "9999";
|
||
panel.style.color = "#ffd97a";
|
||
panel.style.font = "600 14px/1.5 system-ui, sans-serif";
|
||
panel.style.padding = "24px";
|
||
panel.style.whiteSpace = "pre-wrap";
|
||
panel.textContent = "Le chargement a echoue. Verifie ta connexion Internet, puis recharge la page.\n\n" + (event.message || "Erreur inconnue");
|
||
});
|
||
</script>
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
</head>
|
||
<body>
|
||
<div id="boot-status" style="color:#c8d8e8;padding:24px;font:600 14px/1.5 system-ui,sans-serif;">Chargement de Chess Clock...</div>
|
||
<div id="root"></div>
|
||
<script type="text/babel" data-presets="react">
|
||
const { StrictMode, useCallback, useEffect, useRef, useState } = React;
|
||
|
||
|
||
/* ══ HELPERS ══ */
|
||
const fmt = s => Math.floor(Math.abs(s)/60).toString().padStart(2,"0")+":"+Math.floor(Math.abs(s)%60).toString().padStart(2,"0");
|
||
const fmtMs = ms => Math.floor(ms/1000)+"."+(Math.floor((ms%1000)/10)).toString().padStart(2,"0");
|
||
const fmtDate = ts => { const d=new Date(ts); return d.getDate()+"/"+(d.getMonth()+1)+"/"+d.getFullYear()+" "+d.getHours().toString().padStart(2,"0")+"h"+d.getMinutes().toString().padStart(2,"0"); };
|
||
const buildPreview = (m,s,i) => { let t=m>0?m+" min":""; if(m>0&&s>0)t+=" "; if(s>0)t+=s+" sec"; if(!t)t="0 sec"; if(i>0)t+=" + "+i+"s"; return t; };
|
||
|
||
/* ══ SCROLL GUARD ══
|
||
Returns pointer handlers that cancel the action if the finger moves > THRESHOLD px.
|
||
Usage: const {onPD, onPU, onPC, onPM} = useScrollGuard(onDown, onUp, player)
|
||
*/
|
||
const SCROLL_THRESHOLD = 10;
|
||
function useScrollGuard(onDown, onUp, arg){
|
||
const startY = useRef(null);
|
||
const startX = useRef(null);
|
||
const cancelled = useRef(false);
|
||
|
||
const onPD = useCallback((e) => {
|
||
e.stopPropagation();
|
||
startY.current = e.clientY;
|
||
startX.current = e.clientX;
|
||
cancelled.current = false;
|
||
if(arg !== undefined) onDown(arg);
|
||
else onDown();
|
||
}, [onDown, arg]);
|
||
|
||
const onPM = useCallback((e) => {
|
||
if(cancelled.current) return;
|
||
if(startY.current === null) return;
|
||
const dy = Math.abs(e.clientY - startY.current);
|
||
const dx = Math.abs(e.clientX - startX.current);
|
||
if(dy > SCROLL_THRESHOLD || dx > SCROLL_THRESHOLD){
|
||
cancelled.current = true;
|
||
if(arg !== undefined) onUp(arg);
|
||
else onUp();
|
||
}
|
||
}, [onUp, arg]);
|
||
|
||
const onPU = useCallback((e) => {
|
||
e.stopPropagation();
|
||
startY.current = null;
|
||
if(cancelled.current){ cancelled.current = false; return; }
|
||
if(arg !== undefined) onUp(arg);
|
||
else onUp();
|
||
}, [onUp, arg]);
|
||
|
||
const onPC = useCallback((e) => {
|
||
e.stopPropagation();
|
||
startY.current = null;
|
||
cancelled.current = false;
|
||
if(arg !== undefined) onUp(arg);
|
||
else onUp();
|
||
}, [onUp, arg]);
|
||
|
||
return { onPD, onPM, onPU, onPC };
|
||
}
|
||
|
||
/* ══ HAPTIC ══ */
|
||
const haptic = pat => {
|
||
try {
|
||
if(navigator.vibrate){
|
||
navigator.vibrate(typeof pat==="number"?Math.max(50,pat):pat);
|
||
}
|
||
} catch(e){}
|
||
};
|
||
|
||
/* ══ AUDIO ══ */
|
||
let _ac=null;
|
||
const getAC=()=>{ if(!_ac) _ac=new (window.AudioContext||window.webkitAudioContext)(); return _ac; };
|
||
const beep=(freq,dur,type,vol,delay)=>{ try{ const c=getAC(),o=c.createOscillator(),g=c.createGain(); o.connect(g);g.connect(c.destination); o.type=type||"sine"; o.frequency.value=freq; const t=c.currentTime+(delay||0); g.gain.setValueAtTime(vol||0.14,t); g.gain.exponentialRampToValueAtTime(0.001,t+dur); o.start(t); o.stop(t+dur+0.05); }catch(e){} };
|
||
const playTick=()=>beep(900,0.04,"square",0.12);
|
||
const playTock=()=>beep(600,0.04,"square",0.10);
|
||
const playSwoop=()=>{ try{ const c=getAC(),o=c.createOscillator(),g=c.createGain(); o.connect(g);g.connect(c.destination); o.frequency.setValueAtTime(520,c.currentTime); o.frequency.exponentialRampToValueAtTime(180,c.currentTime+0.2); g.gain.setValueAtTime(0.15,c.currentTime); g.gain.exponentialRampToValueAtTime(0.001,c.currentTime+0.2); o.start(); o.stop(c.currentTime+0.22); }catch(e){} };
|
||
const playFanfare=()=>{ [523,659,784,1047].forEach((f,i)=>beep(f,0.22,"sine",0.16,i*0.11)); };
|
||
const playWarn=()=>{ beep(220,0.07,"sawtooth",0.12,0); beep(220,0.07,"sawtooth",0.12,0.14); };
|
||
|
||
/* ══ ELO ══ */
|
||
const calcElo=(rA,rB,won)=>{ const K=32,ea=1/(1+Math.pow(10,(rB-rA)/400)); return Math.round(K*(won-ea)); };
|
||
const tryLoad=(key,def)=>{ try{ return JSON.parse(localStorage.getItem(key)||"null")||def; }catch(e){ return def; } };
|
||
const trySave=(key,val)=>{ try{ localStorage.setItem(key,JSON.stringify(val)); }catch(e){} };
|
||
|
||
/* ══ SCRAMBLE ══ */
|
||
const SFACES=["U","D","F","B","L","R"], SMODS=["","'","2"];
|
||
const genScramble=(n)=>{ const m=[]; let last=-1; for(let i=0;i<(n||20);i++){ let f; do{f=Math.floor(Math.random()*6);}while(f===last); last=f; m.push(SFACES[f]+SMODS[Math.floor(Math.random()*3)]); } return m; };
|
||
|
||
/* ══ CONSTANTS ══ */
|
||
const PRESETS = [
|
||
{base:60, inc:0, tag:"BULLET", lbl:"1 min"},
|
||
{base:60, inc:1, tag:"BULLET", lbl:"1 min + 1s"},
|
||
{base:120, inc:1, tag:"BULLET", lbl:"2 min + 1s"},
|
||
{base:180, inc:0, tag:"BLITZ", lbl:"3 min"},
|
||
{base:180, inc:2, tag:"BLITZ", lbl:"3 min + 2s"},
|
||
{base:300, inc:0, tag:"BLITZ", lbl:"5 min"},
|
||
{base:300, inc:3, tag:"BLITZ", lbl:"5 min + 3s"},
|
||
{base:300, inc:5, tag:"BLITZ", lbl:"5 min + 5s"},
|
||
{base:600, inc:0, tag:"RAPID", lbl:"10 min"},
|
||
{base:600, inc:5, tag:"RAPID", lbl:"10 min + 5s"},
|
||
{base:900, inc:10,tag:"RAPID", lbl:"15 min + 10s"},
|
||
{base:1200,inc:5, tag:"RAPID", lbl:"20 min + 5s"},
|
||
{base:1800,inc:0, tag:"CLASSIC",lbl:"30 min"},
|
||
{base:1800,inc:20,tag:"CLASSIC",lbl:"30 min + 20s"},
|
||
{base:3600,inc:30,tag:"CLASSIC",lbl:"60 min + 30s"},
|
||
];
|
||
const TAG_COLORS = {BULLET:"#f04040",BLITZ:"#e8b84b",RAPID:"#38e878",CLASSIC:"#4b9ee8"};
|
||
const P1C = ["p1c0","p1c1","p1c2","p1c3","p1c4","p1c5","p1c6","p1c7","p1c8"];
|
||
const P2C = ["p2c0","p2c1","p2c2","p2c3","p2c4","p2c5","p2c6","p2c7","p2c8"];
|
||
const AVATARS=["♟","♞","♜","♛","♔","🦁","🐯","⚡","🔥","💎","👑","🎯","🦊","🐉","🌙","⚔"];
|
||
const genId=()=>"u"+Math.random().toString(36).slice(2,8);
|
||
const DEF_USERS=[
|
||
{id:"u_default0",name:"Joueur 1",avatar:"♟",elo:1200,streak:0,wins:0,games:0},
|
||
{id:"u_default1",name:"Joueur 2",avatar:"♞",elo:1200,streak:0,wins:0,games:0},
|
||
];
|
||
|
||
/* ══ RUBIK'S CUBE ══ */
|
||
function RubiksCube({player,size="md"}){
|
||
const colors=player===0?P1C:P2C;
|
||
return <div className={"rcube "+size}>{colors.map((c,i)=><div key={i} className={"rc "+c}/>)}</div>;
|
||
}
|
||
|
||
/* ══ CUSTOM TIME MODAL ══ */
|
||
function CustomModal({initMin,initSec,initInc,onConfirm,onCancel}){
|
||
const [mins,setMins]=useState(initMin);
|
||
const [secs,setSecs]=useState(initSec);
|
||
const [inc,setInc]=useState(initInc);
|
||
const sp=(set,d,mn,mx)=>set(v=>Math.max(mn,Math.min(mx,v+d)));
|
||
const total=mins*60+secs;
|
||
return(
|
||
<div className="modal-bg" onPointerDown={onCancel}>
|
||
<div className="modal" onPointerDown={e=>e.stopPropagation()}>
|
||
<div className="modal-handle"/>
|
||
<div className="modal-title">Temps Personnalisé</div>
|
||
<div className="modal-sub">Définissez votre contrôle de temps</div>
|
||
<div className="modal-fields">
|
||
{[{lbl:"Minutes",val:mins,set:setMins,d:1,mn:0,mx:180,u:"min"},
|
||
{lbl:"Secondes",val:secs,set:setSecs,d:5,mn:0,mx:55,u:"sec (x5)"},
|
||
{lbl:"Incrément",val:inc,set:setInc,d:1,mn:0,mx:60,u:"+sec/coup"}
|
||
].map(f=>(
|
||
<div key={f.lbl} className="modal-field">
|
||
<div className="modal-f-lbl">{f.lbl}</div>
|
||
<div className="modal-spin">
|
||
<div className="modal-sb" onPointerDown={()=>sp(f.set,-f.d,f.mn,f.mx)}>−</div>
|
||
<div className="modal-sv">{f.val}</div>
|
||
<div className="modal-sb" onPointerDown={()=>sp(f.set,+f.d,f.mn,f.mx)}>+</div>
|
||
</div>
|
||
<div className="modal-su">{f.u}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="modal-prev">
|
||
<span className="modal-prev-lbl">Aperçu</span>
|
||
<span className="modal-prev-val">{buildPreview(mins,secs,inc)}</span>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="modal-ok" style={{opacity:total===0?.4:1}} onPointerDown={()=>{if(total>0)onConfirm(total,inc);}}>Confirmer</button>
|
||
<button className="modal-cancel" onPointerDown={onCancel}>Annuler</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ TIME CHART ══ */
|
||
function TimeChart({snapshots,initTime}){
|
||
if(!snapshots||snapshots.length<2) return null;
|
||
const W=300,H=65,P=4;
|
||
const maxM=snapshots[snapshots.length-1].move||1;
|
||
const pts0=snapshots.map(s=>(P+((s.move/maxM)*(W-P*2)))+","+(P+(1-(s.t0/initTime))*(H-P*2)));
|
||
const pts1=snapshots.map(s=>(P+((s.move/maxM)*(W-P*2)))+","+(P+(1-(s.t1/initTime))*(H-P*2)));
|
||
return(
|
||
<div className="chart-wrap">
|
||
<div className="chart-ttl">Temps restant par coup</div>
|
||
<svg width="100%" viewBox={"0 0 "+W+" "+H} preserveAspectRatio="none">
|
||
<polyline points={pts0.join(" ")} fill="none" stroke="var(--gold)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity=".9"/>
|
||
<polyline points={pts1.join(" ")} fill="none" stroke="var(--blu)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity=".9"/>
|
||
</svg>
|
||
<div className="chart-leg">
|
||
<div className="chart-li"><div className="chart-ld" style={{background:"var(--gold)"}}/>J1</div>
|
||
<div className="chart-li"><div className="chart-ld" style={{background:"var(--blu)"}}/>J2</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ USER MANAGER SCREEN ══ */
|
||
function UserManagerScreen({users,onSave,onClose}){
|
||
const [list,setList]=useState(users.map(u=>({...u})));
|
||
const [editId,setEditId]=useState(null);
|
||
const [showAvFor,setShowAvFor]=useState(null);
|
||
const edit=(id,k,v)=>setList(prev=>prev.map(u=>u.id===id?{...u,[k]:v}:u));
|
||
const addUser=()=>{
|
||
const nu={id:genId(),name:"Nouveau",avatar:"♟",elo:1200,streak:0,wins:0,games:0};
|
||
setList(prev=>[...prev,nu]);
|
||
setEditId(nu.id);
|
||
};
|
||
const delUser=(id)=>{
|
||
if(list.length<=2)return;
|
||
setList(prev=>prev.filter(u=>u.id!==id));
|
||
if(editId===id)setEditId(null);
|
||
};
|
||
const saveUser=()=>{ setEditId(null); setShowAvFor(null); };
|
||
return(
|
||
<div className="um">
|
||
<div className="um-hd">
|
||
<div className="um-title">Joueurs</div>
|
||
<button className="um-add-btn" onPointerDown={addUser}>+ Ajouter</button>
|
||
<div className="um-close" onPointerDown={()=>{onSave(list);onClose();}}>✕</div>
|
||
</div>
|
||
<div className="um-body">
|
||
{list.map(u=>{
|
||
const isEdit=editId===u.id;
|
||
return(
|
||
<div key={u.id} className={"um-card"+(isEdit?" editing":"")}>
|
||
<div className="um-row">
|
||
<div className={"um-av"+(showAvFor===u.id?" open":"")}
|
||
onPointerDown={()=>isEdit&&setShowAvFor(showAvFor===u.id?null:u.id)}>
|
||
{u.avatar}
|
||
</div>
|
||
<div className="um-info">
|
||
{isEdit
|
||
?<input className="um-name-input" defaultValue={u.name} maxLength={14}
|
||
onChange={e=>edit(u.id,"name",e.target.value||"Joueur")}
|
||
onPointerDown={e=>e.stopPropagation()} autoFocus/>
|
||
:<div className="um-name">{u.name}</div>
|
||
}
|
||
<div className="um-meta">
|
||
<span className="um-elo">{"ELO "+u.elo}</span>
|
||
{u.streak>=2&&<span className="um-streak">{"🔥 "+u.streak}</span>}
|
||
<span className="um-wins">{u.wins+" victoires"}</span>
|
||
</div>
|
||
</div>
|
||
<div className="um-actions">
|
||
{isEdit
|
||
?(null)
|
||
:<div className="um-btn edit" onPointerDown={()=>{setEditId(u.id);setShowAvFor(null);}}>✏</div>
|
||
}
|
||
{list.length>2&&<div className="um-btn del" onPointerDown={()=>delUser(u.id)}>🗑</div>}
|
||
</div>
|
||
</div>
|
||
{isEdit&&showAvFor===u.id&&(
|
||
<div className="um-av-grid">
|
||
{AVATARS.map(av=>(
|
||
<div key={av} className={"um-av-item"+(u.avatar===av?" sel":"")}
|
||
onPointerDown={()=>{edit(u.id,"avatar",av);setShowAvFor(null);}}>
|
||
{av}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{isEdit&&(
|
||
<div className="um-save-row">
|
||
<button className="um-save-btn" onPointerDown={saveUser}>Confirmer</button>
|
||
<button className="um-cancel-btn" onPointerDown={()=>{setEditId(null);setShowAvFor(null);}}>Annuler</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ PLAYER SELECT SCREEN ══ */
|
||
function PlayerSelectScreen({users,onConfirm,onClose}){
|
||
const [sel0,setSel0]=useState(users[0]?.id||null);
|
||
const [sel1,setSel1]=useState(users[1]?.id||null);
|
||
const ready=sel0&&sel1&&sel0!==sel1;
|
||
const u0=users.find(u=>u.id===sel0);
|
||
const u1=users.find(u=>u.id===sel1);
|
||
return(
|
||
<div className="ps">
|
||
{/* J2 panel — top, rotated */}
|
||
<div className={"ps-half flip"+(u1?" on1":"")}>
|
||
<div className="ps-label">Joueur 2 — Haut de l'écran</div>
|
||
<div className="ps-sel-display">
|
||
{u1
|
||
?<><div className="ps-sel-av">{u1.avatar}</div><div className={"ps-sel-name blue"}>{u1.name}</div></>
|
||
:<div className="ps-sel-empty">Choisissez un joueur</div>
|
||
}
|
||
</div>
|
||
<div className="ps-grid">
|
||
{users.map(u=>{
|
||
let _sy1=null;
|
||
return(
|
||
<div key={u.id}
|
||
className={"ps-chip"+(sel1===u.id?" sel1":"")+(sel0===u.id?" other-taken":"")}
|
||
onPointerDown={e=>{_sy1=e.clientY;}}
|
||
onPointerUp={e=>{if(_sy1!==null&&Math.abs(e.clientY-_sy1)<10){setSel1(u.id);}_sy1=null;}}>
|
||
<div className="ps-chip-av">{u.avatar}</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:1}}>
|
||
<div className="ps-chip-name">{u.name}</div>
|
||
<div className="ps-chip-elo">{"ELO "+u.elo}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div className="ps-div">
|
||
<div className="ps-vs">VS</div>
|
||
</div>
|
||
|
||
{/* J1 panel — bottom */}
|
||
<div className={"ps-half"+(u0?" on0":"")}>
|
||
<div className="ps-label">Joueur 1 — Bas de l'écran</div>
|
||
<div className="ps-sel-display">
|
||
{u0
|
||
?<><div className="ps-sel-av">{u0.avatar}</div><div className="ps-sel-name">{u0.name}</div></>
|
||
:<div className="ps-sel-empty">Choisissez un joueur</div>
|
||
}
|
||
</div>
|
||
<div className="ps-grid">
|
||
{users.map(u=>{
|
||
let _sy0=null;
|
||
return(
|
||
<div key={u.id}
|
||
className={"ps-chip"+(sel0===u.id?" sel0":"")+(sel1===u.id?" other-taken":"")}
|
||
onPointerDown={e=>{_sy0=e.clientY;}}
|
||
onPointerUp={e=>{if(_sy0!==null&&Math.abs(e.clientY-_sy0)<10){setSel0(u.id);}_sy0=null;}}>
|
||
<div className="ps-chip-av">{u.avatar}</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:1}}>
|
||
<div className="ps-chip-name">{u.name}</div>
|
||
<div className="ps-chip-elo">{"ELO "+u.elo}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer buttons */}
|
||
<div className="ps-footer">
|
||
<button className="ps-back" onPointerDown={onClose}>← Retour</button>
|
||
<button className={"ps-go"+(ready?" ready":" wait")}
|
||
onPointerDown={()=>ready&&onConfirm(sel0,sel1)}>
|
||
{ready?"Jouer !":"Sélectionnez 2 joueurs"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ CUBE PREP SCREEN ══ */
|
||
function CubePrepScreen({p0,p1,onReady}){
|
||
const [countdown,setCountdown]=useState(30);
|
||
const [scrambles]=useState([genScramble(18),genScramble(18)]);
|
||
const timerRef=useRef(null);
|
||
useEffect(()=>{
|
||
timerRef.current=setInterval(()=>{
|
||
setCountdown(c=>{ if(c<=1){clearInterval(timerRef.current);onReady();return 0;} return c-1; });
|
||
},1000);
|
||
return()=>clearInterval(timerRef.current);
|
||
},[]);
|
||
const go=()=>{ clearInterval(timerRef.current); onReady(); };
|
||
return(
|
||
<div className="prep">
|
||
<div className="prep-title">Mémorisez !</div>
|
||
<div className="prep-sub">Chaque joueur a son propre mélange</div>
|
||
<div className="prep-players">
|
||
{[p0,p1].map((u,pi)=>(
|
||
<div key={pi} className="prep-p-card">
|
||
<div style={{fontSize:22}}>{u.avatar}</div>
|
||
<div className="prep-p-name">{u.name}</div>
|
||
<RubiksCube player={pi} size="md"/>
|
||
<div className="prep-scramble">
|
||
{scrambles[pi].map((m,i)=><div key={i} className="prep-move">{m}</div>)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="prep-countdown">{countdown}</div>
|
||
<button className="prep-ready-btn" onPointerDown={go}>On est prêts !</button>
|
||
<div className="prep-skip" onPointerDown={go}>Passer</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ SOLO CS TIMER ══ */
|
||
const HOLD_MS=900;
|
||
function SoloCsTimerPanel({player,phase,elapsed,progress,onDown,onUp,user,isDone,isWinner}){
|
||
const {onPD,onPM,onPU,onPC}=useScrollGuard(onDown,onUp,player);
|
||
const CIRC=264;
|
||
const prog=Math.min(1,progress||0);
|
||
const offset=CIRC*(1-prog);
|
||
const ph=isDone?(isWinner?"win":"lose"):phase;
|
||
const bgCls="scs-panel"+(player===1?" flip":"")+" "+(ph==="ready"?"hold":ph)+"-bg";
|
||
|
||
let stateLbl,stateClr,timeLbl,hint,showRing=false;
|
||
if(ph==="win"){stateLbl="VICTOIRE !";stateClr="gold";timeLbl=fmtMs(elapsed);hint=""}
|
||
else if(ph==="lose"){stateLbl="PERDU";stateClr="dim";timeLbl=fmtMs(elapsed);hint=""}
|
||
else if(phase==="idle"){stateLbl="CS TIMER";stateClr="blu";timeLbl="00.00";hint="Maintenez appuyé"}
|
||
else if(phase==="hold"){stateLbl="CHARGEZ…";stateClr="grn";timeLbl=Math.round(prog*100)+"%";hint="Ne relâchez pas !";showRing=true;}
|
||
else if(phase==="ready"){stateLbl="RELÂCHEZ !";stateClr="grn";timeLbl="GO";hint="Relâchez pour démarrer";showRing=true;}
|
||
else if(phase==="run"){stateLbl="⏱ EN COURS";stateClr="red";timeLbl=fmtMs(elapsed);hint="Appuyez pour stopper";}
|
||
else{stateLbl="STOPPÉ";stateClr="dim";timeLbl=fmtMs(elapsed);hint="En attente…";}
|
||
|
||
return(
|
||
<div className={bgCls}
|
||
onPointerDown={onPD}
|
||
onPointerMove={onPM}
|
||
onPointerUp={onPU}
|
||
onPointerCancel={onPC}>
|
||
<div className={"scs-name"}>
|
||
<div className="scs-av">{user.avatar}</div>
|
||
<div className="scs-plbl">{user.name}</div>
|
||
</div>
|
||
<div className={"scs-state "+stateClr}>{stateLbl}</div>
|
||
{showRing?(
|
||
<div className="scs-ring" style={{width:100,height:100}}>
|
||
<svg viewBox="0 0 100 100" width="100" height="100" style={{position:"absolute",top:0,left:0,overflow:"visible"}}>
|
||
<circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,.10)" strokeWidth="5"/>
|
||
<circle cx="50" cy="50" r="42" fill="none"
|
||
stroke={phase==="ready"?"#ffffff":"#7affc0"}
|
||
strokeWidth="7" strokeLinecap="round"
|
||
strokeDasharray={""+CIRC}
|
||
strokeDashoffset={phase==="ready"?"0":""+offset}
|
||
transform="rotate(-90 50 50)"
|
||
style={phase==="ready"?{animation:"wbreathe .4s ease-in-out infinite alternate"}:{}}/>
|
||
</svg>
|
||
<div className={"scs-time "+ph} style={{position:"relative",zIndex:1}}>{timeLbl}</div>
|
||
</div>
|
||
):(phase==="run"&&!isDone)?(
|
||
<div className="scs-ring" style={{width:100,height:100}}>
|
||
<svg viewBox="0 0 100 100" width="100" height="100" style={{position:"absolute",top:0,left:0,overflow:"visible"}}>
|
||
<circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,.08)" strokeWidth="5"/>
|
||
<circle cx="50" cy="50" r="42" fill="none" stroke="#80ffbe" strokeWidth="5" strokeLinecap="round"
|
||
strokeDasharray="60 204" transform="rotate(-90 50 50)"
|
||
style={{animation:"rspin .7s linear infinite"}}/>
|
||
<circle cx="50" cy="50" r="38" fill="none" stroke="rgba(80,255,160,.2)" strokeWidth="3" strokeLinecap="round"
|
||
strokeDasharray="30 208" transform="rotate(-90 50 50)"
|
||
style={{animation:"rspin2 1.4s linear infinite reverse"}}/>
|
||
</svg>
|
||
<div className={"scs-time "+ph} style={{position:"relative",zIndex:1}}>{timeLbl}</div>
|
||
</div>
|
||
):(
|
||
<div className={"scs-time "+ph}>{timeLbl}</div>
|
||
)}
|
||
{ph==="win"&&<div className="scs-win-lbl">♛ Gagné !</div>}
|
||
{hint&&<div className="scs-hint">{hint}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ SOLO CS TIMER — one player ══ */
|
||
function SoloCsTimerSolo({user}){
|
||
const [phase,setPhase]=useState("idle");
|
||
const [elapsed,setElapsed]=useState(0);
|
||
const [progress,setProgress]=useState(0);
|
||
const [history,setHistory]=useState([]);
|
||
|
||
const phaseRef=useRef("idle");
|
||
const startRef=useRef(null);
|
||
const holdStartRef=useRef(null);
|
||
const holdFrameRef=useRef(null);
|
||
const frameRef=useRef(null);
|
||
|
||
useEffect(()=>{ phaseRef.current=phase; },[phase]);
|
||
|
||
// RAF for run
|
||
useEffect(()=>{
|
||
if(phase!=="run"){cancelAnimationFrame(frameRef.current);return;}
|
||
const loop=()=>{
|
||
setElapsed(performance.now()-startRef.current);
|
||
frameRef.current=requestAnimationFrame(loop);
|
||
};
|
||
frameRef.current=requestAnimationFrame(loop);
|
||
return()=>cancelAnimationFrame(frameRef.current);
|
||
},[phase]);
|
||
|
||
const onDown=useCallback(()=>{
|
||
const ph=phaseRef.current;
|
||
if(ph==="idle"||ph==="stop"){
|
||
// Reset elapsed if coming from stop
|
||
if(ph==="stop"){setElapsed(0);}
|
||
holdStartRef.current=performance.now();
|
||
setPhase("hold");
|
||
const loop=()=>{
|
||
if(!holdStartRef.current)return;
|
||
const prog=Math.min(1,(performance.now()-holdStartRef.current)/HOLD_MS);
|
||
setProgress(prog);
|
||
if(prog<1){
|
||
holdFrameRef.current=requestAnimationFrame(loop);
|
||
} else {
|
||
holdStartRef.current=null;
|
||
haptic(50);
|
||
setPhase("ready");
|
||
}
|
||
};
|
||
holdFrameRef.current=requestAnimationFrame(loop);
|
||
} else if(ph==="run"){
|
||
// Stop the timer
|
||
const el=performance.now()-startRef.current;
|
||
cancelAnimationFrame(frameRef.current);
|
||
setElapsed(el);
|
||
setHistory(prev=>[el,...prev].slice(0,10));
|
||
setPhase("stop");
|
||
haptic([60,40,100]);
|
||
}
|
||
},[]);
|
||
|
||
const onUp=useCallback(()=>{
|
||
const ph=phaseRef.current;
|
||
if(ph==="ready"){
|
||
startRef.current=performance.now();
|
||
setPhase("run");
|
||
} else if(ph==="hold"){
|
||
cancelAnimationFrame(holdFrameRef.current);
|
||
holdStartRef.current=null;
|
||
setProgress(0);
|
||
setPhase("idle");
|
||
}
|
||
},[]);
|
||
|
||
const reset=()=>{
|
||
cancelAnimationFrame(frameRef.current);
|
||
cancelAnimationFrame(holdFrameRef.current);
|
||
holdStartRef.current=null;
|
||
startRef.current=null;
|
||
phaseRef.current="idle";
|
||
setPhase("idle");
|
||
setElapsed(0);
|
||
setProgress(0);
|
||
};
|
||
|
||
const CIRC=264;
|
||
const prog=Math.min(1,progress);
|
||
const offset=CIRC*(1-prog);
|
||
const best=history.length>0?Math.min(...history):null;
|
||
|
||
let stateLbl,stateClr,timeDisplay,hint;
|
||
if(phase==="idle"){stateLbl="CS TIMER";stateClr="blu";timeDisplay=user.avatar;hint="Maintenez appuyé";}
|
||
else if(phase==="hold"){stateLbl="CHARGEZ…";stateClr="grn";timeDisplay=Math.round(prog*100)+"%";hint="Ne relâchez pas !";}
|
||
else if(phase==="ready"){stateLbl="RELÂCHEZ !";stateClr="wht";timeDisplay="GO";hint="Relâchez pour démarrer";}
|
||
else if(phase==="run"){stateLbl="⏱ EN COURS";stateClr="red";timeDisplay=fmtMs(elapsed);hint="Appuyez pour stopper";}
|
||
else{stateLbl="✓ TEMPS";stateClr="gold";timeDisplay=fmtMs(elapsed);hint="Utilisez ▶ Rejouer en bas";}
|
||
|
||
const bgStyle=phase==="idle"?"scs-solo-panel idle-bg":phase==="hold"||phase==="ready"?"scs-solo-panel hold-bg":phase==="run"?"scs-solo-panel run-bg":"scs-solo-panel stop-bg";
|
||
|
||
const {onPD,onPM,onPU,onPC}=useScrollGuard(onDown,onUp);
|
||
|
||
const isStop = phase==="stop";
|
||
|
||
return(
|
||
<div className={bgStyle}
|
||
onPointerDown={isStop?undefined:onPD}
|
||
onPointerMove={isStop?undefined:onPM}
|
||
onPointerUp={isStop?undefined:onPU}
|
||
onPointerCancel={isStop?undefined:onPC}>
|
||
|
||
<div style={{display:"flex",alignItems:"center",gap:8,position:"relative",zIndex:1}}>
|
||
<div style={{fontSize:20}}>{user.avatar}</div>
|
||
<div className="scs-plbl">{user.name}</div>
|
||
</div>
|
||
|
||
<div className={"scs-solo-state "+stateClr}>{stateLbl}</div>
|
||
|
||
{(phase==="hold"||phase==="ready")?(
|
||
<div className="scs-ring" style={{width:120,height:120}}>
|
||
<svg viewBox="0 0 120 120" width="120" height="120" style={{position:"absolute",top:0,left:0,overflow:"visible"}}>
|
||
<circle cx="60" cy="60" r="52" fill="none" stroke="rgba(255,255,255,.10)" strokeWidth="6"/>
|
||
<circle cx="60" cy="60" r="52" fill="none"
|
||
stroke={phase==="ready"?"#ffffff":"#7affc0"}
|
||
strokeWidth="8" strokeLinecap="round"
|
||
strokeDasharray={""+Math.round(2*Math.PI*52)}
|
||
strokeDashoffset={phase==="ready"?"0":""+Math.round(2*Math.PI*52*(1-prog))}
|
||
transform="rotate(-90 60 60)"
|
||
style={phase==="ready"?{animation:"wbreathe .4s ease-in-out infinite alternate"}:{}}/>
|
||
</svg>
|
||
<div className={"scs-solo-time "+(phase==="ready"?"ready":"hold")} style={{position:"relative",zIndex:1,fontSize:"clamp(36px,10vw,52px)"}}>
|
||
{timeDisplay}
|
||
</div>
|
||
</div>
|
||
):(
|
||
<div className={"scs-solo-time "+phase}>{timeDisplay}</div>
|
||
)}
|
||
|
||
<div className={"scs-solo-hint"}>{hint}</div>
|
||
|
||
{best!==null&&phase!=="hold"&&phase!=="ready"&&(
|
||
<div className="scs-solo-best" onPointerDown={e=>e.stopPropagation()}>
|
||
<div className="scs-solo-best-lbl">Meilleur</div>
|
||
<div className="scs-solo-best-val">{fmtMs(best)}</div>
|
||
{history.length>1&&(
|
||
<>
|
||
<div className="scs-solo-best-lbl" style={{marginLeft:10}}>Moy.</div>
|
||
<div className="scs-solo-best-val" style={{color:"var(--gold)"}}>
|
||
{fmtMs(history.reduce((a,b)=>a+b,0)/history.length)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{history.length>0&&phase==="stop"&&(
|
||
<div className="scs-solo-history"
|
||
style={{touchAction:"pan-y",overflowY:"auto",maxHeight:"38vh"}}
|
||
onPointerDown={e=>e.stopPropagation()}
|
||
onPointerMove={e=>e.stopPropagation()}
|
||
onPointerUp={e=>e.stopPropagation()}>
|
||
{history.slice(0,10).map((t,i)=>(
|
||
<div key={i} className="scs-solo-hist-row">
|
||
<div className="scs-solo-hist-n">#{i+1}</div>
|
||
<div className={"scs-solo-hist-t"+(t===best?" best":"")}>{fmtMs(t)}</div>
|
||
{t===best&&<div style={{fontSize:9,color:"#80ffbe",letterSpacing:2}}>BEST</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{position:"absolute",bottom:0,left:0,right:0,padding:"6px 14px clamp(8px,2vh,18px)",background:"linear-gradient(to top,rgba(6,10,14,.98) 55%,transparent)",display:"flex",justifyContent:"center",gap:10,zIndex:30}}
|
||
onPointerDown={e=>e.stopPropagation()}>
|
||
{phase==="stop"&&(
|
||
<button className="scs-reset" style={{background:"linear-gradient(135deg,var(--g2),var(--gold))",color:"var(--bg)",border:"none",letterSpacing:4,fontSize:12,padding:"9px 22px"}}
|
||
onPointerDown={e=>{e.stopPropagation();onDown();}}>
|
||
▶ Rejouer
|
||
</button>
|
||
)}
|
||
<button className="scs-reset" onPointerDown={reset}>↺ Reset</button>
|
||
{history.length>0&&<button className="scs-reset" onPointerDown={()=>setHistory([])}>🗑</button>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SoloCsTimer({p0,p1,onClose}){
|
||
const [mode,setMode]=useState("solo");
|
||
const [phases,setPhases]=useState(["idle","idle"]);
|
||
const [elapsed,setElapsed]=useState([0,0]);
|
||
const [progress,setProgress]=useState([0,0]);
|
||
const [done,setDone]=useState(false);
|
||
const [winner,setWinner]=useState(null);
|
||
|
||
const phaseRef=useRef(["idle","idle"]);
|
||
const startRef=useRef([null,null]);
|
||
const holdStartRef=useRef([null,null]);
|
||
const holdFrameRef=useRef([null,null]);
|
||
const elRef=useRef([0,0]);
|
||
const doneRef=useRef(false);
|
||
const frameRef=useRef(null);
|
||
|
||
useEffect(()=>{ phaseRef.current=phases; },[phases]);
|
||
|
||
useEffect(()=>{
|
||
if(done){cancelAnimationFrame(frameRef.current);return;}
|
||
const loop=()=>{
|
||
const now=performance.now();
|
||
setElapsed(prev=>{
|
||
const n=[...prev];
|
||
for(let p=0;p<2;p++)
|
||
if(phaseRef.current[p]==="run"&&startRef.current[p]!==null)
|
||
n[p]=now-startRef.current[p];
|
||
return n;
|
||
});
|
||
frameRef.current=requestAnimationFrame(loop);
|
||
};
|
||
frameRef.current=requestAnimationFrame(loop);
|
||
return()=>cancelAnimationFrame(frameRef.current);
|
||
},[done]);
|
||
|
||
const finalize=useCallback((e0,e1)=>{
|
||
if(doneRef.current)return;
|
||
doneRef.current=true;
|
||
cancelAnimationFrame(frameRef.current);
|
||
const w=e0<=e1?0:1;
|
||
setWinner(w);setDone(true);
|
||
setPhases(prev=>{const n=[...prev];n[w]="win";n[w===0?1:0]="lose";return n;});
|
||
},[]);
|
||
|
||
const onDown=useCallback((player)=>{
|
||
if(doneRef.current)return;
|
||
const ph=phaseRef.current[player];
|
||
if(ph==="idle"){
|
||
holdStartRef.current[player]=performance.now();
|
||
setPhases(prev=>{const n=[...prev];n[player]="hold";return n;});
|
||
const loop=()=>{
|
||
if(!holdStartRef.current[player])return;
|
||
const prog=Math.min(1,(performance.now()-holdStartRef.current[player])/HOLD_MS);
|
||
setProgress(prev=>{const n=[...prev];n[player]=prog;return n;});
|
||
if(prog<1){
|
||
holdFrameRef.current[player]=requestAnimationFrame(loop);
|
||
} else {
|
||
holdStartRef.current[player]=null;
|
||
haptic(50);
|
||
setPhases(prev=>{const n=[...prev];n[player]="ready";return n;});
|
||
}
|
||
};
|
||
holdFrameRef.current[player]=requestAnimationFrame(loop);
|
||
} else if(ph==="run"){
|
||
const el=performance.now()-startRef.current[player];
|
||
startRef.current[player]=null;
|
||
elRef.current[player]=el;
|
||
setElapsed(prev=>{const n=[...prev];n[player]=el;return n;});
|
||
const other=player===0?1:0;
|
||
const otherPh=phaseRef.current[other];
|
||
setPhases(prev=>{const n=[...prev];n[player]="stop";return n;});
|
||
if(otherPh==="stop"){
|
||
finalize(player===0?el:elRef.current[0],player===1?el:elRef.current[1]);
|
||
}
|
||
}
|
||
},[finalize]);
|
||
|
||
const onUp=useCallback((player)=>{
|
||
const ph=phaseRef.current[player];
|
||
if(ph==="ready"){
|
||
startRef.current[player]=performance.now();
|
||
setPhases(prev=>{const n=[...prev];n[player]="run";return n;});
|
||
} else if(ph==="hold"){
|
||
cancelAnimationFrame(holdFrameRef.current[player]);
|
||
holdStartRef.current[player]=null;
|
||
setProgress(prev=>{const n=[...prev];n[player]=0;return n;});
|
||
setPhases(prev=>{const n=[...prev];n[player]="idle";return n;});
|
||
}
|
||
},[]);
|
||
|
||
const reset=()=>{
|
||
cancelAnimationFrame(frameRef.current);
|
||
[0,1].forEach(p=>{
|
||
cancelAnimationFrame(holdFrameRef.current[p]);
|
||
holdStartRef.current[p]=null;
|
||
startRef.current[p]=null;
|
||
});
|
||
doneRef.current=false;
|
||
phaseRef.current=["idle","idle"];
|
||
elRef.current=[0,0];
|
||
setPhases(["idle","idle"]);
|
||
setElapsed([0,0]);
|
||
setProgress([0,0]);
|
||
setDone(false);
|
||
setWinner(null);
|
||
};
|
||
|
||
return(
|
||
<div className="scs">
|
||
{/* Mode toggle header */}
|
||
<div style={{position:"absolute",top:0,left:0,right:0,zIndex:50,display:"flex",justifyContent:"center",padding:"10px 0",pointerEvents:"none"}}>
|
||
<div className="scs-mode-toggle" style={{pointerEvents:"all"}} onPointerDown={e=>e.stopPropagation()}>
|
||
<div className={"scs-mode-btn"+(mode==="solo"?" active":"")} onPointerDown={()=>{setMode("solo");reset();}}>Solo</div>
|
||
<div className={"scs-mode-btn"+(mode==="duel"?" active":"")} onPointerDown={()=>{setMode("duel");reset();}}>Duel</div>
|
||
</div>
|
||
</div>
|
||
|
||
{mode==="solo"?(
|
||
<SoloCsTimerSolo user={p0}/>
|
||
):(
|
||
<>
|
||
<SoloCsTimerPanel player={1} phase={phases[1]} elapsed={elapsed[1]} progress={progress[1]}
|
||
onDown={onDown} onUp={onUp} user={p1} isDone={done} isWinner={winner===1}/>
|
||
<div className="scs-div"><div className="scs-vs">VS</div></div>
|
||
<SoloCsTimerPanel player={0} phase={phases[0]} elapsed={elapsed[0]} progress={progress[0]}
|
||
onDown={onDown} onUp={onUp} user={p0} isDone={done} isWinner={winner===0}/>
|
||
<div className="scs-footer" onPointerDown={e=>e.stopPropagation()}>
|
||
<button className="scs-reset" onPointerDown={reset}>↺ Reset</button>
|
||
<button className="scs-close" onPointerDown={onClose}>✕ Fermer</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Close button always visible in solo mode */}
|
||
{mode==="solo"&&(
|
||
<div style={{position:"absolute",top:10,right:14,zIndex:60}} onPointerDown={e=>e.stopPropagation()}>
|
||
<button className="scs-close" onPointerDown={onClose}>✕</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ TOURNAMENT ══ */
|
||
function buildRound(players){
|
||
return players.reduce((acc,_,i)=>i%2===0?[...acc,{p1:players[i],p2:players[i+1]||null,winner:null}]:acc,[]);
|
||
}
|
||
function TournamentScreen({onClose,onStartMatch}){
|
||
const [size,setSize]=useState(4);
|
||
const DEFAULT_NAMES=["Joueur 1","Joueur 2","Joueur 3","Joueur 4","Joueur 5","Joueur 6","Joueur 7","Joueur 8"];
|
||
const [names,setNames]=useState(DEFAULT_NAMES);
|
||
const [editIdx,setEditIdx]=useState(null);
|
||
const [bracket,setBracket]=useState(null);
|
||
const [phase,setPhase]=useState("setup");
|
||
const setName=(i,v)=>setNames(prev=>{const n=[...prev];n[i]=v||DEFAULT_NAMES[i];return n;});
|
||
const start=()=>{
|
||
setEditIdx(null);
|
||
const players=names.slice(0,size).map((n,i)=>({name:n||DEFAULT_NAMES[i],id:i}));
|
||
setBracket({rounds:[buildRound(players)],currentRound:0,champion:null});
|
||
setPhase("bracket");
|
||
};
|
||
const recordWin=(ri,mi,bracketWinnerId)=>{
|
||
setBracket(prev=>{
|
||
const b=JSON.parse(JSON.stringify(prev));
|
||
b.rounds[ri][mi].winner=bracketWinnerId;
|
||
const round=b.rounds[ri];
|
||
if(round.every(m=>m.winner!==null)){
|
||
const winners=round.map(m=>m.p1&&m.p1.id===m.winner?m.p1:m.p2);
|
||
if(winners.length>1){b.rounds.push(buildRound(winners));b.currentRound=ri+1;}
|
||
else{b.champion=winners[0];}
|
||
}
|
||
return b;
|
||
});
|
||
};
|
||
const champion=bracket&&bracket.champion;
|
||
const totalRounds=Math.log2(size);
|
||
const getRoundName=(ri)=>{
|
||
const rem=totalRounds-ri;
|
||
if(rem===1)return"Finale";
|
||
if(rem===2)return"Demi-finales";
|
||
if(rem===3)return"Quarts de finale";
|
||
return"Tour "+(ri+1);
|
||
};
|
||
return(
|
||
<div className="trn">
|
||
<div className="trn-hd">
|
||
<div className="trn-title">Tournoi</div>
|
||
<div className="trn-close" onPointerDown={onClose}>✕</div>
|
||
</div>
|
||
<div className="trn-body">
|
||
{phase==="setup"&&(
|
||
<div className="trn-setup">
|
||
<div>
|
||
<div className="cfg-section">Nombre de joueurs</div>
|
||
<div className="trn-size-row">
|
||
{[4,8].map(s=>(
|
||
<div key={s} className={"trn-size-btn"+(size===s?" sel":"")} onPointerDown={()=>setSize(s)}>
|
||
{s+" joueurs"}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="cfg-section">Joueurs — appuyez pour renommer</div>
|
||
<div className="trn-players-grid">
|
||
{names.slice(0,size).map((n,i)=>(
|
||
<div key={i} className={"trn-player-slot"+(editIdx===i?" trn-p-slot-edit":"")}
|
||
onPointerDown={()=>setEditIdx(i)}>
|
||
<div className="trn-p-num">{i+1}</div>
|
||
<RubiksCube player={i%2} size="sm"/>
|
||
{editIdx===i
|
||
?<input className="trn-p-input" autoFocus
|
||
defaultValue={n} maxLength={12}
|
||
onChange={e=>setName(i,e.target.value)}
|
||
onBlur={()=>setEditIdx(null)}
|
||
onPointerDown={e=>e.stopPropagation()}/>
|
||
:<div className="trn-p-name">{n}</div>
|
||
}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button className="trn-start-btn" onPointerDown={start}>Démarrer le tournoi</button>
|
||
</div>
|
||
)}
|
||
{phase==="bracket"&&bracket&&(
|
||
<div className="trn-bracket">
|
||
{champion&&(
|
||
<div className="trn-winner-banner">
|
||
<div className="trn-w-ico">🏆</div>
|
||
<div className="trn-w-title">{"Champion : "+champion.name}</div>
|
||
<div className="trn-w-sub">Tournoi terminé !</div>
|
||
<button className="trn-new-btn" onPointerDown={()=>{setBracket(null);setPhase("setup");}}>Nouveau tournoi</button>
|
||
</div>
|
||
)}
|
||
{bracket.rounds.map((round,ri)=>(
|
||
<div key={ri}>
|
||
<div className="trn-round-title">{getRoundName(ri)}</div>
|
||
{round.map((match,mi)=>{
|
||
const isDone=match.winner!==null;
|
||
const isActive=ri===bracket.currentRound&&!isDone&&!champion;
|
||
return(
|
||
<div key={mi} className={"trn-match"+(isActive?" active":isDone?" done":"")}>
|
||
{[match.p1,match.p2].map((pl,pi)=>pl?(
|
||
<div key={pi} className={"trn-m-row"+(match.winner===pl.id?" won":"")}>
|
||
<div className="trn-m-name">{pl.name}</div>
|
||
{match.winner===pl.id&&<span style={{fontSize:12,marginRight:4}}>♛</span>}
|
||
<div className="trn-m-score">{match.winner===pl.id?"1":isDone?"0":"-"}</div>
|
||
</div>
|
||
):(
|
||
<div key={pi} className="trn-m-row" style={{opacity:.4}}>
|
||
<div className="trn-m-name">En attente…</div>
|
||
<div className="trn-m-score">-</div>
|
||
</div>
|
||
))}
|
||
{isActive&&match.p1&&match.p2&&(
|
||
<div className="trn-m-play" onPointerDown={()=>{
|
||
onStartMatch(match,(localW)=>recordWin(ri,mi,localW===0?match.p1.id:match.p2.id));
|
||
}}>▶ Jouer ce match</div>
|
||
)}
|
||
{isActive&&(!match.p1||!match.p2)&&(
|
||
<div className="trn-m-tbd">En attente des matchs précédents</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ HISTORY ══ */
|
||
function HistoryScreen({history,onClose,onClear}){
|
||
return(
|
||
<div className="hist">
|
||
<div className="hist-hd">
|
||
<div className="hist-title">Historique</div>
|
||
<div className="hist-close" onPointerDown={onClose}>✕</div>
|
||
</div>
|
||
<div className="hist-body">
|
||
{history.length===0?(
|
||
<div className="hist-empty">
|
||
<div className="hist-empty-ico">🕐</div>
|
||
<div className="hist-empty-lbl">Aucune partie</div>
|
||
</div>
|
||
):(
|
||
<>
|
||
{[...history].reverse().map((g,idx)=>(
|
||
<div key={g.id} className={"hist-entry w"+g.winner} style={{animationDelay:(idx*0.05)+"s"}}>
|
||
<div className="hist-row1">
|
||
<div className="hist-date">{fmtDate(g.ts)}</div>
|
||
<div className={"hist-badge b"+g.winner}>{"J"+(g.winner+1)+" Gagne"}</div>
|
||
</div>
|
||
<div className="hist-players">
|
||
{[0,1].map(p=>(
|
||
<div key={p} className={"hist-player"+(g.winner===p?" winner":"")}>
|
||
<div className="hist-p-top">
|
||
<RubiksCube player={p} size="sm"/>
|
||
<div className="hist-p-name">{"J"+(p+1)}</div>
|
||
{g.winner===p&&<span style={{fontSize:11}}>♛</span>}
|
||
</div>
|
||
<div className="hist-p-time">{fmt(g.times[p])}</div>
|
||
<div className="hist-p-meta">{"Cube x"+g.cubeWins[p]+(g.penaltyTotal&&g.penaltyTotal[p]>0?" • -"+g.penaltyTotal[p]+"s pénalité":"")}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="hist-footer">
|
||
<div className="hist-meta">{g.moves+" coups • "+g.csCount+" CS"}</div>
|
||
<div className="hist-meta">{g.timeLabel+" • "+g.why}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div style={{display:"flex",justifyContent:"center",paddingTop:8}}>
|
||
<button className="hist-clear" onPointerDown={onClear}>Effacer l'historique</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ CONFIG SCREEN ══ */
|
||
function ConfigScreen({onStart,history,onShowHistory,onShowTournament,onShowUsers,onShowSoloCsTimer,rules,onRulesChange,users,selIds}){
|
||
const [sel,setSel]=useState(2);
|
||
const [sct,setSct]=useState(7);
|
||
const [showModal,setShowModal]=useState(false);
|
||
const [cBase,setCBase]=useState(300);
|
||
const [cInc,setCInc]=useState(0);
|
||
const sp=(set,d,mn,mx)=>set(v=>Math.max(mn,Math.min(mx,v+d)));
|
||
const isCustom=sel===-1;
|
||
const activeBase=isCustom?cBase:PRESETS[sel].base;
|
||
const activeInc=isCustom?cInc:PRESETS[sel].inc;
|
||
const cMins=Math.floor(cBase/60);
|
||
const cSecs=cBase%60;
|
||
const tog=(k)=>onRulesChange({...rules,[k]:!rules[k]});
|
||
const u0=users.find(u=>u.id===selIds[0])||users[0];
|
||
const u1=users.find(u=>u.id===selIds[1])||users[1];
|
||
return(
|
||
<div className="cfg">
|
||
<div className="cfg-hd">
|
||
<div className="cfg-logo">CHESSCUBING CLOCK</div>
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"center",gap:8,marginTop:4}}><img src={"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAALRklEQVR4nO1ZeXBV1R3+zrn3rclL8hJCEiQP0MSqFQVxg2qxxVJtLdIRa0tdatuxY6laGStUwciIjq0jrQu2gwO0Wof2tYgCitbSALUUiUQ2w2ZCIBsvy9v3e+/5+sdLMKTVh0Irf/DN3D/efb97zvfbvnPuucBpnMZpnHT4/X6N5NBLfta8jgsfR7Suru7UdmKA/LZt266JxWKvkFxLcm04HF7Z0NBw1WCbUw4kJUmxefPmi9LpdPxQayt/t3wpFz35BMOhENPpdN+GDRvGkhSnpBMkbfX19XpfT48/mUxw0aIn0r7qKhOAOX/ez1OmabCrq+uF+vp6fffu3fbPmi8AoD+aGklt4F4sFvO3t7epG2dMN8qHFXN4eQkBGEeOdKlkMrls0LMDzS1OhIP+KYlLAFIIYQKwAGDVqhXVtbVjpzscjisLCwtRVTVCK3AXIJ1O4fu336rZ7Q5IKac2NTXN2r9/+yohROeg8XQASgihTsSZj0VdXZ0kqQvxYcDuuO46954D7dMCQf4xnTYjJGmaJtsOH+YvHn+M06+/jhXDvVzw8EMMh8McQCJlhgJ9fHHv3rZra2rgGBhPCAGS+idRq7zp60+xGBydd955Z2K1b+xNhQXWNI9HjAEbkE1VYsnyjVY42CmLikrEu+82oKe3B7FoFC3NH6Cg0IPa2lrectud6js3nKVBTwBiHGJxHIgl8GpHc4P/0i9c1TBoXgmAQggerzMfizUvLattaUvOCSf4rmFaJDtIdtLcd5tl/QvmkaaFylNSxclXXsJ5D85lqbeQw0o9rKos44iqYSz1FhIAJ14xldmO+5WxEaZ18D6LDJDsZMYgw0luaT4Um73kBf+YEybcXzKift260b2h2JokmSL3kX0LyT0XKWuHbli7R1vWwZtpNN9Lxpdw+fKlBMA5P5vNmrNGsdBto7fYzdKSAlaUl7C4qJDvbNlEBp+iceAuWnu/QWvHcMt6TzesvZPJ0NMkW0lm45Fgz59XrlxX1S8Un7zR+xsLBzt7nqEKkttXWVbXU4bZcpmltoOqAVQ7HFQHptAKvUpTkaTiZZdexK9MmczJV07ksLIijvZV8YwR5RQAb7t1JnN9QqruF6iarqDa5qDaCnIbyA/GW0b3i8baxoCKkwz1dDwymMt/Q14Vkp5SN3a+Zpn3fVOJCZfbxITp4IU/gTjDBIquBuwjIQAoIwvY7Fj46OOoe2gexo8bj6LiEqRSSQBAVVUE9/1sLkgCNIHyW4DyW8D0ISD2FloS5+FP20fLFZsDcvfWDcbG56fJL45ye/Lxyy+jUlmgU2NMku9ugdi0BfD5IJe2QtgFoMycmaaDJCoqKlBYWAAhBVwuJ6QUiEaj8HpL4PHk+AipAcoEISCco9Br/hCXzdmMvtZ/Ak4JKSicEhqkMPPSy+sAIGACTApAFYC6BkQiQPoIAOaGELl1TCmFeQ/Mhc3mgBAClVVV0DUNHe3t2Lp1K2bfe8+gUTUAEgIK2VQMyWAvdLuAg4BIGYBSubnzIH8J2SSQzkK1WZB2A0KXoM0BCFv/+B+qnBACy373Im6cMR2bNm1AR0cHhBAIhyOYNGkSljy/NFdCR5ELgBQ6bKYFI2MgYymgKwzDUIDMH9+8FioUBM6ZAHHzzaAohjpkQAUiAI+VZyEEhBAoKytDaWkpas6qgWEYEBDQpIDT6URZ2bCjtoNBENFwEmZnBF5p4rs31eKckU6EwqkTzwA0J5DYD+28Uoiv/wpq70FYDW//R3RIQkqJWCyKcDiMsrIygARBaLqG7u4ADCMLm80OpdQxTkgAV5xdiKtn1qKmdhgaWhPozQCjnCchA3C5gb4ucPHT4LM/gjjyL+jfngFR6MnV6ZBoBoNBZLNZOBxOWJaVi5JuQ29vL0Kh0DG2QggoEt5iB34wcxzeCxJ3/LYJT/1mO3rCFpwuW95V+Dg2cwqgHbTpYNQA1rwObHwT8gvXQ5SWH3VioLbb2g7DMi1oug4r14jQdR2hUAgd7e0YPrwCJAf2PZBSoi+axe1PvgdEUpAeO3SHgB3Ht6/LmwGpAcgoqLACMnYoXYMlvIN7N+dmP9l9e/fB6XJCKQWqnJGuaUinsmhubgYAkMeSoyI8sKDZAZthQMXTUKZ1cpoYioDmhBlQMA/HwYgFprSPNN+xcztKSkoQj8X7yeacsBTw/vu7+u8d+4wAIbNZWLEUMp0hqJ4QHJJQpsrbxPlVKNwLnDcOtiW/Ba79NsxMAczmAGAdy0LTNJBEY+M2VFZWIRjsg5S5+QnAZhPYsmXLUdvBoCIi7UHYs0nccPVILF40FbW+AkRiRt4eyJ+B4nJg/1bIVU/DccXn4XxuCfT5jwNO59FQDqhKc3Mzujo7UD2yGn29PdB0vf9/C263Czt2NKKzszNX//0lRwJup4YFs8bh+UevxoWXjMGzr7Tg/bY0vF7HR9IaQP6FTAAwFdTWJrBpPuAtgv2ab0HYbTkDIaAsC5qmYfXqVSguLoHT5UIoHMpFmoAi4XA4EAgEsXHD3/GdmTfDVAq6rgMgHA4bOgwHfvXsToQDcSCeBq2T1MSQACwBRQnl9MDqjsJ4cRkYi+bUpz+S8Xgc/j/9Eeeeey6CwT6EQiGQhGkaUEpBKQWbTcOKFS8hHo+D5NHMBaMZLFmxB+GeKJwuAUEz1zQnwwEChBIw44AZNaHSAspRBkAAVJBSwmaz4a6f3Inqah+KiorR2toKX7UPXq8XnqIiuN1uGIYBu13Dpo2bsPCRh2G320ECShECQLGLENkMrFgCIpGAUBYg5YmvA0KTErGMlYko2FwakNEgpAXqDgghkYiEULegDsMrKuAuKEAinoBSChMuvgTpdBqpVBKhUBChUBgFhR5cMPYCGKaJn8+9Hw/OX4DCAhccNgkjFoNMJSBBGN0BGqmMBbg+Wu6O1wEVjSZw8WUaLrjATOzcaQGQusMhXG27oVkj0KeX48mnnsFF55+HaddPh9s3CkIKpFIpJFMpUOUWLcs0EYvH0dLSjGXLf4/SYhsee/Rh7D8UQaA7CqeRQrItQEuZ6pJJo8T4sZVaNNwbz8fvIzHwKveP1at9fdHYmmwmQ9bXMzBlClsqfEb62XuVeY2L5tJ5fGXpYk659lqOGDOK1SPKefaYkaw900dvcQGlAIEPr7ISB6ddN5WLFr/Eux7ZSJvvl5zz2F9V5ZkPGVO/9hw3vX2YmUxGRcLBlcfzSnnc75qNjY2TRo8fP8ut1AxHd48dezcje88NptVJzXV+hcDjf8COQ23YadnxQXsHug+2IJtKQmg6vMVFqK3xobTch6SsREGxD7fdvQ6JpmbCo1uv+m/VLx0/GpUVegow/Y2NTc9MmDBh26eO/mAMPcv855tvnt+dTP4mmclGGAyQT8xn0FdkJubfbfHzbvIHM8gNb3Eo/rK+g1++/TUWXLyE35u92nJVzDfnLvgb2zqSzGbSfalU9Nfr16//3KB55Yme2g11RA4+Qly3atXorlhiYYzsZDpN7tvD0IVnml2A1Qmw6+waGn99jS+sP8wRE58jPA8R8n7znC8uNvd9EGQ0ZpLkoWw6PN/vX3PGoHn+t98ShjryzKxZZYe6u38ay2b30LJI/58ZuPxyaw9gcvad6voHNingXnPi15eqlWsO0DBI00jvCAYDP77xjjnFg4n/X78fDBw1DvyeDDibDx+eGSbfJklz7Vry9dV86fUDfOOt5v5CytS3tTXPwCD1I6mf1FL5pCAp6uvrj5HjXdu3fzVErkmSBpnNkMmXd+3a9aUhz53wyfRJBQExlNRbL798tv/VN2qO2vQfx+MTqN9ngqGNSFL6/f68q+oph/6GP/U+J53GaZzGqYN/A3mjuNgXAUTiAAAAAElFTkSuQmCC"} style={{width:32,height:32,borderRadius:7,flexShrink:0}} alt="logo"/><div className="cfg-sub" style={{margin:0}}>Speed Challenge Edition</div></div>
|
||
<div className="cfg-hd-row">
|
||
<div style={{display:"flex",alignItems:"center",gap:5}}>
|
||
<div style={{fontSize:18}}>{u0.avatar}</div>
|
||
<span style={{fontSize:8,letterSpacing:3,color:"var(--dim)",textTransform:"uppercase"}}>{u0.name}</span>
|
||
</div>
|
||
<div className="icon-btn" onPointerDown={onShowUsers}>
|
||
<span style={{fontSize:12}}>👥</span>
|
||
<span className="icon-btn-lbl">Joueurs</span>
|
||
</div>
|
||
<div className="icon-btn" onPointerDown={onShowHistory}>
|
||
<span style={{fontSize:12}}>📋</span>
|
||
<span className="icon-btn-lbl">{"("+history.length+")"}</span>
|
||
</div>
|
||
<div className="icon-btn" onPointerDown={onShowTournament}>
|
||
<span style={{fontSize:12}}>🏆</span>
|
||
<span className="icon-btn-lbl">Tournoi</span>
|
||
</div>
|
||
<div className="icon-btn" onPointerDown={onShowSoloCsTimer}>
|
||
<span style={{fontSize:12}}>⚡</span>
|
||
<span className="icon-btn-lbl">CS Timer</span>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:5}}>
|
||
<span style={{fontSize:8,letterSpacing:3,color:"var(--dim)",textTransform:"uppercase"}}>{u1.name}</span>
|
||
<div style={{fontSize:18}}>{u1.avatar}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="cfg-body">
|
||
<div className="cfg-section">CS Timer — tous les</div>
|
||
<div className="cfg-card">
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">⚡ CS Timer activé</div>
|
||
<div className="toggle-desc">Résolvez un cube Rubik's tous les N coups pour gagner x2</div>
|
||
</div>
|
||
<div className={"toggle"+(rules.csEnabled?" on":"")} onPointerDown={()=>tog("csEnabled")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
{rules.csEnabled&&(
|
||
<div className="cfg-stepper">
|
||
<div className="cfg-s-lbl">Coups avant CS Timer</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:5}}>
|
||
<div className="cfg-btns"><div className="cfg-btn" onPointerDown={()=>sp(setSct,-1,2,20)}>−</div></div>
|
||
<div className="cfg-s-val">{sct}</div>
|
||
<div className="cfg-btns"><div className="cfg-btn" onPointerDown={()=>sp(setSct,+1,2,20)}>+</div></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="cfg-section">Règles de gameplay</div>
|
||
<div className="cfg-card">
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">⚔ Coups interdits</div>
|
||
<div className="toggle-desc">Double coup : prise de dame interdite</div>
|
||
</div>
|
||
<div className={"toggle"+(rules.forbidCapture?" on":"")} onPointerDown={()=>tog("forbidCapture")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">{"⏳ Malus de temps ("+rules.moveLimitSec+"s / -"+rules.penaltySec+"s)"}</div>
|
||
<div className="toggle-desc">Dépasser la limite par coup = pénalité</div>
|
||
</div>
|
||
<div className={"toggle"+(rules.penaltyEnabled?" on":"")} onPointerDown={()=>tog("penaltyEnabled")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
{rules.penaltyEnabled&&(
|
||
<div style={{display:"flex",gap:0}}>
|
||
<div className="cfg-stepper" style={{flex:1,borderRight:"1px solid rgba(255,255,255,.04)"}}>
|
||
<div className="cfg-s-lbl" style={{fontSize:10}}>Limite/coup</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:3}}>
|
||
<div className="cfg-btns"><div className="cfg-btn" style={{width:27,height:27,fontSize:15}} onPointerDown={()=>onRulesChange({...rules,moveLimitSec:Math.max(5,rules.moveLimitSec-5)})}>−</div></div>
|
||
<div className="cfg-s-val" style={{fontSize:18,minWidth:28}}>{rules.moveLimitSec+"s"}</div>
|
||
<div className="cfg-btns"><div className="cfg-btn" style={{width:27,height:27,fontSize:15}} onPointerDown={()=>onRulesChange({...rules,moveLimitSec:Math.min(60,rules.moveLimitSec+5)})}>+</div></div>
|
||
</div>
|
||
</div>
|
||
<div className="cfg-stepper" style={{flex:1}}>
|
||
<div className="cfg-s-lbl" style={{fontSize:10}}>Pénalité</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:3}}>
|
||
<div className="cfg-btns"><div className="cfg-btn" style={{width:27,height:27,fontSize:15}} onPointerDown={()=>onRulesChange({...rules,penaltySec:Math.max(5,rules.penaltySec-5)})}>−</div></div>
|
||
<div className="cfg-s-val" style={{fontSize:18,minWidth:28}}>{rules.penaltySec+"s"}</div>
|
||
<div className="cfg-btns"><div className="cfg-btn" style={{width:27,height:27,fontSize:15}} onPointerDown={()=>onRulesChange({...rules,penaltySec:Math.min(60,rules.penaltySec+5)})}>+</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{rules.csEnabled&&(
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">🎲 Mode Chaos</div>
|
||
<div className="toggle-desc">Seuil CS Timer aléatoire à chaque cycle</div>
|
||
</div>
|
||
<div className={"toggle chaos"+(rules.chaosMode?" on":"")} onPointerDown={()=>tog("chaosMode")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
)}
|
||
{rules.csEnabled&&(
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">🧩 Préparation Rubik's</div>
|
||
<div className="toggle-desc">Mélange officiel à mémoriser avant la partie</div>
|
||
</div>
|
||
<div className={"toggle"+(rules.cubePrepEnabled?" on":"")} onPointerDown={()=>tog("cubePrepEnabled")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="cfg-section">Expérience</div>
|
||
<div className="cfg-card">
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">🔊 Sons</div>
|
||
<div className="toggle-desc">Tic-tac, swoosh CS Timer, fanfare victoire</div>
|
||
</div>
|
||
<div className={"toggle"+(rules.soundEnabled?" on":"")} onPointerDown={()=>tog("soundEnabled")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<div className="toggle-lbl">
|
||
<div className="toggle-name">📳 Vibrations</div>
|
||
<div className="toggle-desc">Retour haptique sur chaque action</div>
|
||
</div>
|
||
<div className={"toggle"+(rules.hapticEnabled?" on":"")} onPointerDown={()=>tog("hapticEnabled")}><div className="toggle-knob"/></div>
|
||
</div>
|
||
</div>
|
||
<div className="cfg-section">Temps de jeu</div>
|
||
<div className={"cfg-custom"+(isCustom?" c-on":"")} onPointerDown={()=>setShowModal(true)}>
|
||
<div>
|
||
<div className="cfg-c-main">{isCustom?buildPreview(cMins,cSecs,cInc):"✦ Temps personnalisé"}</div>
|
||
{isCustom&&<div className="cfg-c-detail">{cInc>0?"+ "+cInc+" sec/coup":"Sans incrément"}{" · Perso"}</div>}
|
||
</div>
|
||
<span className="cfg-c-arrow">{isCustom?"✏":"›"}</span>
|
||
</div>
|
||
{["BULLET","BLITZ","RAPID","CLASSIC"].map(tag=>(
|
||
<div key={tag} style={{marginBottom:8}}>
|
||
<div style={{fontSize:9,letterSpacing:4,color:TAG_COLORS[tag],textTransform:"uppercase",padding:"0 3px 5px",fontWeight:700}}>{tag}</div>
|
||
<div className="cfg-card">
|
||
{PRESETS.filter(p=>p.tag===tag).map(p=>{
|
||
const i=PRESETS.indexOf(p);
|
||
let _sy=null;
|
||
return(
|
||
<div key={i} className={"cfg-row"+(!isCustom&&sel===i?" sel":"")}
|
||
onPointerDown={e=>{_sy=e.clientY;}}
|
||
onPointerUp={e=>{if(_sy!==null&&Math.abs(e.clientY-_sy)<10){setSel(i);}_sy=null;}}>
|
||
<div style={{display:"flex",flexDirection:"column",gap:2}}>
|
||
<span className="cfg-row-lbl">{p.base<60?p.base+"s":Math.floor(p.base/60)+" min"}</span>
|
||
{p.inc>0&&<span style={{fontSize:9,color:"var(--dim)"}}>{"+ "+p.inc+" sec/coup"}</span>}
|
||
</div>
|
||
<span className="cfg-chk">✓</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div style={{padding:"5px 0 3px",fontSize:9,letterSpacing:2,color:"var(--dim)",textAlign:"center",textTransform:"uppercase"}}>
|
||
{"⚡ Après "+sct+" coups — CS Timer — le gagnant joue x2"}
|
||
</div>
|
||
</div>
|
||
<div className="cfg-ft">
|
||
<button className="start-btn" onPointerDown={()=>onStart(activeBase,activeInc,sct,isCustom?buildPreview(cMins,cSecs,cInc):PRESETS[sel].lbl)}>
|
||
Démarrer la Partie
|
||
</button>
|
||
</div>
|
||
{showModal&&(
|
||
<CustomModal initMin={cMins} initSec={cSecs} initInc={cInc}
|
||
onConfirm={(base,inc)=>{setCBase(base);setCInc(inc);setSel(-1);setShowModal(false);}}
|
||
onCancel={()=>setShowModal(false)}/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ MOVE DOTS ══ */
|
||
function Dots({moves,sct}){
|
||
const inC=moves===0?0:((moves-1)%sct)+1;
|
||
const onT=moves>0&&moves%sct===0;
|
||
return(
|
||
<div className="dots">
|
||
{Array.from({length:sct},(_,i)=>{
|
||
const filled=i<(onT?sct:inC);
|
||
return <div key={i} className={"dot"+(filled?" f":"")+(i===sct-1?" t":"")}/>;
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ CS OVERLAY ══ */
|
||
function CSOverlay({phase,elapsed,isDone,isWinner,onDown,onUp,player,holdProgress}){
|
||
const {onPD,onPM,onPU,onPC}=useScrollGuard(onDown,onUp,player);
|
||
const CIRC=264; // circumference for r=42 circle
|
||
const prog=Math.min(1,holdProgress||0);
|
||
const offset=CIRC*(1-prog);
|
||
|
||
let ts,tc,ls,lc,hint;
|
||
if(isDone){ts=fmtMs(elapsed);tc=isWinner?"win":"lose";ls=isWinner?"Victoire !":"Éliminé";lc=isWinner?"grn":"dim";hint=isWinner?"2 coups consécutifs !":"";}
|
||
else if(phase==="idle"){ts="0.00";tc="idle";ls="CS TIMER";lc="blu";hint="Maintenez appuyé";}
|
||
else if(phase==="hold"){ts="0.00";tc="hold";ls="CHARGEZ…";lc="grn";hint="Ne relâchez pas !";}
|
||
else if(phase==="ready"){ts="GO";tc="hold";ls="RELÂCHEZ !";lc="grn";hint="Relâchez pour démarrer";}
|
||
else if(phase==="run"){ts=fmtMs(elapsed);tc="run";ls="⏱";lc="red";hint="Appuyez pour stopper";}
|
||
else{ts=fmtMs(elapsed);tc="stop";ls="Stoppé";lc="dim";hint="En attente…";}
|
||
const bgc=isDone?(isWinner?"win":"lose"):phase==="ready"?"hold":phase;
|
||
|
||
return(
|
||
<div className="cso"
|
||
onPointerDown={onPD}
|
||
onPointerMove={onPM}
|
||
onPointerUp={onPU}
|
||
onPointerCancel={onPC}>
|
||
<div className={"cso-bg "+bgc}/>
|
||
<div className={"cso-lbl "+lc}>{ls}</div>
|
||
|
||
{(phase==="hold"||phase==="ready")&&!isDone&&(
|
||
<div className="cso-ring" style={{width:100,height:100}}>
|
||
<svg viewBox="0 0 100 100" width="100" height="100" style={{position:"absolute",top:0,left:0,overflow:"visible"}}>
|
||
<circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,.10)" strokeWidth="5"/>
|
||
<circle cx="50" cy="50" r="42" fill="none"
|
||
stroke={phase==="ready"?"#ffffff":"#7affc0"}
|
||
strokeWidth="7"
|
||
strokeLinecap="round"
|
||
strokeDasharray={""+CIRC}
|
||
strokeDashoffset={phase==="ready"?"0":""+offset}
|
||
transform="rotate(-90 50 50)"
|
||
style={phase==="ready"?{animation:"wbreathe .4s ease-in-out infinite alternate"}:{}}/>
|
||
</svg>
|
||
<div className={"cso-t "+tc} style={{position:"relative",zIndex:1}}>
|
||
{phase==="ready"?"GO":Math.round(prog*100)+"%"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{phase==="run"&&!isDone&&(
|
||
<div className="cso-ring" style={{width:100,height:100}}>
|
||
<svg viewBox="0 0 100 100" width="100" height="100" style={{position:"absolute",top:0,left:0,overflow:"visible"}}>
|
||
<circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,.08)" strokeWidth="5"/>
|
||
<circle cx="50" cy="50" r="42" fill="none"
|
||
stroke="#80ffbe" strokeWidth="5" strokeLinecap="round"
|
||
strokeDasharray="60 204"
|
||
transform="rotate(-90 50 50)"
|
||
style={{animation:"rspin .7s linear infinite"}}/>
|
||
<circle cx="50" cy="50" r="38" fill="none"
|
||
stroke="rgba(80,255,160,.2)" strokeWidth="3" strokeLinecap="round"
|
||
strokeDasharray="30 208"
|
||
transform="rotate(-90 50 50)"
|
||
style={{animation:"rspin2 1.4s linear infinite reverse"}}/>
|
||
</svg>
|
||
<div className={"cso-t "+tc} style={{position:"relative",zIndex:1}}>{ts}</div>
|
||
</div>
|
||
)}
|
||
|
||
{phase!=="hold"&&phase!=="ready"&&phase!=="run"&&(
|
||
<div className={"cso-t "+tc}>{ts}</div>
|
||
)}
|
||
|
||
{isDone&&isWinner?<div className="cso-win">{hint}</div>:hint?<div className="cso-hint">{hint}</div>:null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ DOUBLE MOVE OVERLAY ══ */
|
||
function DblOverlay({isWinner,movesLeft,forbidCapture,showChallenge}){
|
||
return(
|
||
<div className="dbl">
|
||
{isWinner?<div className="dbl-bw"/>:<div className="dbl-bl"/>}
|
||
{isWinner?(
|
||
<div className="dbl-card win-card">
|
||
<div className="dbl-icon">⚡</div>
|
||
<div className="dbl-title">Double Coup !</div>
|
||
{forbidCapture&&<div className="dbl-sub">⚔ Prise de dame interdite</div>}
|
||
<div className="dbl-pips">
|
||
{[0,1].map(i=><div key={i} className={"dbl-pip"+(i>=movesLeft?" used":"")}/>)}
|
||
</div>
|
||
<div className="dbl-hint">Appuyez pour jouer</div>
|
||
</div>
|
||
):(
|
||
<div className="dbl-card lose-card">
|
||
<div className="dbl-wait-ico">⏳</div>
|
||
<div className="dbl-wait-title">Adversaire joue 2 coups…</div>
|
||
<div className="dbl-wait-sub">Attendez votre tour</div>
|
||
{forbidCapture&&showChallenge&&(
|
||
<div className="dbl-challenge">⚔ Appuyez pour contester une prise</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ GAME SCREEN ══ */
|
||
function GameScreen({initTime,inc,sct,timeLabel,onBack,onGameEnd,rules,playerNames,tournamentCb,profiles}){
|
||
const [times,setTimes]=useState([initTime,initTime]);
|
||
const [active,setActive]=useState(null);
|
||
const [moves,setMoves]=useState(0);
|
||
const [paused,setPaused]=useState(false);
|
||
const [scOn,setScOn]=useState(false);
|
||
const [csPhase,setCsPhase]=useState(["idle","idle"]);
|
||
const [csEl,setCsEl]=useState([0,0]);
|
||
const [scDone,setScDone]=useState(false);
|
||
const [scWin,setScWin]=useState(null);
|
||
const [dbl,setDbl]=useState(false);
|
||
const [dblP,setDblP]=useState(null);
|
||
const [dblL,setDblL]=useState(2);
|
||
const [over,setOver]=useState(false);
|
||
const [winner,setWinner]=useState(null);
|
||
const [why,setWhy]=useState("");
|
||
const [ripple,setRipple]=useState([null,null]);
|
||
const [cubeWins,setCubeWins]=useState([0,0]);
|
||
const [pendingWin,setPendingWin]=useState(null);
|
||
const [penaltyFlash,setPenaltyFlash]=useState(null);
|
||
const [totalPenalty,setTotalPenalty]=useState([0,0]);
|
||
const [moveTimer,setMoveTimer]=useState(0);
|
||
const [showForbidden,setShowForbidden]=useState(null);
|
||
const [showChallenge,setShowChallenge]=useState(false);
|
||
const [snapshots,setSnapshots]=useState([]);
|
||
const [currentSct,setCurrentSct]=useState(sct);
|
||
const [eloDelta,setEloDelta]=useState([0,0]);
|
||
|
||
const clockRef=useRef(null),frameRef=useRef(null),startRef=useRef([null,null]);
|
||
const doneRef=useRef(false),phaseRef=useRef(["idle","idle"]),elRef=useRef([0,0]);
|
||
const movesRef=useRef(0),cubeWinsRef=useRef([0,0]),csCountRef=useRef(0);
|
||
const penaltyRef=useRef([0,0]),pendingTimerRef=useRef(null);
|
||
const moveStartRef=useRef(null),moveFrameRef=useRef(null);
|
||
const timesRef=useRef([initTime,initTime]);
|
||
const currentSctRef=useRef(sct);
|
||
const holdStartRef=useRef([null,null]);
|
||
const holdFrameRef=useRef([null,null]);
|
||
|
||
const HOLD_DURATION=900; // ms to fill the circle
|
||
|
||
const [holdProgress,setHoldProgress]=useState([0,0]);
|
||
|
||
useEffect(()=>{phaseRef.current=csPhase;},[csPhase]);
|
||
useEffect(()=>{elRef.current=csEl;},[csEl]);
|
||
useEffect(()=>{doneRef.current=scDone;},[scDone]);
|
||
useEffect(()=>{movesRef.current=moves;},[moves]);
|
||
useEffect(()=>{cubeWinsRef.current=cubeWins;},[cubeWins]);
|
||
useEffect(()=>{csCountRef.current=Math.floor(moves/currentSct);},[moves,currentSct]);
|
||
useEffect(()=>{penaltyRef.current=totalPenalty;},[totalPenalty]);
|
||
useEffect(()=>{timesRef.current=times;},[times]);
|
||
useEffect(()=>{currentSctRef.current=currentSct;},[currentSct]);
|
||
|
||
// Challenge hint
|
||
useEffect(()=>{
|
||
if(dbl&&rules.forbidCapture){
|
||
const t=setTimeout(()=>setShowChallenge(true),400);
|
||
return()=>clearTimeout(t);
|
||
} else { setShowChallenge(false); }
|
||
},[dbl,rules.forbidCapture]);
|
||
|
||
// Tick-tock sounds
|
||
useEffect(()=>{
|
||
if(!rules.soundEnabled||active===null||paused||scOn||dbl||over)return;
|
||
const id=setInterval(()=>{ if(active===0)playTick(); else playTock(); },1000);
|
||
return()=>clearInterval(id);
|
||
},[active,paused,scOn,dbl,over,rules.soundEnabled]);
|
||
|
||
// Chess clock
|
||
useEffect(()=>{
|
||
if(active===null||paused||scOn||dbl||over){clearInterval(clockRef.current);return;}
|
||
clockRef.current=setInterval(()=>{
|
||
setTimes(prev=>{
|
||
const n=[...prev];
|
||
n[active]=Math.max(0,n[active]-0.1);
|
||
if(n[active]<=0){
|
||
const w=active===0?1:0;
|
||
const d=[calcElo(profiles.p0.elo,profiles.p1.elo,w===0?1:0),calcElo(profiles.p1.elo,profiles.p0.elo,w===1?1:0)];
|
||
setEloDelta(d);
|
||
setOver(true);setWinner(w);setWhy("Temps écoulé");
|
||
if(rules.soundEnabled)playFanfare();
|
||
if(rules.hapticEnabled)haptic([50,50,150]);
|
||
if(onGameEnd)onGameEnd({winner:w,times:n,moves:movesRef.current,csCount:csCountRef.current,cubeWins:cubeWinsRef.current,timeLabel,ts:Date.now(),penaltyTotal:penaltyRef.current,why:"Temps écoulé",snapshots,eloDelta:d,names:[profiles.p0.name,profiles.p1.name],avatars:[profiles.p0.avatar,profiles.p1.avatar]});
|
||
if(tournamentCb)tournamentCb(w);
|
||
}
|
||
return n;
|
||
});
|
||
},100);
|
||
return()=>clearInterval(clockRef.current);
|
||
},[active,paused,scOn,dbl,over]);
|
||
|
||
// Move timer bar
|
||
useEffect(()=>{
|
||
if(!rules.penaltyEnabled||active===null||paused||scOn||dbl||over){cancelAnimationFrame(moveFrameRef.current);return;}
|
||
moveStartRef.current=performance.now();
|
||
const loop=()=>{setMoveTimer((performance.now()-moveStartRef.current)/1000);moveFrameRef.current=requestAnimationFrame(loop);};
|
||
moveFrameRef.current=requestAnimationFrame(loop);
|
||
return()=>cancelAnimationFrame(moveFrameRef.current);
|
||
},[active,paused,scOn,dbl,over,rules.penaltyEnabled]);
|
||
|
||
// CS Timer RAF
|
||
useEffect(()=>{
|
||
if(!scOn||scDone){cancelAnimationFrame(frameRef.current);return;}
|
||
const loop=()=>{
|
||
const now=performance.now();
|
||
setCsEl(prev=>{const n=[...prev];for(let p=0;p<2;p++)if(phaseRef.current[p]==="run"&&startRef.current[p]!==null)n[p]=now-startRef.current[p];return n;});
|
||
frameRef.current=requestAnimationFrame(loop);
|
||
};
|
||
frameRef.current=requestAnimationFrame(loop);
|
||
return()=>cancelAnimationFrame(frameRef.current);
|
||
},[scOn,scDone]);
|
||
|
||
const finalize=useCallback((e0,e1)=>{
|
||
if(doneRef.current)return;
|
||
doneRef.current=true;
|
||
const w=e0<=e1?0:1;
|
||
setScDone(true);setScWin(w);
|
||
cancelAnimationFrame(frameRef.current);
|
||
setCubeWins(prev=>{const n=[...prev];n[w]++;return n;});
|
||
if(rules.soundEnabled)playSwoop();
|
||
if(rules.hapticEnabled)haptic([30,20,80]);
|
||
const nextSct=rules.chaosMode?Math.max(2,sct+Math.floor(Math.random()*5)-1):sct;
|
||
setTimeout(()=>{
|
||
setScOn(false);setCsPhase(["idle","idle"]);setCsEl([0,0]);
|
||
startRef.current=[null,null];setScDone(false);doneRef.current=false;setScWin(null);
|
||
setCurrentSct(nextSct);
|
||
setDbl(true);setDblP(w);setDblL(2);setActive(w);
|
||
},1200);
|
||
},[rules,sct]);
|
||
|
||
const csDown=useCallback((player)=>{
|
||
if(!scOn||doneRef.current)return;
|
||
const ph=phaseRef.current[player];
|
||
if(ph==="idle"){
|
||
holdStartRef.current[player]=performance.now();
|
||
setCsPhase(prev=>{const n=[...prev];n[player]="hold";return n;});
|
||
const loop=()=>{
|
||
if(!holdStartRef.current[player])return;
|
||
const prog=Math.min(1,(performance.now()-holdStartRef.current[player])/900);
|
||
setHoldProgress(prev=>{const n=[...prev];n[player]=prog;return n;});
|
||
if(prog<1){
|
||
holdFrameRef.current[player]=requestAnimationFrame(loop);
|
||
} else {
|
||
// Circle full → ready to launch on release
|
||
holdStartRef.current[player]=null;
|
||
if(rules.hapticEnabled)haptic([60,40,60]);
|
||
setCsPhase(prev=>{const n=[...prev];n[player]="ready";return n;});
|
||
}
|
||
};
|
||
holdFrameRef.current[player]=requestAnimationFrame(loop);
|
||
}
|
||
else if(ph==="run"){
|
||
const el=performance.now()-startRef.current[player];
|
||
startRef.current[player]=null;
|
||
setCsEl(prev=>{const n=[...prev];n[player]=el;return n;});
|
||
setCsPhase(prev=>{const n=[...prev];n[player]="stop";const ot=player===0?1:0;if(n[ot]==="stop")finalize(player===0?el:elRef.current[0],player===1?el:elRef.current[1]);return n;});
|
||
}
|
||
},[scOn,finalize,rules.hapticEnabled]);
|
||
|
||
const csUp=useCallback((player)=>{
|
||
if(!scOn||doneRef.current)return;
|
||
const ph=phaseRef.current[player];
|
||
if(ph==="ready"){
|
||
// Released after full → start timer now
|
||
startRef.current[player]=performance.now();
|
||
setCsPhase(prev=>{const n=[...prev];n[player]="run";return n;});
|
||
} else if(ph==="hold"){
|
||
// Released before full → cancel
|
||
cancelAnimationFrame(holdFrameRef.current[player]);
|
||
holdStartRef.current[player]=null;
|
||
setHoldProgress(prev=>{const n=[...prev];n[player]=0;return n;});
|
||
setCsPhase(prev=>{const n=[...prev];n[player]="idle";return n;});
|
||
}
|
||
},[scOn]);
|
||
|
||
const fireRipple=(p)=>setRipple(prev=>{const n=[...prev];n[p]=Date.now();return n;});
|
||
|
||
const applyPenalty=useCallback((player)=>{
|
||
const pen=rules.penaltySec;
|
||
setTimes(prev=>{const n=[...prev];n[player]=Math.max(0,n[player]-pen);return n;});
|
||
setTotalPenalty(prev=>{const n=[...prev];n[player]+=pen;return n;});
|
||
setPenaltyFlash(player);
|
||
if(rules.soundEnabled)playWarn();
|
||
if(rules.hapticEnabled)haptic([50,30,50,30,80]);
|
||
setTimeout(()=>setPenaltyFlash(null),800);
|
||
},[rules]);
|
||
|
||
const handlePress=useCallback((player)=>{
|
||
if(over||scOn)return;
|
||
if(rules.hapticEnabled)haptic(50);
|
||
fireRipple(player);
|
||
if(active===null){setActive(player===0?1:0);setMoves(1);return;}
|
||
if(dbl&&dblP!==null){
|
||
if(player!==dblP){
|
||
if(rules.forbidCapture){
|
||
setShowChallenge(false);
|
||
setShowForbidden(player);
|
||
if(rules.hapticEnabled)haptic([80,40,80]);
|
||
setTimeout(()=>setShowForbidden(null),2000);
|
||
setDbl(false);setDblP(null);setActive(player);
|
||
}
|
||
return;
|
||
}
|
||
if(active!==player)return;
|
||
const left=dblL-1;setDblL(left);
|
||
if(left===0){setDbl(false);setDblP(null);setActive(player===0?1:0);}
|
||
return;
|
||
}
|
||
if(active!==player)return;
|
||
if(rules.penaltyEnabled&&moveStartRef.current!==null){
|
||
const elapsed=(performance.now()-moveStartRef.current)/1000;
|
||
if(elapsed>rules.moveLimitSec)applyPenalty(player);
|
||
}
|
||
cancelAnimationFrame(moveFrameRef.current);setMoveTimer(0);
|
||
const nm=moves+1;setMoves(nm);
|
||
setSnapshots(prev=>[...prev,{move:nm,t0:timesRef.current[0],t1:timesRef.current[1]}]);
|
||
if(inc>0)setTimes(prev=>{const n=[...prev];n[player]+=inc;return n;});
|
||
if(rules.soundEnabled){if(player===0)playTick();else playTock();}
|
||
setActive(player===0?1:0);
|
||
if(rules.csEnabled&&nm%currentSctRef.current===0){
|
||
doneRef.current=false;startRef.current=[null,null];
|
||
setScOn(true);setCsPhase(["idle","idle"]);setCsEl([0,0]);setScDone(false);setScWin(null);
|
||
if(rules.soundEnabled)playSwoop();
|
||
if(rules.hapticEnabled)haptic([50,40,50,40,50]);
|
||
}
|
||
},[active,over,scOn,dbl,dblP,dblL,moves,inc,rules,applyPenalty]);
|
||
|
||
const declareWinner=useCallback((player)=>{
|
||
if(over||scOn)return;
|
||
if(pendingWin===player){
|
||
clearTimeout(pendingTimerRef.current);setPendingWin(null);
|
||
clearInterval(clockRef.current);
|
||
setOver(true);setWinner(player);setWhy("Échec et mat");
|
||
const d=[calcElo(profiles.p0.elo,profiles.p1.elo,player===0?1:0),calcElo(profiles.p1.elo,profiles.p0.elo,player===1?1:0)];
|
||
setEloDelta(d);
|
||
if(rules.soundEnabled)playFanfare();
|
||
if(rules.hapticEnabled)haptic([50,50,150]);
|
||
if(onGameEnd)onGameEnd({winner:player,times,moves:movesRef.current,csCount:csCountRef.current,cubeWins:cubeWinsRef.current,timeLabel,ts:Date.now(),penaltyTotal:penaltyRef.current,why:"Échec et mat",snapshots,eloDelta:d,names:[profiles.p0.name,profiles.p1.name],avatars:[profiles.p0.avatar,profiles.p1.avatar]});
|
||
if(tournamentCb)tournamentCb(player);
|
||
} else {
|
||
clearTimeout(pendingTimerRef.current);setPendingWin(player);
|
||
pendingTimerRef.current=setTimeout(()=>setPendingWin(null),3000);
|
||
}
|
||
},[over,scOn,pendingWin,times,timeLabel,onGameEnd,tournamentCb,rules,profiles,snapshots]);
|
||
|
||
const reset=useCallback(()=>{
|
||
clearInterval(clockRef.current);cancelAnimationFrame(frameRef.current);
|
||
cancelAnimationFrame(moveFrameRef.current);clearTimeout(pendingTimerRef.current);
|
||
cancelAnimationFrame(holdFrameRef.current[0]);cancelAnimationFrame(holdFrameRef.current[1]);
|
||
holdStartRef.current=[null,null];holdFrameRef.current=[null,null];
|
||
startRef.current=[null,null];doneRef.current=false;
|
||
setTimes([initTime,initTime]);timesRef.current=[initTime,initTime];
|
||
setActive(null);setMoves(0);setPaused(false);
|
||
setScOn(false);setCsPhase(["idle","idle"]);setCsEl([0,0]);setScDone(false);setScWin(null);
|
||
setDbl(false);setDblP(null);setDblL(2);setCurrentSct(sct);
|
||
setOver(false);setWinner(null);setWhy("");setRipple([null,null]);
|
||
setCubeWins([0,0]);setPendingWin(null);setPenaltyFlash(null);
|
||
setTotalPenalty([0,0]);setMoveTimer(0);setShowForbidden(null);setShowChallenge(false);
|
||
setSnapshots([]);setEloDelta([0,0]);setHoldProgress([0,0]);
|
||
},[initTime,sct]);
|
||
|
||
const [t0,t1]=times;
|
||
const p0on=active===0&&!scOn&&!dbl;
|
||
const p1on=active===1&&!scOn&&!dbl;
|
||
const divLabel=over?"FIN":paused?"PAUSE":scOn?"CS TIMER":dbl?"x2":active===null?"APPUYEZ":"J"+(active+1)+" JOUE";
|
||
const pName=playerNames||(profiles?[profiles.p0.name,profiles.p1.name]:["Joueur 1","Joueur 2"]);
|
||
const pAv=profiles?[profiles.p0.avatar,profiles.p1.avatar]:["♟","♞"];
|
||
const pStr=profiles?[profiles.p0.streak||0,profiles.p1.streak||0]:[0,0];
|
||
const mtPct=rules.penaltyEnabled&&active!==null&&!scOn&&!dbl?Math.min(100,(moveTimer/rules.moveLimitSec)*100):0;
|
||
const mtCls=mtPct>=100?"crit":mtPct>=75?"warn":"";
|
||
|
||
return(
|
||
<div className="game">
|
||
{/* Player 2 — top, flipped */}
|
||
<div className={"panel flip "+(p1on?"on":"off")+(t1<30&&p1on?" dng":"")+(penaltyFlash===1?" pf":"")}
|
||
onPointerDown={()=>{if(!scOn)handlePress(1);}}> {ripple[1]&&<div key={ripple[1]} className="p-ripple" style={{top:"50%",left:"50%",marginLeft:-100,marginTop:-100}}/>}
|
||
{scOn&&<CSOverlay phase={csPhase[1]} elapsed={csEl[1]} isDone={scDone} isWinner={scWin===1} onDown={csDown} onUp={csUp} player={1} holdProgress={holdProgress[1]}/>}
|
||
{dbl&&<DblOverlay isWinner={dblP===1} movesLeft={dblL} forbidCapture={rules.forbidCapture} showChallenge={showChallenge}/>}
|
||
{showForbidden===1&&(
|
||
<div className="trn-challenge">
|
||
<div className="trn-ch-ico">⚔</div>
|
||
<div className="trn-ch-title">Contestation !</div>
|
||
<div className="trn-ch-sub">Double coup annulé — prise de dame</div>
|
||
</div>
|
||
)}
|
||
<div className={"clock"+(t1<30?" dng":"")}>{fmt(t1)}</div>
|
||
{rules.penaltyEnabled&&p1on&&<div className="mt-wrap" style={{transform:"rotate(180deg)"}}><div className={"mt-bar "+mtCls} style={{width:mtPct+"%"}}/></div>}
|
||
{rules.csEnabled&&!scOn&&!dbl&&<Dots moves={moves} sct={currentSct}/>}
|
||
{rules.chaosMode&&!scOn&&!dbl&&<div className="chaos-lbl">🎲 Chaos</div>}
|
||
{p1on&&<div className="sonar"/>}
|
||
{totalPenalty[1]>0&&<div className="pen-badge">{"-"+totalPenalty[1]+"s"}</div>}
|
||
<div style={{display:"flex",alignItems:"center",gap:7}}>
|
||
<RubiksCube player={1} size="sm"/>
|
||
<div className="p-av">{pAv[1]}</div>
|
||
<div className="p-label">{pName[1]}</div>
|
||
{pStr[1]>=2&&<div className="streak-badge">{"🔥 "+pStr[1]}</div>}
|
||
<span style={{fontSize:9,color:"var(--dim)"}}>{"x"+cubeWins[1]}</span>
|
||
</div>
|
||
{penaltyFlash===1&&<div className="pen-bar"/>}
|
||
</div>
|
||
|
||
{/* Divider + Win buttons */}
|
||
<div className="div">
|
||
{!over&&(
|
||
<div className={"win-btn wl flip2"+(pendingWin===1?" pnd":"")}
|
||
onPointerDown={e=>{e.stopPropagation();declareWinner(1);}}>
|
||
<div className="win-btn-inner">
|
||
<div className="win-btn-ico">♛</div>
|
||
<div className="win-btn-lbl">{pendingWin===1?"Confirmer":pName[1]+" ♛"}</div>
|
||
{pendingWin===1&&<div style={{fontSize:7,letterSpacing:1,color:"var(--g2)"}}>Retaper ✓</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="div-c">
|
||
<div className="div-badge">{moves===0?"PRÊT":"COUP "+moves}</div>
|
||
<div className="div-sub">{divLabel}</div>
|
||
</div>
|
||
{!over&&(
|
||
<div className={"win-btn wr"+(pendingWin===0?" pnd":"")}
|
||
onPointerDown={e=>{e.stopPropagation();declareWinner(0);}}>
|
||
<div className="win-btn-inner">
|
||
<div className="win-btn-ico">♛</div>
|
||
<div className="win-btn-lbl">{pendingWin===0?"Confirmer":pName[0]+" ♛"}</div>
|
||
{pendingWin===0&&<div style={{fontSize:7,letterSpacing:1,color:"var(--g2)"}}>Retaper ✓</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Player 1 — bottom */}
|
||
<div className={"panel "+(p0on?"on":"off")+(t0<30&&p0on?" dng":"")+(penaltyFlash===0?" pf":"")}
|
||
onPointerDown={()=>{if(!scOn)handlePress(0);}}>
|
||
{ripple[0]&&<div key={ripple[0]} className="p-ripple" style={{top:"50%",left:"50%",marginLeft:-100,marginTop:-100}}/>}
|
||
{scOn&&<CSOverlay phase={csPhase[0]} elapsed={csEl[0]} isDone={scDone} isWinner={scWin===0} onDown={csDown} onUp={csUp} player={0} holdProgress={holdProgress[0]}/>}
|
||
{dbl&&<DblOverlay isWinner={dblP===0} movesLeft={dblL} forbidCapture={rules.forbidCapture} showChallenge={showChallenge}/>}
|
||
{showForbidden===0&&(
|
||
<div className="trn-challenge">
|
||
<div className="trn-ch-ico">⚔</div>
|
||
<div className="trn-ch-title">Contestation !</div>
|
||
<div className="trn-ch-sub">Double coup annulé — prise de dame</div>
|
||
</div>
|
||
)}
|
||
{p0on&&<div className="sonar"/>}
|
||
<div className={"clock"+(t0<30?" dng":"")}>{fmt(t0)}</div>
|
||
{rules.penaltyEnabled&&p0on&&<div className="mt-wrap"><div className={"mt-bar "+mtCls} style={{width:mtPct+"%"}}/></div>}
|
||
{rules.csEnabled&&!scOn&&!dbl&&<Dots moves={moves} sct={currentSct}/>}
|
||
{rules.chaosMode&&!scOn&&!dbl&&<div className="chaos-lbl">🎲 Chaos</div>}
|
||
{totalPenalty[0]>0&&<div className="pen-badge">{"-"+totalPenalty[0]+"s"}</div>}
|
||
<div style={{display:"flex",alignItems:"center",gap:7}}>
|
||
<span style={{fontSize:9,color:"var(--dim)"}}>{"x"+cubeWins[0]}</span>
|
||
{pStr[0]>=2&&<div className="streak-badge">{"🔥 "+pStr[0]}</div>}
|
||
<div className="p-label">{pName[0]}</div>
|
||
<div className="p-av">{pAv[0]}</div>
|
||
<RubiksCube player={0} size="sm"/>
|
||
</div>
|
||
{penaltyFlash===0&&<div className="pen-bar"/>}
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
{!over&&(
|
||
<div className="ctrls">
|
||
<button className="cbtn" onClick={()=>setPaused(p=>!p)}>{paused?"▶ Reprendre":"⏸ Pause"}</button>
|
||
<button className="cbtn" onClick={reset}>↺ Reset</button>
|
||
<button className="cbtn" onClick={onBack}>⚙</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* End */}
|
||
{over&&(
|
||
<div className="end">
|
||
<div className="end-ico">♛</div>
|
||
<div className="end-title">{pName[winner||0]+" Gagne !"}</div>
|
||
<div className="end-cubes">
|
||
{[0,1].map(p=>(
|
||
<div key={p} className={"end-cube-card"+(p===winner?" winner":"")}>
|
||
<div style={{fontSize:26}}>{pAv[p]}</div>
|
||
<RubiksCube player={p} size="lg"/>
|
||
<div className="end-cube-lbl">{p===winner?"♛ Victoire":pName[p]}</div>
|
||
<div className="end-cube-sub">{"Cube x"+cubeWins[p]}</div>
|
||
{totalPenalty[p]>0&&<div className="end-cube-sub" style={{color:"var(--pur)"}}>{"−"+totalPenalty[p]+"s pén."}</div>}
|
||
<div className={"end-elo "+(eloDelta[p]>=0?"pos":"neg")}>{(eloDelta[p]>=0?"+":"")+eloDelta[p]+" ELO"}</div>
|
||
{pStr[p]>=2&&<div style={{fontSize:10,color:"var(--red)"}}>{"🔥 Streak "+pStr[p]}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="end-reason">{why}</div>
|
||
<TimeChart snapshots={snapshots} initTime={initTime}/>
|
||
<div className="end-stats">
|
||
<div className="stat"><div className="stat-v">{moves}</div><div className="stat-l">Coups</div></div>
|
||
<div className="stat"><div className="stat-v">{Math.floor(moves/currentSct)||0}</div><div className="stat-l">CS Timer</div></div>
|
||
<div className="stat"><div className="stat-v">{fmt(times[0])}</div><div className="stat-l">J1 Restant</div></div>
|
||
<div className="stat"><div className="stat-v">{fmt(times[1])}</div><div className="stat-l">J2 Restant</div></div>
|
||
</div>
|
||
<div className="end-btns">
|
||
<button className="btn1" onClick={reset}>Rejouer</button>
|
||
<button className="btn2" onClick={onBack}>⚙ Réglages</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ══ ROOT ══ */
|
||
function ChessClockApp(){
|
||
|
||
// Set favicon
|
||
useEffect(()=>{
|
||
const link=document.querySelector("link[rel*='icon']")||document.createElement("link");
|
||
link.type="image/png";link.rel="shortcut icon";
|
||
link.href="data:image/png;base64,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";
|
||
document.getElementsByTagName("head")[0].appendChild(link);
|
||
document.title="ChessCubing Clock";
|
||
},[]);
|
||
|
||
const [screen,setScreen]=useState("config");
|
||
const [gTime,setGTime]=useState(180);
|
||
const [gInc,setGInc]=useState(2);
|
||
const [gSct,setGSct]=useState(7);
|
||
const [gLabel,setGLabel]=useState("3 min");
|
||
const [showHist,setShowHist]=useState(false);
|
||
const [showTrn,setShowTrn]=useState(false);
|
||
const [showUsers,setShowUsers]=useState(false);
|
||
const [showSelect,setShowSelect]=useState(false);
|
||
const [showPrep,setShowPrep]=useState(false);
|
||
const [showSoloCs,setShowSoloCs]=useState(false);
|
||
const [history,setHistory]=useState([]);
|
||
const [trnCb,setTrnCb]=useState(null);
|
||
const [trnIds,setTrnIds]=useState(null); // [id0, id1] for tournament matches
|
||
const [users,setUsers]=useState(()=>tryLoad("chess_users",DEF_USERS));
|
||
const [selIds,setSelIds]=useState(()=>{
|
||
const u=tryLoad("chess_users",DEF_USERS);
|
||
return [u[0]?.id||DEF_USERS[0].id, u[1]?.id||DEF_USERS[1].id];
|
||
});
|
||
const [rules,setRules]=useState({
|
||
forbidCapture:false,penaltyEnabled:false,moveLimitSec:20,penaltySec:10,
|
||
chaosMode:false,cubePrepEnabled:false,soundEnabled:true,hapticEnabled:true,
|
||
csEnabled:true
|
||
});
|
||
|
||
const saveUsers=(list)=>{ setUsers(list); trySave("chess_users",list); };
|
||
|
||
// Derive profile objects for game
|
||
const getUser=(id)=>users.find(u=>u.id===id)||users[0];
|
||
const p0=getUser(selIds[0]);
|
||
const p1=getUser(selIds[1]);
|
||
const profiles={p0,p1};
|
||
|
||
const updateElo=(entry)=>{
|
||
setUsers(prev=>{
|
||
const next=prev.map(u=>{
|
||
const idx=selIds.indexOf(u.id); // 0=J1, 1=J2, -1=not playing
|
||
if(idx===-1)return u;
|
||
const isWinner=entry.winner===idx;
|
||
const delta=entry.eloDelta?entry.eloDelta[idx]:0;
|
||
return{...u,
|
||
elo:Math.max(100,(u.elo||1200)+delta),
|
||
wins:(u.wins||0)+(isWinner?1:0),
|
||
games:(u.games||0)+1,
|
||
streak:isWinner?(u.streak||0)+1:0,
|
||
};
|
||
});
|
||
trySave("chess_users",next);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const addGame=(entry)=>{ setHistory(prev=>[...prev,{id:Date.now(),...entry}]); updateElo(entry); };
|
||
|
||
const startTournamentMatch=(match,cb)=>{
|
||
setTrnCb(()=>(lw)=>cb(lw));
|
||
setTrnIds([match.p1.userId||selIds[0], match.p2.userId||selIds[1]]);
|
||
setScreen("game");setShowTrn(false);
|
||
};
|
||
|
||
// Config → PlayerSelect → (CubePrepScreen) → Game
|
||
const onConfigStart=(base,inc,sct,label)=>{
|
||
setGTime(base);setGInc(inc);setGSct(sct);setGLabel(label);
|
||
setTrnCb(null);setTrnIds(null);
|
||
setShowSelect(true);
|
||
};
|
||
|
||
const onPlayersConfirmed=(id0,id1)=>{
|
||
setSelIds([id0,id1]);
|
||
setShowSelect(false);
|
||
if(rules.cubePrepEnabled){ setShowPrep(true); }
|
||
else{ setScreen("game"); }
|
||
};
|
||
|
||
const activeSelIds=trnIds||selIds;
|
||
const ap0=getUser(activeSelIds[0]);
|
||
const ap1=getUser(activeSelIds[1]);
|
||
const activeProfiles={p0:ap0,p1:ap1};
|
||
|
||
return(
|
||
<div className="app">
|
||
{showUsers&&<UserManagerScreen users={users} onSave={saveUsers} onClose={()=>setShowUsers(false)}/>}
|
||
{showHist&&<HistoryScreen history={history} onClose={()=>setShowHist(false)} onClear={()=>setHistory([])}/>}
|
||
{showTrn&&<TournamentScreen onClose={()=>setShowTrn(false)} onStartMatch={startTournamentMatch}/>}
|
||
{showSelect&&<PlayerSelectScreen users={users} onConfirm={onPlayersConfirmed} onClose={()=>setShowSelect(false)}/>}
|
||
{showPrep&&<CubePrepScreen p0={ap0} p1={ap1} onReady={()=>{ setShowPrep(false); setScreen("game"); }}/>}
|
||
{showSoloCs&&<SoloCsTimer p0={ap0} p1={ap1} onClose={()=>setShowSoloCs(false)}/>}
|
||
{screen==="config"
|
||
?<ConfigScreen
|
||
history={history}
|
||
users={users}
|
||
selIds={selIds}
|
||
onShowHistory={()=>setShowHist(true)}
|
||
onShowTournament={()=>setShowTrn(true)}
|
||
onShowUsers={()=>setShowUsers(true)}
|
||
onShowSoloCsTimer={()=>setShowSoloCs(true)}
|
||
rules={rules}
|
||
onRulesChange={setRules}
|
||
onStart={onConfigStart}/>
|
||
:<GameScreen
|
||
initTime={gTime} inc={gInc} sct={gSct} timeLabel={gLabel}
|
||
rules={rules}
|
||
profiles={activeProfiles}
|
||
playerNames={null}
|
||
tournamentCb={trnCb}
|
||
onBack={()=>{setScreen("config");if(trnCb){setShowTrn(true);}setTrnCb(null);setTrnIds(null);}}
|
||
onGameEnd={addGame}/>
|
||
}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
document.getElementById("boot-status")?.remove();
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||
root.render(
|
||
<StrictMode>
|
||
<ChessClockApp />
|
||
</StrictMode>,
|
||
);
|
||
</script>
|
||
</body>
|
||
</html>
|