game-builder-ai / renderer.py
AnshulRaj's picture
Sync renderer.py from GitHub project
4550c93 verified
Raw
History Blame Contribute Delete
12.5 kB
"""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 = (
"<!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>"
)