Spaces:
Paused
Paused
| // generate.php — Generate a UNIQUE micro-game from video analysis | |
| // Output: a single playable HTML file with embedded game logic | |
| // Every video produces a different game | |
| require_once __DIR__ . '/config.php'; | |
| ensure_dirs(); | |
| $id = $_POST['id'] ?? $_GET['id'] ?? null; | |
| $format = $_POST['format'] ?? $_GET['format'] ?? 'arcade'; | |
| if (!$id || !preg_match('/^[a-f0-9]{16}$/', $id)) { | |
| fail_json('Invalid id'); | |
| } | |
| $receipt = read_json_file(RECEIPT_DIR . "/$id/receipt.json"); | |
| $analysis = read_json_file(RECEIPT_DIR . "/$id/analysis.json"); | |
| if (!$receipt) fail_json('Receipt not found', 404); | |
| if (!$analysis) fail_json('Analysis missing. Run analyze first.', 400); | |
| $appDir = GENERATED_DIR . "/$id"; | |
| if (!is_dir($appDir)) { | |
| mkdir($appDir, 0775, true); | |
| } | |
| // ─── EXTRACT GAME DESIGN ───────────────────────────── | |
| $gameTitle = htmlspecialchars($analysis['game_title'] ?? 'MicroGame'); | |
| $gameConcept = htmlspecialchars($analysis['game_concept'] ?? 'A game inspired by video content'); | |
| $gameMechanic = htmlspecialchars($analysis['game_mechanic'] ?? 'Click to play'); | |
| $gameGenre = $analysis['game_genre'] ?? 'arcade'; | |
| $gameWin = htmlspecialchars($analysis['game_win'] ?? 'Reach target score'); | |
| $gameLose = htmlspecialchars($analysis['game_lose'] ?? 'Miss too many'); | |
| $inspiration = htmlspecialchars($analysis['inspiration'] ?? ''); | |
| $understanding = htmlspecialchars($analysis['understanding'] ?? ''); | |
| $colors = $analysis['game_colors'] ?? []; | |
| $bgColor = $colors['bg'] ?? '#0a0a0f'; | |
| $primaryColor = $colors['primary'] ?? '#ff8c32'; | |
| $accentColor = $colors['accent'] ?? '#3aff7a'; | |
| $textColor = $colors['text'] ?? '#eee8df'; | |
| // Build JSON config for the game engine (injected via script tag) | |
| $gameConfig = json_encode([ | |
| 'title' => $analysis['game_title'] ?? 'MicroGame', | |
| 'concept' => $analysis['game_concept'] ?? '', | |
| 'mechanic' => $analysis['game_mechanic'] ?? '', | |
| 'genre' => $gameGenre, | |
| 'entities' => $analysis['game_entities'] ?? ['target'], | |
| 'powerups' => $analysis['game_powerups'] ?? [], | |
| 'win' => $analysis['game_win'] ?? 'Reach target score', | |
| 'lose' => $analysis['game_lose'] ?? 'Miss too many', | |
| 'scoring' => $analysis['game_scoring'] ?? 'Score increases on hit', | |
| 'difficulty' => $analysis['game_difficulty'] ?? 'Speed increases over time', | |
| 'inspiration' => $analysis['inspiration'] ?? '', | |
| 'understanding' => $analysis['understanding'] ?? '', | |
| 'colors' => [ | |
| 'bg' => $bgColor, | |
| 'primary' => $primaryColor, | |
| 'accent' => $accentColor, | |
| 'text' => $textColor | |
| ] | |
| ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); | |
| // ─── HTML HEAD + BODY (PHP heredoc with interpolation) ─── | |
| $html = <<<HTML | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>$gameTitle</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{background:$bgColor;color:$textColor;font-family:ui-monospace,monospace;overflow:hidden;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh} | |
| #gameContainer{position:relative;width:min(800px,95vw);height:min(600px,85vh)} | |
| canvas{display:block;border-radius:12px;box-shadow:0 0 40px rgba(255,140,50,.15)} | |
| #hud{position:absolute;top:12px;left:12px;right:12px;display:flex;justify-content:space-between;pointer-events:none;z-index:10} | |
| .hud-item{background:rgba(0,0,0,.6);padding:8px 16px;border-radius:8px;font-size:14px;border:1px solid rgba(255,255,255,.1)} | |
| #score{color:$primaryColor;font-weight:bold} | |
| #lives{color:$accentColor} | |
| #timer{color:$textColor} | |
| #overlay{position:absolute;inset:0;background:rgba(0,0,0,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:20;border-radius:12px} | |
| #overlay h1{color:$primaryColor;font-size:32px;margin-bottom:12px;text-align:center} | |
| #overlay p{color:$textColor;font-size:14px;max-width:400px;text-align:center;margin-bottom:8px;line-height:1.6} | |
| #overlay .concept{color:$accentColor;font-style:italic;margin-bottom:20px} | |
| #startBtn{padding:14px 40px;background:$primaryColor;color:$bgColor;border:none;border-radius:10px;font-size:18px;font-weight:bold;cursor:pointer;font-family:inherit} | |
| #startBtn:hover{opacity:.85} | |
| #inspiration{position:absolute;bottom:12px;left:12px;right:12px;color:rgba(255,255,255,.3);font-size:10px;text-align:center;pointer-events:none} | |
| .hidden{display:none!important} | |
| .game-over-stats{margin:16px 0;text-align:center} | |
| .game-over-stats .label{color:rgba(255,255,255,.4);font-size:11px} | |
| .game-over-stats .value{color:$primaryColor;font-size:24px;font-weight:bold} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="gameContainer"> | |
| <div id="hud"> | |
| <div class="hud-item" id="score">SCORE: 0</div> | |
| <div class="hud-item" id="timer">TIME: 0s</div> | |
| <div class="hud-item" id="lives">LIVES: 3</div> | |
| </div> | |
| <canvas id="game" width="800" height="600"></canvas> | |
| <div id="overlay"> | |
| <h1 id="title">$gameTitle</h1> | |
| <p class="concept">$gameConcept</p> | |
| <p>$gameMechanic</p> | |
| <p style="color:rgba(255,255,255,.5);font-size:12px">Win: $gameWin · Lose: $gameLose</p> | |
| <button id="startBtn">PLAY</button> | |
| </div> | |
| <div id="inspiration">Inspired by video evidence · $gameGenre · source video exposed: false</div> | |
| </div> | |
| <script id="gameConfig" type="application/json">$gameConfig</script> | |
| <script src="game.js"></script> | |
| </body> | |
| </html> | |
| HTML; | |
| // ─── GAME ENGINE JS (PHP nowdoc — NO interpolation) ─── | |
| $js = <<<'JSEOF' | |
| (function(){ | |
| const cfg = JSON.parse(document.getElementById('gameConfig').textContent); | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const overlay = document.getElementById('overlay'); | |
| const scoreEl = document.getElementById('score'); | |
| const livesEl = document.getElementById('lives'); | |
| const timerEl = document.getElementById('timer'); | |
| const startBtn = document.getElementById('startBtn'); | |
| const C = cfg.colors; | |
| let W = canvas.width, H = canvas.height; | |
| let state = 'menu'; | |
| let score = 0, lives = 3, time = 0, startTime = 0; | |
| let entities = [], particles = [], powerupActive = null; | |
| let difficulty = 1; | |
| let lastTime = 0; | |
| let mouse = {x: W/2, y: H/2, down: false}; | |
| function resize() { | |
| const container = document.getElementById('gameContainer'); | |
| const rect = container.getBoundingClientRect(); | |
| canvas.width = Math.min(800, rect.width); | |
| canvas.height = Math.min(600, rect.height); | |
| W = canvas.width; H = canvas.height; | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| canvas.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| mouse.x = (e.clientX - rect.left) * (canvas.width / rect.width); | |
| mouse.y = (e.clientY - rect.top) * (canvas.height / rect.height); | |
| }); | |
| canvas.addEventListener('mousedown', () => { mouse.down = true; }); | |
| canvas.addEventListener('mouseup', () => { mouse.down = false; }); | |
| canvas.addEventListener('click', e => { | |
| if (state === 'playing') handleClick(mouse.x, mouse.y); | |
| }); | |
| function makeEntity(type, x, y) { | |
| const names = cfg.entities.length ? cfg.entities : ['target']; | |
| const name = names[Math.floor(Math.random() * names.length)] || 'target'; | |
| return { | |
| type: type || 'target', | |
| name: name, | |
| x: x !== undefined ? x : Math.random() * (W - 60) + 30, | |
| y: y !== undefined ? y : Math.random() * (H - 60) + 30, | |
| vx: (Math.random() - 0.5) * (2 + difficulty), | |
| vy: (Math.random() - 0.5) * (2 + difficulty), | |
| r: 20 + Math.random() * 15, | |
| hp: type === 'enemy' ? 2 : 1, | |
| maxHp: type === 'enemy' ? 2 : 1, | |
| color: type === 'enemy' ? '#ff4444' : C.primary, | |
| alive: true, | |
| age: 0, | |
| pulse: Math.random() * Math.PI * 2, | |
| points: type === 'enemy' ? 50 : 10 | |
| }; | |
| } | |
| function makeParticle(x, y, color) { | |
| return { | |
| x, y, | |
| vx: (Math.random() - 0.5) * 6, | |
| vy: (Math.random() - 0.5) * 6, | |
| life: 1, | |
| color: color || C.primary, | |
| size: 2 + Math.random() * 3 | |
| }; | |
| } | |
| function spawnEntities() { | |
| const count = 3 + Math.floor(difficulty); | |
| for (let i = 0; i < count; i++) { | |
| const isEnemy = Math.random() < 0.25 + difficulty * 0.03; | |
| entities.push(makeEntity(isEnemy ? 'enemy' : 'target')); | |
| } | |
| } | |
| function handleClick(x, y) { | |
| let hit = false; | |
| for (let e of entities) { | |
| if (!e.alive) continue; | |
| const dx = x - e.x, dy = y - e.y; | |
| if (dx*dx + dy*dy < e.r*e.r) { | |
| e.hp--; | |
| hit = true; | |
| if (e.hp <= 0) { | |
| e.alive = false; | |
| score += e.points; | |
| scoreEl.textContent = 'SCORE: ' + score; | |
| for (let i = 0; i < 12; i++) { | |
| particles.push(makeParticle(e.x, e.y, e.color)); | |
| } | |
| if (cfg.powerups.length > 0 && Math.random() < 0.15) { | |
| const pu = cfg.powerups[Math.floor(Math.random() * cfg.powerups.length)]; | |
| powerupActive = {name: pu, time: 5}; | |
| } | |
| } else { | |
| for (let i = 0; i < 5; i++) { | |
| particles.push(makeParticle(e.x, e.y, e.color)); | |
| } | |
| } | |
| } | |
| } | |
| if (!hit && (cfg.genre === 'arcade' || cfg.genre === 'action')) { | |
| score = Math.max(0, score - 5); | |
| scoreEl.textContent = 'SCORE: ' + score; | |
| } | |
| } | |
| function updateGame(dt) { | |
| time = (Date.now() - startTime) / 1000; | |
| timerEl.textContent = 'TIME: ' + Math.floor(time) + 's'; | |
| difficulty = 1 + time * 0.05; | |
| for (let e of entities) { | |
| if (!e.alive) continue; | |
| e.age += dt; | |
| e.pulse += dt * 3; | |
| if (cfg.genre === 'action' || cfg.genre === 'arcade') { | |
| e.x += e.vx * difficulty; | |
| e.y += e.vy * difficulty; | |
| if (e.x < e.r) { e.x = e.r; e.vx *= -1; } | |
| if (e.x > W - e.r) { e.x = W - e.r; e.vx *= -1; } | |
| if (e.y < e.r) { e.y = e.r; e.vy *= -1; } | |
| if (e.y > H - e.r) { e.y = H - e.r; e.vy *= -1; } | |
| } else if (cfg.genre === 'puzzle') { | |
| e.x += e.vx * 0.3; | |
| e.y += e.vy * 0.3; | |
| if (e.x < e.r || e.x > W - e.r) e.vx *= -1; | |
| if (e.y < e.r || e.y > H - e.r) e.vy *= -1; | |
| } else { | |
| e.y += Math.sin(e.pulse) * 0.5; | |
| e.x += Math.cos(e.pulse * 0.7) * 0.3; | |
| } | |
| if (e.type === 'enemy' && cfg.genre === 'action') { | |
| const dx = mouse.x - e.x, dy = mouse.y - e.y; | |
| if (dx*dx + dy*dy < (e.r + 15) * (e.r + 15)) { | |
| lives--; | |
| livesEl.textContent = 'LIVES: ' + lives; | |
| e.alive = false; | |
| for (let i = 0; i < 15; i++) { | |
| particles.push(makeParticle(e.x, e.y, '#ff4444')); | |
| } | |
| if (lives <= 0) { gameOver(); return; } | |
| } | |
| } | |
| } | |
| entities = entities.filter(e => e.alive); | |
| if (entities.length < 3 + Math.floor(difficulty)) { | |
| const isEnemy = Math.random() < 0.2 + difficulty * 0.03; | |
| entities.push(makeEntity(isEnemy ? 'enemy' : 'target')); | |
| } | |
| for (let p of particles) { | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.vy += 0.15; | |
| p.life -= dt * 1.5; | |
| } | |
| particles = particles.filter(p => p.life > 0); | |
| if (powerupActive) { | |
| powerupActive.time -= dt; | |
| if (powerupActive.time <= 0) powerupActive = null; | |
| } | |
| if (score >= 500) gameWin(); | |
| } | |
| function drawGame() { | |
| ctx.fillStyle = C.bg; | |
| ctx.fillRect(0, 0, W, H); | |
| ctx.strokeStyle = 'rgba(255,255,255,.03)'; | |
| ctx.lineWidth = 1; | |
| for (let x = 0; x < W; x += 40) { | |
| ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); | |
| } | |
| for (let y = 0; y < H; y += 40) { | |
| ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); | |
| } | |
| for (let e of entities) { | |
| if (!e.alive) continue; | |
| const pulseR = e.r + Math.sin(e.pulse) * 3; | |
| const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, pulseR * 2); | |
| grad.addColorStop(0, e.color + '88'); | |
| grad.addColorStop(1, e.color + '00'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| ctx.arc(e.x, e.y, pulseR * 2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = e.color; | |
| ctx.beginPath(); | |
| ctx.arc(e.x, e.y, pulseR, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = 'rgba(255,255,255,.3)'; | |
| ctx.beginPath(); | |
| ctx.arc(e.x - pulseR * 0.3, e.y - pulseR * 0.3, pulseR * 0.3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| if (e.type === 'enemy' && e.hp < e.maxHp) { | |
| ctx.fillStyle = 'rgba(255,255,255,.2)'; | |
| ctx.fillRect(e.x - e.r, e.y - e.r - 8, e.r * 2, 4); | |
| ctx.fillStyle = '#ff4444'; | |
| ctx.fillRect(e.x - e.r, e.y - e.r - 8, (e.r * 2) * (e.hp / e.maxHp), 4); | |
| } | |
| ctx.fillStyle = 'rgba(255,255,255,.4)'; | |
| ctx.font = '9px monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(e.name.substring(0, 12), e.x, e.y + e.r + 14); | |
| } | |
| for (let p of particles) { | |
| ctx.globalAlpha = p.life; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| if (state === 'playing') { | |
| ctx.strokeStyle = C.accent; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(mouse.x, mouse.y, 12, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(mouse.x - 6, mouse.y); | |
| ctx.lineTo(mouse.x + 6, mouse.y); | |
| ctx.moveTo(mouse.x, mouse.y - 6); | |
| ctx.lineTo(mouse.x, mouse.y + 6); | |
| ctx.stroke(); | |
| } | |
| if (powerupActive) { | |
| ctx.fillStyle = C.accent; | |
| ctx.font = 'bold 14px monospace'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText('PWR: ' + powerupActive.name + ' (' + powerupActive.time.toFixed(1) + 's)', 12, H - 12); | |
| } | |
| } | |
| function startGame() { | |
| state = 'playing'; | |
| score = 0; lives = 3; time = 0; | |
| startTime = Date.now(); | |
| difficulty = 1; | |
| entities = []; particles = []; | |
| powerupActive = null; | |
| scoreEl.textContent = 'SCORE: 0'; | |
| livesEl.textContent = 'LIVES: 3'; | |
| timerEl.textContent = 'TIME: 0s'; | |
| overlay.classList.add('hidden'); | |
| spawnEntities(); | |
| } | |
| function gameWin() { | |
| state = 'won'; | |
| overlay.classList.remove('hidden'); | |
| overlay.innerHTML = | |
| '<h1 style="color:' + C.accent + '">YOU WIN!</h1>' + | |
| '<div class="game-over-stats">' + | |
| '<div class="label">FINAL SCORE</div>' + | |
| '<div class="value">' + score + '</div>' + | |
| '<div class="label">TIME</div>' + | |
| '<div class="value" style="font-size:18px">' + Math.floor(time) + 's</div>' + | |
| '</div>' + | |
| '<p style="color:rgba(255,255,255,.4);font-size:11px;max-width:350px">' + cfg.inspiration + '</p>' + | |
| '<button id="startBtn" style="margin-top:16px">PLAY AGAIN</button>'; | |
| document.getElementById('startBtn').onclick = startGame; | |
| } | |
| function gameOver() { | |
| state = 'lost'; | |
| overlay.classList.remove('hidden'); | |
| overlay.innerHTML = | |
| '<h1 style="color:#ff4444">GAME OVER</h1>' + | |
| '<div class="game-over-stats">' + | |
| '<div class="label">FINAL SCORE</div>' + | |
| '<div class="value">' + score + '</div>' + | |
| '<div class="label">TIME SURVIVED</div>' + | |
| '<div class="value" style="font-size:18px">' + Math.floor(time) + 's</div>' + | |
| '</div>' + | |
| '<p style="color:rgba(255,255,255,.4);font-size:11px;max-width:350px">' + cfg.inspiration + '</p>' + | |
| '<button id="startBtn" style="margin-top:16px">TRY AGAIN</button>'; | |
| document.getElementById('startBtn').onclick = startGame; | |
| } | |
| function loop(ts) { | |
| const dt = Math.min(0.05, (ts - lastTime) / 1000) || 0; | |
| lastTime = ts; | |
| if (state === 'playing') updateGame(dt); | |
| drawGame(); | |
| requestAnimationFrame(loop); | |
| } | |
| startBtn.onclick = startGame; | |
| requestAnimationFrame(loop); | |
| })(); | |
| JSEOF; | |
| file_put_contents("$appDir/index.html", $html); | |
| file_put_contents("$appDir/game.js", $js); | |
| write_json_file("$appDir/app_manifest.json", [ | |
| 'id' => $id, | |
| 'game_title' => $analysis['game_title'] ?? 'MicroGame', | |
| 'game_genre' => $gameGenre, | |
| 'game_concept' => $analysis['game_concept'] ?? '', | |
| 'inspiration' => $analysis['inspiration'] ?? '', | |
| 'understanding' => $analysis['understanding'] ?? '', | |
| 'created_at' => time(), | |
| 'source_video_exposed' => false | |
| ]); | |
| // Save to gallery | |
| $galleryFile = RECEIPT_DIR . '/gallery.json'; | |
| $gallery = file_exists($galleryFile) ? (read_json_file($galleryFile) ?: []) : []; | |
| $entry = [ | |
| 'videoId' => $id, | |
| 'videoName' => $receipt['videoName'] ?? 'unknown', | |
| 'gameTitle' => $analysis['game_title'] ?? 'MicroGame', | |
| 'gameGenre' => $gameGenre, | |
| 'gameConcept' => $analysis['game_concept'] ?? '', | |
| 'createdAt' => date('c'), | |
| 'duration' => $receipt['duration'] ?? 0 | |
| ]; | |
| $gallery = array_filter($gallery, fn($e) => ($e['videoId'] ?? '') !== $id); | |
| array_unshift($gallery, $entry); | |
| $gallery = array_slice($gallery, 0, 50); | |
| write_json_file($galleryFile, $gallery); | |
| json_response([ | |
| 'ok' => true, | |
| 'id' => $id, | |
| 'game_title' => $analysis['game_title'] ?? 'MicroGame', | |
| 'game_genre' => $gameGenre, | |
| 'game_concept' => $analysis['game_concept'] ?? '', | |
| 'preview_url' => "/generated/$id/index.html" | |
| ]); | |