Files
Chesscubing/index.html

1836 lines
92 KiB
HTML
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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,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"} 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,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAReUlEQVR4nO1aaXhV1bl+1977DDnhZKJhkilAGUSoMokdvA4tVR8BhYIoUIdeW7F2siL2am+8pVWKWpRqr6JQS5VCKFjHFqiAFCNDQkhCwmDIDCRkOENyxr3XevvjDAQK1UMFvbe8z3OePDlnr7W+9a7ve7+19vqAC7iAC7iAC/j3hTjfA5IUAPQz/CyFEDyf9vzb47x5AEmhaRoXLFjQ/e677/6B2+3uR1KRBDRNdPh8h5ctW7Z08eLFnQDw/80TBEkNgGhoaNhIkh1+P6PRCJWU7OzsIEk21NevAQCSZwqR/5tITOjw4cNLSFJKhu+4Y4751WuvNLMy3ea3777TJBkmyf379z/Wtc25hnYexhCapskZM2bYs7Ozp0spuXLlcltmZpZx8OBBIxIJGU1NTcbSZ5bYADArK+sb+fn5mq7r8jzYdm5BUmzZssWIuz/a29sPBYMBPvaLn8np06awW7qdvXpmEwDHjhklSbKpqWlPvK1GUo9njXOGc+IBBQUFOkldCMGrr77aEkKoFStWjLLb7TmaprG2tkYUFxcDiKlwepoOXTcEALpcrp6vvvrqcCGEEkJIIQRJJkn8zCI/P18jaQhxYsFmXzcho7x87y319Q1veL3eEGNQxcVFfOihBzluzGg6HDb+5KH5LCnZw3hWoMfjCR1tPPpacfEH08aOhSvRn6ZpIGnk5+d/Nsg41cXjENs2v/2lupqqpR5fpJ5dEAwE2NBQz18vXcKpU27gtJsnc1BeP/br25uvrV/HQCCQJCGBNm+gurrqwJOb3lk3/pSxP5EQOZvGoqCgQJsxYwaEEEmh+sMfnhsx5tIvT8vK6TE9p0evywwAUDVA1C+PeS/C2jUrtY7OgMjOzkaH34cPPzyElpYW+P0+HD9+HM1NzejVuzeGDh2GKVOm8hszb1MuuVtoGSM0IBcRC/C1H93Z3tr8x/Liwj/N/OZ9VV3I0NeuXYuZM2cqAOdu/3Cq6+U/cHuvvXsKv3X0aPPGziCjyWVrfknJ8ivM6N+gGHiTk66fwXSXgw/Ov59LljzFcWNHc0D/3hyU15fDhg7i8GGD2K9vT3Zz2Zjm1AmACx//DelfxMhWIWXlJJOeN5PddwYZPHr06FsVZbvmPPTdud2TKyPEP9j4SSLhLY7tW9+9obq65hVPB9uSVnW8Rdn4Q9NqWyflkV9RHbyZ5o4e5LE5fO43LxAAhw8bxGeefoojL/48nQ6N2ZkuZmY4meF2Mie7G3vkZjG3ewZzcz/Hkj07yA8vp7Urj/LAbMpjL9BqWSHZeL/JUGFyWK+fTVVVtcvf2bj5Wpz5jPGvIR5vYtOmTeNb29r3n5j0Xyhr7rGssoGW3AOlSkBVAqqDE6gaf0zZ9CytI4+RZgMnT5lKIcAf3/8DTrt5Ml1pBnv1yGbP3Cz2+Fwme+RmsVfPHAqAC3/+OMk6Rut+SXXkSaqquVRlg6iKQVUEyt1QVtloy6pfYDF4goy21tbi9evXjyApPlFPSIhcXX3juyQpa3dGreBhy2q4R6nSblQ7QLUbVHsQM7IEVJVjqFpW0IwbV15eSrtN51X/8SXOu+c/CYAup06nQ9BpF3Sl6XTaNQ4ZPIBeTzulIqUiVeNiqtIRVLsQ+xSB1k7Q3A6yJJ1s+p6SkRarqMYXJcn6urqCuM0fyxuMj8sBAOgudya8jUr91+WaNmycjol3gaO2QrirgbaXgVATkDMTyJwOOIYAAHRasCRwySWjcccd38Tu3buhazomT74e7m5ukAqhUBiBQADl5RWYN++7yMzKhmWa0A0duGh+7BOsALzrAU8B9PQ+QI+70Bi4ButKNLFy8yG9tK4N3t/foLLc6d272vxJERDvUklA0+AXkjuLgMIisHs2xKgpEJN/CjFyYpdnJUACQgcgQRL3fvf7uOvO29HR4cfAAQOxZctmKCoYugG7w4Fhw4Zg7jdvB0loug5AAMqM9eEaCbpGQvR+GDuqw3huRTPeLiqFp9kHQEdauoAmNC1KldIWOjUCNAoogp0EbDZQ0yFa/WDB70BPE/Rf/gWwwoBuA6AlZVPTYuEopURWVhaipgWCuGzMGNjtdkhLoqamGt26dYPf70dubg+Q8QUURpJQSgnNsOMnL5di65Y6IMcJm8MGaSowGpu3JlLL7GchFAIMCzBMIGSBpg2w6RA2dxeDT3SrlIKmaThwYD++8uWJ6JGbixEjRiDDnYE+vXsjGo6guGg3amtr8MEHhRhz2SjUVFdD0zQopU42VRgAiDShwXDaYVgKVigKFTaBSBRn8wYhNQ+AABTBqA5BPebiNoBhCURPP3piIgMH5uHFF1dgyZInUVZeBqfTgUgkimg0Cp/Piw5/J4YPH4YH5i9Ar969Y2GgnW59BGhasIJh6NAhpIJQhB5/VJ2mxT/DWXiABtVkQR2PgCYAaQMiAoieuYUQAg6HA7fNnosBA/PQv39/AAKHDh1Cy/FmkEQ0amHc+PG4bfZc2O320/aTpNiyIKIWdCUBKaHaAgi0+CHOYkKpPR8KAN17Qf/f1cDEr0L5o1CNHVA+gtZHdyWlRGtrK3JycpCWlgaH3Qab3Q4BAd0Ajh07Bik/WsM0KDBiIdrUAeUP4JoJn0PBomvgNIBA2ExJBFILAZsdCPugtRdBe3ABVPgRcMsGmL9bAXYGY52dLhLi7iylhN/nw9DPfx6macZ+UgQEoWsaPB7PGdz+ZPg9nchxSdwxfSRuvGogpN2OzZXtuImAw0htM5gSAdQNIBwAX3oSyvEkMOpL0L82F2L1HwF3bkwTTjMBIpYQ/H4//H4fMjIyYZomEkdnRcKw2dBy/Dg6OzvhdruhlII4RdE1IUASz82fiIBFHDgWRv5rdfjbnuMAJR6ZNRyGoaUkhSkRIABAaIArpsbc+T7ke+8D2ZnQ7nkUGDwUUCqe+7sQEE9pzU1NCAaDcLszEI1GASFinRIwDANt7a1obmqC2+0GyX8gIPadhm1VAfz8d5VobuoADB3CoSPdrscUUKQW1ak9LeJZoEOCAYKaHcxIB4/6ILe8gzOdrhMEHK4+DCkVXC4XIpHIiQmSMAwDHR0B1NbVnNTmdCas2liL5sYOONw26DYBhqOwQpH46KnlgRRFkwAFVFgDQwIMKaighLIEaM88c6v4ZCor98HlcsFmtyMSiUBLrJYQ0DQNpklUffjhSW1OhyynDqFkfA8QAcImtKgZ15/UppRaCGg6EJGQrRLIdAAQgCaggoQWiTN/GrsTK11WWorc3FxIKREJhyG0WEwj/hEC2F9Z8ZF2qHAEDIUBw4CuCFoKwVAk3o1KKQukRBejYaBbJjj0EqiGCOTRMGRAAVEDsM6svrquQ0qJ0tK96NOnDzo7/IhEo0lRA2JCaLdrKCsvA+MhcUajqWBIC4iYsFr8kEfaMDYvHXYdMGVq28HU/MU0AVc6nC+uhW31mxBTZkGGDZhtFmR752kVIKHmVVVVqK4+jLy8QfB6PLAsMxbQACAElFJwOp2orKxAfX09RPy7rkhMLdgZgtXYDhEO4uav9ce65ZOx8qkboeuAUqkRkFoaTEsHPE1Qd4+HftVUOL89B+qHDyCy/nUwYd4pyp04C2zduhkAMKD/ABTvKYptdYUGxcRrPAG73YbWVi8KC7ejf//+ybYJJHr+Ql43TLh/HG66cQR8UYGVG2ux4dkSNL4+C2lO4xynQQL0dUKtfhVY8yrE6EvhvGEOxKRbYu58au7WNAgh8PrrryEvbxDcGRloaWlBvCtAxE/NIkaCEMBfN27ArbfO/oc0KOIhs2DeFVj3XiO+92w59pQ2A1EJW44Rs02llgVSPAzFYQnQaQOhAXv3Qr29F/rthbAtXQcomdwHKKWg6zpqa2tQ+P52TJ16M0wzira2tngGYNKvydjz3dKdeHfzX+HzeZGZmXXShkiRMDQN9zxRhDfXHgC626E7NUAXMCx5VlkgpaeFAKAAGQZkUIEhCaW7wAwDDJ8SrySklFBK4cVlz8PnD2H48OEwTQterxc2uw1CCAhNQIiYl1Ap2O121Ncfw8svr4BSCpZlnRg//jcSsmC4Ddh0QoUjkKFoLCsQSHUfkJoGMLZiKhTbcBGA0BQQsEAV55InnrXb7Zg5YxrsDjvmP/Aj7Nq1G+npLkSjEYTDYWhabEqapsMwDBiGAdM0MXbsF/DE4sXw+3z46X//D0zTPCkraFSwQhEYQgBmLBskbyg+xlnirAkAYlonI0ZSD4QOMACICCCoQCXBeNwXvr8d48aNx5qCNYhGoujfvx8A4qabpiEQDKCzowPhcBgejwfHjzejtrYW4YhCRcU+DB48DL1798Hu3bswbtz4mEcpxrQiGgbCQUAICEsCUSv2PpyASPG1SIobIaHBUirSbMJwAiItLaYHUUBECAgN0u6AoSRWrXoFc+begTmzb8GPfvgj7KuoQNOxIwgEgjDN2MsDSypQKTgcTvTr1x8XX3wJ+vS5CBf17YuePXrg5ZdXYN68eXjjzbfx9a9fB7sttroqEgFCIWi6Dk1KRL1BdDICKqU0oaV0HPy4BGgApOzs9KJvX835/fuiHb9/RQiPV9N0Aeo6hK0bWFUIBsPA6GvQHoqCACorKqAIDB48GBMmXA6H3QFLWgiFQjDj7wYFBEzThNfrQXNzE4r+tAuVFfsQjkRgSUAwBE0T2PC3GmRlpsFtU9DDIURNAj4/ci/KlN/+zrUy3aXZjzT42xPrlQoR/xRdLkYua/d49pEkfT76HvuFbBg0yDoI8Og3ZlOteojWlaD1+Bz69mzjI48/xgGDBjDL7WS/Xt15yYghnDhhDL/8xQn8wqgR7JmbRbsB2gxQxNQj+dE1cPTIIXxi8S/4xruHeON3XieyFnLhc4W8adYKAndyyMiF1hNP/VWGI7G7h/b2tqJUL0Y+NkskRbxwyVG0ffuteaNH35vjdo8HAM+vl1JqdpVtr9HV4sVAJqA506BfdxuOT70Xb5ftx3sfvI/K3btwtK4WncEAZDy9aUKDpmuwO9KQ4c7A4Lz+uGzMaFw+8SvI7H0ZfrXiMN7cuA+IRoCj7Xj0ieuZky6UEEK7756rBAD4fd5tFZWHnv3iFy9/DYCF5CH7E8YprIqibdtubG5p2ZS4z1aVxQzf9TUreLFNBfuB/m4g1ywjn11AvvEK2emnR0oebmxkSdFu7tqxgzt27mRJSQmbjjVQWUEeaw3xl8v38ZHny/jw0zsI42Giez7R/RF15aTnzaK9jcmrsNaW5rfee2/7pKRB5/hyNDkOSb3rFnXrW29d3VBbuz5gSYskWVbM0H23m54spwq+sJT+KwfQ6wQ7xubRfDyfrKvhqSiv8vLehVuZc8XzRMbPOH7a7/ngwg000uerWd/6g1m4K1ZqYFmRSEND4ytvvPHnKxLjkxTxq7DzW/jJUwoU/rxu3ZiGmpqX/KbVSZKsqWa0ptryXjNeegB67GArwJZMF9tuvYVWXTX3VHk46VvrqV/yNNF3EdFvEZH2MCfdslJW7D9mHTjUGqco2l5XU/frtavWjuwyvlZQUPDpl9TFa4KSLvHC4sWDD+/fv8gXDjclBDOw6DGrecgg2QjwCMBagFz9Wz766iEi42fEgEVETj4Hj3/aWvTMNunvlCRJMxqq37//4KPPPLOsb6L/OPGfjTKZrohni+SK/OTOO3P379v3Y28gcCB5o758uWwYM9baB5CrlvOnL5UR4n51+fXLrN+uKk6WxwQ6faXl5RXzpk69PatL/zo/K/VB/wyJgqnE/wMAZ/G2bXNaWluTl/neNQVkWbG5ufiIue5PpUktaGtr3bx9+wfT0WWPwliV2Hkv7P6XwXgRVdfvCjdtuq6xsfHPkZMkUFqNjY2vbdjw3jWJ54QQiBdgndOJny9WE7XCKlEE/c7q1eOHXnrpzaYQVmnJvnWzZk0vBZLl9JoQ4twWPH1aOFUwE/i0FP1Ti6s4CQkiVHzFL+ACLuACLuACLuACzhv+DlDtt88tgU89AAAAAElFTkSuQmCC";
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>