← back to Baguette Brawl

src/game.js

824 lines

/* ============================================================================
   Baguette Brawl — Duel de Paris
   A 2D fighting game where two crusty baguette duelists brawl beneath the
   Eiffel Tower. Vanilla JS + Canvas, no dependencies, file:// friendly.

   Rebuilt & extended from the Model Arena starting artifact:
     - press-edge input (attacks no longer auto-repeat while held)
     - clean round state machine (intro → fight → KO → result), no dt*0 hack
     - super meter + unblockable "Crust Crusher" special
     - selectable AI difficulty with real spacing / anti-air / whiff punish
     - on-screen touch controls for mobile
     - pause (P / Esc), rematch (R), slow-mo KO, "FIGHT!" / "K.O." banners
   ========================================================================== */
(() => {
'use strict';

const cv  = document.getElementById('c');
const ctx = cv.getContext('2d');
const W = 960, H = 540;
const GROUND = 470;

const menuEl   = document.getElementById('menu');
const resEl    = document.getElementById('result');
const pauseEl  = document.getElementById('pause');
const resTitle = document.getElementById('resTitle');
const resSub   = document.getElementById('resSub');
const resKicker= document.getElementById('resKicker');
const resBtns  = document.getElementById('resBtns');
const touchEl  = document.getElementById('touch');

// ---------------------------------------------------------------------------
// Audio (tiny synth, no external files)
// ---------------------------------------------------------------------------
let AC = null, muted = false;
function actx(){ if(!AC){ try{ AC = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } return AC; }
function tone(freq, dur, type='sine', vol=.15, slideTo=null){
  if(muted) return; const a=actx(); if(!a) return;
  const o=a.createOscillator(), g=a.createGain();
  o.type=type; o.frequency.setValueAtTime(freq,a.currentTime);
  if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo,a.currentTime+dur);
  g.gain.setValueAtTime(vol,a.currentTime);
  g.gain.exponentialRampToValueAtTime(.0001,a.currentTime+dur);
  o.connect(g); g.connect(a.destination); o.start(); o.stop(a.currentTime+dur+.02);
}
function noise(dur, vol=.2, hp=800){
  if(muted) return; const a=actx(); if(!a) return;
  const n=Math.floor(a.sampleRate*dur), buf=a.createBuffer(1,n,a.sampleRate), d=buf.getChannelData(0);
  for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
  const s=a.createBufferSource(); s.buffer=buf;
  const f=a.createBiquadFilter(); f.type='highpass'; f.frequency.value=hp;
  const g=a.createGain(); g.gain.value=vol;
  s.connect(f); f.connect(g); g.connect(a.destination); s.start();
}
const sfx = {
  swing(){ noise(.12,.12,1200); },
  hit(){ noise(.16,.32,500); tone(120,.1,'square',.14,70); },
  crush(){ noise(.26,.4,300); tone(90,.28,'sawtooth',.22,50); },
  block(){ tone(880,.09,'triangle',.16,660); noise(.05,.08,2500); },
  jump(){ tone(300,.12,'sine',.1,520); },
  super(){ tone(440,.18,'square',.16,880); setTimeout(()=>tone(660,.2,'square',.16,1200),90); },
  ko(){ tone(400,.5,'sawtooth',.2,80); },
  count(){ tone(660,.08,'triangle',.12); },
  fight(){ tone(523,.1,'triangle',.16); setTimeout(()=>tone(784,.18,'triangle',.18),100); },
  win(){ tone(523,.12,'triangle',.16); setTimeout(()=>tone(659,.12,'triangle',.16),120); setTimeout(()=>tone(784,.2,'triangle',.18),240); }
};

// ---------------------------------------------------------------------------
// Input — held keys + press-edge (justPressed) so attacks fire once per tap
// ---------------------------------------------------------------------------
const keys = new Set();
const justPressed = new Set();
function normKey(e){ return e.key.length===1 ? e.key.toLowerCase() : e.key; }
addEventListener('keydown', e=>{
  const k=normKey(e);
  if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','/'].includes(e.key)) e.preventDefault();
  if(!keys.has(k)) justPressed.add(k);   // edge only on the first frame held
  keys.add(k);
  handleGlobalKeys(k);
});
addEventListener('keyup', e=>{ keys.delete(normKey(e)); });
function clearEdges(){ justPressed.clear(); }

function handleGlobalKeys(k){
  if(k==='m'){ muted=!muted; }
  if(state==='fight'){
    if(k==='p'||k==='Escape'){ togglePause(); }
    if(training){
      if(k==='1') dummyMode='stand';
      else if(k==='2') dummyMode='block';
      else if(k==='3') dummyMode='cpu';
      else if(k==='r') resetTraining();
    }
  } else if(state==='paused'){
    if(k==='p'||k==='Escape'){ togglePause(); }
  }
  if(state==='result' && (k==='r'||k==='Enter')){ const b=resBtns.querySelector('button'); if(b) b.click(); }
}

// Touch controls write into the same `keys`/`justPressed` sets as P1.
function bindTouch(){
  if(!touchEl) return;
  touchEl.querySelectorAll('[data-key]').forEach(btn=>{
    const key = btn.getAttribute('data-key');
    const press = (e)=>{ e.preventDefault(); if(!keys.has(key)) justPressed.add(key); keys.add(key); btn.classList.add('down'); };
    const release= (e)=>{ e.preventDefault(); keys.delete(key); btn.classList.remove('down'); };
    btn.addEventListener('touchstart',press,{passive:false});
    btn.addEventListener('touchend',release,{passive:false});
    btn.addEventListener('touchcancel',release,{passive:false});
    btn.addEventListener('mousedown',press);
    btn.addEventListener('mouseup',release);
    btn.addEventListener('mouseleave',release);
  });
}

// ---------------------------------------------------------------------------
// Fighter
// ---------------------------------------------------------------------------
const P1CTRL = {left:'a',right:'d',jump:'w',light:'j',heavy:'k',block:'l',super:'u'};
const P2CTRL = {left:'ArrowLeft',right:'ArrowRight',jump:'ArrowUp',light:',',heavy:'.',block:'/',super:';'};

function makeFighter(x, facing, cfg){
  return {
    x, y:GROUND, vx:0, vy:0, facing, onGround:true,
    hp:100, maxhp:100, meter:0, maxMeter:100,
    state:'idle',        // idle | attack | hurt | ko
    atk:null, atkT:0, atkDur:0, atkHit:false,
    hurtT:0, blockT:0, blocking:false,
    swing:0, walkPhase:0,
    color:cfg.color, beret:cfg.beret, name:cfg.name,
    isAI:cfg.isAI||false, ctrl:cfg.ctrl,
    hitFlash:0, wins:0, _t:0, _think:0, combo:0, comboT:0, isDummy:false
  };
}

let f1, f2, mode=1, difficulty='normal', state='menu';
let roundNum=1, roundTime=60, timeLeft=60, roundPhase='intro';
let freeze=0, shake=0, particles=[], clouds=[], stars=[], koTimer=null;
let flash=0, flashCol='#ffffff', zoom=0, comboShow={n:0,x:W/2,t:0};
let showAnnounce='', announceT=0, countdownT=0, lastCount=99;
let training=false, dummyMode='stand', trainMax=0;   // training-room state

const AI_TUNE = {
  easy:   {react:.55, aggr:.35, blockCh:.30, superCh:.15, whiff:.25, jumpCh:.006, speed:2.6},
  normal: {react:.30, aggr:.55, blockCh:.55, superCh:.40, whiff:.55, jumpCh:.010, speed:3.0},
  hard:   {react:.14, aggr:.75, blockCh:.80, superCh:.70, whiff:.85, jumpCh:.016, speed:3.3}
};

function initClouds(){
  clouds=[]; for(let i=0;i<5;i++) clouds.push({x:Math.random()*W,y:40+Math.random()*120,s:.15+Math.random()*.25,w:80+Math.random()*90});
  stars=[]; for(let i=0;i<70;i++) stars.push({x:Math.random()*W,y:Math.random()*300,r:Math.random()*1.4+.3,t:Math.random()*6});
}
initClouds();

function startGame(m){
  mode=m; training=(m===3); roundNum=1;
  f1=makeFighter(300, 1,{color:'#e0a458',beret:'#c62f2f',name:'Pierre',ctrl:P1CTRL});
  f2=makeFighter(660,-1,{color:'#d9974a',beret:'#2f4b6b',name:training?'Sac':'Gaston',ctrl:P2CTRL,isAI:(m===1)});
  f1.wins=0; f2.wins=0;
  f2.isDummy=training;
  if(training){ dummyMode='stand'; trainMax=0; }
  actx();
  startRound();
}
function resetFighter(f,x,facing){
  Object.assign(f,{x,y:GROUND,vx:0,vy:0,hp:100,meter:f.meter*0.5|0,
    state:'idle',atk:null,atkT:0,atkDur:0,atkHit:false,hurtT:0,blockT:0,
    blocking:false,swing:0,facing,hitFlash:0,onGround:true,combo:0,comboT:0});
}
function startRound(){
  clearTimeout(koTimer); koTimer=null;
  resEl.classList.add('hide'); menuEl.classList.add('hide'); pauseEl.classList.add('hide');
  resetFighter(f1,300,1);
  resetFighter(f2,660,-1);
  timeLeft=roundTime; particles=[]; freeze=0; shake=0; flash=0; zoom=0; comboShow={n:0,x:W/2,t:0};
  showAnnounce=training?'ENTRAÎNEMENT':`MANCHE ${roundNum}`; announceT=1.6; countdownT=3.2; lastCount=99;
  roundPhase = training ? 'fight' : 'intro';   // training skips the countdown — jump straight in
  state='fight';
}

// ---------------------------------------------------------------------------
// Combat tuning
// ---------------------------------------------------------------------------
const ATK = {
  light:{dur:.34, active:[.08,.18], reach:78, dmg:7,  kb:3.2, stun:.28, meter:6,  cost:0},
  heavy:{dur:.62, active:[.22,.36], reach:96, dmg:16, kb:8.5, stun:.5,  meter:10, cost:0},
  // Crust Crusher — unblockable overhead super, costs a full meter
  super:{dur:.72, active:[.30,.46], reach:112,dmg:30, kb:12,  stun:.7,  meter:0,  cost:100, unblock:true}
};

function tryAttack(f, type){
  if(f.state==='attack'||f.state==='hurt'||f.state==='ko') return;
  const a = ATK[type];
  if(type==='super' && f.meter < a.cost) return;
  if(!f.onGround && type!=='light') return;    // only jabs in the air
  if(type==='super'){ f.meter=0; sfx.super(); spawnParticles(f.x,f.y-70,20,'#ffe7b0',7); }
  else sfx.swing();
  f.state='attack'; f.atk=type; f.atkT=0; f.atkDur=a.dur; f.atkHit=false;
}

function addMeter(f, amt){ f.meter=Math.min(f.maxMeter, f.meter+amt); }

function updateFighter(f, other, dt){
  const c=f.ctrl;
  const canAct = f.state!=='hurt' && f.state!=='ko';
  f.blocking=false;

  if(f.onGround && f.state!=='attack'){ f.facing = other.x < f.x ? -1 : 1; }

  if(f.isDummy){
    dummyControl(f, other, dt);
  } else if(!f.isAI){
    let mv=0;
    if(canAct && f.state!=='attack'){
      if(keys.has(c.left))  mv-=1;
      if(keys.has(c.right)) mv+=1;
      if(keys.has(c.block)){ f.blocking=true; mv=0; }
    }
    f.vx = mv*3.4;
    if(canAct && justPressed.has(c.jump) && f.onGround){ f.vy=-13.4; f.onGround=false; sfx.jump(); }
    if(canAct){
      if(justPressed.has(c.super))      tryAttack(f,'super');
      else if(justPressed.has(c.light)) tryAttack(f,'light');
      else if(justPressed.has(c.heavy)) tryAttack(f,'heavy');
    }
  } else {
    aiControl(f, other, dt);
  }

  if(f.blocking) f.blockT=Math.min(f.blockT+dt,.2);

  // physics
  f.vy += 42*dt;
  f.y  += f.vy;
  f.x  += f.vx;
  if(f.y>=GROUND){ f.y=GROUND; f.vy=0; f.onGround=true; } else f.onGround=false;
  f.x = Math.max(60, Math.min(W-60, f.x));

  // simple body separation so they don't overlap
  const gap=Math.abs(f.x-other.x);
  if(gap<64 && Math.abs(f.y-other.y)<90){
    const push=(64-gap)/2, dir=f.x<other.x?-1:1;
    f.x+=dir*push*0.5; other.x-=dir*push*0.5;
    f.x=Math.max(60,Math.min(W-60,f.x)); other.x=Math.max(60,Math.min(W-60,other.x));
  }

  if(Math.abs(f.vx)>.2 && f.onGround) f.walkPhase+=dt*12; else f.walkPhase*=.8;

  if(f.state==='attack'){
    f.atkT+=dt;
    const a=ATK[f.atk];
    const p=f.atkT/f.atkDur;
    f.swing = swingCurve(p);
    if(!f.atkHit && p>=a.active[0] && p<=a.active[1]){
      const dx=other.x-f.x;
      const inFront = (f.facing>0 && dx>0)||(f.facing<0 && dx<0);
      const dist=Math.abs(dx);
      const vgap=Math.abs(other.y-f.y);
      if(inFront && dist < a.reach+40 && dist>10 && vgap<120){
        f.atkHit=true; addMeter(f,a.meter); landHit(f, other, a);
      }
    }
    if(f.atkT>=f.atkDur){ f.state='idle'; f.atk=null; f.swing=0; }
  } else {
    f.swing*=.7;
  }

  if(f.state==='hurt'){ f.hurtT-=dt; if(f.hurtT<=0) f.state='idle'; }
  if(f.hitFlash>0) f.hitFlash-=dt;
  if(f.blockT>0 && !f.blocking) f.blockT-=dt*.5;
  if(f.comboT>0){ f.comboT-=dt; if(f.comboT<=0) f.combo=0; }   // combo window lapses → reset
}

function swingCurve(p){
  if(p<.35) return -(p/.35)*.5;
  const q=(p-.35)/.65;
  return -0.5 + q*q*(3-2*q)*1.9;
}

function landHit(attacker, target, a){
  if(target.state==='ko') return;
  let dmg=a.dmg, kb=a.kb;
  const facingRight = target.facing>0;
  const attackerOnGuardSide = (facingRight && attacker.x>target.x)||(!facingRight && attacker.x<target.x);
  const blocked = !a.unblock && target.blocking && target.onGround && attackerOnGuardSide;

  if(blocked){
    dmg*=0.18; kb*=.5;
    addMeter(target,4);                    // reward defense a little
    attacker.combo=0; attacker.comboT=0;   // string was guarded — pressure resets
    sfx.block();
    spawnParticles(target.x+target.facing*-20, target.y-70, 6, '#ffe7b0', 3);
    shake=Math.max(shake,4); target.blockT=.2;
  } else {
    // combo scaling — each extra hit in a string does progressively less (curbs juggles)
    attacker.combo++; attacker.comboT=1.1;
    const scale = attacker.combo<=1 ? 1 : Math.max(0.5, 1-(attacker.combo-1)*0.12);
    dmg = Math.round(dmg*scale);
    if(attacker.combo>=2){ comboShow={n:attacker.combo, x:target.x, t:0.9}; }
    a.unblock ? sfx.crush() : sfx.hit();
    addMeter(target, a.unblock?0:6);
    target.state='hurt'; target.hurtT=a.stun;
    spawnParticles(target.x, target.y-70, a.unblock?26:16, attacker.color, a.unblock?9:6);
    spawnBurst(target.x, target.y-72, a.unblock?12:7, '#fff3dd', a.unblock?11:8, attacker.facing);
    target.hitFlash=.18;
    shake=Math.max(shake, a.dmg>10?11:6);
    freeze=a.unblock?.11:(a.dmg>10?.07:.035);
    if(a.unblock){ flash=Math.max(flash,.55); flashCol='#ffe7a0'; zoom=Math.max(zoom,.06); }
    else if(a.dmg>10){ zoom=Math.max(zoom,.035); }
  }
  target.hp=Math.max(target.isDummy?1:0, target.hp-dmg);   // training dummy never dies
  target.vx = attacker.facing*kb;
  if(!blocked && a.dmg>10){ target.vy=-4-(a.unblock?3:0); target.onGround=false; }
  if(target.hp<=0 && !target.isDummy){
    target.state='ko'; target.vx=attacker.facing*5; target.vy=-8; target.onGround=false;
    flash=Math.max(flash,.7); flashCol='#ffffff'; zoom=Math.max(zoom,.08); sfx.ko();
  }
}

// ---------------------------------------------------------------------------
// Training dummy — Immobile (stand) / Garde (auto-block) / CPU (spar)
// ---------------------------------------------------------------------------
function dummyControl(f, o, dt){
  f.vx=0;
  if(dummyMode==='block'){ f.blocking = (f.state!=='attack'); }   // "Guard: All" — blocks even through a string
  else if(dummyMode==='cpu'){ aiControl(f, o, dt); }
  // 'stand' → just take it (physics/gravity still run in updateFighter)
}

// ---------------------------------------------------------------------------
// AI
// ---------------------------------------------------------------------------
function aiControl(f, o, dt){
  if(roundPhase!=='fight'){ f.vx*=.85; return; }   // no thinking during intro / ko slow-mo
  const T=AI_TUNE[difficulty]||AI_TUNE.normal;
  f._t=(f._t||0)+dt; f._think=(f._think||0)-dt;
  const dist=Math.abs(o.x-f.x);
  const dir=o.x<f.x?-1:1;
  let mv=0;

  if(f.state==='attack'||f.state==='hurt'){ f.vx*=.85; return; }
  const nearWall = f.x<130 || f.x>W-130;

  // the Crust Crusher is UNBLOCKABLE — don't guard it, RETREAT out of its reach
  if(o.state==='attack' && o.atk==='super' && dist<170){
    if(f.onGround && Math.random()<T.blockCh) { f.vy=-12.5; f.onGround=false; }  // hop away
    f.vx=-dir*T.speed; return;
  }

  // anti-air: opponent airborne & closing → guard
  if(!o.onGround && o.vy>-2 && dist<120 && Math.random()<T.blockCh*0.6){ f.blocking=true; f.vx=0; return; }

  // block a committed grounded heavy/jab in range (per-frame prob → reliable over startup)
  if(o.state==='attack' && o.atk!=='super' && dist<130 && Math.random()<T.blockCh*0.22){ f.blocking=true; f.vx=0; return; }

  // fire super decisively: opponent stunned, or simply in range (scaled by difficulty)
  if(f.meter>=100 && dist<115 && (o.state==='hurt' || Math.random()<T.superCh*0.14)){ tryAttack(f,'super'); return; }

  const ideal=90;
  if(dist>ideal+30) mv=dir;
  else if(dist<ideal-30) mv=-dir;
  else {
    if(f._think<=0){
      f._think = T.react + Math.random()*.4;
      if(Math.random()<T.aggr){
        if(dist<100 && Math.random()<.5) tryAttack(f,'heavy');
        else tryAttack(f,'light');
      }
    }
    mv = (Math.random()<.3 ? -dir : 0);
  }

  // punish a whiffed opponent attack: dash in
  if(o.state==='attack' && o.atkHit===false && dist<160 && dist>90 && Math.random()<T.whiff*0.1){ mv=dir; }

  // cornered & pressured → hop over instead of walking into the wall
  if(nearWall && dist<150 && f.onGround && Math.random()<0.04){ f.vy=-13; f.onGround=false; mv=dir; }
  else if(f.onGround && Math.random()<T.jumpCh && dist<180){ f.vy=-13; f.onGround=false; }
  f.vx=mv*T.speed;
}

// ---------------------------------------------------------------------------
// Particles
// ---------------------------------------------------------------------------
function spawnParticles(x,y,n,color,spd){
  for(let i=0;i<n;i++){
    const ang=Math.random()*Math.PI*2, s=Math.random()*spd+1;
    particles.push({x,y,vx:Math.cos(ang)*s,vy:Math.sin(ang)*s-2,life:1,color,r:Math.random()*3+1.5});
  }
}
// directional spark cone — sprays in the knockback direction (dir = attacker.facing)
function spawnBurst(x,y,n,color,spd,dir){
  for(let i=0;i<n;i++){
    const ang=(-0.5+Math.random())*0.9, s=Math.random()*spd+2;
    particles.push({x,y,vx:dir*Math.cos(ang)*s,vy:Math.sin(ang)*s-1.5,life:1,color,r:Math.random()*2.5+1.2});
  }
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.vy+=18*dt; p.x+=p.vx; p.y+=p.vy; p.life-=dt*1.7;
    if(p.life<=0||p.y>GROUND+10) particles.splice(i,1);
  }
}

// ---------------------------------------------------------------------------
// Round flow
// ---------------------------------------------------------------------------
function checkRoundEnd(){
  if(roundPhase!=='fight') return;
  let winner=null;
  if(f1.hp<=0 && f2.hp<=0) winner='draw';
  else if(f1.hp<=0) winner=f2;
  else if(f2.hp<=0) winner=f1;
  else if(timeLeft<=0){
    if(f1.hp>f2.hp) winner=f1; else if(f2.hp>f1.hp) winner=f2; else winner='draw';
  }
  if(winner!==null){
    roundPhase='ko';
    if(winner!=='draw'){ showAnnounce='K.O.'; announceT=1.4; }
    clearTimeout(koTimer);
    koTimer=setTimeout(()=>{ koTimer=null; endRound(winner); }, 1100);
  }
}
function endRound(winner){
  if(winner!=='draw' && winner) winner.wins++;
  const matchOver = f1.wins>=2 || f2.wins>=2;
  state='result';
  if(matchOver){
    const champ = f1.wins>f2.wins? f1 : f2;
    sfx.win();
    resKicker.textContent='Vainqueur du duel';
    resTitle.innerHTML = `<span class="result-name">${champ.name}</span> l'emporte !`;
    resSub.textContent = `${champ.name} remporte le match ${Math.max(f1.wins,f2.wins)}–${Math.min(f1.wins,f2.wins)} et devient le Roi de la Baguette.`;
    resBtns.innerHTML='';
    addBtn('Rejouer', ()=>startGame(mode), false);
    addBtn('Menu', ()=>toMenu(), true);
  } else {
    resKicker.textContent='Manche terminée';
    if(winner==='draw'){ resTitle.textContent='Égalité !'; resSub.textContent='Personne ne cède une miette.'; }
    else { resTitle.innerHTML=`<span class="result-name">${winner.name}</span> gagne la manche`; resSub.textContent=`Score — Pierre ${f1.wins} · ${f2.wins} Gaston`; }
    resBtns.innerHTML='';
    roundNum++;
    addBtn(`Manche ${roundNum} →`, ()=>startRound(), false);
    addBtn('Menu', ()=>toMenu(), true);
  }
  resEl.classList.remove('hide');
}
function addBtn(label, fn, alt){
  const b=document.createElement('button'); b.className='play'+(alt?' alt':''); b.textContent=label;
  b.onclick=fn; resBtns.appendChild(b);
}
function toMenu(){ clearTimeout(koTimer); koTimer=null; training=false; state='menu'; resEl.classList.add('hide'); pauseEl.classList.add('hide'); menuEl.classList.remove('hide'); }

// training room: infinite meter, track best combo, regen the dummy between strings
function trainingTick(dt){
  f1.meter=f1.maxMeter;
  if(f1.combo>trainMax) trainMax=f1.combo;
  if(f1.combo===0 && f2.state!=='hurt' && f2.hp<100) f2.hp=Math.min(100, f2.hp+70*dt);
}
function resetTraining(){
  resetFighter(f1,300,1); resetFighter(f2,660,-1);
  f1.meter=f1.maxMeter; trainMax=0; particles=[]; flash=0; zoom=0; comboShow={n:0,x:W/2,t:0};
}
function togglePause(){
  if(state==='fight'){ state='paused'; pauseEl.classList.remove('hide'); }
  else if(state==='paused'){ state='fight'; pauseEl.classList.add('hide'); }
}

// ---------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------
function skyGradient(){
  const g=ctx.createLinearGradient(0,0,0,H);
  g.addColorStop(0,'#241a35'); g.addColorStop(.4,'#4a2e46'); g.addColorStop(.7,'#8a4a45'); g.addColorStop(1,'#c9754a');
  return g;
}
function drawScene(){
  ctx.fillStyle=skyGradient(); ctx.fillRect(0,0,W,H);
  // moon
  ctx.save(); ctx.shadowColor='rgba(255,236,200,.7)'; ctx.shadowBlur=40;
  ctx.fillStyle='#fdf1d6'; ctx.beginPath(); ctx.arc(770,110,46,0,7); ctx.fill(); ctx.restore();
  // stars
  for(const s of stars){ s.t+=.03; const tw=.5+.5*Math.sin(s.t);
    ctx.globalAlpha=.3+tw*.5; ctx.fillStyle='#fff7e6'; ctx.fillRect(s.x,s.y,s.r,s.r); }
  ctx.globalAlpha=1;
  // clouds
  for(const cl of clouds){ cl.x-=cl.s; if(cl.x<-cl.w) cl.x=W+cl.w;
    ctx.fillStyle='rgba(60,40,55,.35)';
    ctx.beginPath(); ctx.ellipse(cl.x,cl.y,cl.w,cl.w*.32,0,0,7); ctx.fill(); }
  drawEiffel(480, GROUND+40, 300);
  drawSkyline();
  // ground
  const gg=ctx.createLinearGradient(0,GROUND+18,0,H);
  gg.addColorStop(0,'#3a2c22'); gg.addColorStop(1,'#241a14');
  ctx.fillStyle=gg; ctx.fillRect(0,GROUND+18,W,H-GROUND);
  ctx.strokeStyle='rgba(224,164,88,.15)'; ctx.lineWidth=2;
  ctx.beginPath(); ctx.moveTo(0,GROUND+18); ctx.lineTo(W,GROUND+18); ctx.stroke();
  for(let x=0;x<W;x+=46){ ctx.beginPath(); ctx.moveTo(x,GROUND+22); ctx.lineTo(x+20,H);
    ctx.strokeStyle='rgba(0,0,0,.18)'; ctx.stroke(); }
}
function draw(){
  ctx.save();
  if(zoom>0){ const z=1+zoom; ctx.translate(W/2,H/2); ctx.scale(z,z); ctx.translate(-W/2,-H/2); }  // impact punch-in
  if(shake>0){ ctx.translate((Math.random()-.5)*shake,(Math.random()-.5)*shake); }
  drawScene();
  if(f1&&f2){
    drawShadow(f1); drawShadow(f2);
    // draw the fighter that's further back (higher on screen / smaller x-depth) first
    if(f1.y<=f2.y){ drawFighter(f1); drawFighter(f2); } else { drawFighter(f2); drawFighter(f1); }
  }
  for(const p of particles){ ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle=p.color;
    ctx.fillRect(p.x-p.r/2,p.y-p.r/2,p.r,p.r); }
  ctx.globalAlpha=1;
  ctx.restore();

  if(flash>0){ ctx.save(); ctx.globalAlpha=Math.min(.6,flash); ctx.fillStyle=flashCol; ctx.fillRect(0,0,W,H); ctx.restore(); }
  if(f1&&f2) drawHUD();
  if(comboShow.t>0) drawCombo();
  if(announceT>0) drawAnnounce();
  if(countdownT>0 && countdownT<3.2 && roundPhase==='intro') drawCountdown();
}
function drawCombo(){
  ctx.save();
  ctx.globalAlpha=Math.min(1,comboShow.t*2);
  const rise=(0.9-comboShow.t)*26;
  ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillStyle='#ffe7a0'; ctx.font="italic 800 30px Georgia,serif";
  ctx.shadowColor='rgba(0,0,0,.6)'; ctx.shadowBlur=10;
  ctx.fillText(`COMBO ×${comboShow.n}`, Math.max(90,Math.min(W-90,comboShow.x)), 150-rise);
  ctx.restore();
}

function drawShadow(f){
  const sc=f.onGround?1:0.6;
  ctx.fillStyle='rgba(0,0,0,.28)';
  ctx.beginPath(); ctx.ellipse(f.x,GROUND+18,34*sc,9*sc,0,0,7); ctx.fill();
}
function drawEiffel(cx,baseY,h){
  ctx.save();
  ctx.strokeStyle='rgba(30,18,26,.65)'; ctx.fillStyle='rgba(30,18,26,.55)';
  ctx.lineWidth=3;
  const topY=baseY-h, topW=6, baseW=110, midY=baseY-h*.42, midW=52, plY=baseY-h*.14, plW=88;
  function leg(sx){
    ctx.beginPath();
    ctx.moveTo(cx-baseW*sx, baseY);
    ctx.quadraticCurveTo(cx-plW*sx*.7, plY, cx-midW*sx, midY);
    ctx.quadraticCurveTo(cx-topW*sx*3, topY+h*.25, cx-topW*sx, topY);
    ctx.stroke();
  }
  leg(1); leg(-1);
  ctx.beginPath(); ctx.moveTo(cx-baseW*.72,plY); ctx.quadraticCurveTo(cx,plY-38,cx+baseW*.72,plY); ctx.stroke();
  ctx.fillRect(cx-plW,plY-4,plW*2,8);
  ctx.fillRect(cx-midW,midY-3,midW*2,6);
  ctx.lineWidth=1.5; ctx.strokeStyle='rgba(30,18,26,.4)';
  for(let i=0;i<6;i++){ const y=plY-(plY-midY)*(i/6); const wl=plW-(plW-midW)*(i/6);
    ctx.beginPath(); ctx.moveTo(cx-wl,y); ctx.lineTo(cx+wl,y); ctx.stroke(); }
  ctx.strokeStyle='rgba(30,18,26,.65)'; ctx.lineWidth=3;
  ctx.beginPath(); ctx.moveTo(cx,topY); ctx.lineTo(cx,topY-18); ctx.stroke();
  ctx.fillStyle='rgba(255,220,150,.9)'; ctx.beginPath(); ctx.arc(cx,topY-20,3,0,7); ctx.fill();
  ctx.restore();
}
function drawSkyline(){
  ctx.fillStyle='rgba(25,16,24,.5)';
  const b=[[40,60,120],[130,90,90],[210,40,150],[720,70,120],[810,110,100],[900,50,150]];
  for(const [x,w,hh] of b){ ctx.fillRect(x,GROUND+18-hh,w,hh);
    ctx.fillStyle='rgba(255,210,140,.18)';
    for(let wy=GROUND+18-hh+10;wy<GROUND+8;wy+=18) for(let wx=x+6;wx<x+w-6;wx+=16) if((wx*7+wy*13)%5<2) ctx.fillRect(wx,wy,4,6);
    ctx.fillStyle='rgba(25,16,24,.5)';
  }
}
function drawFighter(f){
  ctx.save();
  ctx.translate(f.x, f.y);
  ctx.scale(f.facing,1);

  const hurt = f.hitFlash>0 && Math.floor(f.hitFlash*30)%2===0;
  const kod = f.state==='ko';
  if(kod) ctx.rotate(f.facing* -0.9);

  const step=Math.sin(f.walkPhase)*8;
  ctx.strokeStyle='#8a5a2a'; ctx.lineWidth=6; ctx.lineCap='round';
  ctx.beginPath(); ctx.moveTo(-8,-8); ctx.lineTo(-10-step*0.3, 0); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(8,-8);  ctx.lineTo(10+step*0.3, 0); ctx.stroke();
  ctx.fillStyle='#5c3a1a'; ctx.beginPath(); ctx.ellipse(-11,2,7,4,0,0,7); ctx.fill();
  ctx.beginPath(); ctx.ellipse(11,2,7,4,0,0,7); ctx.fill();

  const bh=96, bw=40;
  const bg=ctx.createLinearGradient(-bw/2,0,bw/2,0);
  bg.addColorStop(0,'#a86a2e'); bg.addColorStop(.4,f.color); bg.addColorStop(.6,'#f2c987'); bg.addColorStop(1,'#a86a2e');
  ctx.fillStyle=hurt?'#fff':bg;
  roundedLoaf(0,-bh/2-8,bw,bh); ctx.fill();
  ctx.strokeStyle='rgba(90,50,20,.55)'; ctx.lineWidth=2.5;
  for(let i=0;i<4;i++){ const yy=-bh-2+ i*22+16;
    ctx.beginPath(); ctx.moveTo(-6,yy); ctx.lineTo(8,yy-10); ctx.stroke(); }
  ctx.fillStyle='rgba(255,250,235,.30)';
  for(let i=0;i<7;i++){ const fx=((i*97)%bw)-bw/2+4, fy=-bh-4+((i*53)%bh); ctx.fillRect(fx,fy,2,2); }

  // beret
  ctx.save(); ctx.translate(0,-bh-6);
  ctx.fillStyle=f.beret;
  ctx.beginPath(); ctx.ellipse(2,-4,24,13,-.15,0,7); ctx.fill();
  ctx.beginPath(); ctx.ellipse(0,2,20,7,0,0,7); ctx.fill();
  ctx.fillStyle='#3a2c22'; ctx.beginPath(); ctx.arc(14,-11,2.5,0,7); ctx.fill();
  ctx.restore();

  // eyes
  ctx.fillStyle='#3a2c22'; const eyY=-bh+8;
  if(kod){ ctx.strokeStyle='#3a2c22'; ctx.lineWidth=2.4; xeye(6,eyY); xeye(20,eyY); }
  else {
    ctx.beginPath(); ctx.arc(8,eyY,3.4,0,7); ctx.fill();
    ctx.beginPath(); ctx.arc(20,eyY,3.4,0,7); ctx.fill();
    ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(9,eyY-1,1.1,0,7); ctx.fill();
    ctx.beginPath(); ctx.arc(21,eyY-1,1.1,0,7); ctx.fill();
  }
  ctx.strokeStyle='#5c3a1a'; ctx.lineWidth=2; ctx.beginPath();
  if(f.state==='hurt'){ ctx.arc(14,eyY+13,3.2,Math.PI,2*Math.PI); }   // "ow" mouth
  else if(f.state==='attack'){ ctx.arc(14,eyY+13,4,0,Math.PI); }
  else { ctx.moveTo(9,eyY+12); ctx.quadraticCurveTo(14,eyY+15,20,eyY+12); }
  ctx.stroke();

  // dizzy stars circling the head while stunned
  if(f.state==='hurt'){
    const t=performance.now()/220; ctx.fillStyle='#ffe7a0';
    for(let i=0;i<3;i++){ const aa=t+i*2.094; spark(14+Math.cos(aa)*16, -bh-6+Math.sin(aa)*5, 3.4); }
  }

  // arm + weapon baguette
  ctx.save();
  ctx.translate(16,-bh/2-4);
  const isSuper = f.state==='attack' && f.atk==='super';
  const base=-0.5, ang=base + f.swing*1.5;
  ctx.rotate(ang);
  ctx.strokeStyle='#8a5a2a'; ctx.lineWidth=6; ctx.lineCap='round';
  ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(22,0); ctx.stroke();
  ctx.translate(22,0);
  const wlen = isSuper?74:58;
  const wg=ctx.createLinearGradient(0,-6,0,6);
  if(isSuper){ wg.addColorStop(0,'#ffe07a'); wg.addColorStop(.5,'#fff1b0'); wg.addColorStop(1,'#e0a12a'); }
  else { wg.addColorStop(0,'#e8b96e'); wg.addColorStop(.5,'#f2c987'); wg.addColorStop(1,'#b07a34'); }
  ctx.fillStyle=wg; roundedRect(0,-7,wlen,14,7); ctx.fill();
  ctx.strokeStyle='rgba(90,50,20,.5)'; ctx.lineWidth=1.8;
  for(let i=1;i<5;i++){ ctx.beginPath(); ctx.moveTo(6+i*10,-3); ctx.lineTo(10+i*10,3); ctx.stroke(); }
  if(f.state==='attack' && f.swing>.3){
    ctx.globalAlpha=isSuper?.4:.25; ctx.strokeStyle=isSuper?'#fff6cf':'#fff3dd';
    ctx.lineWidth=isSuper?16:10; ctx.lineCap='round';
    ctx.beginPath(); ctx.arc(-22,0,isSuper?76:64,-0.6,0.5); ctx.stroke(); ctx.globalAlpha=1;
  }
  ctx.restore();

  if(f.blocking && f.blockT>0){
    ctx.globalAlpha=Math.min(.5,f.blockT*2.5);
    ctx.strokeStyle='#ffe7b0'; ctx.lineWidth=3;
    ctx.beginPath(); ctx.ellipse(22,-bh/2-6,26,54,0,-1.2,1.2); ctx.stroke();
    ctx.globalAlpha=1;
  }
  ctx.restore();
}
function xeye(x,y){ ctx.beginPath(); ctx.moveTo(x-3,y-3); ctx.lineTo(x+3,y+3); ctx.moveTo(x+3,y-3); ctx.lineTo(x-3,y+3); ctx.stroke(); }
function spark(x,y,r){ ctx.beginPath(); ctx.moveTo(x,y-r); ctx.lineTo(x+r*.3,y-r*.3); ctx.lineTo(x+r,y); ctx.lineTo(x+r*.3,y+r*.3); ctx.lineTo(x,y+r); ctx.lineTo(x-r*.3,y+r*.3); ctx.lineTo(x-r,y); ctx.lineTo(x-r*.3,y-r*.3); ctx.closePath(); ctx.fill(); }
function roundedLoaf(cx,topY,w,h){
  const r=w/2; ctx.beginPath();
  ctx.moveTo(cx-r,topY+r); ctx.arc(cx,topY+r,r,Math.PI,0);
  ctx.lineTo(cx+r,topY+h-r); ctx.arc(cx,topY+h-r,r,0,Math.PI); ctx.closePath();
}
function roundedRect(x,y,w,h,r){
  ctx.beginPath(); ctx.moveTo(x+r,y);
  ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
  ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}

// ---------------------------------------------------------------------------
// HUD
// ---------------------------------------------------------------------------
function drawHUD(){
  hpBar(30,26,360,f1,false,'Pierre',f1.wins);
  hpBar(W-30-360,26,360,f2,true,'Gaston',f2.wins);
  meterBar(30,52,360,f1,false);
  meterBar(W-30-360,52,360,f2,true);
  if(training){ drawTrainingHUD(); }
  else {
    ctx.save();
    ctx.fillStyle='rgba(18,15,22,.7)'; roundedRect(W/2-40,20,80,54,10); ctx.fill();
    ctx.strokeStyle='rgba(224,164,88,.5)'; ctx.lineWidth=1.5; ctx.stroke();
    ctx.fillStyle='#f5f2ea'; ctx.font='800 30px Georgia,serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText(Math.ceil(Math.max(0,timeLeft)), W/2, 48);
    ctx.restore();
  }
  if(muted){ ctx.save(); ctx.fillStyle='rgba(245,242,234,.6)'; ctx.font='600 12px -apple-system,Arial';
    ctx.textAlign='center'; ctx.fillText('🔇 muet (M)', W/2, 92); ctx.restore(); }
}
function drawTrainingHUD(){
  const labels={stand:'Immobile', block:'Garde', cpu:'CPU'};
  ctx.save();
  ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillStyle='rgba(18,15,22,.72)'; roundedRect(W/2-195,18,390,42,10); ctx.fill();
  ctx.strokeStyle='rgba(224,164,88,.4)'; ctx.lineWidth=1.5; ctx.stroke();
  ctx.fillStyle='#e0a458'; ctx.font='700 12px -apple-system,Arial';
  ctx.fillText(`ENTRAÎNEMENT · Sac: ${labels[dummyMode]} · Combo max: ${trainMax}`, W/2, 32);
  ctx.fillStyle='rgba(240,230,210,.6)'; ctx.font='600 10px -apple-system,Arial';
  ctx.fillText('[1] Immobile   [2] Garde   [3] CPU   [R] Reset   [P] Pause', W/2, 48);
  ctx.restore();
}
function hpBar(x,y,w,f,right,name,wins){
  const h=20; ctx.save();
  ctx.fillStyle='#f5f2ea'; ctx.font='700 15px -apple-system,Arial'; ctx.textBaseline='alphabetic';
  ctx.textAlign=right?'right':'left'; ctx.fillText(name, right? x+w : x, y-6);
  if(!training) for(let i=0;i<2;i++){ const px=right? x+w-8-i*16 : x+8+i*16;
    ctx.beginPath(); ctx.arc(px, y-11, 5, 0,7);
    ctx.fillStyle = i<wins? '#e0a458':'rgba(255,255,255,.22)'; ctx.fill(); }
  ctx.fillStyle='rgba(18,15,22,.6)'; roundedRect(x-3,y-3,w+6,h+6,7); ctx.fill();
  ctx.fillStyle='rgba(0,0,0,.4)'; roundedRect(x,y,w,h,5); ctx.fill();
  const pct=f.hp/f.maxhp, fw=w*pct, fx = right? x+w-fw : x;
  const grad=ctx.createLinearGradient(0,y,0,y+h);
  const col = pct>.4? ['#e0a458','#c65f38'] : ['#e07a45','#a8321f'];
  grad.addColorStop(0,col[0]); grad.addColorStop(1,col[1]);
  ctx.fillStyle=grad; roundedRect(fx,y,fw,h,5); ctx.fill();
  ctx.strokeStyle='rgba(224,164,88,.4)'; ctx.lineWidth=1.5; roundedRect(x-3,y-3,w+6,h+6,7); ctx.stroke();
  ctx.restore();
}
function meterBar(x,y,w,f,right){
  const h=7; ctx.save();
  ctx.fillStyle='rgba(0,0,0,.4)'; roundedRect(x,y,w,h,3); ctx.fill();
  const pct=f.meter/f.maxMeter, fw=w*pct, fx=right? x+w-fw : x;
  const full=f.meter>=f.maxMeter;
  const grad=ctx.createLinearGradient(0,y,0,y+h);
  if(full){ const t=.5+.5*Math.sin(performance.now()/120);
    grad.addColorStop(0,`rgba(255,${200+40*t|0},120,1)`); grad.addColorStop(1,'#e0a12a'); }
  else { grad.addColorStop(0,'#7fd3e0'); grad.addColorStop(1,'#3a8fb0'); }
  ctx.fillStyle=grad; roundedRect(fx,y,fw,h,3); ctx.fill();
  if(full){ ctx.fillStyle='#ffe7b0'; ctx.font='700 9px -apple-system,Arial';
    ctx.textAlign=right?'right':'left'; ctx.textBaseline='top';
    ctx.fillText('CRUST CRUSHER PRÊT', right? x+w : x, y+9); }
  ctx.restore();
}
function drawAnnounce(){
  ctx.save();
  const a=Math.min(1,announceT*1.4)*Math.min(1,(1.6-announceT)*4+.2);
  ctx.globalAlpha=Math.max(0,Math.min(1,a));
  ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillStyle=showAnnounce==='K.O.'?'#e0603a':'#f5f2ea';
  ctx.font="italic 800 60px Georgia,'Playfair Display',serif";
  ctx.shadowColor='rgba(198,95,56,.6)'; ctx.shadowBlur=24;
  ctx.fillText(showAnnounce, W/2, 200);
  ctx.restore();
}
function drawCountdown(){
  const n=Math.ceil(countdownT-0.2);
  ctx.save(); ctx.textAlign='center'; ctx.textBaseline='middle';
  const frac=(countdownT-0.2)%1;
  ctx.globalAlpha=Math.min(1,frac*2);
  const sc=1+ (1-frac)*0.4;
  ctx.translate(W/2,270); ctx.scale(sc,sc);
  if(n>0){ ctx.fillStyle='#e0a458'; ctx.font="800 90px Georgia,serif";
    ctx.shadowColor='rgba(0,0,0,.5)'; ctx.shadowBlur=18; ctx.fillText(String(n),0,0); }
  ctx.restore();
}

// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
let last=performance.now();
function loop(now){
  let dt=(now-last)/1000; last=now;
  if(dt>0.05) dt=0.05;

  if(state==='fight'){
    if(freeze>0){ freeze-=dt; }
    else {
      if(announceT>0) announceT-=dt;

      if(roundPhase==='intro'){
        countdownT-=dt;
        const n=Math.ceil(countdownT-0.2);
        if(n<lastCount && n>0){ lastCount=n; sfx.count(); }
        if(countdownT<=0.2){ roundPhase='fight'; showAnnounce='COMBATTEZ !'; announceT=1.0; sfx.fight(); }
        // idle facing during intro (light physics only)
        if(f1&&f2){ f1.vx*=.8; f2.vx*=.8; f1.facing=f2.x<f1.x?-1:1; f2.facing=f1.x<f2.x?-1:1; }
      } else if(roundPhase==='fight'){
        if(!training && timeLeft>0) timeLeft-=dt;
        updateFighter(f1,f2,dt);
        updateFighter(f2,f1,dt);
        if(training) trainingTick(dt); else checkRoundEnd();
      } else if(roundPhase==='ko'){
        // let bodies settle in slow-mo
        updateFighter(f1,f2,dt*0.4);
        updateFighter(f2,f1,dt*0.4);
      }
      updateParticles(dt);
      if(shake>0) shake=Math.max(0,shake-dt*40);
      if(flash>0) flash=Math.max(0,flash-dt*2.2);
      if(zoom>0)  zoom =Math.max(0,zoom -dt*0.34);
      if(comboShow.t>0) comboShow.t-=dt;
    }
  }

  if(f1&&f2) draw(); else drawScene();
  clearEdges();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

// ---------------------------------------------------------------------------
// Menu wiring
// ---------------------------------------------------------------------------
document.querySelectorAll('.play[data-mode]').forEach(b=>{
  b.addEventListener('click',()=>{
    const diffSel=document.getElementById('difficulty');
    if(diffSel) difficulty=diffSel.value;
    startGame(parseInt(b.dataset.mode));
  });
});
document.querySelectorAll('#pause [data-pause]').forEach(b=>{
  b.addEventListener('click',()=>{
    const act=b.getAttribute('data-pause');
    if(act==='resume') togglePause();
    else if(act==='menu'){ togglePause(); toMenu(); }
  });
});
bindTouch();

// expose a tiny hook for automated smoke tests
window.__BB__ = { get state(){return state;}, get f1(){return f1;}, get f2(){return f2;}, startGame };
})();