| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | <title>Flappy Bird AI - SimpleTool Demo</title> |
| | <style> |
| | *{margin:0;padding:0;box-sizing:border-box} |
| | body{background:linear-gradient(180deg,#1a1a2e 0%,#16213e 100%);min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:'Segoe UI',system-ui,sans-serif;color:#fff} |
| | .container{display:flex;gap:20px;align-items:flex-start} |
| | .game-wrapper{display:flex;flex-direction:column;align-items:center} |
| | .title{font-size:28px;font-weight:bold;margin-bottom:15px;background:linear-gradient(90deg,#ffd700,#ff6b6b);-webkit-background-clip:text;-webkit-text-fill-color:transparent} |
| | .stats-bar{display:flex;gap:20px;margin-bottom:10px;font-size:14px} |
| | .stat{padding:8px 16px;background:rgba(255,215,0,.1);border:1px solid #ffd700;border-radius:20px} |
| | .stat span{color:#ffd700;font-weight:bold} |
| | .game-area{position:relative} |
| | canvas{border:2px solid #ffd700;box-shadow:0 0 30px rgba(255,215,0,.3);border-radius:8px} |
| | .controls{margin-top:15px;display:flex;gap:10px} |
| | .btn{padding:10px 25px;font-size:14px;font-weight:bold;background:transparent;border:2px solid #ffd700;color:#ffd700;cursor:pointer;border-radius:6px;transition:all .2s} |
| | .btn:hover{background:#ffd700;color:#000} |
| | .btn.stop{border-color:#f44;color:#f44} |
| | .btn.stop:hover{background:#f44;color:#fff} |
| | .panel{width:320px;background:rgba(0,0,0,.8);border:1px solid #ffd700;border-radius:10px;padding:15px;font-size:12px} |
| | .panel h3{color:#ffd700;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #333} |
| | .config-row{display:flex;align-items:center;justify-content:space-between;margin:8px 0} |
| | .config-row label{color:#aaa} |
| | .config-row input{width:200px;padding:6px 10px;background:#111;border:1px solid #ffd700;color:#fff;border-radius:4px;font-size:12px} |
| | .config-row select{background:#111;border:1px solid #ffd700;color:#fff;padding:6px;border-radius:4px} |
| | .status-row{display:flex;align-items:center;gap:8px;margin:10px 0} |
| | .status-dot{width:10px;height:10px;border-radius:50%;background:#666} |
| | .status-dot.ok{background:#0f0;box-shadow:0 0 8px #0f0} |
| | .status-dot.err{background:#f44} |
| | .log-section{max-height:180px;overflow-y:auto;background:#050505;border-radius:6px;padding:10px;margin-top:10px} |
| | .log-entry{padding:4px 8px;margin:2px 0;background:rgba(255,215,0,.05);border-left:3px solid #ffd700;font-family:monospace;font-size:11px} |
| | .log-entry.flap{border-color:#0f0;background:rgba(0,255,0,.1)} |
| | .log-entry.wait{border-color:#888} |
| | .log-entry .action{font-weight:bold} |
| | .log-entry .ms{color:#f0f} |
| | .env-display{background:#111;padding:10px;border-radius:6px;font-family:monospace;font-size:10px;color:#888;margin-top:10px;white-space:pre-wrap;max-height:120px;overflow-y:auto} |
| | .overlay{position:absolute;inset:0;background:rgba(0,0,0,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px} |
| | .overlay.hidden{display:none} |
| | .overlay h2{font-size:36px;color:#ffd700;margin-bottom:10px} |
| | .overlay .score-display{font-size:24px;color:#0ff;margin:10px 0} |
| | .high-score{color:#f0f;font-size:14px;margin-top:5px} |
| | </style> |
| | </head> |
| | <body> |
| | <div class="container"> |
| | <div class="game-wrapper"> |
| | <div class="title">🐦 FLAPPY BIRD AI</div> |
| | <div class="stats-bar"> |
| | <div class="stat">Score: <span id="score">0</span></div> |
| | <div class="stat">Best: <span id="best">0</span></div> |
| | <div class="stat">Flaps: <span id="flaps">0</span></div> |
| | <div class="stat">Avg: <span id="avg-latency">--</span>ms</div> |
| | </div> |
| | <div class="game-area"> |
| | <canvas id="game" width="400" height="600"></canvas> |
| | <div class="overlay" id="overlay"> |
| | <h2 id="overlay-title">FLAPPY BIRD AI</h2> |
| | <div class="score-display" id="overlay-score"></div> |
| | <div class="high-score" id="overlay-best"></div> |
| | <button class="btn" id="start-btn">START</button> |
| | </div> |
| | </div> |
| | <div class="controls"> |
| | <button class="btn" id="restart-btn">RESTART</button> |
| | <button class="btn stop" id="stop-btn">STOP</button> |
| | </div> |
| | </div> |
| | <div class="panel"> |
| | <h3>⚙️ Configuration</h3> |
| | <div class="config-row"> |
| | <label>Server URL</label> |
| | </div> |
| | <div class="config-row"> |
| | <input type="text" id="server-url" value="http://localhost:8899"> |
| | </div> |
| | <div class="status-row"> |
| | <div class="status-dot" id="status-dot"></div> |
| | <span id="status-text">Click Start to connect</span> |
| | </div> |
| | <div class="config-row"> |
| | <label>Difficulty</label> |
| | <select id="difficulty"> |
| | <option value="easy">Easy (wide gaps)</option> |
| | <option value="normal" selected>Normal</option> |
| | <option value="hard">Hard (narrow gaps)</option> |
| | </select> |
| | </div> |
| | <div class="config-row"> |
| | <label>AI Decision Rate</label> |
| | <select id="decision-rate"> |
| | <option value="1">Every frame</option> |
| | <option value="3" selected>Every 3 frames</option> |
| | <option value="5">Every 5 frames</option> |
| | </select> |
| | </div> |
| | <h3>📊 AI Decision Log</h3> |
| | <div class="log-section" id="log-section"></div> |
| | <div class="env-display" id="env-display">Environment will show here...</div> |
| | </div> |
| | </div> |
| | <script> |
| | const $ = id => document.getElementById(id); |
| | |
| | |
| | const TOOLS = [{ |
| | type: "function", |
| | function: { |
| | name: "act", |
| | description: "Flappy bird action. Flap to gain height, wait to fall by gravity.", |
| | parameters: { |
| | type: "object", |
| | properties: { |
| | action: { |
| | type: "string", |
| | enum: ["flap", "wait"], |
| | description: "flap=jump up ~50px, wait=fall by gravity ~3px/frame" |
| | } |
| | }, |
| | required: ["action"] |
| | } |
| | } |
| | }]; |
| | |
| | class FCClient { |
| | constructor(url) { this.url = url.replace(/\/$/, ''); } |
| | async health() { |
| | try { |
| | const r = await fetch(`${this.url}/health`, { signal: AbortSignal.timeout(3000) }); |
| | const d = await r.json(); |
| | return { ok: d.loaded === true || d.status === 'ok' }; |
| | } catch(e) { return { ok: false }; } |
| | } |
| | async call(messages, env) { |
| | const t0 = performance.now(); |
| | try { |
| | const r = await fetch(`${this.url}/v1/function_call`, { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/json' }, |
| | body: JSON.stringify({ messages, tools: TOOLS, environment: env }) |
| | }); |
| | const d = await r.json(); |
| | const ms = performance.now() - t0; |
| | let action = String(d.args?.action || d.heads?.arg1 || '').toLowerCase().trim(); |
| | if (!['flap','wait'].includes(action)) action = 'wait'; |
| | return { action, ms, raw: d }; |
| | } catch(e) { |
| | return { action: 'wait', ms: performance.now() - t0, error: e.message }; |
| | } |
| | } |
| | } |
| | |
| | class FlappyGame { |
| | constructor() { |
| | this.canvas = $('game'); |
| | this.ctx = this.canvas.getContext('2d'); |
| | this.W = this.canvas.width; |
| | this.H = this.canvas.height; |
| | this.client = null; |
| | this.running = false; |
| | this.bestScore = 0; |
| | this.latencies = []; |
| | this.reset(); |
| | } |
| | |
| | getDifficultyParams() { |
| | const d = $('difficulty').value; |
| | return { |
| | easy: { gapSize: 180, pipeSpeed: 2.5 }, |
| | normal: { gapSize: 150, pipeSpeed: 3 }, |
| | hard: { gapSize: 120, pipeSpeed: 4 } |
| | }[d]; |
| | } |
| | |
| | reset() { |
| | const params = this.getDifficultyParams(); |
| | this.bird = { x: 80, y: this.H / 2, vy: 0, radius: 15 }; |
| | this.gravity = 0.4; |
| | this.flapStrength = -7; |
| | this.pipes = []; |
| | this.pipeWidth = 60; |
| | this.gapSize = params.gapSize; |
| | this.pipeSpeed = params.pipeSpeed; |
| | this.pipeInterval = 100; |
| | this.frameCount = 0; |
| | this.score = 0; |
| | this.flapCount = 0; |
| | this.latencies = []; |
| | this.pendingDecision = null; |
| | this.decisionRate = parseInt($('decision-rate').value); |
| | this.spawnPipe(); |
| | this.updateStats(); |
| | this.render(); |
| | } |
| | |
| | spawnPipe() { |
| | const minY = 80; |
| | const maxY = this.H - this.gapSize - 80; |
| | const gapY = minY + Math.random() * (maxY - minY); |
| | this.pipes.push({ |
| | x: this.W + 50, |
| | gapY: gapY, |
| | passed: false |
| | }); |
| | } |
| | |
| | async start() { |
| | this.client = new FCClient($('server-url').value); |
| | const health = await this.client.health(); |
| | $('status-dot').className = 'status-dot ' + (health.ok ? 'ok' : 'err'); |
| | $('status-text').textContent = health.ok ? 'Connected' : 'Connection failed'; |
| | if (!health.ok) return; |
| | |
| | this.reset(); |
| | this.running = true; |
| | $('overlay').classList.add('hidden'); |
| | this.loop(); |
| | } |
| | |
| | stop() { |
| | this.running = false; |
| | } |
| | |
| | async loop() { |
| | if (!this.running) return; |
| | |
| | this.frameCount++; |
| | |
| | |
| | if (this.frameCount % this.decisionRate === 0) { |
| | await this.aiDecision(); |
| | } |
| | |
| | |
| | this.bird.vy += this.gravity; |
| | this.bird.y += this.bird.vy; |
| | |
| | |
| | for (const pipe of this.pipes) { |
| | pipe.x -= this.pipeSpeed; |
| | if (!pipe.passed && pipe.x + this.pipeWidth < this.bird.x) { |
| | pipe.passed = true; |
| | this.score++; |
| | } |
| | } |
| | |
| | |
| | this.pipes = this.pipes.filter(p => p.x > -this.pipeWidth); |
| | |
| | |
| | if (this.pipes.length === 0 || this.pipes[this.pipes.length - 1].x < this.W - this.pipeInterval) { |
| | this.spawnPipe(); |
| | } |
| | |
| | |
| | if (this.checkCollision()) { |
| | this.gameOver(); |
| | return; |
| | } |
| | |
| | this.updateStats(); |
| | this.render(); |
| | requestAnimationFrame(() => this.loop()); |
| | } |
| | |
| | async aiDecision() { |
| | const bird = this.bird; |
| | const nextPipe = this.pipes.find(p => p.x + this.pipeWidth > bird.x); |
| | |
| | if (!nextPipe) return; |
| | |
| | const pipeCenter = nextPipe.gapY + this.gapSize / 2; |
| | const birdToCenter = pipeCenter - bird.y; |
| | const distToPipe = nextPipe.x - bird.x; |
| | |
| | |
| | const futureY = bird.y + bird.vy * 5 + 0.5 * this.gravity * 25; |
| | const willBeAboveGap = futureY < nextPipe.gapY + 20; |
| | const willBeBelowGap = futureY > nextPipe.gapY + this.gapSize - 20; |
| | |
| | |
| | const env = [ |
| | `bird_y=${Math.round(bird.y)}`, |
| | `bird_vy=${bird.vy.toFixed(1)}`, |
| | `pipe_x=${Math.round(distToPipe)}`, |
| | `gap_top=${Math.round(nextPipe.gapY)}`, |
| | `gap_bottom=${Math.round(nextPipe.gapY + this.gapSize)}`, |
| | `gap_center=${Math.round(pipeCenter)}`, |
| | `bird_to_center=${Math.round(birdToCenter)}`, |
| | `predicted_y=${Math.round(futureY)}`, |
| | `ceiling=${0}`, |
| | `floor=${this.H}` |
| | ]; |
| | |
| | |
| | let instruction; |
| | if (bird.y < 30) { |
| | instruction = `DANGER: Too high! WAIT to fall.`; |
| | } else if (bird.y > this.H - 50) { |
| | instruction = `DANGER: Too low! FLAP now!`; |
| | } else if (distToPipe < 100) { |
| | |
| | if (bird.y > pipeCenter + 20) { |
| | instruction = `Approaching pipe, below center. FLAP to rise!`; |
| | } else if (bird.y < pipeCenter - 30) { |
| | instruction = `Approaching pipe, above center. WAIT to fall.`; |
| | } else { |
| | instruction = `Aligned with gap. ${bird.vy > 2 ? 'FLAP to slow descent.' : 'WAIT to maintain.'}`; |
| | } |
| | } else { |
| | |
| | if (birdToCenter > 40) { |
| | instruction = `Far from pipe. Below gap center by ${Math.round(birdToCenter)}px. FLAP!`; |
| | } else if (birdToCenter < -40) { |
| | instruction = `Far from pipe. Above gap center. WAIT.`; |
| | } else { |
| | instruction = `Good position. ${bird.vy > 3 ? 'FLAP to control speed.' : 'WAIT.'}`; |
| | } |
| | } |
| | |
| | const query = `Flappy bird. Screen ${this.W}x${this.H}. ${instruction} Call act(flap) or act(wait).`; |
| | $('env-display').textContent = `Query: ${query}\n\nEnv:\n${env.join('\n')}`; |
| | |
| | const result = await this.client.call([{ role: 'user', content: query }], env); |
| | this.latencies.push(result.ms); |
| | this.logDecision(result); |
| | |
| | if (result.action === 'flap') { |
| | this.bird.vy = this.flapStrength; |
| | this.flapCount++; |
| | } |
| | } |
| | |
| | checkCollision() { |
| | const b = this.bird; |
| | |
| | if (b.y - b.radius < 0 || b.y + b.radius > this.H) return true; |
| | |
| | |
| | for (const pipe of this.pipes) { |
| | if (b.x + b.radius > pipe.x && b.x - b.radius < pipe.x + this.pipeWidth) { |
| | if (b.y - b.radius < pipe.gapY || b.y + b.radius > pipe.gapY + this.gapSize) { |
| | return true; |
| | } |
| | } |
| | } |
| | return false; |
| | } |
| | |
| | gameOver() { |
| | this.running = false; |
| | if (this.score > this.bestScore) this.bestScore = this.score; |
| | $('overlay').classList.remove('hidden'); |
| | $('overlay-title').textContent = 'GAME OVER'; |
| | $('overlay-score').textContent = `Score: ${this.score}`; |
| | $('overlay-best').textContent = `Best: ${this.bestScore}`; |
| | $('best').textContent = this.bestScore; |
| | } |
| | |
| | updateStats() { |
| | $('score').textContent = this.score; |
| | $('flaps').textContent = this.flapCount; |
| | const avg = this.latencies.length ? Math.round(this.latencies.reduce((a,b)=>a+b,0)/this.latencies.length) : '--'; |
| | $('avg-latency').textContent = avg; |
| | } |
| | |
| | logDecision(result) { |
| | const entry = document.createElement('div'); |
| | entry.className = 'log-entry ' + result.action; |
| | entry.innerHTML = `<span class="action">${result.action.toUpperCase()}</span> <span class="ms">${Math.round(result.ms)}ms</span>`; |
| | $('log-section').insertBefore(entry, $('log-section').firstChild); |
| | while ($('log-section').children.length > 30) $('log-section').lastChild.remove(); |
| | } |
| | |
| | render() { |
| | const ctx = this.ctx; |
| | |
| | |
| | const grad = ctx.createLinearGradient(0, 0, 0, this.H); |
| | grad.addColorStop(0, '#1a1a2e'); |
| | grad.addColorStop(1, '#16213e'); |
| | ctx.fillStyle = grad; |
| | ctx.fillRect(0, 0, this.W, this.H); |
| | |
| | |
| | ctx.fillStyle = 'rgba(255,255,255,0.3)'; |
| | for (let i = 0; i < 30; i++) { |
| | const x = (i * 37 + this.frameCount * 0.2) % this.W; |
| | const y = (i * 23) % this.H; |
| | ctx.beginPath(); |
| | ctx.arc(x, y, 1, 0, Math.PI * 2); |
| | ctx.fill(); |
| | } |
| | |
| | |
| | for (const pipe of this.pipes) { |
| | |
| | const topGrad = ctx.createLinearGradient(pipe.x, 0, pipe.x + this.pipeWidth, 0); |
| | topGrad.addColorStop(0, '#0a5'); |
| | topGrad.addColorStop(0.5, '#0d8'); |
| | topGrad.addColorStop(1, '#0a5'); |
| | ctx.fillStyle = topGrad; |
| | ctx.fillRect(pipe.x, 0, this.pipeWidth, pipe.gapY); |
| | ctx.fillRect(pipe.x - 5, pipe.gapY - 20, this.pipeWidth + 10, 20); |
| | |
| | |
| | ctx.fillRect(pipe.x, pipe.gapY + this.gapSize, this.pipeWidth, this.H - pipe.gapY - this.gapSize); |
| | ctx.fillRect(pipe.x - 5, pipe.gapY + this.gapSize, this.pipeWidth + 10, 20); |
| | |
| | |
| | ctx.strokeStyle = '#0f0'; |
| | ctx.lineWidth = 2; |
| | ctx.shadowBlur = 10; |
| | ctx.shadowColor = '#0f0'; |
| | ctx.strokeRect(pipe.x, 0, this.pipeWidth, pipe.gapY); |
| | ctx.strokeRect(pipe.x, pipe.gapY + this.gapSize, this.pipeWidth, this.H - pipe.gapY - this.gapSize); |
| | } |
| | |
| | |
| | ctx.shadowBlur = 20; |
| | ctx.shadowColor = '#ffd700'; |
| | ctx.fillStyle = '#ffd700'; |
| | ctx.beginPath(); |
| | ctx.arc(this.bird.x, this.bird.y, this.bird.radius, 0, Math.PI * 2); |
| | ctx.fill(); |
| | |
| | |
| | ctx.shadowBlur = 0; |
| | ctx.fillStyle = '#fff'; |
| | ctx.beginPath(); |
| | ctx.arc(this.bird.x + 5, this.bird.y - 3, 5, 0, Math.PI * 2); |
| | ctx.fill(); |
| | ctx.fillStyle = '#000'; |
| | ctx.beginPath(); |
| | ctx.arc(this.bird.x + 7, this.bird.y - 3, 2, 0, Math.PI * 2); |
| | ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = '#f44'; |
| | ctx.beginPath(); |
| | ctx.moveTo(this.bird.x + this.bird.radius, this.bird.y); |
| | ctx.lineTo(this.bird.x + this.bird.radius + 10, this.bird.y + 3); |
| | ctx.lineTo(this.bird.x + this.bird.radius, this.bird.y + 6); |
| | ctx.closePath(); |
| | ctx.fill(); |
| | |
| | |
| | ctx.fillStyle = '#e5c100'; |
| | ctx.save(); |
| | ctx.translate(this.bird.x - 5, this.bird.y); |
| | ctx.rotate(this.bird.vy * 0.05); |
| | ctx.fillRect(-10, -3, 10, 8); |
| | ctx.restore(); |
| | |
| | ctx.shadowBlur = 0; |
| | } |
| | } |
| | |
| | const game = new FlappyGame(); |
| | |
| | $('start-btn').onclick = () => game.start(); |
| | $('restart-btn').onclick = () => { game.stop(); game.start(); }; |
| | $('stop-btn').onclick = () => { game.stop(); $('overlay').classList.remove('hidden'); $('overlay-title').textContent = 'PAUSED'; }; |
| | |
| | game.render(); |
| | </script> |
| | </body> |
| | </html> |
| |
|