| """Converts a CanvasGame schema into a self-contained HTML5 canvas game.""" |
| from __future__ import annotations |
|
|
| import html as html_lib |
|
|
| from schema import CanvasGame |
|
|
| |
| _JS_ENGINE = r""" |
| /* ===== GAME ENGINE ===== */ |
| const canvas = document.getElementById('gameCanvas'); |
| const ctx = canvas.getContext('2d'); |
| |
| let score = 0; |
| let lives = CONFIG.lives || 3; |
| let gameState = 'playing'; |
| let frameCount = 0; |
| |
| /* ---- Input ---- */ |
| const keys = {}; |
| window.addEventListener('keydown', e => { |
| keys[e.code] = true; |
| if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space'].includes(e.code)) e.preventDefault(); |
| }); |
| window.addEventListener('keyup', e => { keys[e.code] = false; }); |
| |
| const KEY_MAP = { |
| arrow_left: 'ArrowLeft', |
| arrow_right: 'ArrowRight', |
| arrow_up: 'ArrowUp', |
| arrow_down: 'ArrowDown', |
| space: 'Space', |
| }; |
| |
| /* ---- Sprite instances ---- */ |
| let spriteInstances = []; |
| |
| function initSprites() { |
| spriteInstances = []; |
| for (const def of CONFIG.sprites) { |
| const count = def.count || 1; |
| for (let i = 0; i < count; i++) { |
| let sx = def.x || 0; |
| let sy = def.y || 0; |
| if (count > 1) { |
| if (def.movement_pattern === 'rain_down') { |
| sx = Math.random() * Math.max(0, CONFIG.width - (def.width || 30)); |
| sy = -(def.height || 30) - Math.random() * CONFIG.height; |
| } else if (def.movement_pattern === 'bounce') { |
| sx = Math.random() * Math.max(0, CONFIG.width - (def.width || 30)); |
| sy = Math.random() * Math.max(0, CONFIG.height - (def.height || 30)); |
| } |
| } |
| spriteInstances.push({ |
| ...def, |
| instanceId: count > 1 ? `${def.id}_${i}` : def.id, |
| x: sx, |
| y: sy, |
| width: def.width || 30, |
| height: def.height || 30, |
| vx: 0, vy: 0, |
| alive: true, |
| _init: false, |
| }); |
| } |
| } |
| } |
| |
| /* ---- Drawing ---- */ |
| function drawStar(cx, cy, spikes, outerR, innerR) { |
| let rot = (Math.PI / 2) * 3; |
| const step = Math.PI / spikes; |
| ctx.beginPath(); |
| ctx.moveTo(cx, cy - outerR); |
| for (let i = 0; i < spikes; i++) { |
| ctx.lineTo(cx + Math.cos(rot) * outerR, cy + Math.sin(rot) * outerR); |
| rot += step; |
| ctx.lineTo(cx + Math.cos(rot) * innerR, cy + Math.sin(rot) * innerR); |
| rot += step; |
| } |
| ctx.closePath(); |
| } |
| |
| function drawSprite(s) { |
| if (!s.alive) return; |
| ctx.save(); |
| ctx.fillStyle = s.color || '#ffffff'; |
| ctx.shadowBlur = 12; |
| ctx.shadowColor = s.color || '#ffffff'; |
| |
| const cx = s.x + s.width / 2; |
| const cy = s.y + s.height / 2; |
| const r = Math.min(s.width, s.height) / 2; |
| |
| switch (s.shape) { |
| case 'circle': |
| ctx.beginPath(); |
| ctx.arc(cx, cy, r, 0, Math.PI * 2); |
| ctx.fill(); |
| break; |
| case 'rectangle': |
| ctx.fillRect(s.x, s.y, s.width, s.height); |
| break; |
| case 'triangle': |
| ctx.beginPath(); |
| ctx.moveTo(cx, s.y); |
| ctx.lineTo(s.x, s.y + s.height); |
| ctx.lineTo(s.x + s.width, s.y + s.height); |
| ctx.closePath(); |
| ctx.fill(); |
| break; |
| case 'star': |
| drawStar(cx, cy, 5, r, r * 0.42); |
| ctx.fill(); |
| break; |
| default: |
| ctx.fillRect(s.x, s.y, s.width, s.height); |
| } |
| ctx.restore(); |
| } |
| |
| /* ---- Input binding ---- */ |
| function applyInput(s) { |
| if (!CONFIG.input_bindings) return; |
| s.vx = 0; s.vy = 0; |
| for (const b of CONFIG.input_bindings) { |
| const match = b.target === s.id || |
| b.target === s.instanceId || |
| (s.instanceId && s.instanceId.startsWith(b.target + '_')); |
| if (!match || !keys[KEY_MAP[b.key]]) continue; |
| switch (b.action) { |
| case 'move_left': s.vx -= s.speed; break; |
| case 'move_right': s.vx += s.speed; break; |
| case 'move_up': s.vy -= s.speed; break; |
| case 'move_down': s.vy += s.speed; break; |
| } |
| } |
| } |
| |
| const getPlayer = () => spriteInstances.find(s => s.is_player && s.alive); |
| |
| /* ---- Movement patterns ---- */ |
| function updateSprite(s) { |
| if (!s.alive) return; |
| const p = getPlayer(); |
| |
| switch (s.movement_pattern) { |
| case 'static': |
| break; |
| |
| case 'keyboard_horizontal': |
| applyInput(s); |
| s.x = Math.max(0, Math.min(CONFIG.width - s.width, s.x + s.vx)); |
| break; |
| |
| case 'keyboard_all': |
| applyInput(s); |
| s.x = Math.max(0, Math.min(CONFIG.width - s.width, s.x + s.vx)); |
| s.y = Math.max(0, Math.min(CONFIG.height - s.height, s.y + s.vy)); |
| break; |
| |
| case 'rain_down': |
| s.y += s.speed; |
| if (s.y > CONFIG.height + s.height) { |
| s.y = -(s.height + Math.random() * 200); |
| s.x = Math.random() * Math.max(0, CONFIG.width - s.width); |
| } |
| break; |
| |
| case 'bounce': |
| if (!s._init) { |
| const a = Math.random() * Math.PI * 2; |
| s.vx = Math.cos(a) * s.speed; |
| s.vy = Math.sin(a) * s.speed; |
| s._init = true; |
| } |
| s.x += s.vx; s.y += s.vy; |
| if (s.x <= 0 || s.x + s.width >= CONFIG.width) { s.vx *= -1; s.x = Math.max(0, Math.min(CONFIG.width - s.width, s.x)); } |
| if (s.y <= 0 || s.y + s.height >= CONFIG.height) { s.vy *= -1; s.y = Math.max(0, Math.min(CONFIG.height - s.height, s.y)); } |
| break; |
| |
| case 'chase_player': |
| if (p) { |
| const dx = (p.x + p.width/2) - (s.x + s.width/2); |
| const dy = (p.y + p.height/2) - (s.y + s.height/2); |
| const dist = Math.hypot(dx, dy); |
| if (dist > 1) { s.x += (dx / dist) * s.speed * 0.5; s.y += (dy / dist) * s.speed * 0.5; } |
| } |
| break; |
| |
| case 'zigzag': |
| if (!s._init) { |
| s.vx = s.speed; s.vy = s.speed * 0.4; |
| s._zt = 0; s._init = true; |
| } |
| s._zt++; |
| if (s._zt % 50 === 0) s.vx *= -1; |
| s.x += s.vx; s.y += s.vy; |
| if (s.y > CONFIG.height) { s.y = -s.height; s.x = Math.random() * CONFIG.width; } |
| if (s.x < -s.width) s.x = CONFIG.width; |
| if (s.x > CONFIG.width) s.x = -s.width; |
| break; |
| |
| case 'circular': |
| if (!s._init) { |
| s._ocx = s.x + s.width / 2; |
| s._ocy = s.y + s.height / 2; |
| s._or = 60 + Math.random() * 30; |
| s._oa = Math.random() * Math.PI * 2; |
| s._init = true; |
| } |
| s._oa += s.speed * 0.025; |
| s.x = s._ocx + Math.cos(s._oa) * s._or - s.width / 2; |
| s.y = s._ocy + Math.sin(s._oa) * s._or - s.height / 2; |
| break; |
| } |
| } |
| |
| /* ---- Collision detection (AABB) ---- */ |
| function aabb(a, b) { |
| return a.x < b.x + b.width && a.x + a.width > b.x && |
| a.y < b.y + b.height && a.y + a.height > b.y; |
| } |
| |
| function findGroup(id) { |
| return spriteInstances.filter(s => |
| s.alive && (s.id === id || s.instanceId === id || |
| (s.instanceId && s.instanceId.startsWith(id + '_'))) |
| ); |
| } |
| |
| function handleCollision(rule, a, b) { |
| switch (rule.action) { |
| case 'game_over': |
| lives--; |
| if (lives <= 0) { |
| gameState = 'game_over'; |
| } else { |
| /* Reset player to starting position */ |
| if (a.is_player) { |
| const orig = CONFIG.sprites.find(d => d.is_player); |
| if (orig) { a.x = orig.x; a.y = orig.y; } |
| } |
| } |
| break; |
| |
| case 'score_plus': |
| score += (rule.value || 1); |
| if (b.movement_pattern === 'rain_down') { |
| b.y = -(b.height + Math.random() * 200); |
| b.x = Math.random() * Math.max(0, CONFIG.width - b.width); |
| } else { |
| b.alive = false; |
| } |
| break; |
| |
| case 'score_minus': |
| score = Math.max(0, score - (rule.value || 1)); |
| break; |
| |
| case 'destroy_target': |
| b.alive = false; |
| score += (rule.value || 1); |
| break; |
| |
| case 'bounce_back': |
| a.vx = -(a.vx || a.speed * 0.5); |
| a.vy = -(a.vy || 0); |
| break; |
| } |
| } |
| |
| function checkCollisions() { |
| if (!CONFIG.collision_rules) return; |
| for (const rule of CONFIG.collision_rules) { |
| const ga = findGroup(rule.between[0]); |
| const gb = findGroup(rule.between[1]); |
| for (const a of ga) { |
| for (const b of gb) { |
| if (!a.alive || !b.alive || a === b) continue; |
| if (aabb(a, b)) handleCollision(rule, a, b); |
| } |
| } |
| } |
| } |
| |
| /* ---- HUD ---- */ |
| function drawHUD() { |
| if (!CONFIG.score_display) return; |
| const sd = CONFIG.score_display; |
| const fs = sd.font_size || 22; |
| ctx.save(); |
| ctx.font = `bold ${fs}px 'Courier New', monospace`; |
| ctx.fillStyle = sd.color || '#00ffcc'; |
| ctx.shadowBlur = 8; |
| ctx.shadowColor = sd.color || '#00ffcc'; |
| |
| const text = `${sd.label || 'SCORE'}: ${score}`; |
| const tw = ctx.measureText(text).width; |
| const sy = fs + 8; |
| let sx; |
| switch (sd.position) { |
| case 'top_left': sx = 10; break; |
| case 'top_center': sx = (CONFIG.width - tw) / 2; break; |
| default: sx = CONFIG.width - tw - 10; |
| } |
| ctx.fillText(text, sx, sy); |
| |
| /* Lives */ |
| ctx.fillStyle = '#ff4466'; |
| ctx.shadowColor = '#ff4466'; |
| ctx.font = `${Math.floor(fs * 0.85)}px monospace`; |
| ctx.fillText('♥'.repeat(Math.max(0, lives)), 10, sy); |
| ctx.restore(); |
| } |
| |
| /* ---- Game over screen ---- */ |
| function drawGameOver() { |
| ctx.fillStyle = 'rgba(0,0,0,0.88)'; |
| ctx.fillRect(0, 0, CONFIG.width, CONFIG.height); |
| ctx.save(); |
| ctx.textAlign = 'center'; |
| |
| ctx.shadowBlur = 25; ctx.shadowColor = '#ff3333'; |
| ctx.fillStyle = '#ff4444'; |
| ctx.font = "bold 48px 'Courier New', monospace"; |
| ctx.fillText('GAME OVER', CONFIG.width / 2, CONFIG.height / 2 - 50); |
| |
| ctx.shadowColor = '#00ffcc'; ctx.fillStyle = '#00ffcc'; |
| ctx.font = "28px 'Courier New', monospace"; |
| ctx.fillText(`SCORE: ${score}`, CONFIG.width / 2, CONFIG.height / 2 + 10); |
| |
| ctx.shadowBlur = 5; ctx.fillStyle = '#aaa'; |
| ctx.font = "16px 'Courier New', monospace"; |
| ctx.fillText('SPACE or click to restart', CONFIG.width / 2, CONFIG.height / 2 + 55); |
| ctx.restore(); |
| } |
| |
| /* ---- Restart ---- */ |
| function restart() { |
| score = 0; lives = CONFIG.lives || 3; |
| gameState = 'playing'; frameCount = 0; |
| initSprites(); |
| requestAnimationFrame(gameLoop); |
| } |
| |
| window.addEventListener('keydown', e => { |
| if (e.code === 'Space' && gameState === 'game_over') { e.preventDefault(); restart(); } |
| }); |
| canvas.addEventListener('click', () => { if (gameState === 'game_over') restart(); }); |
| |
| canvas.setAttribute('tabindex', '0'); |
| canvas.addEventListener('click', () => canvas.focus()); |
| canvas.focus(); |
| |
| /* ---- Main loop ---- */ |
| function gameLoop() { |
| if (gameState === 'game_over') { drawGameOver(); return; } |
| |
| ctx.fillStyle = CONFIG.background_color || '#111'; |
| ctx.fillRect(0, 0, CONFIG.width, CONFIG.height); |
| |
| for (const s of spriteInstances) updateSprite(s); |
| checkCollisions(); |
| for (const s of spriteInstances) drawSprite(s); |
| drawHUD(); |
| |
| frameCount++; |
| requestAnimationFrame(gameLoop); |
| } |
| |
| initSprites(); |
| requestAnimationFrame(gameLoop); |
| """ |
|
|
|
|
| def render_game(game: CanvasGame) -> str: |
| """Return a fully self-contained HTML5 game page from a CanvasGame config.""" |
| config_json = game.model_dump_json() |
| bg = game.background_color |
|
|
| html_header = ( |
| "<!DOCTYPE html>\n" |
| "<html>\n" |
| "<head>\n" |
| '<meta charset="utf-8">\n' |
| "<style>\n" |
| "* { margin: 0; padding: 0; box-sizing: border-box; }\n" |
| f"body {{ background: {bg}; display: flex; flex-direction: column;" |
| " align-items: center; justify-content: center; height: 100vh;" |
| " font-family: 'Courier New', monospace; overflow: hidden; }\n" |
| "#title { color: #00ffcc; font-size: 15px; margin-bottom: 6px;" |
| " text-transform: uppercase; letter-spacing: 2px;" |
| " text-shadow: 0 0 8px #00ffcc; }\n" |
| "canvas { display: block; }\n" |
| "</style>\n" |
| "</head>\n" |
| "<body>\n" |
| f'<div id="title">{game.title}</div>\n' |
| f'<canvas id="gameCanvas" width="{game.width}" height="{game.height}"></canvas>\n' |
| "<script>\n" |
| f"const CONFIG = {config_json};\n" |
| ) |
|
|
| html_footer = "\n</script>\n</body>\n</html>" |
|
|
| return html_header + _JS_ENGINE + html_footer |
|
|
|
|
| def render_for_display(game: CanvasGame) -> str: |
| """Wrap the game in an iframe for safe embedding inside Gradio.""" |
| game_html = render_game(game) |
| escaped = html_lib.escape(game_html, quote=True) |
| w = game.width + 40 |
| h = game.height + 80 |
| return ( |
| '<div style="display:flex;justify-content:center;' |
| 'padding:8px;background:#0a0a0a;border-radius:8px;">' |
| f'<iframe srcdoc="{escaped}" width="{w}" height="{h}" ' |
| 'style="border:none;border-radius:8px;background:#0a0a0a;">' |
| "</iframe></div>" |
| ) |
|
|