← back to Games Agentabrams
games/baguette-brawl/index.html
1038 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Baguette Brawl — Duel de Paris</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥖</text></svg>">
<style>
:root{
--bg:#f7efe6; --surface:#fff9f1; --text:#3a2c22; --muted:#a08c7a;
--accent:#c65f38; --accent2:#e0a458;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:radial-gradient(120% 120% at 50% 0%,#20222e 0%,#12131a 55%,#0a0a0f 100%);
color:var(--text);
font-family:-apple-system,'Segoe UI','Helvetica Neue',Arial,sans-serif;
display:flex;align-items:center;justify-content:center;
min-height:100%;padding:20px;overflow:hidden;
}
#wrap{
position:relative;width:960px;max-width:100%;
aspect-ratio:960/540;border-radius:14px;overflow:hidden;
box-shadow:0 16px 48px rgba(0,0,0,.55), 0 0 0 1px rgba(224,164,88,.25);
background:#1a1720;
}
canvas{display:block;width:100%;height:100%;image-rendering:auto}
/* overlays */
.overlay{
position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center;text-align:center;
background:linear-gradient(160deg,rgba(18,15,22,.86),rgba(30,20,14,.92));
backdrop-filter:blur(2px);gap:16px;padding:32px;
color:var(--bg);z-index:10;
}
.overlay.hide{display:none}
.kicker{
font-size:12px;letter-spacing:.28em;text-transform:uppercase;
color:var(--accent2);font-weight:600;
animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both;
}
h1{
font-family:'Playfair Display',Georgia,'Bodoni MT',serif;
font-weight:800;font-size:clamp(38px,7vw,67px);line-height:.98;
color:var(--surface);letter-spacing:-.01em;
text-shadow:0 2px 24px rgba(224,164,88,.25);
animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both;animation-delay:.09s;
}
h1 .em{color:var(--accent2);font-style:italic}
.sub{
max-width:520px;color:#e8dfd2;font-size:16px;line-height:1.5;
animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both;animation-delay:.18s;
}
.btns{display:flex;gap:14px;flex-wrap:wrap;justify-content:center;
animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both;animation-delay:.27s;}
button.play{
font-family:inherit;cursor:pointer;border:none;border-radius:12px;
padding:15px 26px;font-size:15px;font-weight:700;letter-spacing:.02em;
color:#fff;background:linear-gradient(135deg,var(--accent),#a8471f);
box-shadow:0 8px 24px rgba(198,95,56,.4);transition:transform .16s ease,box-shadow .16s ease;
}
button.play.alt{background:linear-gradient(135deg,#5c7d8f,#38505d);box-shadow:0 8px 24px rgba(75,107,122,.4)}
button.play.train{background:linear-gradient(135deg,#6a8f5c,#3d5d38);box-shadow:0 8px 24px rgba(106,143,92,.4)}
button.play:hover{transform:translateY(-2px);box-shadow:0 16px 40px rgba(198,95,56,.5)}
button.play.alt:hover{box-shadow:0 16px 40px rgba(75,107,122,.5)}
button.play.train:hover{box-shadow:0 16px 40px rgba(106,143,92,.5)}
.diffrow{display:flex;align-items:center;gap:10px;font-size:13px;color:#e0d7c9;
animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both;animation-delay:.22s;}
.diffrow label{letter-spacing:.12em;text-transform:uppercase;font-size:11px;color:var(--accent2);font-weight:700}
select#difficulty{
font-family:inherit;font-size:13px;color:#f2e9dd;background:#2a2530;
border:1px solid #4a4150;border-radius:8px;padding:7px 12px;cursor:pointer;
}
.controls{
display:flex;gap:32px;flex-wrap:wrap;justify-content:center;margin-top:6px;
animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both;animation-delay:.36s;
}
.ctrlcard{
background:rgba(255,249,241,.06);border:1px solid rgba(224,164,88,.22);
border-radius:12px;padding:14px 18px;min-width:200px;text-align:left;
}
.ctrlcard h4{font-size:12px;letter-spacing:.15em;text-transform:uppercase;color:var(--accent2);margin-bottom:8px}
.ctrlcard .row{display:flex;justify-content:space-between;font-size:13px;color:#e0d7c9;margin:3px 0;gap:12px}
kbd{
background:#2a2530;border:1px solid #4a4150;border-bottom-width:2px;border-radius:5px;
padding:1px 7px;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:#f2e9dd;
}
.result-name{font-family:'Playfair Display',Georgia,serif;font-style:italic}
.foot{position:absolute;bottom:12px;font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--muted)}
@keyframes fadeUp{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:none}}
/* pause overlay is lighter */
#pause{background:linear-gradient(160deg,rgba(18,15,22,.72),rgba(30,20,14,.8))}
/* ---- touch controls (mobile only) ---- */
#touch{
position:absolute;inset:auto 0 0 0;z-index:8;display:none;
justify-content:space-between;padding:0 14px 14px;pointer-events:none;
user-select:none;-webkit-user-select:none;touch-action:none;
}
#touch .pad,#touch .acts{display:flex;gap:12px;pointer-events:auto}
#touch button{
width:58px;height:58px;border-radius:50%;border:1px solid rgba(224,164,88,.4);
background:rgba(30,25,32,.55);color:#f2e9dd;font-size:20px;font-weight:800;
backdrop-filter:blur(3px);cursor:pointer;
}
#touch button.down{background:rgba(198,95,56,.75);transform:scale(.94)}
#touch button.sup{background:rgba(224,161,42,.35);font-size:15px}
@media (hover:none) and (pointer:coarse){ #touch{display:flex} .controls{display:none} }
</style>
</head>
<body>
<div id="wrap">
<canvas id="c" width="960" height="540"></canvas>
<!-- MENU -->
<div id="menu" class="overlay">
<div class="kicker">Sous la lune de Montmartre</div>
<h1>Baguette <span class="em">Brawl</span></h1>
<p class="sub">Two crusty duelists face off beneath the Eiffel Tower. Swing your loaf, guard your crust, charge the meter and unleash the <b>Crust Crusher</b>. First to <b>2 rounds</b> wins the boulangerie.</p>
<div class="diffrow">
<label for="difficulty">Chef IA</label>
<select id="difficulty">
<option value="easy">Apprenti (facile)</option>
<option value="normal" selected>Boulanger (normal)</option>
<option value="hard">Maître (difficile)</option>
</select>
</div>
<div class="btns">
<button class="play" data-mode="1">Solo vs. Chef IA</button>
<button class="play alt" data-mode="2">Deux Joueurs</button>
<button class="play train" data-mode="3">Entraînement</button>
</div>
<div class="controls">
<div class="ctrlcard">
<h4>Pierre · Joueur 1</h4>
<div class="row"><span>Move / Jump</span><span><kbd>A</kbd><kbd>D</kbd><kbd>W</kbd></span></div>
<div class="row"><span>Jab · Heavy</span><span><kbd>J</kbd><kbd>K</kbd></span></div>
<div class="row"><span>Guard · Super</span><span><kbd>L</kbd><kbd>U</kbd></span></div>
</div>
<div class="ctrlcard">
<h4>Gaston · Joueur 2</h4>
<div class="row"><span>Move / Jump</span><span><kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd></span></div>
<div class="row"><span>Jab · Heavy</span><span><kbd>,</kbd><kbd>.</kbd></span></div>
<div class="row"><span>Guard · Super</span><span><kbd>/</kbd><kbd>;</kbd></span></div>
</div>
</div>
<div class="foot">Pause <kbd>P</kbd> · Mute <kbd>M</kbd> · Entraînement: <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> dummy, <kbd>R</kbd> reset · Un jeu de pain · Paris</div>
</div>
<!-- PAUSE -->
<div id="pause" class="overlay hide">
<div class="kicker">Pause</div>
<h1>En pause</h1>
<div class="btns">
<button class="play" data-pause="resume">Reprendre</button>
<button class="play alt" data-pause="menu">Menu</button>
</div>
<div class="foot"><kbd>P</kbd> pour reprendre</div>
</div>
<!-- ROUND / RESULT -->
<div id="result" class="overlay hide">
<div class="kicker" id="resKicker">Manche terminée</div>
<h1 id="resTitle">—</h1>
<p class="sub" id="resSub"></p>
<div class="btns" id="resBtns"></div>
</div>
<!-- TOUCH CONTROLS (mobile) -->
<div id="touch">
<div class="pad">
<button data-key="a">◀</button>
<button data-key="w">▲</button>
<button data-key="d">▶</button>
</div>
<div class="acts">
<button data-key="l">🛡</button>
<button data-key="j">J</button>
<button data-key="k">K</button>
<button data-key="u" class="sup">SUP</button>
</div>
</div>
</div>
<script>
/* ============================================================================
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();
// Breton striped shirt (marinière) — horizontal navy/white stripes on lower 60% of body
if(!hurt){
ctx.save();
roundedLoaf(0,-bh/2-8,bw,bh); ctx.clip();
const shirtTop = -bh*0.3, shirtBot = bh/2-8, stripeH = 8;
for(let s=0; s*stripeH<(shirtBot-shirtTop); s++){
ctx.fillStyle = s%2===0 ? 'rgba(20,40,90,0.62)' : 'rgba(240,245,255,0.45)';
ctx.fillRect(-bw/2-2, shirtTop+s*stripeH, bw+4, stripeH);
}
ctx.restore();
}
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); }
// thin French mustache
if(!hurt){
ctx.save(); ctx.strokeStyle='#3a2c22'; ctx.lineWidth=2.2; ctx.lineCap='round';
const myY=-bh+18;
ctx.beginPath(); ctx.moveTo(8,myY); ctx.quadraticCurveTo(11,myY-3,14,myY);
ctx.moveTo(14,myY); ctx.quadraticCurveTo(17,myY-3,20,myY);
ctx.stroke(); ctx.restore();
}
// 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 };
})();
</script>
</body>
</html>