← back to Neon Baguette Battle
Baguette Battle: playable neon-Paris fighting game (baguette fighters, flying food hazards, French-accent voice SFX)
fb4abc6ab5ec9b48e89bf9143169f3e8e4604bd2 · 2026-08-31 12:54:39 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A .gitignoreA README.mdA index.htmlA screenshot.png
Diff
commit fb4abc6ab5ec9b48e89bf9143169f3e8e4604bd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 31 12:54:39 2026 -0700
Baguette Battle: playable neon-Paris fighting game (baguette fighters, flying food hazards, French-accent voice SFX)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 8 +
README.md | 46 +++
index.html | 1056 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
screenshot.png | Bin 0 -> 304810 bytes
4 files changed, 1110 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c3e495
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+# Baguette Battle — Neon Paris 🥖⚔️
+
+A playable browser fighting game. Two artisanal baguettes duel beneath a neon
+Eiffel Tower while pies, french rolls, and croissants fly across the arena.
+Single self-contained `index.html` — no build step, no dependencies, no assets.
+Open it in any modern browser and play.
+
+Built from a Model Arena challenge ("a game where baguettes in Paris fight each
+other"). The starting artifact (Qwen2.5-local) was just a title card with an
+`alert()`; this is the real game built on top of it — its Parisian-serif look
+and title screen were kept, everything else added.
+
+## Play
+Open `index.html` in a browser → **1 Player (vs CPU)** or **2 Players (local)**.
+Best of 3 rounds. Each round is 60s; on timeout the higher-HP loaf wins.
+
+## Controls
+| Action | Player 1 | Player 2 |
+|---|---|---|
+| Move | `A` / `D` | `←` / `→` |
+| Jump | `W` | `↑` |
+| Block | `S` | `↓` |
+| Poke (fast, weak) | `F` | `,` |
+| Swing (slow, strong, knockback) | `G` | `.` |
+| Crumb Blast (projectile, needs full meter) | `H` | `/` |
+| Pause | `P` | |
+| Mute (audio + voice) | `M` | |
+
+## Features
+- **Real combat**: startup/active/recovery attack frames, blocking with chip
+ damage, knockback, hit-stun, a build-up special meter, and a **Crumb Blast**
+ projectile.
+- **CPU opponent** with an approach/attack/block/retreat/blast state machine.
+- **Flying food hazards**: pies (cream splat, big damage), french rolls (fast),
+ and croissants arc in from off-screen and hit either fighter.
+- **Neon Paris arena**: animated Eiffel Tower, moon, twinkling stars, skyline
+ with lit windows, glowing perspective-grid floor, screen shake + vignette.
+- **Sound**: WebAudio synth SFX for pokes/swings/blocks/blasts/KO, plus comedic
+ **voice lines** via the browser Speech API — an American hamming up a French
+ accent ("Take zat!", "Oh la la, magnifique!", "Zut alors!").
+- Health/meter bars, round pips, round timer, KO / winner flow, pause menu.
+
+## Notes
+- Voice lines use `speechSynthesis`; they sound best when a French system voice
+ is installed (the game auto-selects one). Everything still works without it.
+- 100% local, $0 to run — canvas + WebAudio + Speech API, zero network calls.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..c3b3234
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1056 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<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;
+ }
+ canvas{ 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; }
+</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 artisanal baguettes settle their differences beneath a neon Eiffel Tower.
+ Poke, swing, block, and unleash the <b>Crumb Blast</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</td><td><span class="key">F</span></td></tr>
+ <tr><td>Swing</td><td><span class="key">G</span></td></tr>
+ <tr><td>Crumb Blast</td><td><span class="key">H</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</td><td><span class="key">,</span></td></tr>
+ <tr><td>Swing</td><td><span class="key">.</span></td></tr>
+ <tr><td>Crumb Blast</td><td><span class="key">/</span></td></tr>
+ </table>
+ </div>
+ </div>
+ <div class="tiny">Press <span class="key">P</span> to pause · <span class="key">M</span> mute</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>
+</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();
+ }
+ 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); },
+ 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; return muted; },
+ resume: () => { const a = ensure(); if (a && a.state === 'suspended') a.resume(); },
+ 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("Crumb blast! Boom!", {rate:0.9, pitch:1.0, 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 = {};
+ const P1MAP = { left:'a', right:'d', jump:'w', block:'s', poke:'f', swing:'g', blast:'h' };
+ const P2MAP = { left:'arrowleft', right:'arrowright', jump:'arrowup', block:'arrowdown', poke:',', swing:'.', blast:'/' };
+ addEventListener('keydown', e => {
+ const k = e.key.toLowerCase();
+ if (['arrowup','arrowdown','arrowleft','arrowright',' ','/'].includes(k)) e.preventDefault();
+ if (!keys[k]) keys['_pressed_'+k] = true; // edge trigger
+ keys[k] = true;
+ 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 ----------
+ const COLORS = {
+ p1: { crust:'#d9a441', crustDk:'#a9761f', accent:'#ff4d4d', beret:'#c0392b', glow:'#ff6b6b' },
+ p2: { crust:'#e0b45a', crustDk:'#b08228', accent:'#4d9dff', beret:'#2c6fb0', glow:'#6bb8ff' },
+ };
+
+ class Fighter {
+ constructor(x, side, name, human){
+ this.x = x; this.y = GROUND; this.vx = 0; this.vy = 0;
+ this.w = 58; this.h = 118;
+ this.side = side; // 'p1' | 'p2'
+ this.name = name;
+ 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
+ }
+ 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;
+ if (type === 'blast'){
+ if (this.meter < 100) return;
+ this.meter = 0;
+ spawnBlast(this);
+ Audio.blast(); Voice.blast();
+ this.attack = { type:'blast', phase:'recover', t:0, recover:16 };
+ 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;
+ 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 left = keys[map.left], right = keys[map.right];
+ const speed = 4.4;
+ const canMove = !this.attack;
+ if (canMove){
+ if (left && !right){ this.vx = -speed; this.facing = -1; }
+ else if (right && !left){ this.vx = speed; this.facing = 1; }
+ }
+ if (keys[map.block] && this.onGround && !this.attack){ this.state = 'block'; }
+ else if (this.state==='block'){ this.state = 'idle'; }
+ if (pressed(map.jump) && this.onGround && !this.attack){ this.vy = -16; this.onGround = false; Audio.jump(); }
+ if (pressed(map.poke)) this.startAttack('poke');
+ if (pressed(map.swing)) this.startAttack('swing');
+ if (pressed(map.blast)) this.startAttack('blast');
+ }
+
+ 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 = 4.0;
+
+ // 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 (!this.attack) this.vx = this.facing * speed;
+ break;
+ case 'blast':
+ if (this.meter>=100) this.startAttack('blast');
+ this.aiIntent='approach'; break;
+ }
+ }
+
+ update(foe){
+ this.animT += 0.16;
+ if (this.invuln>0) this.invuln--;
+ // attack phase machine
+ if (this.attack){
+ this.attack.t++;
+ const a = this.attack;
+ if (a.type==='blast'){
+ 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 = COLORS[this.side];
+ const f = this.facing;
+ g.save();
+ g.translate(this.x, this.y);
+
+ // shadow
+ g.save();
+ g.scale(1, .35);
+ g.beginPath(); g.arc(0, 6/.35, this.w*0.5+6, 0, Math.PI*2);
+ g.fillStyle = 'rgba(0,0,0,.45)'; 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);
+
+ // ---- baguette body ----
+ const bx=0, by=-this.h, bw=this.w, bh=this.h;
+ g.save();
+ g.translate(0,0);
+ // body glow
+ g.shadowColor = c.glow; g.shadowBlur = hurtFlash?26:14;
+ roundRectPath(g, -bw/2, by, bw, bh, bw/2);
+ const grd = g.createLinearGradient(-bw/2,0,bw/2,0);
+ grd.addColorStop(0, c.crustDk); grd.addColorStop(0.5, c.crust); grd.addColorStop(1, c.crustDk);
+ g.fillStyle = hurtFlash ? '#ffffff' : grd; g.fill();
+ g.shadowBlur = 0;
+ // crust rim
+ g.lineWidth = 3; g.strokeStyle = 'rgba(90,55,15,.7)'; g.stroke();
+
+ // baguette diagonal score marks
+ if (!hurtFlash){
+ g.strokeStyle = 'rgba(120,72,20,.85)'; g.lineWidth = 3;
+ for (let i=0;i<5;i++){
+ const yy = by + 22 + i*(bh-40)/4;
+ g.beginPath();
+ g.moveTo(-bw*0.22, yy);
+ g.quadraticCurveTo(0, yy-8, bw*0.24, yy-3);
+ g.stroke();
+ }
+ // flour speckles
+ g.fillStyle = 'rgba(255,248,230,.5)';
+ for (let i=0;i<8;i++){
+ const sx=(Math.sin(i*12.9+this.side.length)*0.5)*bw*0.7;
+ const sy=by+18+((i*37)%(bh-30));
+ g.beginPath(); g.arc(sx, sy, 1.6, 0, Math.PI*2); g.fill();
+ }
+ }
+ g.restore();
+
+ // ---- beret ----
+ g.save();
+ g.translate(0, by+4);
+ g.fillStyle = c.beret;
+ g.beginPath(); g.ellipse(2, 0, bw*0.42, 12, 0, 0, Math.PI*2); g.fill();
+ g.beginPath(); g.arc(2, -6, bw*0.34, Math.PI, 0); g.fill();
+ g.fillStyle = '#1a1a1a'; g.beginPath(); g.arc(bw*0.30, -8, 2.5, 0, Math.PI*2); g.fill(); // stalk
+ g.restore();
+
+ // ---- face ----
+ const eyeY = by + 34;
+ g.fillStyle = '#20140a';
+ // eyes
+ g.beginPath(); g.ellipse(-9, eyeY, 4, 6, 0, 0, Math.PI*2); g.fill();
+ g.beginPath(); g.ellipse( 9, eyeY, 4, 6, 0, 0, Math.PI*2); g.fill();
+ if (this.state!=='ko'){
+ g.fillStyle='#fff';
+ g.beginPath(); g.arc(-8, eyeY-2, 1.6,0,Math.PI*2); g.fill();
+ g.beginPath(); g.arc(10, eyeY-2, 1.6,0,Math.PI*2); g.fill();
+ }
+ // angry brows
+ g.strokeStyle='#20140a'; g.lineWidth=3; g.beginPath();
+ g.moveTo(-15, eyeY-9); g.lineTo(-4, eyeY-5);
+ g.moveTo( 15, eyeY-9); g.lineTo( 4, eyeY-5); g.stroke();
+ // mouth
+ g.beginPath();
+ if (this.state==='ko'){ g.arc(0, eyeY+20, 6, 0, Math.PI, true); }
+ else if (this.state==='hurt'){ g.arc(0, eyeY+18, 7, 0, Math.PI*2); }
+ else if (this.attack){ g.arc(0, eyeY+16, 6, 0, Math.PI*2); }
+ else { g.arc(0, eyeY+20, 8, 0.15*Math.PI, 0.85*Math.PI); }
+ g.stroke();
+
+ // ---- scarf (accent) ----
+ g.fillStyle = c.accent;
+ g.fillRect(-bw/2, by+bh*0.52, bw, 10);
+ g.fillRect(bw/2-14, by+bh*0.52, 10, 26);
+
+ // ---- arms ----
+ const armExt = this.armSwing;
+ g.strokeStyle = c.crustDk; g.lineWidth = 8; g.lineCap='round';
+ // back arm
+ g.beginPath(); g.moveTo(-bw*0.35, by+bh*0.45); g.lineTo(-bw*0.35-8, by+bh*0.62); g.stroke();
+ // front arm (attacking)
+ const shoulderX = bw*0.35, shoulderY = by+bh*0.44;
+ let handX, handY;
+ if (this.state==='swing'){
+ const ang = -0.7 + armExt*2.0;
+ handX = shoulderX + Math.cos(ang)*(46+armExt*34);
+ handY = shoulderY + Math.sin(ang)*(46+armExt*34);
+ } else if (this.state==='poke' || (this.attack&&this.attack.type==='blast')){
+ handX = shoulderX + (34 + armExt*30);
+ handY = shoulderY + 6;
+ } else if (this.state==='block'){
+ handX = shoulderX - 6; handY = shoulderY + 4;
+ } else {
+ handX = shoulderX + 6 + Math.sin(this.animT)*2; handY = shoulderY + 20;
+ }
+ g.beginPath(); g.moveTo(shoulderX, shoulderY); g.lineTo(handX, handY); g.stroke();
+ // little baguette-crumb fist
+ g.fillStyle = c.crust; g.beginPath(); g.arc(handX, handY, 7, 0, Math.PI*2); g.fill();
+
+ // block shield shimmer
+ if (this.state==='block'){
+ g.strokeStyle='rgba(180,225,255,.8)'; g.lineWidth=3;
+ g.beginPath(); g.arc(bw*0.2, by+bh*0.5, bw*0.6, -0.7, 0.7); g.stroke();
+ }
+
+ // ---- legs ----
+ g.strokeStyle=c.crustDk; g.lineWidth=8;
+ const legPhase = this.state==='walk'? Math.sin(this.animT):0;
+ g.beginPath(); g.moveTo(-10, by+bh-6); g.lineTo(-12 - legPhase*8, by+bh+10); g.stroke();
+ g.beginPath(); g.moveTo( 10, by+bh-6); g.lineTo( 12 + legPhase*8, by+bh+10); g.stroke();
+
+ g.restore(); // bob/ko
+ g.restore(); // translate/scale
+ }
+ }
+
+ function roundRectPath(g,x,y,w,h,r){
+ r = Math.min(r, w/2, h/2);
+ 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 spawnBlast(owner){
+ blasts.push({ x: owner.x + owner.facing*owner.w*0.5, y: owner.y - owner.h*0.5,
+ vx: owner.facing*10, r: 16, owner, life: 90, t:0 });
+ }
+ 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; b.life--;
+ const foe = b.owner === f1 ? f2 : f1;
+ const hb = foe.hurtbox();
+ if (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 (foe.takeHit(18, 16, Math.sign(b.vx))) { b.life = 0; spawnSpark(b.x,b.y,'#ffb347',18); }
+ }
+ if (b.x < ARENA_L-40 || b.x > ARENA_R+40) 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 drawProjectiles(g){
+ for (const b of blasts){
+ const c = COLORS[b.owner.side];
+ g.save();
+ g.globalAlpha = 0.9;
+ g.shadowColor = c.glow; g.shadowBlur = 24;
+ const grd = g.createRadialGradient(b.x,b.y,2,b.x,b.y,b.r);
+ grd.addColorStop(0,'#fff'); grd.addColorStop(0.4,c.crust); grd.addColorStop(1,c.glow);
+ g.fillStyle = grd;
+ g.beginPath(); g.arc(b.x,b.y,b.r,0,Math.PI*2); g.fill();
+ // trailing crumbs
+ for (let i=1;i<=3;i++){ g.globalAlpha=0.4/i; g.beginPath(); g.arc(b.x-b.vx*i*1.6,b.y,b.r*(1-i*0.2),0,Math.PI*2); g.fill(); }
+ 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();
+ }
+ function drawFloor(g){
+ g.fillStyle = '#0b0714'; g.fillRect(0, GROUND, W, H-GROUND);
+ g.save();
+ g.strokeStyle='rgba(255,61,154,.6)'; g.shadowColor='#ff3d9a'; g.shadowBlur=8; g.lineWidth=2;
+ g.beginPath(); g.moveTo(0,GROUND); g.lineTo(W,GROUND); g.stroke();
+ // perspective grid
+ g.strokeStyle='rgba(34,224,255,.35)'; g.shadowColor='#22e0ff'; g.lineWidth=1;
+ const vpx = W/2;
+ for (let i=-10;i<=10;i++){
+ const gx = vpx + i*90;
+ g.beginPath(); g.moveTo(vpx + i*16, GROUND); g.lineTo(gx, H); g.stroke();
+ }
+ for (let j=1;j<=6;j++){
+ const y = GROUND + (H-GROUND)*(j/6)*(j/6);
+ g.beginPath(); g.moveTo(0,y); g.lineTo(W,y); g.stroke();
+ }
+ g.restore();
+ }
+
+ // ---------- HUD ----------
+ function drawHUD(g){
+ // health bars
+ drawBar(g, 30, 26, 380, f1, false, COLORS.p1);
+ drawBar(g, W-30-380, 26, 380, f2, true, COLORS.p2);
+ // meter bars
+ drawMeter(g, 30, 60, 260, f1.meter, false, COLORS.p1);
+ drawMeter(g, W-30-260, 60, 260, f2.meter, true, COLORS.p2);
+ // 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){
+ g.globalAlpha = Math.min(1, announce.t/12) * Math.min(1, announce.life/16);
+ g.textAlign='center';
+ g.font = '800 '+announce.size+'px Georgia';
+ g.fillStyle = announce.color; g.shadowColor=announce.color; g.shadowBlur=24;
+ g.fillText(announce.text, W/2, H*0.42);
+ 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.fillStyle=c.accent; g.font='700 9px Georgia'; g.textAlign=flip?'right':'left';
+ g.fillText('CRUMB BLAST READY', flip?x+w:x, y+18); g.textAlign='left'; }
+ }
+ function drawPips(g,x,y,n,flip){
+ for (let i=0;i<2;i++){
+ const px = flip ? x-8-i*20 : x+8+i*20;
+ g.beginPath(); g.arc(px,y,6,0,Math.PI*2);
+ g.fillStyle = i<n ? '#ffd166' : 'rgba(255,255,255,.15)';
+ if (i<n){ g.shadowColor='#ffd166'; g.shadowBlur=10; }
+ g.fill(); g.shadowBlur=0;
+ }
+ }
+
+ // ---------- 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}; }
+
+ function newMatch(m){
+ mode = m;
+ f1 = new Fighter(W*0.32,'p1','BAGUETTE ROUGE', true);
+ f2 = new Fighter(W*0.68,'p2','BAGUETTE BLEUE', 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;
+ setAnnounce('ROUND '+roundNum,'#c9a961',56,10);
+ Voice.round();
+ }
+ let introT=0;
+ function beginFight(){ state='fight'; setAnnounce('FIGHT!','#ff3d9a',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'].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') return;
+ paused = !paused;
+ $('pause').classList.toggle('hidden', !paused);
+ }
+
+ $('btn1p').onclick = () => newMatch('1p');
+ $('btn2p').onclick = () => newMatch('2p');
+ $('btnRematch').onclick = () => newMatch(mode);
+ $('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, kb = atk.attack.kb;
+ def.takeHit(dmg, kb, dir);
+ atk.meter = Math.min(100, atk.meter + 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);
+ }
+
+ requestAnimationFrame(loop);
+})();
+</script>
+</body>
+</html>
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..0a9643b
Binary files /dev/null and b/screenshot.png differ
(oldest)
·
back to Neon Baguette Battle
·
Add two-player character select: 6 baguette fighters with Sp b93e403 →