"""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 game engine — written as a plain string so {} braces need no escaping. _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 = ( "\n" "\n" "
\n" '\n' "\n" "\n" "\n" f'