[object Object]

← back to Baguette Brawl

Add Training mode: practice room with stand/block/CPU dummy, infinite meter, live combo readout, never-dying regen dummy, reset

d6af7d698d3efb6e21b1ebadf2f22643e2565b17 · 2026-08-31 13:37:44 -0700 · Steve Abrams

Files touched

Diff

commit d6af7d698d3efb6e21b1ebadf2f22643e2565b17
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 13:37:44 2026 -0700

    Add Training mode: practice room with stand/block/CPU dummy, infinite meter, live combo readout, never-dying regen dummy, reset
---
 README.md      |  5 ++++
 src/game.js    | 83 +++++++++++++++++++++++++++++++++++++++++++++-------------
 src/index.html |  3 ++-
 src/styles.css |  2 ++
 4 files changed, 74 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md
index 62fa968..6e2bb97 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,11 @@ On a touch device, on-screen buttons appear automatically and drive Player 1.
 - **Solo vs. Chef IA** — three AI difficulties (Apprenti / Boulanger / Maître)
   with real spacing, anti-air, whiff-punishing and super usage.
 - **Deux Joueurs** — local two-player on one keyboard.
+- **Entraînement (Training)** — a practice room: no timer, no rounds, **infinite
+  meter** for free super practice, a live **combo / max-combo** readout, and a
+  configurable dummy that never dies (regenerates between strings). Switch dummy
+  behavior on the fly with `1` Immobile (stand) · `2` Garde (blocks everything) ·
+  `3` CPU (spars back), and `R` to reset positions.
 
 ## Fighting system
 
diff --git a/src/game.js b/src/game.js
index 743e654..f9ebea3 100644
--- a/src/game.js
+++ b/src/game.js
@@ -84,6 +84,12 @@ 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(); }
   }
@@ -122,7 +128,7 @@ function makeFighter(x, facing, cfg){
     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
+    hitFlash:0, wins:0, _t:0, _think:0, combo:0, comboT:0, isDummy:false
   };
 }
 
@@ -131,6 +137,7 @@ 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},
@@ -145,10 +152,12 @@ function initClouds(){
 initClouds();
 
 function startGame(m){
-  mode=m; roundNum=1;
+  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:'Gaston',ctrl:P2CTRL,isAI:(m===1)});
+  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();
 }
@@ -163,8 +172,8 @@ function startRound(){
   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=`MANCHE ${roundNum}`; announceT=1.6; countdownT=3.2; lastCount=99;
-  roundPhase='intro';
+  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';
 }
 
@@ -197,7 +206,9 @@ function updateFighter(f, other, dt){
 
   if(f.onGround && f.state!=='attack'){ f.facing = other.x < f.x ? -1 : 1; }
 
-  if(!f.isAI){
+  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;
@@ -296,15 +307,25 @@ function landHit(attacker, target, a){
     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(0,target.hp-dmg);
+  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){
+  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
 // ---------------------------------------------------------------------------
@@ -428,7 +449,18 @@ 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; state='menu'; resEl.classList.add('hide'); pauseEl.classList.add('hide'); menuEl.classList.remove('hide'); }
+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'); }
@@ -644,20 +676,35 @@ function drawHUD(){
   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);
-  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(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);
-  for(let i=0;i<2;i++){ const px=right? x+w-8-i*16 : x+8+i*16;
+  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();
@@ -729,10 +776,10 @@ function loop(now){
         // 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(timeLeft>0) timeLeft-=dt;
+        if(!training && timeLeft>0) timeLeft-=dt;
         updateFighter(f1,f2,dt);
         updateFighter(f2,f1,dt);
-        checkRoundEnd();
+        if(training) trainingTick(dt); else checkRoundEnd();
       } else if(roundPhase==='ko'){
         // let bodies settle in slow-mo
         updateFighter(f1,f2,dt*0.4);
diff --git a/src/index.html b/src/index.html
index ab0e3bc..1cb7448 100644
--- a/src/index.html
+++ b/src/index.html
@@ -27,6 +27,7 @@
     <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">
@@ -42,7 +43,7 @@
         <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> · Un jeu de pain · Paris</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 -->
diff --git a/src/styles.css b/src/styles.css
index 06088be..a180fb7 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -54,8 +54,10 @@ button.play{
   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;}

← f7fad89 Refine: combo system + damage scaling, impact juice (flash/z  ·  back to Baguette Brawl  ·  (newest)