/* ============================================================ FWIIP — moteur de jeu jouable (prototype) Modes : vs Bots · Pass & Play ============================================================ */ const { useReducer, useRef, useEffect } = React; // value -> representative card image const VIMG = {1:'c1',2:'c2',3:'c3',4:'c20',5:'c5',6:'c30',7:'c7','X':'c16'}; const imgOf = (v)=>`assets/cards/${VIMG[v]}.png`; const BOT_NAMES = ['Lina','Marco','Zoé','Tariq','Nina','Hugo']; const AVATARS = ['#E5231F','#F0892C','#F4A833','#EA4A26','#BE1813','#1A1411']; let _uid = 0; // Composition réelle du jeu : 42 cartes jouables (6 par joueur jusqu'à 7 joueurs) // 35 cartes numérotées + 7 jokers X const DECK_COMPO = {1:4, 2:4, 3:4, 4:5, 5:8, 6:5, 7:5}; function makeDeck(){ const d=[]; for(const v in DECK_COMPO){ for(let k=0;k0;i--){const j=Math.floor(Math.random()*(i+1)); [d[i],d[j]]=[d[j],d[i]];} return d; } function freshGame(mode, nPlayers){ const deck = makeDeck(); const players = []; // 42 cartes : 6 par joueur jusqu'à 7 joueurs (sans pioche) const dealN = Math.min(6, Math.floor(deck.length / Math.max(2, nPlayers))); for(let i=0;ival(a)-val(b)), color: AVATARS[i%AVATARS.length], }); } return { mode, players, V:0, dir:1, turn:0, lastPlacer:null, rally:0, bestRally:0, pile:[], discard:[], phase:'play', winner:null, selected:[], xPanel:false, sparks:[], shuttle:null, curtain: mode==='passplay', revealReady:false, log:[{t:`Nouvelle manche · ${nPlayers} joueurs. ${players[0].name} commence.`}], flash:null, }; } const val = (c)=> c.value==='X' ? 99 : c.value; // ---- WebAudio SFX : petits sons "sportifs" synthétisés (pas d'asset externe) ---- const Sound = { ctx:null, on: (localStorage.getItem('fwiip_sound')!=='off'), ensure(){ if(!this.ctx){ try{ this.ctx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); return this.ctx; }, toggle(){ this.on=!this.on; localStorage.setItem('fwiip_sound', this.on?'on':'off'); if(this.on) this.ensure(); return this.on; }, // percussion "pock" du volant frappé pock(freq=520, gain=.5){ if(!this.on) return; const ctx=this.ensure(); if(!ctx) return; const t=ctx.currentTime; const o=ctx.createOscillator(), g=ctx.createGain(); o.type='triangle'; o.frequency.setValueAtTime(freq,t); o.frequency.exponentialRampToValueAtTime(freq*0.4,t+0.09); g.gain.setValueAtTime(gain,t); g.gain.exponentialRampToValueAtTime(0.0001,t+0.13); o.connect(g).connect(ctx.destination); o.start(t); o.stop(t+0.14); // petit bruit de corde const nb=ctx.createBufferSource(), buf=ctx.createBuffer(1,1200,ctx.sampleRate); const d=buf.getChannelData(0); for(let i=0;ithis.pock(420,.35),40); }, reset(){ this.pock(300,.5); }, reverse(){ if(!this.on) return; const ctx=this.ensure(); if(!ctx) return; const t=ctx.currentTime; const o=ctx.createOscillator(), g=ctx.createGain(); o.type='sawtooth'; o.frequency.setValueAtTime(300,t); o.frequency.linearRampToValueAtTime(620,t+0.18); g.gain.setValueAtTime(.3,t); g.gain.exponentialRampToValueAtTime(0.0001,t+0.2); o.connect(g).connect(ctx.destination); o.start(t); o.stop(t+0.2); }, fanfare(){ if(!this.on) return; const ctx=this.ensure(); if(!ctx) return; [0,4,7,12].forEach((s,i)=>{ const t=ctx.currentTime+i*0.11; const o=ctx.createOscillator(), g=ctx.createGain(); o.type='triangle'; o.frequency.setValueAtTime(330*Math.pow(2,s/12),t); g.gain.setValueAtTime(.0001,t); g.gain.linearRampToValueAtTime(.35,t+0.02); g.gain.exponentialRampToValueAtTime(0.0001,t+0.4); o.connect(g).connect(ctx.destination); o.start(t); o.stop(t+0.42); }); }, out(){ if(!this.on) return; const ctx=this.ensure(); if(!ctx) return; const t=ctx.currentTime; const o=ctx.createOscillator(), g=ctx.createGain(); o.type='sawtooth'; o.frequency.setValueAtTime(380,t); o.frequency.exponentialRampToValueAtTime(90,t+0.4); g.gain.setValueAtTime(.3,t); g.gain.exponentialRampToValueAtTime(0.0001,t+0.42); o.connect(g).connect(ctx.destination); o.start(t); o.stop(t+0.42); }, }; // ---- legality helpers (for a given hand vs current value V) ---- function singlesBeating(hand, V){ return hand.filter(c=>c.value!=='X' && c.value>V); } function equalCards(hand, V){ return V>0 ? hand.filter(c=>c.value!=='X' && c.value===V) : []; } function hasReset(hand){ return hand.some(c=>c.value===1); } function hasX(hand){ return hand.some(c=>c.value==='X'); } function fusions(hand, V){ const nums = hand.filter(c=>c.value!=='X'); const res=[]; for(let i=0;iV) res.push({cards:[nums[i],nums[j]], sum:s}); } return res.sort((a,b)=>a.sum-b.sum); } function canAct(hand, V){ if(V===0) return hand.length>0; return singlesBeating(hand,V).length || equalCards(hand,V).length || hasReset(hand) || fusions(hand,V).length || hasX(hand); } // ===================================================================== // Vitesse de réflexion des IA (ms avant qu'un bot joue son coup) const SPEED_MS = { lent:2000, normal:1050, rapide:480 }; const SPEED_LABEL = { lent:'Lent', normal:'Normal', rapide:'Rapide' }; function readSpeed(){ try{ return localStorage.getItem('fwiip_speed')||'normal'; }catch(e){ return 'normal'; } } function botDelay(){ return SPEED_MS[readSpeed()] || SPEED_MS.normal; } function Game(){ const G = useRef(null); const [,force] = useReducer(x=>x+1,0); const timer = useRef(null); const scores = useRef({}); if(G.current===null){ let savedMode='bots', savedN=3, savedSpeed='normal'; try{ savedMode=localStorage.getItem('fwiip_mode')||'bots'; savedN=parseInt(localStorage.getItem('fwiip_nplayers'),10)||3; savedSpeed=localStorage.getItem('fwiip_speed')||'normal'; }catch(e){} G.current={phase:'setup', mode:savedMode, nPlayers:savedN, botSpeed:savedSpeed}; } const g = G.current; const commit = ()=>force(); function log(t){ const g=G.current; g.log.unshift({t}); if(g.log.length>30) g.log.pop(); } function flash(t){ const g=G.current; g.flash=t; commit(); setTimeout(()=>{ if(G.current.flash===t){G.current.flash=null; commit();} }, 1100); } function shake(){ const g=G.current; g.shake=true; commit(); setTimeout(()=>{ if(G.current){G.current.shake=false; commit();} }, 340); } // ---- spark burst : éclats projetés depuis le point d'impact ---- function burst(kind='smash'){ const g=G.current; if(!g.sparks) g.sparks=[]; const n = kind==='reset' ? 10 : (kind==='big'?20:14); const palette = kind==='reset' ? ['#FF4A3D','#fff','#F0892C'] : ['#FFD23F','#FF7A1A','#FF4A3D','#fff']; const id = ++_uid; const batch = Array.from({length:n}).map((_,i)=>{ const ang = (Math.PI*2*i)/n + (Math.random()-.5)*0.5; const dist = (kind==='big'?120:88) + Math.random()*60; return { id:id*100+i, dx: Math.cos(ang)*dist, dy: Math.sin(ang)*dist, sz: 5+Math.random()*7, col: palette[i%palette.length], rot: (Math.random()*180-90)|0 }; }); g.sparks = g.sparks.concat(batch); commit(); setTimeout(()=>{ if(G.current){ const ids=new Set(batch.map(b=>b.id)); G.current.sparks=(G.current.sparks||[]).filter(s=>!ids.has(s.id)); commit(); } }, 620); } function alivePlayers(){ return G.current.players.filter(p=>p.alive); } function nextAliveIdx(from, dir){ const g=G.current; let i=from; const n=g.players.length; for(let k=0;k{ player.hand = player.hand.filter(h=>h.id!==c.id); }); // la pile précédente part dans la défausse centrale (qui reste au centre et s'accumule) if(g.pile && g.pile.length){ if(!g.discard) g.discard=[]; g.pile.forEach(pc=>{ g.discard.push({ id:pc.id, value:pc.value, dx:(Math.random()*34-17)|0, dy:(Math.random()*26-13)|0, rot:(Math.random()*26-13)|0 }); }); // pile précédente compacte derrière la carte active if(g.discard.length>7) g.discard = g.discard.slice(-7); } g.pile = cards.map(c=>({...c})); g.V = newV; g.lastPlacer = player.id; // volant : traverse le court depuis le côté du joueur const sid = ++_uid; g.shuttle = { id:sid, side:(g.mode==='bots' && player.id===0)?'bot':'top' }; setTimeout(()=>{ if(G.current && G.current.shuttle && G.current.shuttle.id===sid){ G.current.shuttle=null; commit(); } }, 460); if(reverse){ g.dir*=-1; } // rally streak : grimpe à chaque échange tenu, retombe sur un RESET if(reset){ g.rally=0; } else { g.rally=(g.rally||0)+1; if(g.rally>(g.bestRally||0)) g.bestRally=g.rally; } // SFX + feedback if(reset){ Sound.reset(); burst('reset'); } else if(reverse){ Sound.reverse(); } else if(cards.length===2){ Sound.smash(); shake(); burst('big'); } else if(newV>=6){ Sound.smash(); shake(); burst('big'); } else { Sound.pock(440+newV*40, .5); if(newV>=4) burst('smash'); } log(`${player.name} ${label}.`); } function checkWinner(){ const g=G.current; const alive = alivePlayers(); if(alive.length<=1){ g.phase='roundover'; g.winner = alive[0]||null; if(alive[0]) scores.current[alive[0].id]=(scores.current[alive[0].id]||0)+1; g.curtain=false; Sound.fanfare(); log(alive[0] ? `🏆 ${alive[0].name} remporte la manche !` : 'Manche terminée.'); commit(); return true; } return false; } function eliminate(player, reason){ const g=G.current; player.alive=false; Sound.out(); // option 4+ : give strongest card to lastPlacer if(g.players.length>=4 && g.lastPlacer!=null && player.hand.length){ const lp = g.players[g.lastPlacer]; if(lp && lp.alive){ const strongest = player.hand.slice().sort((a,b)=>val(b)-val(a))[0]; lp.hand.push(strongest); lp.hand.sort((a,b)=>val(a)-val(b)); log(`${player.name} est éliminé·e et donne son ${strongest.value==='X'?'X':strongest.value} à ${lp.name}.`); } else log(`${player.name} est éliminé·e (${reason}).`); } else { log(`${player.name} est éliminé·e (${reason}).`); } player.hand=[]; } // ---- advance to next player & drive bot/auto turns ---- function advance(){ const g=G.current; g.selected=[]; g.xPanel=false; if(checkWinner()) return; g.turn = nextAliveIdx(g.turn, g.dir); scheduleNext(); } function scheduleNext(){ const g=G.current; commit(); const p = g.players[g.turn]; if(!p || g.phase!=='play') return; if(p.isBot){ clearTimeout(timer.current); timer.current = setTimeout(()=>botTurn(p), botDelay()); } else { // human turn if(g.mode==='passplay'){ g.curtain=true; commit(); return; } // can the human act? if(!canAct(p.hand, g.V)){ flash(`${p.name} ne peut pas jouer !`); eliminate(p, 'ne peut pas suivre'); clearTimeout(timer.current); timer.current=setTimeout(()=>advance(), 1200); } commit(); } } // ---- BOT ---- function botTurn(p){ const g=G.current; if(g.phase!=='play' || !p.alive) return; const V=g.V; if(V===0){ // lead with lowest numeric >=2 if possible, else lowest const nums=p.hand.filter(c=>c.value!=='X').sort((a,b)=>a.value-b.value); const pick = nums.find(c=>c.value>=2) || nums[0]; if(pick){ place(p,[pick],pick.value,{label:`ouvre avec un ${pick.value}`}); flash(`${p.name} ouvre · ${pick.value}`); return advance(); } // only X const x=p.hand.find(c=>c.value==='X'); if(x){ place(p,[x],2,{label:'ouvre avec un X (valeur 2)'}); return advance(); } eliminate(p,'main vide'); return advance(); } const singles=singlesBeating(p.hand,V).sort((a,b)=>a.value-b.value); const eq=equalCards(p.hand,V); const fus=fusions(p.hand,V); if(singles.length){ const c=singles[0]; place(p,[c],c.value,{label:`pose un ${c.value}`}); flash(`${p.name} · ${c.value}`); return advance(); } if(eq.length){ const c=eq[0]; place(p,[c],V,{reverse:true,label:`pose un ${V} — choc d'égaux, sens inversé !`}); flash(`↺ Inversion !`); return advance(); } if(fus.length){ const f=fus[0]; place(p,f.cards,f.sum,{label:`fusionne ${f.cards[0].value}+${f.cards[1].value}=${f.sum}`}); flash(`${p.name} fusionne · ${f.sum}`); return advance(); } if(hasReset(p.hand)){ const c=p.hand.find(h=>h.value===1); place(p,[c],1,{reset:true,label:'joue un RESET (valeur → 1)'}); flash(`${p.name} · RESET`); return advance(); } if(hasX(p.hand)){ const x=p.hand.find(h=>h.value==='X'); p.hand=p.hand.filter(h=>h.id!==x.id); log(`${p.name} passe avec un X.`); flash(`${p.name} passe (X)`); return advance(); } eliminate(p,'ne peut pas suivre'); flash(`${p.name} éliminé·e`); return advance(); } // ---- HUMAN actions ---- function toggleSelect(card){ const g=G.current; const p=g.players[g.turn]; if(p.isBot) return; const idx=g.selected.indexOf(card.id); if(idx>=0) g.selected.splice(idx,1); else { if(g.selected.length>=2) g.selected.shift(); g.selected.push(card.id); } g.xPanel=false; g.xMode=null; commit(); } function humanPlay(){ const g=G.current; const p=g.players[g.turn]; if(p.isBot) return; const sel = g.selected.map(id=>p.hand.find(h=>h.id===id)).filter(Boolean); if(!sel.length) return; const V=g.V; if(sel.length===1){ const c=sel[0]; if(c.value==='X'){ g.xMode='single'; g.xPanel=true; commit(); return; } if(c.value===1){ place(p,[c],1,{reset:true,label:'joue un RESET (valeur → 1)'}); flash('RESET · valeur → 1'); return advance(); } if(c.value>V){ place(p,[c],c.value,{label:`pose un ${c.value}`}); return advance(); } if(V>0 && c.value===V){ place(p,[c],V,{reverse:true,label:`pose un ${V} — choc d'égaux !`}); flash('↺ Sens inversé !'); return advance(); } flash('Trop faible — il faut dépasser '+V); return; } if(sel.length===2){ const xs=sel.filter(c=>c.value==='X'); if(xs.length===0){ const sum=sel[0].value+sel[1].value; if(sum>V){ place(p,sel,sum,{label:`fusionne ${sel[0].value}+${sel[1].value}=${sum}`}); flash('Fusion · '+sum); return advance(); } flash(`Fusion ${sum} ≤ ${V}, trop faible`); return; } if(xs.length===1){ g.xMode='fusion'; g.xPanel=true; commit(); return; } flash('Fusion : un seul X à la fois'); return; } } function playX(value){ // value = 'pass' or 2..7 const g=G.current; const p=g.players[g.turn]; if(g.xMode==='fusion'){ const sel=g.selected.map(id=>p.hand.find(h=>h.id===id)).filter(Boolean); const x=sel.find(c=>c.value==='X'); const other=sel.find(c=>c.value!=='X'); if(!x||!other){ g.xPanel=false; g.xMode=null; commit(); return; } const sum=other.value+value; if(sum>g.V){ place(p,[x,other],sum,{label:`fusionne X(${value})+${other.value}=${sum}`}); flash('Fusion · '+sum); g.xPanel=false; g.xMode=null; return advance(); } flash(`Fusion ${sum} ≤ ${g.V}, trop faible`); return; } const x=p.hand.find(h=>h.id===g.selected[0]) || p.hand.find(h=>h.value==='X'); if(!x) return; if(value==='pass'){ p.hand=p.hand.filter(h=>h.id!==x.id); log(`${p.name} passe avec un X.`); flash('Vous passez (X)'); g.xPanel=false; g.xMode=null; return advance(); } place(p,[x],value,{label:`joue un X comme ${value}`}); flash('X joué · '+value); g.xPanel=false; g.xMode=null; return advance(); } function humanPass(){ // when stuck offers explicit elimination const g=G.current; const p=g.players[g.turn]; if(hasX(p.hand)){ playX('pass'); return; } eliminate(p,'abandon'); advance(); } function revealHand(){ const g=G.current; g.curtain=false; const p=g.players[g.turn]; if(!canAct(p.hand,g.V)){ flash(`${p.name} ne peut pas jouer !`); eliminate(p,'ne peut pas suivre'); setTimeout(()=>advance(),1200); } commit(); } useEffect(()=>()=>clearTimeout(timer.current),[]); // ---- keyboard controls ---- useEffect(()=>{ function onKey(e){ const g=G.current; if(!g||g.phase!=='play'||g.curtain) return; const me=g.players[g.turn]; if(!me||me.isBot) return; // X panel value pick with number keys if(g.xPanel){ if(e.key>='2'&&e.key<='7'){ playX(parseInt(e.key,10)); e.preventDefault(); } else if(e.key==='Escape'){ g.xPanel=false; g.xMode=null; commit(); } else if((e.key==='p'||e.key==='P')&&g.xMode!=='fusion'){ playX('pass'); } return; } if(e.key>='1'&&e.key<='9'){ const idx=parseInt(e.key,10)-1; if(me.hand[idx]){ toggleSelect(me.hand[idx]); e.preventDefault(); } } else if(e.key==='Enter'){ if(g.selected.length){ humanPlay(); e.preventDefault(); } } else if(e.key==='Escape'){ if(g.selected.length){ g.selected=[]; commit(); } } else if(e.key==='p'||e.key==='P'){ humanPass(); } } window.addEventListener('keydown', onKey); return ()=>window.removeEventListener('keydown', onKey); },[]); // =================== RENDER =================== if(g.phase==='setup') return ; const me = g.players[g.turn]; const isHumanTurn = me && !me.isBot && g.phase==='play' && !g.curtain; const human = g.mode==='bots' ? g.players[0] : me; return (
{g.phase==='play' && ( )}
{g.flash &&
{g.flash}
} {g.tuto && g.phase==='play' && !g.curtain && me && !me.isBot && ( { G.current.tuto=false; try{localStorage.setItem('fwiip_tuto_done','1');}catch(e){} commit(); }} /> )} {g.curtain && } {g.phase==='roundover' && {G.current={phase:'setup',mode:g.mode,nPlayers:g.players.length}; commit();}} />} {g.phase!=='setup' && g.mode==='bots' && }
); } // ---------- sub components ---------- function Arena(){ return ( ); } function Setup({g, commit, start}){ return (
Salle de jeu

