← back to Neon Baguette Battle
index.html
1474 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, user-scalable=no">
<title>Baguette Battle — Neon Paris</title>
<style>
:root{ --gold:#c9a961; --cream:#f5f2ea; --pink:#ff3d9a; --cyan:#22e0ff; }
*{ box-sizing:border-box; }
html,body{ margin:0; height:100%; background:#05050a; }
body{
font-family:'Didot','Bodoni MT','Playfair Display',Georgia,serif;
color:var(--cream);
display:flex; justify-content:center; align-items:center; min-height:100vh;
overflow:hidden;
-webkit-user-select:none; user-select:none;
}
#stage{
position:relative;
width:min(96vw,960px);
aspect-ratio:960/540;
border-radius:14px;
overflow:hidden;
box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 24px 80px rgba(255,61,154,.18), 0 8px 40px rgba(34,224,255,.12);
background:#0d0d14;
}
#game{ position:absolute; inset:0; width:100%; height:100%; display:block; image-rendering:auto; }
/* overlay screens */
.screen{
position:absolute; inset:0; z-index:5;
display:flex; flex-direction:column; justify-content:center; align-items:center;
text-align:center; padding:24px;
background:radial-gradient(120% 90% at 50% 10%, rgba(20,16,40,.55), rgba(5,5,12,.9));
backdrop-filter:blur(2px);
}
.screen.hidden{ display:none; }
h1{
font-size:clamp(34px,7vw,64px); letter-spacing:.14em; margin:0 0 6px;
color:var(--cream);
text-shadow:0 0 18px rgba(255,61,154,.65), 0 0 40px rgba(34,224,255,.35);
}
.sub{ letter-spacing:.35em; text-transform:uppercase; font-size:clamp(10px,1.6vw,14px);
color:var(--gold); margin-bottom:26px; }
p{ font-size:clamp(13px,1.8vw,16px); line-height:1.5; max-width:640px; color:#d9d3c6; }
.btns{ display:flex; gap:16px; flex-wrap:wrap; justify-content:center; margin-top:22px; }
button{
font-family:inherit; font-size:clamp(14px,2vw,18px); letter-spacing:.08em;
padding:14px 30px; border:1px solid rgba(201,169,97,.5); border-radius:8px;
background:linear-gradient(180deg,rgba(201,169,97,.18),rgba(201,169,97,.05));
color:var(--cream); cursor:pointer; transition:.18s;
}
button:hover{ border-color:var(--pink); box-shadow:0 0 22px rgba(255,61,154,.4); transform:translateY(-2px); }
button.primary{ background:linear-gradient(180deg,#c9a961,#a4854a); color:#1a140a; border-color:#e7c987; font-weight:600; }
.controls{ display:flex; gap:34px; flex-wrap:wrap; justify-content:center; margin-top:12px; font-size:13px; }
.ctl{ text-align:left; }
.ctl h3{ margin:0 0 8px; letter-spacing:.16em; text-transform:uppercase; font-size:12px; }
.ctl.p1 h3{ color:#ff6b6b; } .ctl.p2 h3{ color:#6bb8ff; }
.ctl table{ border-collapse:collapse; }
.ctl td{ padding:2px 10px 2px 0; color:#cfc9bd; }
.key{ display:inline-block; min-width:20px; padding:1px 7px; margin-right:2px;
border:1px solid rgba(255,255,255,.25); border-radius:4px; background:rgba(255,255,255,.06);
font-family:ui-monospace,Menlo,monospace; font-size:11px; color:#fff; }
.tiny{ font-size:11px; letter-spacing:.2em; color:#8a8478; margin-top:26px; text-transform:uppercase; }
.flag{ display:inline-block; width:34px; height:22px; border-radius:2px; vertical-align:middle;
background:linear-gradient(90deg,#0055A4 0 33%,#fff 33% 66%,#EF4135 66% 100%); margin:0 8px; }
/* character select */
.selHead{ display:flex; align-items:center; justify-content:center; gap:22px; margin:2px 0 12px; flex-wrap:wrap; }
.pTag{ font-size:13px; letter-spacing:.08em; padding:6px 14px; border-radius:6px; border:1px solid; min-width:170px; }
.pTag.p1{ color:#ff8a8a; border-color:rgba(255,107,107,.5); }
.pTag.p2{ color:#8ac2ff; border-color:rgba(107,184,255,.5); }
.roster{ display:grid; grid-template-columns:repeat(3,1fr); gap:14px; max-width:620px; margin:2px auto; }
@media(max-width:620px){ .roster{ grid-template-columns:repeat(2,1fr); max-width:340px; } }
.card{ position:relative; background:rgba(255,255,255,.04); border:2px solid rgba(255,255,255,.1);
border-radius:10px; padding:8px 8px 10px; cursor:pointer; transition:.12s; }
.card:hover{ border-color:rgba(201,169,97,.6); }
.card canvas{ width:72px; height:90px; display:block; margin:0 auto 4px; }
.card .cname{ font-size:13px; font-weight:600; letter-spacing:.02em; color:#f2efe6; text-align:center; line-height:1.15;
min-height:30px; display:flex; align-items:center; justify-content:center; font-family:Georgia,serif; }
.card .stat{ display:flex; align-items:center; gap:6px; font-size:11px; color:#c3bdb0; margin:3px 4px; letter-spacing:.06em; }
.card .stat:first-of-type{ margin-top:7px; padding-top:7px; border-top:1px solid rgba(255,255,255,.09); }
.card .stat b{ width:30px; display:inline-block; }
.card .bar{ position:relative; flex:1; height:7px; background:rgba(255,255,255,.10); border-radius:3px; overflow:hidden; }
.card .bar i{ display:block; height:100%; }
.card .bar.spd i{ background:linear-gradient(90deg,#17a2bd,#22e0ff); }
.card .bar.pwr i{ background:linear-gradient(90deg,#c02d78,#ff3d9a); }
.card .bar.def i{ background:linear-gradient(90deg,#9a7d3a,#e7c987); }
.card .bar::after{ content:''; position:absolute; inset:0; pointer-events:none;
background:repeating-linear-gradient(90deg, transparent 0 19.4%, rgba(0,0,0,.5) 19.4% 20%); }
.card.p1cursor{ border:3px solid #ff6b6b; box-shadow:0 0 18px rgba(255,61,90,.6); transform:scale(1.05); }
.card.p2cursor{ border:3px solid #6bb8ff; box-shadow:0 0 18px rgba(34,120,255,.6); transform:scale(1.05); }
.card.p1cursor.p2cursor{ border-color:#c9a961; box-shadow:0 0 20px rgba(201,169,97,.65); }
.card .rdy{ position:absolute; top:5px; font-size:11px; font-weight:700; padding:2px 8px; border-radius:5px; z-index:2; }
.card .rdy.r1{ left:5px; background:#c0392b; color:#fff; }
.card .rdy.r2{ right:5px; background:#2c6fb0; color:#fff; }
/* touch controls (mobile) */
#touch{ position:absolute; inset:0; z-index:6; display:none; pointer-events:none; }
body.touch #touch.on{ display:block; }
.tc{ position:absolute; bottom:12px; display:flex; flex-wrap:wrap; gap:8px; pointer-events:none; }
.tc-left{ left:12px; width:118px; }
.tc-right{ right:12px; width:190px; justify-content:flex-end; }
.tbtn{ pointer-events:auto; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none;
touch-action:none; width:52px; height:52px; border-radius:50%;
border:1px solid rgba(255,255,255,.32); background:rgba(18,14,28,.5);
color:#f5f2ea; font:600 11px/1 Georgia,serif; -webkit-backdrop-filter:blur(2px); backdrop-filter:blur(2px);
display:flex; align-items:center; justify-content:center; box-shadow:0 2px 10px rgba(0,0,0,.4); }
.tbtn.wide{ width:55px; height:44px; border-radius:12px; font-size:11px; letter-spacing:.04em; }
.tbtn.atk{ width:56px; height:56px; font-size:12px; }
.tbtn[data-k="a"], .tbtn[data-k="d"]{ font-size:20px; }
.tbtn:active{ background:rgba(201,169,97,.55); color:#1a140a; transform:scale(.93); }
#rotate{ display:none; }
@media (orientation:portrait) and (max-width:820px){
body.touch #rotate{ position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
background:rgba(5,5,12,.92); color:#f5f2ea; font-size:20px; letter-spacing:.06em; text-align:center; padding:24px; }
}
</style>
</head>
<body>
<div id="stage">
<canvas id="game" width="960" height="540"></canvas>
<!-- START SCREEN -->
<div class="screen" id="menu">
<h1>BAGUETTE BATTLE</h1>
<div class="sub"><span class="flag"></span> Neon Paris Combat Arena <span class="flag"></span></div>
<p>Two Parisians settle their differences with baguettes beneath a neon Eiffel Tower.
Poke, whack, block — then <b>launch your baguette like a missile</b>. Best of 3 rounds wins.</p>
<div class="btns">
<button class="primary" id="btn1p">1 PLAYER · vs CPU</button>
<button id="btn2p">2 PLAYERS · local</button>
</div>
<div class="controls">
<div class="ctl p1"><h3>🥖 Player 1</h3>
<table>
<tr><td>Move</td><td><span class="key">A</span><span class="key">D</span></td></tr>
<tr><td>Jump</td><td><span class="key">W</span></td></tr>
<tr><td>Block</td><td><span class="key">S</span></td></tr>
<tr><td>Poke (baguette)</td><td><span class="key">Space</span><span class="key">F</span></td></tr>
<tr><td>Whack</td><td><span class="key">G</span></td></tr>
<tr><td>Throw baguette 🥖</td><td><span class="key">Return</span><span class="key">H</span></td></tr>
<tr><td>Rocket 🚀</td><td><span class="key">R</span></td></tr>
<tr><td>Bomb 💣</td><td><span class="key">B</span></td></tr>
</table>
</div>
<div class="ctl p2"><h3>🥖 Player 2</h3>
<table>
<tr><td>Move</td><td><span class="key">←</span><span class="key">→</span></td></tr>
<tr><td>Jump</td><td><span class="key">↑</span></td></tr>
<tr><td>Block</td><td><span class="key">↓</span></td></tr>
<tr><td>Poke (baguette)</td><td><span class="key">,</span></td></tr>
<tr><td>Whack</td><td><span class="key">.</span></td></tr>
<tr><td>Throw baguette 🥖</td><td><span class="key">/</span></td></tr>
<tr><td>Rocket 🚀</td><td><span class="key">;</span></td></tr>
<tr><td>Bomb 💣</td><td><span class="key">'</span></td></tr>
</table>
</div>
</div>
<div class="tiny">1-Player also: <span class="key">←</span><span class="key">→</span> move · <span class="key">↑</span> jump · <span class="key">↓</span> block · <span class="key">Space</span> poke · <span class="key">Return</span> throw · <span class="key">R</span> rocket · <span class="key">B</span> bomb · <span class="key">P</span> pause · <span class="key">M</span> mute</div>
</div>
<!-- CHARACTER SELECT -->
<div class="screen hidden" id="select">
<h1 style="font-size:clamp(24px,4.6vw,40px);margin-bottom:2px;">CHOOSE YOUR BAGUETTE</h1>
<div class="selHead">
<div class="pTag p1" id="p1tag">P1: —</div>
<div class="sub" style="margin:0;"><span class="flag"></span></div>
<div class="pTag p2" id="p2tag">P2: —</div>
</div>
<div class="roster" id="roster"></div>
<div class="btns" style="margin-top:14px;">
<button class="primary" id="btnStart" disabled>SELECT FIGHTERS</button>
<button id="btnSelBack">BACK</button>
</div>
<div class="tiny" id="selHint"></div>
</div>
<!-- RESULT SCREEN -->
<div class="screen hidden" id="result">
<h1 id="resultTitle">PLAYER 1 WINS</h1>
<div class="sub" id="resultSub">Magnifique!</div>
<div class="btns">
<button class="primary" id="btnRematch">REMATCH</button>
<button id="btnMenu">MAIN MENU</button>
</div>
</div>
<!-- PAUSE SCREEN -->
<div class="screen hidden" id="pause">
<h1>PAUSED</h1>
<div class="btns">
<button class="primary" id="btnResume">RESUME</button>
<button id="btnQuit">QUIT TO MENU</button>
</div>
<div class="tiny">Press <span class="key">P</span> to resume</div>
</div>
<!-- TOUCH CONTROLS (mobile) — drive Player 1 -->
<div id="touch" aria-hidden="true">
<div class="tc tc-left">
<button class="tbtn wide" data-k="w">JUMP</button>
<button class="tbtn wide" data-k="s">BLOCK</button>
<button class="tbtn" data-k="a">◀</button>
<button class="tbtn" data-k="d">▶</button>
</div>
<div class="tc tc-right">
<button class="tbtn atk" data-k="f">POKE</button>
<button class="tbtn atk" data-k="g">WHACK</button>
<button class="tbtn atk" data-k="h">🥖</button>
<button class="tbtn atk" data-k="r">🚀</button>
<button class="tbtn atk" data-k="b">💣</button>
</div>
</div>
<div id="rotate">↺ Rotate your device to landscape</div>
</div>
<script>
"use strict";
(() => {
const cv = document.getElementById('game');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const GROUND = H - 92; // y of the floor line
const GRAV = 0.9;
const ARENA_L = 60, ARENA_R = W - 60;
// ---------- Audio (tiny WebAudio synth, no assets) ----------
const Audio = (() => {
let ac = null, muted = false;
const ensure = () => { if (!ac) { try { ac = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){} } return ac; };
function tone(freq, dur, type='square', vol=0.15, slideTo=null){
if (muted) return; const a = ensure(); if(!a) return;
const o = a.createOscillator(), g = a.createGain();
o.type = type; o.frequency.value = freq;
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, a.currentTime + dur);
g.gain.value = vol;
g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
o.connect(g); g.connect(a.destination);
o.start(); o.stop(a.currentTime + dur);
}
function noise(dur=0.12, vol=0.2){
if (muted) return; const a = ensure(); if(!a) return;
const n = a.createBufferSource();
const buf = a.createBuffer(1, a.sampleRate*dur, a.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<d.length;i++) d[i] = (Math.random()*2-1) * (1 - i/d.length);
n.buffer = buf;
const g = a.createGain(); g.gain.value = vol;
g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
const f = a.createBiquadFilter(); f.type='bandpass'; f.frequency.value=1400;
n.connect(f); f.connect(g); g.connect(a.destination); n.start();
}
// ---- La Marseillaise (opening) played on the melody synth ----
let anthemStarted=false, anthemOsc=[];
const NF = { 'D4':293.66, 'F#4':369.99, 'G4':392.00, 'A4':440.00, 'B4':493.88, 'C5':523.25, 'D5':587.33 };
const ANTHEM = [ // [note, beats] — "Allons enfants de la patrie, le jour de gloire est arrivé..."
['D4',.5],['D4',.75],['D4',.25],['G4',1],['G4',.5],['A4',.5],['A4',1],
['D5',.75],['B4',.25],['G4',.5],['G4',.5],['B4',.75],['G4',.25],['C5',1],['A4',1],
['F#4',.5],['G4',.5],['G4',.5],['A4',.5],['B4',.75],['B4',.25],['B4',.5],['C5',1],
['B4',.5],['B4',.5],['A4',.5],['A4',.5],['B4',.75],['C5',.25],['C5',.5],['C5',.5],['D5',1],['C5',.5],['B4',1.5]
];
function _scheduleAnthem(a){
const beat=0.42; let t=a.currentTime+0.12;
const master=a.createGain(); master.gain.value=0.9; master.connect(a.destination);
for (const [n,b] of ANTHEM){
const f=NF[n], dur=b*beat;
const o=a.createOscillator(), g=a.createGain(), lp=a.createBiquadFilter();
o.type='sawtooth'; o.frequency.value=f;
lp.type='lowpass'; lp.frequency.value=2200;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.13, t+0.03);
g.gain.setValueAtTime(0.13, t+dur*0.72);
g.gain.exponentialRampToValueAtTime(0.0001, t+dur*0.99);
o.connect(lp); lp.connect(g); g.connect(master);
o.start(t); o.stop(t+dur+0.02);
anthemOsc.push(o);
t+=dur;
}
}
function startAnthem(){
if (anthemStarted || muted) return;
const a=ensure(); if(!a) return;
const go=()=>{ if(anthemStarted) return; anthemStarted=true; _scheduleAnthem(a); };
if (a.state==='suspended') a.resume().then(go).catch(()=>{}); else go();
}
function stopAnthem(){ for(const o of anthemOsc){ try{o.stop();}catch(e){} } anthemOsc=[]; }
return {
poke: () => tone(520, .08, 'square', .12, 380),
swing: () => { tone(180,.18,'sawtooth',.16,90); noise(.14,.14); },
block: () => tone(240, .1, 'triangle', .14),
hurt: () => { noise(.16,.22); tone(140,.15,'sawtooth',.12,70); },
blast: () => { tone(660,.25,'sawtooth',.16,120); noise(.2,.16); },
boom: () => { tone(90,.4,'sawtooth',.2,40); noise(.35,.3); },
jump: () => tone(300,.12,'sine',.1,540),
ko: () => { tone(120,.5,'sawtooth',.2,50); noise(.4,.2); },
bell: () => { tone(880,.5,'sine',.12); tone(1320,.5,'sine',.06); },
toggle: () => { muted = !muted; if (muted) stopAnthem(); return muted; },
resume: () => { const a = ensure(); if (a && a.state === 'suspended') a.resume(); },
startAnthem, stopAnthem,
get muted(){ return muted; }
};
})();
// ---------- Voice (American hamming a French accent, via a French TTS voice) ----------
const Voice = (() => {
let voice = null, enabled = true, lastAt = 0;
const has = ('speechSynthesis' in window);
function pick(){
if (!has) return;
const vs = speechSynthesis.getVoices();
voice = vs.find(v => /fr-FR|fr_FR/i.test(v.lang))
|| vs.find(v => /^fr/i.test(v.lang))
|| vs.find(v => /(français|france|thomas|amelie|aur[eé]lie)/i.test(v.name))
|| vs[0] || null;
}
if (has){ pick(); speechSynthesis.onvoiceschanged = pick; }
const rnd = a => a[Math.floor(Math.random()*a.length)];
function say(text, {rate=0.95, pitch=1.1, force=false} = {}){
if (!enabled || Audio.muted || !has) return;
const now = performance.now();
if (!force && now - lastAt < 750) return; // throttle chatter
lastAt = now;
try {
if (force) speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
if (voice) u.voice = voice;
u.lang = voice ? voice.lang : 'fr-FR';
u.rate = rate; u.pitch = pitch; u.volume = 1;
speechSynthesis.speak(u);
} catch(e){}
}
return {
// phonetic English so a French voice reads it with the accent
taunt: () => say(rnd(["Take zat!","Ooh la la!","On garde!","Zut alors!","Eat my baguette!","Ha! Too slow, mon ami!"]), {pitch:1.15}),
hurt: () => say(rnd(["Oh non!","Mon dieu!","Sacrebleu!","Ze pain!","Aïe!"]), {pitch:1.25, rate:1.05}),
block: () => say(rnd(["Non non non!","Not today!"]), {pitch:1.15}),
blast: () => say(rnd(["Baguette missile! Allez!","Fire ze baguette!","Incoming! Ha ha!"]), {rate:0.95, pitch:1.05, force:true}),
baguette: () => say(rnd(["Throw ze baguette!","Catch, mon ami!","Voilà!"]), {pitch:1.1, force:true}),
rocket: () => say(rnd(["Rocket! Allez!","Boom!","Fire ze rocket!"]), {rate:0.95, force:true}),
bomb: () => say(rnd(["Bombe!","Boom boom!","Ha! Ze bomb!"]), {pitch:1.15, force:true}),
round: () => say("Allez!", {force:true, pitch:1.1}),
fight: () => say("Fight, mon ami!", {force:true, pitch:1.15}),
ko: () => say("Oh la la! Magnifique!", {force:true, pitch:1.05}),
win: () => say(rnd(["Champion de Paris!","Formidable!","Très bien! Ze winner!"]), {force:true}),
hazard: () => say(rnd(["Incoming pie!","Look out! Ze french roll!","Duck, mon ami!"]), {pitch:1.15}),
stop: () => { if (has) try { speechSynthesis.cancel(); } catch(e){} },
set enabled(v){ enabled = v; if (!v) this.stop(); },
get enabled(){ return enabled; }
};
})();
// ---------- Input ----------
const keys = {};
// maps are arrays so an action can have several keys.
// poke = baguette jab · swing = whack · baguette = throw baguette · rocket = launch rocket (needs meter) · bomb = lob bomb
const P1_ATTACK = {
poke: ['f','j',' ','shift'], // Space = poke with baguette
swing: ['g','k'], // whack
baguette: ['h','enter','backspace','delete'], // Return = throw baguette
rocket: ['r','meta','control'], // R = rocket
bomb: ['b','alt'], // B = bomb
};
const P1MAP_1P = { left:['a','arrowleft'], right:['d','arrowright'], jump:['w','arrowup'], block:['s','arrowdown'], ...P1_ATTACK };
const P1MAP_2P = { left:['a'], right:['d'], jump:['w'], block:['s'], ...P1_ATTACK }; // arrows belong to P2 in 2P
const P2MAP = { left:['arrowleft'], right:['arrowright'], jump:['arrowup'], block:['arrowdown'],
poke:[','], swing:['.'], baguette:['/'], rocket:[';'], bomb:["'"] };
let p1map = P1MAP_1P;
addEventListener('keydown', e => {
const k = e.key.toLowerCase();
if (['arrowup','arrowdown','arrowleft','arrowright',' ','/','backspace','delete'].includes(k)) e.preventDefault();
if (!keys[k]) keys['_pressed_'+k] = true; // edge trigger
keys[k] = true;
if (state === 'select'){
if (k==='a') selMove(1,-1);
else if (k==='d') selMove(1,1);
else if (k==='f') selReady(1);
else if (mode==='2p' && k==='arrowleft') selMove(2,-1);
else if (mode==='2p' && k==='arrowright') selMove(2,1);
else if (mode==='2p' && k===',') selReady(2);
else if (k==='enter' || k===' ') beginSelectedMatch();
else if (k==='escape'){ state='menu'; hideScreens(); document.getElementById('menu').classList.remove('hidden'); }
return;
}
if (k === 'p') togglePause();
if (k === 'm') { const m = Audio.toggle(); Voice.enabled = !m; }
});
addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
const pressed = (k) => { if (keys['_pressed_'+k]) { keys['_pressed_'+k] = false; return true; } return false; };
// ---------- Fighter ----------
// Selectable roster. spd/pow/def are 1..5 (shown as bars); converted to gameplay factors in Fighter.
const ROSTER = [
{ id:'rouge', name:'Baguette Rouge', spd:3, pow:3, def:3,
colors:{ crust:'#d9a441', crustDk:'#a9761f', accent:'#ff4d4d', beret:'#c0392b', glow:'#ff6b6b' } },
{ id:'bleu', name:'Baguette Bleue', spd:3, pow:3, def:3,
colors:{ crust:'#e0b45a', crustDk:'#b08228', accent:'#4d9dff', beret:'#2c6fb0', glow:'#6bb8ff' } },
{ id:'costaud', name:'Le Costaud', spd:2, pow:5, def:4,
colors:{ crust:'#c98a3e', crustDk:'#8a5a17', accent:'#ff9a3d', beret:'#7a3b12', glow:'#ffb347' } },
{ id:'petit', name:'Petit Éclair', spd:5, pow:2, def:2,
colors:{ crust:'#f0d488', crustDk:'#c9a94e', accent:'#ffe14d', beret:'#e0a800', glow:'#ffe066' } },
{ id:'sourdough',name:'Pain de Campagne',spd:2, pow:4, def:5,
colors:{ crust:'#b8894c', crustDk:'#7c561f', accent:'#d8b48c', beret:'#5b3a1a', glow:'#e6c79a' } },
{ id:'noir', name:'Baguette Noire', spd:4, pow:5, def:1,
colors:{ crust:'#8a6a3a', crustDk:'#4a3418', accent:'#ff3d9a', beret:'#141414', glow:'#ff3d9a' } },
];
class Fighter {
constructor(x, side, cfg, human){
this.x = x; this.y = GROUND; this.vx = 0; this.vy = 0;
this.w = 58; this.h = 118;
this.side = side; // 'p1' | 'p2' (controls + facing)
this.cfg = cfg;
this.name = cfg.name;
this.colors = cfg.colors;
this.spd = 3.4 + cfg.spd * 0.42; // movement px/frame
this.powF = 0.82 + cfg.pow * 0.07;// outgoing-damage multiplier
this.defF = 1.18 - cfg.def * 0.06;// incoming-damage/knockback multiplier (lower = tankier)
this.human = human; // false => CPU
this.facing = side === 'p1' ? 1 : -1;
this.reset();
this.rounds = 0;
}
reset(){
this.hp = 100; this.meter = 0;
this.vx = 0; this.vy = 0; this.onGround = true; this.y = GROUND;
this.state = 'idle'; // idle|walk|jump|poke|swing|block|hurt|ko
this.stateTime = 0;
this.attack = null; // {type,startup,active,recover,t,hit,dmg,reach,kb}
this.hurtTimer = 0; this.blockHold = 0; this.invuln = 0;
this.animT = Math.random()*10;
this.aiTimer = 0; this.aiIntent = 'idle';
this.armSwing = 0; // visual arm extension 0..1
this.cd = { baguette:0, bomb:0 }; // per-weapon cooldowns (rocket uses the meter)
}
get top(){ return this.y - this.h; }
get cx(){ return this.x; }
get cy(){ return this.y - this.h*0.5; }
get alive(){ return this.hp > 0; }
hurtbox(){ return { x:this.x - this.w/2, y:this.y - this.h, w:this.w, h:this.h }; }
// attack hitbox in front of the fighter, when in active frames
hitbox(){
if (!this.attack || this.attack.phase !== 'active') return null;
const reach = this.attack.reach;
const hy = this.attack.type === 'swing' ? this.y - this.h*0.62 : this.y - this.h*0.5;
const hh = this.attack.type === 'swing' ? 64 : 34;
const hx = this.facing === 1 ? this.x + this.w/2 : this.x - this.w/2 - reach;
return { x:hx, y:hy - hh/2, w:reach, h:hh };
}
startAttack(type){
if (this.attack || this.state==='hurt' || this.state==='ko' || !this.onGround) return;
const THROW = { baguette:14, rocket:16, bomb:15 }; // recover frames per thrown weapon
if (type in THROW){
if (type === 'rocket'){ if (this.meter < 100) return; this.meter = 0; } // rocket costs the meter
else { if (this.cd[type] > 0) return; this.cd[type] = (type==='baguette' ? 48 : 90); } // cooldown
spawnProjectile(this, type);
if (type==='bomb'){ Audio.swing(); Voice.bomb(); }
else if (type==='rocket'){ Audio.blast(); Voice.rocket(); }
else { Audio.blast(); Voice.baguette(); }
this.attack = { type, phase:'recover', t:0, recover:THROW[type] };
this.state = 'poke'; this.stateTime = 0; this.armSwing = 1;
return;
}
const spec = type === 'poke'
? { startup:4, active:5, recover:9, dmg:6, reach:52, kb:5, self:2.5 }
: { startup:9, active:6, recover:20, dmg:15, reach:74, kb:12, self:5 };
this.attack = { type, phase:'startup', t:0, hit:false, ...spec };
this.state = type; this.stateTime = 0;
if (type==='poke') Audio.poke(); else Audio.swing();
if (type==='swing' && Math.random()<0.5) Voice.taunt();
else if (type==='poke' && Math.random()<0.22) Voice.taunt();
}
takeHit(dmg, kb, fromDir){
if (this.invuln > 0 || this.state==='ko') return false;
dmg *= this.defF; kb *= this.defF; // defense stat: tankier fighters take less
const blocking = this.state === 'block';
let dealt = dmg;
if (blocking){
dealt = Math.ceil(dmg * 0.22); // chip damage
kb *= 0.4;
Audio.block(); if (Math.random()<0.45) Voice.block();
spawnSpark(this.x + fromDir* -this.w*0.4, this.cy, '#bfe9ff', 6);
this.meter = Math.min(100, this.meter + dmg*0.5);
} else {
Audio.hurt(); if (Math.random()<0.55) Voice.hurt();
this.state = 'hurt'; this.stateTime = 0; this.hurtTimer = 16;
this.attack = null;
spawnSpark(this.x + fromDir* -this.w*0.4, this.cy, '#ffd27a', 14);
this.meter = Math.min(100, this.meter + dmg*0.8);
}
this.hp = Math.max(0, this.hp - dealt);
this.vx += fromDir * kb;
if (!blocking){ this.vy = -Math.min(9, kb*0.7); this.onGround = false; }
if (this.hp <= 0){ this.state='ko'; this.stateTime=0; this.attack=null; }
shake = Math.min(16, shake + (blocking?3:8));
return true;
}
controlHuman(map){
if (this.state==='hurt'||this.state==='ko') return;
const down = a => a && a.some(k => keys[k]);
const hit = a => { if (!a) return false; let h=false; for (const k of a){ if (keys['_pressed_'+k]){ keys['_pressed_'+k]=false; h=true; } } return h; };
const speed = this.spd;
const L = down(map.left), R = down(map.right);
if (!this.attack){
if (L && !R){ this.vx = -speed; this.facing = -1; }
else if (R && !L){ this.vx = speed; this.facing = 1; }
}
if (down(map.block) && this.onGround && !this.attack){ this.state = 'block'; }
else if (this.state==='block'){ this.state = 'idle'; }
if (hit(map.jump) && this.onGround && !this.attack){ this.vy = -16; this.onGround = false; Audio.jump(); }
if (hit(map.poke)) this.startAttack('poke');
if (hit(map.swing)) this.startAttack('swing');
if (hit(map.baguette)) this.startAttack('baguette');
if (hit(map.rocket)) this.startAttack('rocket');
if (hit(map.bomb)) this.startAttack('bomb');
}
controlAI(foe){
if (this.state==='hurt'||this.state==='ko') return;
this.aiTimer--;
const dist = foe.x - this.x;
const adist = Math.abs(dist);
this.facing = dist >= 0 ? 1 : -1;
const speed = this.spd;
// re-decide intent periodically
if (this.aiTimer <= 0){
this.aiTimer = 14 + Math.floor(Math.random()*22);
const r = Math.random();
if (this.hp < 28 && r < 0.35) this.aiIntent = 'retreat';
else if (adist > 150) this.aiIntent = 'approach';
else if (adist < 95){
this.aiIntent = r < 0.5 ? 'attack' : (r < 0.72 ? 'block' : (r<0.86?'retreat':'jump'));
} else this.aiIntent = r < 0.62 ? 'approach' : 'attack';
if (this.meter >= 100 && adist < 260 && r < 0.6) this.aiIntent = 'blast';
}
// react to incoming attack -> chance to block
if (foe.attack && foe.attack.phase!=='recover' && adist < 110 && Math.random() < 0.14){
this.aiIntent = 'block';
}
if (this.state === 'block' && this.aiIntent !== 'block') this.state = 'idle';
switch(this.aiIntent){
case 'approach': if (!this.attack) this.vx = this.facing * speed; break;
case 'retreat': if (!this.attack) this.vx = -this.facing * speed; break;
case 'jump':
if (this.onGround && !this.attack){ this.vy=-15; this.onGround=false; Audio.jump(); }
if (!this.attack) this.vx = this.facing * speed*0.6;
this.aiIntent='approach'; break;
case 'block':
if (this.onGround && !this.attack) this.state='block';
break;
case 'attack':
if (adist < 100) this.startAttack(Math.random()<0.55?'poke':'swing');
else if (adist < 300 && Math.random()<0.4) this.startAttack(Math.random()<0.6?'baguette':'bomb');
else if (!this.attack) this.vx = this.facing * speed;
break;
case 'blast':
if (this.meter>=100) this.startAttack('rocket'); else this.startAttack('baguette');
this.aiIntent='approach'; break;
}
}
update(foe){
this.animT += 0.16;
if (this.invuln>0) this.invuln--;
if (this.cd.baguette>0) this.cd.baguette--;
if (this.cd.bomb>0) this.cd.bomb--;
// attack phase machine
if (this.attack){
this.attack.t++;
const a = this.attack;
if (a.type==='baguette' || a.type==='rocket' || a.type==='bomb'){
if (a.t >= a.recover){ this.attack=null; if(this.state!=='hurt') this.state='idle'; }
} else {
if (a.phase==='startup' && a.t>=a.startup){ a.phase='active'; a.t=0; }
else if (a.phase==='active' && a.t>=a.active){ a.phase='recover'; a.t=0; }
else if (a.phase==='recover' && a.t>=a.recover){ this.attack=null; if(this.state!=='hurt') this.state='idle'; }
// arm extension visual
if (a.phase==='startup') this.armSwing = a.t/Math.max(1,a.startup)*0.5;
else if (a.phase==='active') this.armSwing = 1;
else this.armSwing = Math.max(0, 1 - a.t/Math.max(1,a.recover));
}
} else {
this.armSwing *= 0.7;
}
if (this.state==='hurt'){ this.hurtTimer--; if (this.hurtTimer<=0 && this.onGround) this.state='idle'; }
// physics
this.x += this.vx;
if (!this.attack && this.state!=='hurt') this.vx *= this.onGround ? 0.72 : 0.9;
else this.vx *= 0.86;
this.vy += GRAV;
this.y += this.vy;
if (this.y >= GROUND){ this.y = GROUND; this.vy = 0; if(!this.onGround){ this.onGround = true; if(this.state==='jump') this.state='idle'; } }
else { this.onGround = false; if(this.state!=='hurt' && this.state!=='ko' && !this.attack) this.state='jump'; }
// arena bounds
this.x = Math.max(ARENA_L, Math.min(ARENA_R, this.x));
// determine walk state
if (this.onGround && !this.attack && this.state!=='hurt' && this.state!=='block' && this.state!=='ko'){
this.state = Math.abs(this.vx) > 0.6 ? 'walk' : 'idle';
}
// passive meter gain
this.meter = Math.min(100, this.meter + 0.06);
this.stateTime++;
}
draw(g){
const c = this.colors;
const f = this.facing;
g.save();
g.translate(this.x, this.y);
// ground shadow — wide soft halo + tight dark core, so warm fighters separate from the cobbles
g.save();
g.scale(1, .32);
g.beginPath(); g.arc(0, 6/.32, this.w*0.5+13, 0, Math.PI*2); g.fillStyle='rgba(0,0,0,.32)'; g.fill();
g.beginPath(); g.arc(0, 6/.32, this.w*0.42, 0, Math.PI*2); g.fillStyle='rgba(0,0,0,.6)'; g.fill();
g.restore();
g.scale(f, 1); // face direction
const hurtFlash = this.state==='hurt' && (Math.floor(this.stateTime/2)%2===0);
const lean = this.state==='walk' ? Math.sin(this.animT)*3 : 0;
const bob = this.state==='idle' ? Math.sin(this.animT*0.8)*2 : 0;
const koFall = this.state==='ko' ? Math.min(1,this.stateTime/26) : 0;
g.save();
g.translate(0, bob);
if (koFall>0){ g.rotate(koFall*Math.PI*0.5*0.9); g.translate(0, koFall*10); }
g.rotate(lean*Math.PI/180);
// ===== Frenchman with beret, wielding a baguette =====
const legPhase = this.state==='walk' ? Math.sin(this.animT) : 0;
const armExt = this.armSwing;
const skin='#e8b892', skinDk='#c98f68', trouser='#242a42', shoe='#141414';
const shirt = hurtFlash ? '#ffffff' : '#f2efe6';
const hipY=-46, shoY=-86, headR=14, headCY=-104;
// ---- legs + shoes ----
g.lineCap='round';
g.strokeStyle=trouser; g.lineWidth=13;
g.beginPath(); g.moveTo(-8,hipY); g.lineTo(-9 - legPhase*7, -3); g.stroke();
g.beginPath(); g.moveTo( 8,hipY); g.lineTo( 9 + legPhase*7, -3); g.stroke();
g.strokeStyle=shoe; g.lineWidth=7;
g.beginPath(); g.moveTo(-9-legPhase*7,-2); g.lineTo(-14-legPhase*7,-1); g.stroke();
g.beginPath(); g.moveTo( 9+legPhase*7,-2); g.lineTo( 14+legPhase*7,-1); g.stroke();
// ---- back arm (behind torso) ----
const backHy = shoY+12+22+Math.sin(this.animT)*2;
g.strokeStyle=shirt; g.lineWidth=8; g.lineCap='round';
g.beginPath(); g.moveTo(-this.w*0.16, shoY+12); g.lineTo(-this.w*0.16-6, backHy); g.stroke();
g.fillStyle=skin; g.beginPath(); g.arc(-this.w*0.16-6, backHy, 4,0,Math.PI*2); g.fill();
// ---- torso: Breton-striped shirt ----
const tw=this.w*0.8, tx=-tw/2, ty=shoY, thh=hipY-shoY+6;
g.save();
g.shadowColor=c.glow; g.shadowBlur=hurtFlash?24:12;
roundRectPath(g, tx, ty, tw, thh, 9); g.fillStyle=shirt; g.fill();
g.shadowBlur=0;
if (!hurtFlash){
g.save(); roundRectPath(g, tx, ty, tw, thh, 9); g.clip();
g.fillStyle=c.accent;
for (let sy=ty+5; sy<ty+thh; sy+=10) g.fillRect(tx, sy, tw, 5);
g.restore();
}
g.lineWidth=2.5; g.strokeStyle='rgba(0,0,0,.5)'; roundRectPath(g, tx, ty, tw, thh, 9); g.stroke(); // dark outline for floor separation
g.restore();
// ---- neckerchief ----
g.fillStyle=c.beret;
g.beginPath(); g.moveTo(-8, shoY+2); g.lineTo(8, shoY+2); g.lineTo(0, shoY+13); g.closePath(); g.fill();
// ---- front arm + baguette weapon ----
const sx=this.w*0.2, sy=shoY+10;
let hx, hy, bang;
if (this.state==='swing'){ // overhead whack that sweeps down
bang = -2.1 + armExt*2.7;
hx = sx + 6 + Math.cos(bang)*6; hy = sy + 2 + Math.sin(bang)*6;
} else if (this.state==='poke' || (this.attack && (this.attack.type==='baguette'||this.attack.type==='rocket'||this.attack.type==='bomb'))){
hx = sx + 12 + armExt*26; hy = sy + 8; bang = -0.12; // thrust / throw
} else if (this.state==='block'){
hx = sx - 2; hy = sy + 4; bang = -1.45; // raised guard
} else {
hx = sx + 8 + Math.sin(this.animT)*2; hy = sy + 16; bang = -0.15; // held ready
}
g.strokeStyle=shirt; g.lineWidth=9; g.lineCap='round';
g.beginPath(); g.moveTo(sx, sy); g.lineTo(hx, hy); g.stroke();
const hideBag = this.attack && this.attack.type==='baguette'; // baguette left the hand as a missile
if (!hideBag) drawBaguetteWeapon(g, hx, hy, bang, 46, true);
g.fillStyle=skin; g.beginPath(); g.arc(hx, hy, 5, 0, Math.PI*2); g.fill();
// block shimmer
if (this.state==='block'){
g.strokeStyle='rgba(180,225,255,.85)'; g.lineWidth=3;
g.beginPath(); g.arc(this.w*0.12, shoY+thh*0.4, this.w*0.55, -0.7, 0.7); g.stroke();
}
// ---- head ----
g.save();
g.translate(2, headCY);
g.fillStyle=skin; g.fillRect(-4, headR-5, 8, 8); // neck
g.fillStyle = hurtFlash ? '#ffffff' : skin; // face
g.beginPath(); g.arc(0,0,headR,0,Math.PI*2); g.fill();
g.strokeStyle=skinDk; g.lineWidth=1.5; g.stroke();
g.fillStyle=skin; g.beginPath(); g.arc(-headR+2, 2, 3, 0, Math.PI*2); g.fill(); // ear
if (this.state==='ko'){
g.strokeStyle='#20140a'; g.lineWidth=2;
for (const ex of [4,10]){ g.beginPath(); g.moveTo(ex-2,-5); g.lineTo(ex+2,-1); g.moveTo(ex+2,-5); g.lineTo(ex-2,-1); g.stroke(); }
} else {
g.fillStyle='#20140a';
g.beginPath(); g.arc(5,-3,2,0,Math.PI*2); g.fill();
g.beginPath(); g.arc(11,-3,2,0,Math.PI*2); g.fill();
g.strokeStyle='#20140a'; g.lineWidth=2.5;
g.beginPath(); g.moveTo(1,-8); g.lineTo(8,-5); g.moveTo(8,-6); g.lineTo(14,-8); g.stroke(); // brows
}
// grand moustache
g.strokeStyle='#3b2415'; g.lineWidth=3; g.lineCap='round';
g.beginPath(); g.moveTo(1,6); g.quadraticCurveTo(5,3,9,5);
g.moveTo(9,5); g.quadraticCurveTo(12,3,15,7); g.stroke();
// mouth
g.strokeStyle='#20140a'; g.lineWidth=2; g.beginPath();
if (this.state==='hurt') g.arc(7, 9, 3.5, 0, Math.PI*2);
else if (this.attack) g.arc(7, 9, 3, 0, Math.PI*2);
else g.arc(7, 8, 4, 0.1*Math.PI, 0.9*Math.PI);
g.stroke();
// ---- beret (hat) ----
g.fillStyle=c.beret;
g.beginPath(); g.ellipse(1, -headR+3, headR*0.98, 5.5, 0, 0, Math.PI*2); g.fill();
g.beginPath(); g.arc(1, -headR-2, headR*0.86, Math.PI, 0); g.fill();
g.fillStyle='#1a1a1a'; g.beginPath(); g.arc(headR*0.55, -headR-3, 2.2, 0, Math.PI*2); g.fill(); // stalk
g.restore();
g.restore(); // bob/ko
g.restore(); // translate/scale
}
}
// draws a baguette starting at (x,y) extending length `len` along +x, rotated by `angle`
function drawBaguetteWeapon(g, x, y, angle, len, glow){
g.save();
g.translate(x,y); g.rotate(angle);
const bw = 9;
g.shadowColor='#ffcf6b'; g.shadowBlur = glow?10:0;
roundRectPath(g, 0, -bw/2, len, bw, bw/2);
const grd = g.createLinearGradient(0,-bw/2,0,bw/2);
grd.addColorStop(0,'#e9c079'); grd.addColorStop(.5,'#d9a441'); grd.addColorStop(1,'#a9761f');
g.fillStyle = grd; g.fill();
g.shadowBlur = 0;
g.lineWidth = 1.5; g.strokeStyle = 'rgba(90,55,15,.65)'; g.stroke();
// score marks
g.strokeStyle = 'rgba(110,66,18,.8)'; g.lineWidth = 1.6;
for (let i=1;i<4;i++){ const xx = len*i/4; g.beginPath(); g.moveTo(xx-3,-2); g.quadraticCurveTo(xx, -bw*0.6, xx+3,-1); g.stroke(); }
// rounded tips highlight
g.fillStyle='rgba(255,245,220,.35)'; g.beginPath(); g.ellipse(len-3,0,2.5,bw*0.28,0,0,Math.PI*2); g.fill();
g.restore();
}
// rocket drawn pointing +x (caller flips by facing)
function drawRocket(g, r){
g.fillStyle='#e6e6ee'; roundRectPath(g, -r, -8, r*1.7, 16, 6); g.fill();
g.lineWidth=1.5; g.strokeStyle='rgba(0,0,0,.25)'; roundRectPath(g, -r, -8, r*1.7, 16, 6); g.stroke();
g.fillStyle='#ff4d4d'; g.beginPath(); g.moveTo(r*0.7,-8); g.lineTo(r*0.7+18,0); g.lineTo(r*0.7,8); g.closePath(); g.fill(); // nose
g.fillStyle='#4d9dff';
g.beginPath(); g.moveTo(-r,-8); g.lineTo(-r-9,-15); g.lineTo(-r+7,-8); g.closePath(); g.fill(); // fins
g.beginPath(); g.moveTo(-r, 8); g.lineTo(-r-9, 15); g.lineTo(-r+7, 8); g.closePath(); g.fill();
g.fillStyle='#22e0ff'; g.beginPath(); g.arc(r*0.05,0,4,0,Math.PI*2); g.fill(); // window
}
// bomb drawn spinning; t drives the fuse-spark flicker
function drawBomb(g, r, t){
g.shadowColor='rgba(0,0,0,.5)'; g.shadowBlur=6;
g.fillStyle='#141414'; g.beginPath(); g.arc(0,0,r*0.72,0,Math.PI*2); g.fill();
g.shadowBlur=0;
g.fillStyle='rgba(255,255,255,.18)'; g.beginPath(); g.arc(-r*0.22,-r*0.22,r*0.22,0,Math.PI*2); g.fill();
g.fillStyle='#5a4a2a'; g.fillRect(-3,-r*0.82,6,5); // fuse cap
g.strokeStyle='#8a5a2a'; g.lineWidth=2; g.beginPath(); g.moveTo(0,-r*0.82); g.quadraticCurveTo(7,-r-2,12,-r*0.72); g.stroke();
g.fillStyle=(Math.floor(t/3)%2)?'#ffd166':'#ff6a3d'; g.shadowColor='#ffb347'; g.shadowBlur=8;
g.beginPath(); g.arc(12,-r*0.72,3.2,0,Math.PI*2); g.fill(); g.shadowBlur=0;
}
function roundRectPath(g,x,y,w,h,r){
r = Math.max(0, Math.min(r, w/2, h/2)); // never negative (thin cobble rows)
g.beginPath();
g.moveTo(x+r,y);
g.arcTo(x+w,y,x+w,y+h,r);
g.arcTo(x+w,y+h,x,y+h,r);
g.arcTo(x,y+h,x,y,r);
g.arcTo(x,y,x+w,y,r);
g.closePath();
}
// ---------- Projectiles & particles ----------
let blasts = [], sparks = [], hazards = [], hazardTimer = 140, shake = 0;
function spawnProjectile(owner, kind){
const f = owner.facing, base = { owner, kind, t:0, rot:0, hit:false };
if (kind === 'rocket'){
blasts.push({ ...base, x: owner.x + f*owner.w*0.5, y: owner.y - owner.h*0.55, vx: f*26, vy:0, grav:0, r:30, dmg:22, kb:18, life:80 });
} else if (kind === 'bomb'){
blasts.push({ ...base, x: owner.x + f*owner.w*0.4, y: owner.y - owner.h*0.9, vx: f*9, vy:-11, grav:0.45, spin:f*0.3, r:20, dmg:14, kb:14, life:220 });
} else { // baguette missile (fast + big)
blasts.push({ ...base, x: owner.x + f*owner.w*0.5, y: owner.y - owner.h*0.62, vx: f*19, vy:0, grav:0, r:34, dmg:16, kb:16, life:90 });
}
}
function spawnSpark(x,y,color,n){
for (let i=0;i<n;i++){
const a = Math.random()*Math.PI*2, sp = 1+Math.random()*5;
sparks.push({ x,y, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp-2, life:20+Math.random()*16, color, r:2+Math.random()*3 });
}
}
function updateProjectiles(f1,f2){
for (const b of blasts){
b.t++; b.x += b.vx;
if (b.grav){ b.vy += b.grav; b.y += b.vy; }
b.rot += (b.spin != null ? b.spin : 0.7*Math.sign(b.vx));
b.life--;
const foe = b.owner === f1 ? f2 : f1;
const hb = foe.hurtbox();
const overlap = (b.x+b.r > hb.x && b.x-b.r < hb.x+hb.w && b.y+b.r>hb.y && b.y-b.r<hb.y+hb.h);
if (b.kind === 'bomb'){
if (overlap || b.y >= GROUND-1 || b.life<=0){ explodeBomb(b, foe); b.life=0; }
} else {
if (overlap){ if (foe.takeHit(b.dmg * b.owner.powF, b.kb, Math.sign(b.vx))){ b.life=0; spawnSpark(b.x,b.y,'#ffb347',18); } }
if (b.x < ARENA_L-60 || b.x > ARENA_R+60) b.life = 0;
}
}
blasts = blasts.filter(b => b.life>0);
for (const s of sparks){ s.x+=s.vx; s.y+=s.vy; s.vy+=0.25; s.life--; }
sparks = sparks.filter(s => s.life>0);
}
function explodeBomb(b, foe){
spawnSpark(b.x, b.y, '#ffd166', 26); spawnSpark(b.x, b.y, '#ff6a3d', 18); spawnSpark(b.x, b.y, '#ffffff', 8);
shake = Math.min(22, shake + 11); Audio.boom();
const d = Math.hypot(foe.x - b.x, (foe.y - foe.h*0.5) - b.y);
if (d < 96) foe.takeHit(b.dmg * b.owner.powF * (1 - d/150), 15, Math.sign(foe.x - b.x || b.vx));
}
function drawProjectiles(g){
for (const b of blasts){
const c = b.owner.colors;
g.save();
if (b.kind === 'rocket'){
g.fillStyle = '#ff9a3d';
for (let i=1;i<=6;i++){ g.globalAlpha=0.32/i; g.beginPath(); g.arc(b.x-b.vx*i*0.7, b.y, 11-i, 0, Math.PI*2); g.fill(); }
g.globalAlpha=1; g.translate(b.x, b.y); g.scale(Math.sign(b.vx), 1);
drawRocket(g, b.r);
} else if (b.kind === 'bomb'){
g.translate(b.x, b.y);
g.fillStyle='rgba(130,130,140,.4)';
for (let i=1;i<=4;i++){ g.globalAlpha=0.32/i; g.beginPath(); g.arc(-b.vx*i*0.6, -b.vy*i*0.3, 7-i, 0, Math.PI*2); g.fill(); }
g.globalAlpha=1; g.rotate(b.rot);
drawBomb(g, b.r, b.t);
} else { // baguette missile
g.fillStyle = c.glow;
for (let i=1;i<=6;i++){ g.globalAlpha=0.30/i; g.beginPath(); g.arc(b.x-b.vx*i*0.9, b.y, 12-i, 0, Math.PI*2); g.fill(); }
g.globalAlpha=1; g.translate(b.x, b.y); g.rotate(b.rot); g.scale(1.5, 1.5);
drawBaguetteWeapon(g, -33, 0, 0, 66, true);
}
g.restore();
}
for (const s of sparks){
g.globalAlpha = Math.max(0, s.life/26);
g.fillStyle = s.color;
g.beginPath(); g.arc(s.x,s.y,s.r,0,Math.PI*2); g.fill();
}
g.globalAlpha = 1;
}
// ---------- Flying food hazards (pies, french rolls, croissants) ----------
function spawnHazard(){
const r = Math.random();
const type = r < 0.42 ? 'pie' : (r < 0.75 ? 'roll' : 'croissant');
const fromLeft = Math.random() < 0.5;
hazards.push({
type,
x: fromLeft ? ARENA_L - 40 : ARENA_R + 40,
y: 110 + Math.random() * (GROUND - 210),
vx: (fromLeft ? 1 : -1) * (type === 'roll' ? 8.5 + Math.random()*3 : 5.5 + Math.random()*2),
vy: type === 'pie' ? -3 - Math.random()*2 : (Math.random()-0.5)*3,
rot: 0, spin: (Math.random()-0.5) * 0.45,
r: type === 'pie' ? 21 : type === 'croissant' ? 17 : 13,
dmg: type === 'pie' ? 12 : type === 'roll' ? 8 : 10,
kb: type === 'pie' ? 12 : type === 'roll' ? 9 : 10,
life: 220, hit: false
});
}
function splat(h){
spawnSpark(h.x, h.y, h.type === 'pie' ? '#fff6e0' : '#e8c37a', h.type === 'pie' ? 20 : 11);
if (h.type === 'pie') spawnSpark(h.x, h.y, '#ffd9e6', 8);
shake = Math.min(16, shake + 5);
}
function updateHazards(dt){
if (state === 'fight'){
hazardTimer -= dt;
if (hazardTimer <= 0){
spawnHazard();
hazardTimer = 90 + Math.random()*130;
Voice.hazard();
}
}
for (const h of hazards){
h.x += h.vx; h.y += h.vy; h.vy += 0.14; h.rot += h.spin; h.life--;
if (!h.hit){
for (const fter of [f1, f2]){
const b = fter.hurtbox();
if (h.x > b.x && h.x < b.x+b.w && h.y > b.y && h.y < b.y+b.h){
h.hit = true;
fter.takeHit(h.dmg, h.kb, Math.sign(h.vx) || 1);
splat(h); h.life = 0; break;
}
}
}
if (h.life > 0 && h.y > GROUND + 2){ splat(h); h.life = 0; }
if (h.x < ARENA_L - 90 || h.x > ARENA_R + 90) h.life = 0;
}
hazards = hazards.filter(h => h.life > 0);
}
function drawHazards(g){
for (const h of hazards){
g.save();
g.translate(h.x, h.y); g.rotate(h.rot);
g.shadowColor = 'rgba(255,220,150,.7)'; g.shadowBlur = 14;
if (h.type === 'pie'){
g.fillStyle = '#b98a4b'; g.beginPath(); g.arc(0,0,h.r,0,Math.PI*2); g.fill(); // tin
g.fillStyle = '#fff6e0'; g.beginPath(); g.arc(0,-2,h.r*0.82,0,Math.PI*2); g.fill(); // cream
g.fillStyle = '#ffd0dd'; g.beginPath(); g.arc(0,-3,h.r*0.34,0,Math.PI*2); g.fill(); // cherry
} else if (h.type === 'croissant'){
g.fillStyle = '#e0a94e'; g.strokeStyle = '#a9761f'; g.lineWidth = 2;
g.beginPath();
g.arc(0, 0, h.r, 0.18*Math.PI, 1.82*Math.PI);
g.arc(5, 0, h.r*0.62, 1.82*Math.PI, 0.18*Math.PI, true);
g.closePath(); g.fill(); g.stroke();
} else { // french roll
g.fillStyle = '#e8c37a'; g.strokeStyle = '#a9761f'; g.lineWidth = 2;
g.beginPath(); g.ellipse(0,0,h.r,h.r*0.62,0,0,Math.PI*2); g.fill(); g.stroke();
g.strokeStyle = 'rgba(120,72,20,.7)'; g.lineWidth = 1.5;
g.beginPath(); g.moveTo(-h.r*0.55,0); g.lineTo(h.r*0.55,0); g.stroke();
}
g.restore();
}
g.shadowBlur = 0;
}
// ---------- Background (neon Paris) ----------
const stars = Array.from({length:70}, () => ({ x:Math.random()*W, y:Math.random()*GROUND*0.8, r:Math.random()*1.4+0.3, p:Math.random()*Math.PI*2 }));
let bgT = 0;
function drawBackground(g){
bgT += 0.01;
// sky
const sky = g.createLinearGradient(0,0,0,GROUND);
sky.addColorStop(0,'#0a0620'); sky.addColorStop(0.5,'#1a0f38'); sky.addColorStop(1,'#3a1550');
g.fillStyle = sky; g.fillRect(0,0,W,GROUND);
// moon
g.save(); g.shadowColor='#fff6d0'; g.shadowBlur=40;
g.fillStyle='#f3ecc7'; g.beginPath(); g.arc(W-120,90,34,0,Math.PI*2); g.fill();
g.restore();
// stars
for (const s of stars){
const tw = 0.5+0.5*Math.sin(bgT*4+s.p);
g.globalAlpha = 0.3+tw*0.7; g.fillStyle='#fff';
g.beginPath(); g.arc(s.x,s.y,s.r,0,Math.PI*2); g.fill();
}
g.globalAlpha=1;
// distant skyline silhouettes with neon windows
drawSkyline(g);
// Eiffel Tower (neon)
drawEiffel(g, W*0.5, GROUND, 300);
// neon grid floor
drawFloor(g);
}
function drawSkyline(g){
g.fillStyle='rgba(8,4,20,.85)';
const base = GROUND;
const blocks = [ [40,120,90],[150,80,140],[240,150,80],[720,110,120],[820,90,150],[900,70,110] ];
for (const [x,w,h] of blocks){
g.fillRect(x, base-h, w, h);
// neon windows
g.fillStyle = Math.random()>0 ? 'rgba(34,224,255,.25)' : '#22e0ff';
for (let wy=base-h+12; wy<base-8; wy+=16)
for (let wx=x+8; wx<x+w-6; wx+=14){
g.globalAlpha = 0.12 + (Math.sin(wx*wy)+1)*0.18;
g.fillStyle = (wx+wy)%2? '#22e0ff':'#ff3d9a';
g.fillRect(wx,wy,5,7);
}
g.globalAlpha=1; g.fillStyle='rgba(8,4,20,.85)';
}
}
function drawEiffel(g, cx, baseY, h){
g.save();
g.strokeStyle = '#ff3d9a';
g.shadowColor = '#ff3d9a'; g.shadowBlur = 16;
g.lineWidth = 3;
const topY = baseY - h, topW = 8, baseW = 150;
const wAt = (y) => { const t=(baseY-y)/h; return baseW*(1-t)*(1-t)*0.9 + topW; };
// legs (curved)
for (const dir of [-1,1]){
g.beginPath();
for (let y=baseY; y>=topY; y-=6){ const w=wAt(y)/2; g.lineTo(cx+dir*w, y); }
g.stroke();
}
// horizontal platforms
g.lineWidth=2;
for (const py of [baseY-h*0.22, baseY-h*0.5, baseY-h*0.78]){
const w = wAt(py)/2; g.beginPath(); g.moveTo(cx-w,py); g.lineTo(cx+w,py); g.stroke();
}
// cross lattice
g.strokeStyle='rgba(34,224,255,.5)'; g.shadowColor='#22e0ff'; g.lineWidth=1;
for (let y=baseY; y>topY; y-=22){
const w1=wAt(y)/2, w2=wAt(y-22)/2;
g.beginPath(); g.moveTo(cx-w1,y); g.lineTo(cx+w2,y-22);
g.moveTo(cx+w1,y); g.lineTo(cx-w2,y-22); g.stroke();
}
// top spire + beacon
g.strokeStyle='#ff3d9a'; g.shadowColor='#ff3d9a'; g.lineWidth=3;
g.beginPath(); g.moveTo(cx, topY); g.lineTo(cx, topY-24); g.stroke();
g.fillStyle='#fff'; g.shadowColor='#fff'; g.shadowBlur=20;
g.beginPath(); g.arc(cx, topY-26, 3+Math.sin(bgT*6)*1.5, 0, Math.PI*2); g.fill();
g.restore();
}
const CROISSANT_SPOTS = [
[150, GROUND+34, 1.00, 0.35], [395, GROUND+16, 0.75, -0.25], [590, GROUND+64, 1.35, 0.50],
[815, GROUND+28, 0.85, -0.50], [300, GROUND+92, 1.55, 0.15], [700, GROUND+96, 1.6, -0.3]
];
function drawStreetCroissants(g){
for (const [x,y,s,rot] of CROISSANT_SPOTS){
g.save(); g.translate(x,y);
g.fillStyle='rgba(0,0,0,.35)'; g.beginPath(); g.ellipse(0, 4*s, 15*s, 5*s, 0, 0, Math.PI*2); g.fill(); // shadow
g.rotate(rot); g.scale(s,s);
g.fillStyle='#e0a94e'; g.strokeStyle='#a9761f'; g.lineWidth=2;
g.beginPath(); g.arc(0,0,13,0.18*Math.PI,1.82*Math.PI); g.arc(5,0,8,1.82*Math.PI,0.18*Math.PI,true); g.closePath(); g.fill(); g.stroke();
g.strokeStyle='rgba(120,72,20,.5)'; g.lineWidth=1;
for (let i=-1;i<=1;i++){ g.beginPath(); g.moveTo(i*5,-6); g.lineTo(i*5,6); g.stroke(); }
g.fillStyle='rgba(255,245,220,.3)'; g.beginPath(); g.ellipse(-3,-4,4,2,0.4,0,Math.PI*2); g.fill(); // flour sheen
g.restore();
}
}
function drawFloor(g){
// stone base
const fg = g.createLinearGradient(0,GROUND,0,H);
fg.addColorStop(0,'#2a2033'); fg.addColorStop(1,'#0b0712');
g.fillStyle=fg; g.fillRect(0, GROUND, W, H-GROUND);
// cobblestones, in perspective (rows grow toward the viewer)
const rows=7, span=H-GROUND;
for (let j=0;j<rows;j++){
const t0=j/rows, t1=(j+1)/rows;
const y0=GROUND + span*t0*t0;
const y1=GROUND + span*t1*t1;
const rh=y1-y0, sw=26 + t0*80, off=(j%2)*(sw*0.5);
const pad=Math.min(2, rh*0.2), sh=Math.max(1, rh-pad*2), rr=Math.max(0, Math.min(10, rh*0.3));
for (let x=-sw; x<W+sw; x+=sw){
const cx=x+off;
const h=(Math.sin(cx*0.21 + j*1.7)*0.5+0.5); // stable per-stone shade
const r=Math.round(30+h*14), gg=Math.round(26+h*12), bl=Math.round(46+h*20); // cooler + darker so warm fighters pop
roundRectPath(g, cx+2, y0+pad, sw-4, sh, rr);
g.fillStyle='rgb('+r+','+gg+','+bl+')'; g.fill();
g.lineWidth=1.4; g.strokeStyle='rgba(255,61,154,'+(0.05+t0*0.13).toFixed(3)+')'; g.stroke(); // neon rim
g.strokeStyle='rgba(185,215,255,'+(0.04+t0*0.09).toFixed(3)+')'; g.lineWidth=1;
g.beginPath(); g.moveTo(cx+5, y0+3); g.lineTo(cx+sw-6, y0+3); g.stroke(); // top highlight
}
}
// glowing kerb at the horizon
g.save(); g.strokeStyle='rgba(255,61,154,.7)'; g.shadowColor='#ff3d9a'; g.shadowBlur=10; g.lineWidth=2;
g.beginPath(); g.moveTo(0,GROUND); g.lineTo(W,GROUND); g.stroke(); g.restore();
drawStreetCroissants(g);
}
// ---------- HUD ----------
function drawHUD(g){
// health bars
drawBar(g, 30, 26, 380, f1, false, f1.colors);
drawBar(g, W-30-380, 26, 380, f2, true, f2.colors);
// meter bars
drawMeter(g, 30, 60, 260, f1.meter, false, f1.colors);
drawMeter(g, W-30-260, 60, 260, f2.meter, true, f2.colors);
// round pips
drawPips(g, 30, 78, f1.rounds, false);
drawPips(g, W-30, 78, f2.rounds, true);
// timer
g.fillStyle='#fff'; g.font='700 30px Georgia'; g.textAlign='center';
g.shadowColor='#ff3d9a'; g.shadowBlur=12;
g.fillText(Math.ceil(roundTime).toString().padStart(2,'0'), W/2, 52);
g.shadowBlur=0;
g.font='12px Georgia'; g.fillStyle='#c9a961';
g.fillText('ROUND '+roundNum, W/2, 70);
// announce
if (announce.text){
const a = Math.min(1, announce.t/12) * Math.min(1, announce.life/16);
const ay = H*0.42;
g.textAlign='center';
g.font = '800 '+announce.size+'px Georgia';
const tw = g.measureText(announce.text).width;
// pill backdrop so the callout pops off the neon Eiffel Tower behind it
g.globalAlpha = a*0.6;
g.fillStyle = 'rgba(8,4,20,1)';
roundRectPath(g, W/2 - tw/2 - 22, ay - announce.size*0.82, tw+44, announce.size+18, 14); g.fill();
// text
g.globalAlpha = a;
g.fillStyle = announce.color; g.shadowColor=announce.color; g.shadowBlur=24;
g.fillText(announce.text, W/2, ay);
g.shadowBlur=0; g.globalAlpha=1;
}
// mute indicator
if (Audio.muted){ g.fillStyle='#8a8478'; g.font='12px Georgia'; g.textAlign='right'; g.fillText('🔇 muted (M)', W-30, H-16); }
g.textAlign='left';
}
function drawBar(g,x,y,w,fighter,flip,c){
const h=18;
g.fillStyle='rgba(0,0,0,.5)'; g.fillRect(x-2,y-2,w+4,h+4);
g.fillStyle='#2a0f22'; g.fillRect(x,y,w,h);
const pct = Math.max(0,fighter.hp)/100;
const bw = w*pct;
const bx = flip ? x+w-bw : x;
const grd = g.createLinearGradient(x,0,x+w,0);
grd.addColorStop(0, pct>0.3?'#ff9a3d':'#ff3d3d'); grd.addColorStop(1, c.glow);
g.fillStyle = grd; g.fillRect(bx, y, bw, h);
g.strokeStyle='rgba(255,255,255,.3)'; g.lineWidth=1; g.strokeRect(x,y,w,h);
// name
g.fillStyle='#fff'; g.font='700 14px Georgia';
g.textAlign = flip?'right':'left';
g.fillText(fighter.name, flip?x+w:x, y-8);
g.textAlign='left';
}
function drawMeter(g,x,y,w,val,flip,c){
const h=8;
g.fillStyle='rgba(0,0,0,.5)'; g.fillRect(x,y,w,h);
const bw=w*val/100, bx=flip?x+w-bw:x;
g.fillStyle = val>=100 ? '#fff' : c.accent;
if (val>=100){ g.shadowColor=c.glow; g.shadowBlur=10; }
g.fillRect(bx,y,bw,h); g.shadowBlur=0;
if (val>=100){ g.save(); g.font='700 11px Georgia'; g.textAlign=flip?'right':'left';
g.shadowColor='#000'; g.shadowBlur=4; g.fillStyle=c.accent;
g.fillText('ROCKET READY', flip?x+w:x, y+19); g.restore(); g.textAlign='left'; }
}
function drawPips(g,x,y,n,flip){
for (let i=0;i<2;i++){
const px = flip ? x-9-i*22 : x+9+i*22;
g.beginPath(); g.arc(px,y,8,0,Math.PI*2);
if (i<n){ g.fillStyle='#ffd166'; g.shadowColor='#ffd166'; g.shadowBlur=10; g.fill(); g.shadowBlur=0; }
else { g.fillStyle='rgba(255,255,255,.12)'; g.fill(); g.strokeStyle='rgba(255,255,255,.42)'; g.lineWidth=1.5; g.stroke(); } // empty slot ring
}
}
// ---------- Match / round flow ----------
let f1, f2, mode='1p';
let state = 'menu'; // menu | intro | fight | koPause | result
let roundNum = 1, roundTime = 60, roundTimer=0;
let announce = { text:'', color:'#fff', size:56, t:0, life:0 };
let koDelay = 0;
function setAnnounce(text,color='#fff',size=56,life=70){ announce={text,color,size,t:0,life}; }
// ---- character select ----
let p1Idx = 0, p2Idx = 1, p1Ready = false, p2Ready = false;
function drawMini(g, c){
g.clearRect(0,0,56,70);
g.save(); g.translate(28, 64); // feet near bottom
const skin='#e8b892';
// legs
g.strokeStyle='#242a42'; g.lineWidth=6; g.lineCap='round';
g.beginPath(); g.moveTo(-5,-20); g.lineTo(-6,-2); g.stroke();
g.beginPath(); g.moveTo( 5,-20); g.lineTo( 6,-2); g.stroke();
// striped torso
const tw=20, th=22, tx=-tw/2, ty=-44;
roundRectPath(g,tx,ty,tw,th,5); g.fillStyle='#f2efe6'; g.fill();
g.save(); roundRectPath(g,tx,ty,tw,th,5); g.clip(); g.fillStyle=c.accent;
for (let sy=ty+3; sy<ty+th; sy+=6) g.fillRect(tx,sy,tw,3); g.restore();
g.lineWidth=1.4; g.strokeStyle='rgba(0,0,0,.25)'; roundRectPath(g,tx,ty,tw,th,5); g.stroke();
// arm + held baguette
g.strokeStyle='#f2efe6'; g.lineWidth=5; g.beginPath(); g.moveTo(6,-40); g.lineTo(13,-34); g.stroke();
drawBaguetteWeapon(g, 13, -34, -0.15, 22, false);
g.fillStyle=skin; g.beginPath(); g.arc(13,-34,3,0,Math.PI*2); g.fill();
// head
g.save(); g.translate(0,-50);
g.fillStyle=skin; g.beginPath(); g.arc(0,0,9,0,Math.PI*2); g.fill();
g.strokeStyle='#3b2415'; g.lineWidth=2; g.lineCap='round';
g.beginPath(); g.moveTo(-4,3); g.quadraticCurveTo(-1,1,0,3); g.moveTo(0,3); g.quadraticCurveTo(1,1,4,3); g.stroke(); // moustache
g.fillStyle='#20140a'; g.beginPath(); g.arc(-3,-1,1.4,0,7); g.fill(); g.beginPath(); g.arc(3,-1,1.4,0,7); g.fill();
g.fillStyle=c.beret; g.beginPath(); g.ellipse(0,-8,10,3.5,0,0,Math.PI*2); g.fill();
g.beginPath(); g.arc(1,-11,8,Math.PI,0); g.fill();
g.restore();
g.restore();
}
function buildRoster(){
const r = document.getElementById('roster'); r.innerHTML = '';
ROSTER.forEach((c, i) => {
const card = document.createElement('div');
card.className = 'card'; card.dataset.idx = i;
const cv = document.createElement('canvas'); cv.width = 112; cv.height = 140; // 2x, drawn in 56x70 space
const cvx = cv.getContext('2d'); cvx.scale(2,2); drawMini(cvx, c.colors); card.appendChild(cv);
const nm = document.createElement('div'); nm.className = 'cname'; nm.textContent = c.name; card.appendChild(nm);
[['SPD',c.spd],['PWR',c.pow],['DEF',c.def]].forEach(([lbl,v]) => {
const s = document.createElement('div'); s.className = 'stat';
s.innerHTML = '<b>'+lbl+'</b><span class="bar '+lbl.toLowerCase()+'"><i style="width:'+(v/5*100)+'%"></i></span>';
card.appendChild(s);
});
const r1 = document.createElement('div'); r1.className='rdy r1'; r1.textContent='P1'; r1.style.display='none'; card.appendChild(r1);
const r2 = document.createElement('div'); r2.className='rdy r2'; r2.textContent='P2'; r2.style.display='none'; card.appendChild(r2);
card.addEventListener('click', () => { p1Idx = i; p1Ready = false; if (mode!=='2p') p2Ready = false; Audio.poke(); renderSelect(); });
r.appendChild(card);
});
}
function renderSelect(){
const cards = [...document.querySelectorAll('#roster .card')];
cards.forEach((card,i) => {
card.classList.toggle('p1cursor', i===p1Idx);
card.classList.toggle('p2cursor', i===p2Idx);
card.querySelector('.rdy.r1').style.display = (i===p1Idx && p1Ready) ? 'block' : 'none';
card.querySelector('.rdy.r2').style.display = (i===p2Idx && p2Ready) ? 'block' : 'none';
});
document.getElementById('p1tag').textContent = 'P1: ' + ROSTER[p1Idx].name + (p1Ready?' ✔':'');
document.getElementById('p2tag').textContent = (mode==='2p'?'P2: ':'CPU: ') + ROSTER[p2Idx].name + (p2Ready?' ✔':'');
const b = document.getElementById('btnStart');
b.disabled = false; b.textContent = 'START FIGHT ▶'; // always launchable with current picks (mobile-friendly)
document.getElementById('selHint').innerHTML = mode==='2p'
? 'P1 <span class="key">A</span><span class="key">D</span> move · <span class="key">F</span> ready · P2 <span class="key">←</span><span class="key">→</span> move · <span class="key">,</span> ready'
: 'P1 <span class="key">A</span><span class="key">D</span> move · <span class="key">F</span> ready · CPU auto-picks · click a card to choose';
}
function openSelect(m){
mode = m; state = 'select';
p1Idx = 0; p2Idx = 1; p1Ready = false; p2Ready = false;
hideScreens(); document.getElementById('select').classList.remove('hidden');
Audio.resume(); renderSelect();
}
function selMove(player, dir){
if (player===1){ p1Idx = (p1Idx+dir+ROSTER.length)%ROSTER.length; p1Ready=false; if(mode!=='2p') p2Ready=false; }
else { p2Idx = (p2Idx+dir+ROSTER.length)%ROSTER.length; p2Ready=false; }
Audio.poke(); renderSelect();
}
function selReady(player){
if (player===1){
p1Ready = !p1Ready;
if (mode!=='2p'){ // CPU picks a random fighter when P1 readies
if (p1Ready){ p2Idx = Math.floor(Math.random()*ROSTER.length); p2Ready = true; }
else p2Ready = false;
}
} else if (mode==='2p'){ p2Ready = !p2Ready; }
Audio.bell(); renderSelect();
}
function beginSelectedMatch(){
p1Ready = true; p2Ready = true; // launch with whatever is currently selected
p1map = (mode==='2p') ? P1MAP_2P : P1MAP_1P; // 1P: P1 also gets arrows + space/⌘
f1 = new Fighter(W*0.32,'p1', ROSTER[p1Idx], true);
f2 = new Fighter(W*0.68,'p2', ROSTER[p2Idx], mode==='2p');
f1.rounds=0; f2.rounds=0; roundNum=1;
hideScreens(); Audio.resume();
startRound();
}
function startRound(){
f1.reset(); f2.reset();
f1.x=W*0.32; f2.x=W*0.68; f1.facing=1; f2.facing=-1;
roundTime=60; roundTimer=0; blasts=[]; sparks=[]; hazards=[]; hazardTimer=150; koDelay=0;
state='intro'; introT=0;
for (const kk in keys) if (kk.startsWith('_pressed_')) keys[kk] = false; // drop stale edge triggers from select
setAnnounce('ROUND '+roundNum,'#c9a961',56,10);
Voice.round();
}
let introT=0;
function beginFight(){ state='fight'; setAnnounce('FIGHT!','#ffe566',64,40); Audio.bell(); Voice.fight(); }
function endRound(winner){
if (state!=='fight') return;
state='koPause'; koDelay=0;
if (winner==='draw'){ setAnnounce('DOUBLE K.O.!','#fff',56,120); }
else {
const wf = winner===f1?f1:f2; wf.rounds++;
Audio.ko(); Voice.ko();
setAnnounce('K.O.','#ff3d3d',88,120);
}
}
function afterKO(){
if (f1.rounds>=2 || f2.rounds>=2 || (roundNum>=3)){
// match over
let champ = f1.rounds>f2.rounds ? f1 : (f2.rounds>f1.rounds ? f2 : null);
showResult(champ);
} else {
roundNum++;
startRound();
}
}
// ---------- Screens ----------
const $ = id => document.getElementById(id);
function hideScreens(){ ['menu','result','pause','select'].forEach(id=>$(id).classList.add('hidden')); }
function showResult(champ){
state='result';
if (champ){
$('resultTitle').textContent = champ.name + ' WINS';
$('resultSub').innerHTML = (champ.side==='p1'?'🥖 ':'🥖 ') +
['Magnifique!','Formidable!','Très bien!','Champion de Paris!'][Math.floor(Math.random()*4)];
Voice.win();
} else {
$('resultTitle').textContent = 'DRAW';
$('resultSub').textContent = 'Both loaves crumbled.';
}
$('result').classList.remove('hidden');
}
let paused=false;
function togglePause(){
if (state==='menu'||state==='result'||state==='select') return;
paused = !paused;
$('pause').classList.toggle('hidden', !paused);
}
$('btn1p').onclick = () => openSelect('1p');
$('btn2p').onclick = () => openSelect('2p');
$('btnStart').onclick = () => beginSelectedMatch();
$('btnSelBack').onclick = () => { state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
$('btnRematch').onclick = () => beginSelectedMatch(); // same fighters
$('btnMenu').onclick = () => { state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
$('btnResume').onclick = () => togglePause();
$('btnQuit').onclick = () => { paused=false; state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
// ---------- Combat resolution ----------
function resolveHits(atk, def){
if (!atk.attack || atk.attack.hit) return;
const hb = atk.hitbox(); if (!hb) return;
const db = def.hurtbox();
if (hb.x < db.x+db.w && hb.x+hb.w > db.x && hb.y < db.y+db.h && hb.y+hb.h > db.y){
atk.attack.hit = true;
const dir = Math.sign(atk.facing);
const dmg = atk.attack.dmg * atk.powF, kb = atk.attack.kb;
def.takeHit(dmg, kb, dir);
atk.meter = Math.min(100, atk.meter + atk.attack.dmg*0.6);
}
}
// ---------- Main loop ----------
let last=performance.now();
function loop(now){
const dt = Math.min(2, (now-last)/16.67); last=now;
requestAnimationFrame(loop);
if (!paused) step(dt);
render();
}
function step(dt){
// announce timers
if (announce.life>0){ announce.t+=dt*2; announce.life-=dt; }
if (state==='intro'){
introT += dt;
// idle physics so they stand
f1.update(f2); f2.update(f1);
if (introT > 46) beginFight();
return;
}
if (state==='fight'){
roundTimer += dt; roundTime = Math.max(0, 60 - roundTimer/60*60*0); // handled below
roundTime = Math.max(0, 60 - roundTimer/60);
// control
if (f1.human) f1.controlHuman(p1map); else f1.controlAI(f2);
if (f2.human) f2.controlHuman(P2MAP); else f2.controlAI(f1);
f1.update(f2); f2.update(f1);
resolveHits(f1,f2); resolveHits(f2,f1);
updateProjectiles(f1,f2);
updateHazards(dt);
// separation so loaves don't overlap fully
const overlap = (f1.w/2+f2.w/2) - Math.abs(f1.x-f2.x);
if (overlap>0){ const push=overlap/2*Math.sign(f1.x-f2.x||1); f1.x+=push; f2.x-=push;
f1.x=Math.max(ARENA_L,Math.min(ARENA_R,f1.x)); f2.x=Math.max(ARENA_L,Math.min(ARENA_R,f2.x)); }
// win checks
if (!f1.alive && !f2.alive) endRound('draw');
else if (!f1.alive) endRound(f2);
else if (!f2.alive) endRound(f1);
else if (roundTime<=0){
// time out -> higher hp wins
if (f1.hp>f2.hp) endRound(f1); else if (f2.hp>f1.hp) endRound(f2); else endRound('draw');
}
}
else if (state==='koPause'){
koDelay += dt;
f1.update(f2); f2.update(f1);
updateProjectiles(f1,f2);
updateHazards(dt);
if (koDelay>70) afterKO();
}
if (shake>0) shake=Math.max(0, shake-dt*1.2);
}
function render(){
ctx.save();
if (shake>0){ ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake); }
drawBackground(ctx);
if (state!=='menu' && f1 && f2){
// draw order: farther-back fighter first (by facing not needed) — draw both
drawProjectiles(ctx);
// draw the one behind first for slight depth: draw lower hp? just draw p2 then p1
f2.draw(ctx); f1.draw(ctx);
drawHazards(ctx);
drawProjectiles(ctx); // sparks on top
drawHUD(ctx);
}
ctx.restore();
// subtle scanline / vignette
const vg = ctx.createRadialGradient(W/2,H/2,H*0.4,W/2,H/2,H*0.9);
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,.45)');
ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
updateTouchVisibility();
}
// ---------- Mobile touch controls (drive Player 1) ----------
const touchEl = document.getElementById('touch');
let touchEnabled = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
const TOUCH_KEYS = ['a','d','w','s','f','g','h','r','b'];
let touchWasActive = false;
function updateTouchVisibility(){
const active = touchEnabled && (state==='fight' || state==='intro' || state==='koPause');
if (active !== touchWasActive){
touchEl.classList.toggle('on', active);
if (!active) TOUCH_KEYS.forEach(k => { keys[k]=false; }); // release any held buttons on leave
touchWasActive = active;
}
}
function setupTouch(){
if (touchEnabled) document.body.classList.add('touch');
// some devices only reveal touch after the first touch
window.addEventListener('touchstart', () => { touchEnabled = true; document.body.classList.add('touch'); }, { once:true, passive:true });
const press = k => { if(!keys[k]) keys['_pressed_'+k]=true; keys[k]=true; };
const release = k => { keys[k]=false; };
touchEl.querySelectorAll('.tbtn').forEach(btn => {
const k = btn.dataset.k;
const on = e => { e.preventDefault(); press(k); };
const off = e => { e.preventDefault(); release(k); };
btn.addEventListener('touchstart', on, { passive:false });
btn.addEventListener('touchend', off, { passive:false });
btn.addEventListener('touchcancel',off, { passive:false });
btn.addEventListener('mousedown', on); // desktop testing
btn.addEventListener('mouseup', off);
btn.addEventListener('mouseleave', off);
});
}
setupTouch();
buildRoster();
// tiny test hook (only when the URL ends in #debug) — no effect on normal play
if (location.hash === '#debug') window.__bb = { get f1(){ return f1; }, get f2(){ return f2; }, get state(){ return state; }, get blasts(){ return blasts; } };
// La Marseillaise on load — try immediately; browsers gate autoplay, so also fire on the first interaction
Audio.startAnthem();
const kickAnthem = () => Audio.startAnthem();
window.addEventListener('pointerdown', kickAnthem, { once:true });
window.addEventListener('keydown', kickAnthem, { once:true });
requestAnimationFrame(loop);
})();
</script>
</body>
</html>