Lance ta partie

Prototype jouable — le vrai multijoueur en ligne arrive plus tard.

Mode
Joueurs
{[2,3,4,5,6,7].map(n=>( ))}

{g.mode==='bots' ? 'Vous + '+(g.nPlayers-1)+' bot(s).' : g.nPlayers+' joueurs sur le même appareil, chacun son tour.'}

{g.mode==='bots' &&
Vitesse des bots
{['lent','normal','rapide'].map(s=>( ))}
} {g.mode==='bots' &&

{g.botSpeed==='lent'?'Idéal pour apprendre — les bots prennent leur temps.':g.botSpeed==='rapide'?'Partie nerveuse pour joueurs aguerris.':'Rythme équilibré.'}

} Revoir les règles d'abord →
); } function Opponents({g, scores}){ const opps = g.mode==='bots' ? g.players.slice(1) : g.players.filter((p,i)=>i!==g.turn); return (
{opps.map(p=>{ const n=Math.min(p.hand.length,7); return (
{p.name[0]}{g.turn===p.id && }
{p.name}
{p.alive? `${p.hand.length} carte${p.hand.length>1?'s':''}` : 'éliminé·e'}{(scores&&scores[p.id])?` · ${scores[p.id]} ★`:''}
); })}
); } function Court({g}){ const fromTop = g.mode==='bots' ? (g.lastPlacer!==0) : true; const leader = g.lastPlacer!=null ? g.players[g.lastPlacer] : null; const heat = (g.V>=1 && g.V<=7) ? `var(--h${g.V})` : 'var(--red)'; return (
=6?' blaze':'')} style={{'--heat':heat}} key={'emb'+(g.pile[0]?.id||'x')}>
À battre
{g.V||'—'}
{g.dir===1?'↻ sens horaire':'↺ sens inversé'}
{leader && g.pile.length>0 ? {leader.name} mène l'échange : Échange ouvert} {g.rally>=2 &&
=5?' hot':'')} key={'r'+g.rally}> {g.rally>=5?'🔥':'⚡'} Échange ×{g.rally}
}
{g.pile.length===0 && (!g.discard||g.discard.length===0) &&
À toi d'ouvrir
l'échange
} {(g.discard||[]).map((d,i)=>(
))} {g.pile.length>0 &&
} {g.shuttle && } {(g.sparks||[]).map(s=>( ))} {g.pile.map((c,i)=>(
{c.value}/
))}
); } function HandBar({g, human, isHumanTurn, toggleSelect, humanPlay, humanPass, playX}){ if(!human) return null; const V=g.V; const stuck = isHumanTurn && !canAct(human.hand, V); const selCards = g.selected.map(id=>human.hand.find(h=>h.id===id)).filter(Boolean); const otherNum = selCards.find(c=>c.value!=='X'); const base = otherNum ? otherNum.value : 0; // ---- coach : coups légaux disponibles maintenant ---- const beats = isHumanTurn ? [...new Set(singlesBeating(human.hand,V).map(c=>c.value))].sort((a,b)=>a-b) : []; const eqs = isHumanTurn ? equalCards(human.hand,V) : []; const fus = isHumanTurn ? fusions(human.hand,V) : []; const canReset = isHumanTurn && hasReset(human.hand); const canX = isHumanTurn && hasX(human.hand); const hints = []; if(isHumanTurn){ if(V===0){ hints.push({k:'open', t:'Ouvre l\'échange — pose la carte de ton choix'}); } else { if(beats.length) hints.push({k:'beat', t:`Dépasser · ${beats.join(' ')}`}); if(eqs.length) hints.push({k:'eq', t:`Choc d'égaux · pose un ${V} (inverse le sens)`}); if(fus.length) hints.push({k:'fus', t:`Fusion · ${fus[0].cards[0].value}+${fus[0].cards[1].value}=${fus[0].sum}`}); if(canReset) hints.push({k:'reset', t:'Reset · le 1 ramène la valeur à 1'}); if(canX) hints.push({k:'x', t:'X · joker (2→7) ou passe en sécurité'}); } } return (
{isHumanTurn ? À vous de jouer — {V>0?`dépassez ${V}`:'ouvrez l\'échange'} : En attente — {g.players[g.turn]?.name} joue…}
{isHumanTurn && hints.length>0 && (
{hints.map(h=>{h.t})}
)}
{human.hand.map(c=>{ const sel=g.selected.includes(c.id); const playable = isHumanTurn && (V===0 || c.value==='X' || c.value===1 || c.value>V || c.value===V); const reason = !isHumanTurn ? '' : (V===0 ? 'Carte jouable' : c.value==='X' ? 'Joker — toujours jouable' : c.value===1 ? 'Reset — toujours jouable' : c.value>V ? `Dépasse ${V}` : c.value===V ? `Égal à ${V} — choc d'égaux` : `Trop faible — il faut dépasser ${V}`); return ( ); })}
{isHumanTurn && (
{!g.xPanel && <> } {g.xPanel && g.xMode==='fusion' &&
X + {base} — valeur du X : {[2,3,4,5,6,7].map(v=>( ))}
} {g.xPanel && g.xMode!=='fusion' &&
Jouer le X comme : {[2,3,4,5,6,7].map(v=>( ))}
}
)} {stuck && !g.xPanel &&
Aucun coup possible… vous allez être éliminé·e.
}
); } function Curtain({g, reveal}){ const p=g.players[g.turn]; return (
{p.name[0]}

{p.name}

Passez l'appareil. Personne d'autre ne doit voir votre main.

); } function RoundOver({g, scores, again, home}){ const w=g.winner; const board = g.players.map(p=>({name:p.name, w:(scores&&scores[p.id])||0})).filter(x=>x.w>0).sort((a,b)=>b.w-a.w); return (
{w && }
🏆
Manche terminée

{w?`${w.name} gagne !`:'Égalité'}

Dernier·e à tenir le court. Beau smash.

{g.bestRally>=3 &&

Meilleur échange de la manche : ×{g.bestRally}

} {board.length>0 &&
{board.map((b,i)=>( {b.w} ★ {b.name} ))}
}
); } function Coach({onClose}){ return (
e.stopPropagation()}> Comment jouer

Sois plus fort que le centre

  • Pose une carte plus forte que la valeur affichée au centre, puis appuie sur Poser.
  • Sous ta main, le coach liste tes coups possibles à chaque tour (dépasser, fusion, reset, X…).
  • Les cartes en relief sont jouables ; les grisées sont trop faibles.
); } function Confetti(){ const COLS=['#E5231F','#FF4A3D','#F0892C','#F4A833','#F6D44E','#fff']; const pieces = React.useMemo(()=>Array.from({length:70}).map((_,i)=>({ id:i, left:Math.random()*100, delay:Math.random()*0.7, dur:1.8+Math.random()*1.8, col:COLS[i%COLS.length], sz:6+Math.random()*8, rot:(Math.random()*360)|0, drift:(Math.random()*120-60)|0, round:Math.random()>0.6, })),[]); return ( ); } function Log({g}){ return (
{g.log.slice(0,6).map((l,i)=>
{l.t}
)}
); } const MOVES = [ {g:'↑', t:'Supérieur', d:'Pose une carte de valeur strictement plus haute.'}, {g:'=', t:"Choc d'égaux", d:'Même valeur : passe le tour ET inverse le sens.'}, {g:'+', t:'Fusion', d:'Combine 2 cartes ; leur somme doit dépasser la valeur.'}, {g:'1', t:'Reset', d:'La carte 1 redescend le jeu à 1. Imparable.'}, {g:'X', t:'Joker', d:'Vaut 2 à 7 au choix, ou passe ton tour.'}, ]; function CheatSheet(){ const [open,setOpen]=React.useState(false); return ( {open && (
Rappel des coups
{MOVES.map((m,i)=>(
{m.g}
{m.t}{m.d}
))}
1–9 sélectionner · Entrée poser · P passer · Échap annuler
)}
); } function SoundToggle(){ const [on,setOn]=React.useState(Sound.on); return ( ); } function SpeedToggle(){ const order=['lent','normal','rapide']; const [sp,setSp]=React.useState(readSpeed()); const icon = sp==='lent'?'🐢':sp==='rapide'?'⚡':'🎾'; function cycle(){ const next = order[(order.indexOf(sp)+1)%order.length]; try{ localStorage.setItem('fwiip_speed', next); }catch(e){} setSp(next); } return ( ); } ReactDOM.createRoot(document.getElementById('game')).render();