GameGen / app.py
Matys BIECHE
Add 20 WarioWare-style microgame templates with modular canvas engine
5214e3b
Raw
History Blame Contribute Delete
46.1 kB
# -*- coding: utf-8 -*-
"""GameGen - POC mini-jeux génératifs visuels (HuggingFace Space / ZeroGPU)
Qwen3-4B génère la spec JSON, SDXL génère les assets, un canvas exécute le mini-jeu.
Adapté du POC Colab pour tourner sur HuggingFace Spaces avec ZeroGPU.
"""
import spaces # doit être importé avant torch sur ZeroGPU
import torch, json, re, base64, io, html
from PIL import Image
from pydantic import BaseModel, Field
from typing import Literal, List, Dict
TEMPLATES = [
"grab_target", # cliquer la cible qui bouge parmi des leurres
"avoid_falling", # déplacer la cible pour éviter les objets qui tombent
"timing_hit", # cliquer/espace quand la cible passe dans la zone
"catch_falling", # déplacer un panier pour attraper les cibles, éviter les leurres
"whack_a_mole", # taper les cibles qui surgissent, éviter les leurres
"find_target", # retrouver et cliquer l'unique cible cachée parmi les leurres
"swat_all", # cliquer TOUS les objets qui bougent avant la fin
"shoot_target", # tirer (clic) plusieurs fois sur la cible qui fuit
"protect_center", # cliquer les leurres avant qu'ils n'atteignent la cible centrale
"dodge_sides", # déplacer la cible pour esquiver les projectiles latéraux
"chase_flee", # rattraper avec le curseur la cible qui s'enfuit
"drag_to_goal", # guider la cible (souris) jusqu'à la zone but sans toucher les leurres
"balance", # garder la cible en équilibre au centre (flèches gauche/droite)
"mash_fill", # marteler clic/espace pour remplir la barre
"pump_inflate", # gonfler la cible jusqu'à la bonne taille sans la faire exploser
"hold_steady", # garder le curseur dans la zone sans en sortir
"rhythm_tap", # taper en rythme quand la cible pulse
"stop_meter", # stopper l'aiguille pile dans la zone verte
"quick_draw", # attendre le signal puis cliquer le plus vite possible
"sort_lr", # trier : cible à droite (→), leurre à gauche (←)
]
class VisualMiniGameSpec(BaseModel):
template: Literal[
"grab_target", "avoid_falling", "timing_hit", "catch_falling",
"whack_a_mole", "find_target", "swat_all", "shoot_target",
"protect_center", "dodge_sides", "chase_flee", "drag_to_goal",
"balance", "mash_fill", "pump_inflate", "hold_steady",
"rhythm_tap", "stop_meter", "quick_draw", "sort_lr",
]
instruction: str = Field(min_length=5, max_length=80)
theme: str = Field(min_length=3, max_length=80)
duration_seconds: int = Field(ge=3, le=8)
difficulty: int = Field(ge=1, le=5)
visual_style: str = Field(min_length=10, max_length=200)
background_prompt: str = Field(min_length=10, max_length=300)
target_prompt: str = Field(min_length=10, max_length=300)
decoy_prompts: List[str] = Field(min_length=2, max_length=2)
success_text: str = Field(min_length=2, max_length=80)
failure_text: str = Field(min_length=2, max_length=80)
# ---------------------------------------------------------------------------
# Chargement des modèles (au démarrage du Space)
# ---------------------------------------------------------------------------
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
LLM_ID = "Qwen/Qwen3-4B-Instruct-2507"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(LLM_ID, trust_remote_code=True)
llm = AutoModelForCausalLM.from_pretrained(
LLM_ID,
quantization_config=bnb_config,
device_map="cuda",
trust_remote_code=True,
)
from diffusers import StableDiffusionXLPipeline, LCMScheduler
IMAGE_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
LCM_LORA_ID = "latent-consistency/lcm-lora-sdxl"
# Nombre de steps et guidance optimisés pour LCM (au lieu de 18 / 7.0 en SDXL classique)
IMAGE_STEPS = 5
IMAGE_GUIDANCE = 1.2
pipe = StableDiffusionXLPipeline.from_pretrained(
IMAGE_MODEL_ID,
torch_dtype=torch.float16,
use_safetensors=True,
variant="fp16",
)
pipe.to("cuda")
# LCM-LoRA : distillation par consistency model -> ~4-5 steps suffisent.
# On réutilise le même SDXL base (déjà téléchargé), on ajoute juste le LoRA + le scheduler LCM.
pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config)
pipe.load_lora_weights(LCM_LORA_ID)
pipe.fuse_lora()
# ---------------------------------------------------------------------------
# Génération de la spec (LLM)
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """
Tu es un game designer pour micro-jeux très courts type WarioWare-like.
Tu dois répondre uniquement en JSON valide.
Aucun markdown. Aucun commentaire. Aucun texte hors JSON.
Tu génères un mini-jeu visuel, pas un quiz.
Les objets doivent être rendus comme des vrais assets visuels générables par un modèle image.
Dans CHAQUE jeu il y a:
- "target" = l'objet HÉROS, celui que l'instruction met en avant (target_prompt le décrit)
- "decoys" = 2 autres objets du même univers (leurres, obstacles ou distracteurs)
Templates autorisés (choisis celui qui colle le mieux à l'inspiration):
- grab_target: cliquer la cible qui bouge parmi les leurres ("Attrape ...!")
- avoid_falling: déplacer la cible pour éviter les leurres qui tombent ("Évite ...!")
- timing_hit: cliquer pile quand la cible passe dans la zone ("Pile au centre !")
- catch_falling: bouger un panier pour attraper les cibles, éviter les leurres ("Attrape qui tombe !")
- whack_a_mole: taper les cibles qui surgissent, pas les leurres ("Tape les ...!")
- find_target: retrouver l'unique cible cachée parmi les leurres ("Trouve la ...!")
- swat_all: cliquer TOUS les objets qui bougent avant la fin ("Écrase tout !")
- shoot_target: tirer plusieurs fois sur la cible qui fuit ("Tire sur la ...!")
- protect_center: cliquer les leurres avant qu'ils n'atteignent la cible centrale ("Protège la ...!")
- dodge_sides: déplacer la cible pour esquiver les projectiles latéraux ("Esquive !")
- chase_flee: rattraper avec le curseur la cible qui s'enfuit ("Rattrape la ...!")
- drag_to_goal: guider la cible à la souris jusqu'au but sans toucher les leurres ("Amène la ... au but !")
- balance: garder la cible en équilibre au centre, flèches gauche/droite ("Garde l'équilibre !")
- mash_fill: marteler clic/espace pour remplir la barre ("Tape vite !")
- pump_inflate: gonfler la cible à la bonne taille sans la faire exploser ("Gonfle sans exploser !")
- hold_steady: garder le curseur dans la zone sans en sortir ("Ne bouge plus !")
- rhythm_tap: taper en rythme quand la cible pulse ("Tape en rythme !")
- stop_meter: stopper l'aiguille pile dans la zone verte ("Stoppe au bon moment !")
- quick_draw: attendre le signal puis cliquer le plus vite possible ("Dégaine !")
- sort_lr: trier la cible à droite, le leurre à gauche, avec les flèches ("Trie vite !")
Contraintes:
- duration_seconds entre 3 et 8
- difficulty entre 1 et 5
- instruction courte, impérative, arcade
- prompts visuels en anglais
- style cohérent entre background, target et decoys
- target_prompt doit décrire un objet visuel concret
- decoy_prompts doivent décrire de vrais objets visuels, pas du texte
- pas de violence réaliste
- pas de texte dans les images
COHÉRENCE OBLIGATOIRE (très important):
- L'objet nommé dans "instruction" DOIT être EXACTEMENT le même que celui de "target_prompt".
Exemple: si instruction = "Attrape la banane !", alors target_prompt DOIT décrire une banane.
- "target_prompt" décrit UN SEUL objet iconique, reconnaissable au premier coup d'œil,
centré et entièrement visible (pas coupé).
- Les "decoy_prompts" décrivent des objets DIFFÉRENTS de la cible mais du même univers visuel,
faciles à confondre en un coup d'œil rapide.
- "theme" et "visual_style" doivent rester cohérents entre tous les prompts.
- Choisis un template VARIÉ et adapté à l'inspiration (ne prends pas toujours grab_target).
"""
EXAMPLE_JSON = {
"template": "grab_target",
"instruction": "Attrape la banane !",
"theme": "marché fruité arcade",
"duration_seconds": 5,
"difficulty": 2,
"visual_style": "colorful arcade sticker art, bold black outlines, clean silhouettes, playful exaggerated shapes",
"background_prompt": "colorful arcade fruit market background, dynamic playful composition, bold shapes, vibrant colors, no text",
"target_prompt": "single ripe banana, sticker art, bold black outline, centered, clean silhouette, vibrant yellow, no text",
"decoy_prompts": [
"single yellow boomerang, sticker art, bold black outline, centered, clean silhouette, no text",
"single crescent moon, sticker art, bold black outline, centered, clean silhouette, no text",
],
"success_text": "Bien joué !",
"failure_text": "Raté !",
}
def extract_json(text: str):
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
raise ValueError("Aucun JSON trouvé")
return json.loads(match.group(0))
def generate_spec(user_prompt: str, temperature: float = 0.75) -> VisualMiniGameSpec:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f"""
Inspiration utilisateur: {user_prompt}
Génère un JSON exactement dans ce style (mais choisis le template le plus adapté):
{json.dumps(EXAMPLE_JSON, ensure_ascii=False, indent=2)}
""",
},
]
try:
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
inputs = tokenizer(prompt, return_tensors="pt").to(llm.device)
with torch.no_grad():
output = llm.generate(
**inputs,
max_new_tokens=420,
do_sample=True,
temperature=temperature,
top_p=0.9,
repetition_penalty=1.05,
)
decoded = tokenizer.decode(
output[0][inputs["input_ids"].shape[-1]:],
skip_special_tokens=True,
)
data = extract_json(decoded)
return VisualMiniGameSpec(**data)
def fallback_spec():
return VisualMiniGameSpec(**EXAMPLE_JSON)
def safe_generate_spec(prompt):
# On tente plusieurs fois (température décroissante = JSON plus stable)
# AVANT de retomber sur la spec d'exemple, pour éviter le "fallback banane".
attempts = [0.75, 0.5, 0.3]
for i, temp in enumerate(attempts):
try:
return generate_spec(prompt, temperature=temp)
except Exception as e:
print(f"Spec attempt {i + 1}/{len(attempts)} failed:", e)
print("Fallback spec après tous les essais")
return fallback_spec()
# ---------------------------------------------------------------------------
# Génération des assets (SDXL)
# ---------------------------------------------------------------------------
ASSET_CACHE = {}
NEGATIVE_PROMPT = """
text, words, letters, watermark, logo, blurry, low quality,
photorealistic, realistic photo, messy background, cropped object,
multiple objects, extra limbs, duplicate, unreadable
"""
def pil_to_b64(img: Image.Image) -> str:
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def cutout_white(img: Image.Image) -> Image.Image:
"""Rend transparent le fond blanc d'un objet généré "isolated on white".
On part des 4 coins (qui sont du fond) et on remplit par contiguïté les zones
quasi-blanches : seuls les blancs CONNECTÉS au bord deviennent transparents,
les blancs internes à l'objet (reflets) sont préservés. Aucune dépendance lourde.
"""
import numpy as np
from PIL import ImageDraw
rgb = img.convert("RGB")
w, h = rgb.size
work = rgb.copy()
sentinel = (255, 0, 255) # magenta improbable dans une image générée
for corner in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]:
ImageDraw.floodfill(work, corner, sentinel, thresh=60)
arr_work = np.array(work)
arr_orig = np.array(rgb)
mask_bg = np.all(arr_work == sentinel, axis=-1)
alpha = np.where(mask_bg, 0, 255).astype("uint8")
rgba = np.dstack([arr_orig, alpha])
return Image.fromarray(rgba, "RGBA")
def generate_images_batch(prompts, width, height, steps=IMAGE_STEPS):
"""Génère plusieurs images de MÊME taille en un seul appel GPU (batch).
Les prompts déjà en cache sont renvoyés directement ; seuls les manquants
sont générés, puis l'ordre d'origine est reconstruit.
"""
results = [None] * len(prompts)
todo_idx, todo_prompts = [], []
for i, p in enumerate(prompts):
key = (p, width, height, steps)
if key in ASSET_CACHE:
results[i] = ASSET_CACHE[key]
else:
todo_idx.append(i)
todo_prompts.append(p)
if todo_prompts:
with torch.no_grad():
out = pipe(
prompt=todo_prompts,
negative_prompt=[NEGATIVE_PROMPT] * len(todo_prompts),
width=width,
height=height,
num_inference_steps=steps,
guidance_scale=IMAGE_GUIDANCE,
).images
for slot, p, img in zip(todo_idx, todo_prompts, out):
ASSET_CACHE[(p, width, height, steps)] = img
results[slot] = img
return results
def generate_assets(spec: VisualMiniGameSpec):
style = spec.visual_style
# Background (768x512) -> 1 appel
(background,) = generate_images_batch(
[f"{spec.background_prompt}, {style}, no text, game background"],
width=768,
height=512,
)
# Target + 2 decoys (512x512, même taille) -> 1 seul appel batché.
# Prompts orientés "objet unique, centré, entièrement visible, gros" pour que
# l'objet nommé soit toujours reconnaissable.
obj_suffix = (
f"{style}, single object, centered, full object fully visible, "
"large, clean silhouette, isolated on pure white background, no text, no shadow"
)
object_prompts = [
f"{spec.target_prompt}, {obj_suffix}",
f"{spec.decoy_prompts[0]}, {obj_suffix}",
f"{spec.decoy_prompts[1]}, {obj_suffix}",
]
target, decoy_1, decoy_2 = generate_images_batch(
object_prompts, width=512, height=512
)
# Détourage : fond blanc -> transparent, l'objet ressort net sur le canvas.
target = cutout_white(target)
decoy_1 = cutout_white(decoy_1)
decoy_2 = cutout_white(decoy_2)
return {
"background": pil_to_b64(background),
"target": pil_to_b64(target),
"decoy_1": pil_to_b64(decoy_1),
"decoy_2": pil_to_b64(decoy_2),
}
# ---------------------------------------------------------------------------
# Rendu du mini-jeu (canvas dans une iframe)
#
# Moteur modulaire : un même contrat d'assets (background + target + 2 decoys)
# alimente 20 mécaniques de micro-jeu différentes (registre GAMES côté JS).
# ---------------------------------------------------------------------------
GAME_HTML_HEAD = """<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#111; font-family: system-ui, sans-serif; }
#wrap { position:relative; width:100%; height:100vh; background:#111; }
canvas { display:block; width:100%; height:100%; background:#000; }
#hud { position:absolute; left:16px; right:16px; top:14px; display:flex; justify-content:space-between; align-items:flex-start; pointer-events:none; color:white; text-shadow:0 3px 0 #000; }
#instruction { font-size:30px; font-weight:1000; line-height:1.05; max-width:72%; }
#timer { font-size:42px; font-weight:1000; background:#ff2d55; border:4px solid white; color:white; padding:4px 16px; border-radius:16px; box-shadow:0 5px 0 #000; }
#center { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
#start { font-size:30px; font-weight:1000; padding:20px 42px; border:5px solid white; border-radius:22px; background:#ffe600; color:#111; cursor:pointer; box-shadow:0 8px 0 #000; }
#result { position:absolute; left:0; right:0; bottom:26px; text-align:center; color:white; font-size:42px; font-weight:1000; text-shadow:0 4px 0 #000; pointer-events:none; }
</style>
</head>
<body>
<div id="wrap">
<canvas id="game" width="768" height="512"></canvas>
<div id="hud"><div id="instruction"></div><div id="timer"></div></div>
<div id="center"><button id="start">START</button></div>
<div id="result"></div>
</div>
<script>
"""
# JS du moteur : chaîne BRUTE (pas de f-string) -> accolades et ${} littérales.
GAME_ENGINE_JS = r"""
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const instruction = document.getElementById("instruction");
const timer = document.getElementById("timer");
const startBtn = document.getElementById("start");
const result = document.getElementById("result");
const W = canvas.width, H = canvas.height;
const D = SPEC.difficulty, DUR = SPEC.duration_seconds;
instruction.textContent = SPEC.instruction;
timer.textContent = DUR;
let images = {};
let running = false, startTime = 0, raf = null;
let particles = [];
let S = null; // état du jeu courant
let hud = ""; // texte de progression dessiné en haut au centre
const mouse = { x: W / 2, y: H / 2, down: false };
function loadImage(src) {
return new Promise(res => { const im = new Image(); im.onload = () => res(im); im.src = "data:image/png;base64," + src; });
}
async function loadAssets() {
images.bg = await loadImage(ASSETS.background);
images.target = await loadImage(ASSETS.target);
images.decoy1 = await loadImage(ASSETS.decoy_1);
images.decoy2 = await loadImage(ASSETS.decoy_2);
}
function anyDecoy() { return Math.random() < 0.5 ? images.decoy1 : images.decoy2; }
function rand(a, b) { return a + Math.random() * (b - a); }
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx * dx + dy * dy); }
function drawCover(img, x, y, w, h) {
const s = Math.max(w / img.width, h / img.height);
const nw = img.width * s, nh = img.height * s;
ctx.drawImage(img, x + (w - nw) / 2, y + (h - nh) / 2, nw, nh);
}
function drawSprite(img, x, y, size, angle, pulse, card) {
if (angle === undefined) angle = 0;
if (pulse === undefined) pulse = 1;
if (card === undefined) card = true;
ctx.save();
ctx.translate(x, y); ctx.rotate(angle); ctx.scale(pulse, pulse);
ctx.shadowColor = "rgba(0,0,0,0.45)"; ctx.shadowBlur = 16; ctx.shadowOffsetY = 8;
if (card) {
ctx.fillStyle = "white";
ctx.beginPath(); ctx.roundRect(-size / 2, -size / 2, size, size, 24); ctx.fill();
}
ctx.shadowColor = "transparent";
const pad = card ? 8 : 0;
ctx.drawImage(img, -size / 2 + pad, -size / 2 + pad, size - 2 * pad, size - 2 * pad);
ctx.restore();
}
function addBurst(x, y, color) {
color = color || "#ffe600";
for (let i = 0; i < 26; i++) particles.push({ x, y, vx: rand(-7, 7), vy: rand(-7, 7), life: rand(18, 34), color });
}
function drawParticles() {
particles = particles.filter(p => p.life > 0);
for (const p of particles) {
p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.life -= 1;
ctx.globalAlpha = clamp(p.life / 30, 0, 1);
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
}
}
function bigText(txt, color) {
ctx.save();
ctx.fillStyle = color; ctx.font = "900 64px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowOffsetY = 4;
ctx.fillText(txt, W / 2, H / 2 + 60);
ctx.restore();
}
function timeRemaining(now) { return Math.max(0, DUR - (now - startTime) / 1000); }
function drawBackground() {
drawCover(images.bg, 0, 0, W, H);
ctx.fillStyle = "rgba(0,0,0,0.22)"; ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 8; ctx.strokeRect(8, 8, W - 16, H - 16);
}
function drawHud() {
if (!hud) return;
ctx.save();
ctx.fillStyle = "white"; ctx.font = "900 26px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.shadowColor = "#000"; ctx.shadowOffsetY = 3;
ctx.fillText(hud, W / 2, 70);
ctx.restore();
}
function bounceObj(o, top) {
if (top === undefined) top = 100;
o.x += o.vx; o.y += o.vy;
const r = o.size / 2;
if (o.x < r || o.x > W - r) o.vx *= -1;
if (o.y < top + r || o.y > H - r) o.vy *= -1;
o.x = clamp(o.x, r, W - r); o.y = clamp(o.y, top + r, H - r);
}
function endGame(success) {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
result.textContent = success ? SPEC.success_text : SPEC.failure_text;
result.style.color = success ? "#35ff6b" : "#ff3b30";
addBurst(W / 2, H / 2, success ? "#35ff6b" : "#ff3b30");
let n = 0;
const anim = () => {
drawBackground();
drawParticles();
if (n++ < 32) requestAnimationFrame(anim);
};
requestAnimationFrame(anim);
startBtn.textContent = "REJOUER";
startBtn.style.display = "block";
}
function win() { endGame(true); }
function lose() { endGame(false); }
// =========================================================================
// Registre des 20 mini-jeux. Chacun : setup(), frame(now) + handlers optionnels
// click(p) / move(p) / key(e) / timeout(). timeout par défaut = défaite.
// =========================================================================
const GAMES = {
grab_target: {
setup() {
const sp = 2.6 + D * 0.8;
S = {
target: { x: rand(130, W - 130), y: rand(150, H - 110), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 120 },
decoys: [
{ x: rand(100, W - 100), y: rand(150, H - 100), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 105, img: images.decoy1 },
{ x: rand(100, W - 100), y: rand(150, H - 100), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 105, img: images.decoy2 },
],
};
},
frame(now) {
const t = now / 1000;
for (const d of S.decoys) { bounceObj(d); drawSprite(d.img, d.x, d.y, d.size, Math.sin(t * 4) * 0.12); }
bounceObj(S.target);
drawSprite(images.target, S.target.x, S.target.y, S.target.size, Math.sin(t * 5) * 0.16, 1 + Math.sin(t * 8) * 0.04);
},
click(p) {
const g = S.target;
if (dist(p.x, p.y, g.x, g.y) < g.size * 0.5) { addBurst(g.x, g.y, "#35ff6b"); win(); return; }
for (const d of S.decoys) if (dist(p.x, p.y, d.x, d.y) < d.size * 0.5) { addBurst(d.x, d.y, "#ff3b30"); lose(); return; }
},
},
avoid_falling: {
setup() {
const n = 4 + D * 2;
S = { p: { x: W / 2, y: H - 70, size: 88 }, hz: [] };
for (let i = 0; i < n; i++) S.hz.push({ x: rand(60, W - 60), y: rand(-H, -40), size: rand(64, 98), speed: rand(2.4, 4.2) + D * 0.6, img: anyDecoy() });
},
frame(now) {
const t = now / 1000;
drawSprite(images.target, S.p.x, S.p.y, S.p.size);
for (const h of S.hz) {
h.y += h.speed;
if (h.y > H + 80) { h.y = rand(-220, -60); h.x = rand(60, W - 60); }
drawSprite(h.img, h.x, h.y, h.size, Math.sin(t * 6 + h.x) * 0.2);
if (dist(h.x, h.y, S.p.x, S.p.y) < (h.size + S.p.size) * 0.38) { addBurst(S.p.x, S.p.y, "#ff3b30"); lose(); return; }
}
},
move(p) { S.p.x = clamp(p.x, 55, W - 55); },
key(e) { if (e.key === "ArrowLeft") S.p.x = clamp(S.p.x - 42, 55, W - 55); if (e.key === "ArrowRight") S.p.x = clamp(S.p.x + 42, 55, W - 55); },
timeout() { win(); },
},
timing_hit: {
setup() { const zw = 150 - D * 12; S = { obj: { x: -80, y: H / 2 + 20, size: 108, speed: 4 + D * 1.1 }, zx: W / 2 - zw / 2, zw }; },
frame(now) {
ctx.save();
ctx.fillStyle = "rgba(53,255,107,0.30)"; ctx.fillRect(S.zx, 100, S.zw, H - 130);
ctx.strokeStyle = "#35ff6b"; ctx.lineWidth = 6; ctx.strokeRect(S.zx, 100, S.zw, H - 130);
ctx.restore();
S.obj.x += S.obj.speed;
drawSprite(images.target, S.obj.x, S.obj.y, S.obj.size, Math.sin(now / 130) * 0.15);
if (S.obj.x > W + 100) lose();
},
resolve() { const ok = S.obj.x > S.zx && S.obj.x < S.zx + S.zw; addBurst(S.obj.x, S.obj.y, ok ? "#35ff6b" : "#ff3b30"); ok ? win() : lose(); },
click() { this.resolve(); },
key(e) { if (e.code === "Space" || e.key === "Enter" || e.key === " ") this.resolve(); },
},
catch_falling: {
setup() { S = { paddle: { x: W / 2, w: 150, y: H - 46 }, items: [], last: 0, score: 0, need: 2 + D, spd: 2.6 + D * 0.5 }; hud = "0 / " + S.need; },
frame(now) {
if (now - S.last > 1000 - D * 60) {
S.last = now;
const good = Math.random() < 0.6;
S.items.push({ x: rand(60, W - 60), y: -40, size: 78, good, img: good ? images.target : anyDecoy() });
}
ctx.save();
ctx.fillStyle = "#ffe600"; ctx.strokeStyle = "#000"; ctx.lineWidth = 4;
ctx.beginPath(); ctx.roundRect(S.paddle.x - S.paddle.w / 2, S.paddle.y, S.paddle.w, 26, 12); ctx.fill(); ctx.stroke();
ctx.restore();
const next = [];
for (const it of S.items) {
it.y += S.spd;
if (it.y >= S.paddle.y - 24 && it.y <= S.paddle.y + 30 && Math.abs(it.x - S.paddle.x) < S.paddle.w / 2) {
if (it.good) { S.score++; addBurst(it.x, it.y, "#35ff6b"); hud = S.score + " / " + S.need; if (S.score >= S.need) { win(); return; } }
else { addBurst(it.x, it.y, "#ff3b30"); lose(); return; }
continue;
}
if (it.y > H + 60) continue;
drawSprite(it.img, it.x, it.y, it.size);
next.push(it);
}
S.items = next;
},
move(p) { S.paddle.x = clamp(p.x, S.paddle.w / 2, W - S.paddle.w / 2); },
},
whack_a_mole: {
setup() {
const cols = 4, rows = 2;
S = { holes: [], active: null, score: 0, need: 3 + D };
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) S.holes.push({ x: 120 + c * (W - 240) / (cols - 1), y: 190 + r * 170 });
hud = "0 / " + S.need;
},
frame(now) {
for (const h of S.holes) { ctx.save(); ctx.fillStyle = "rgba(0,0,0,0.35)"; ctx.beginPath(); ctx.ellipse(h.x, h.y + 42, 58, 20, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }
if (!S.active || now > S.active.until) {
const h = S.holes[Math.floor(Math.random() * S.holes.length)];
const good = Math.random() < 0.62;
S.active = { x: h.x, y: h.y, size: 96, good, img: good ? images.target : anyDecoy(), until: now + (950 - D * 90), born: now };
}
const a = S.active, k = Math.min(1, (now - a.born) / 120);
drawSprite(a.img, a.x, a.y - 18, a.size * k);
},
click(p) {
const a = S.active;
if (a && dist(p.x, p.y, a.x, a.y - 18) < a.size * 0.5) {
if (a.good) { S.score++; addBurst(a.x, a.y, "#35ff6b"); hud = S.score + " / " + S.need; S.active = null; if (S.score >= S.need) win(); }
else { addBurst(a.x, a.y, "#ff3b30"); lose(); }
}
},
},
find_target: {
setup() {
S = { items: [] };
const n = 10 + D * 3;
for (let i = 0; i < n; i++) S.items.push({ x: rand(70, W - 70), y: rand(150, H - 70), size: rand(58, 78), img: anyDecoy(), target: false });
S.items.push({ x: rand(70, W - 70), y: rand(150, H - 70), size: 72, img: images.target, target: true });
for (let i = S.items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = S.items[i]; S.items[i] = S.items[j]; S.items[j] = tmp; }
hud = "Trouve la cible !";
},
frame() { for (const it of S.items) drawSprite(it.img, it.x, it.y, it.size); },
click(p) {
for (let i = S.items.length - 1; i >= 0; i--) {
const it = S.items[i];
if (dist(p.x, p.y, it.x, it.y) < it.size * 0.5) {
if (it.target) { addBurst(it.x, it.y, "#35ff6b"); win(); } else { addBurst(it.x, it.y, "#ff3b30"); lose(); }
return;
}
}
},
},
swat_all: {
setup() {
const n = 3 + D * 2, sp = 2.2 + D * 0.7;
S = { bugs: [], left: n, total: n };
for (let i = 0; i < n; i++) S.bugs.push({ x: rand(80, W - 80), y: rand(150, H - 80), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 74, img: i % 2 ? images.decoy1 : images.target, alive: true });
hud = "0 / " + n;
},
frame(now) {
const t = now / 1000;
for (const b of S.bugs) { if (!b.alive) continue; bounceObj(b); drawSprite(b.img, b.x, b.y, b.size, Math.sin(t * 8 + b.x) * 0.2); }
},
click(p) {
for (const b of S.bugs) if (b.alive && dist(p.x, p.y, b.x, b.y) < b.size * 0.5) {
b.alive = false; S.left--; addBurst(b.x, b.y, "#35ff6b"); hud = (S.total - S.left) + " / " + S.total;
if (S.left <= 0) win();
return;
}
},
},
shoot_target: {
setup() { const sp = 4 + D * 1.2; S = { t: { x: rand(150, W - 150), y: rand(150, H - 120), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 104 }, hp: 2 + D, max: 2 + D }; hud = "PV " + S.hp; },
frame(now) {
bounceObj(S.t);
drawSprite(images.target, S.t.x, S.t.y, S.t.size, Math.sin(now / 120) * 0.12);
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(mouse.x, mouse.y, 16, 0, Math.PI * 2);
ctx.moveTo(mouse.x - 22, mouse.y); ctx.lineTo(mouse.x + 22, mouse.y);
ctx.moveTo(mouse.x, mouse.y - 22); ctx.lineTo(mouse.x, mouse.y + 22);
ctx.stroke(); ctx.restore();
},
click(p) {
if (dist(p.x, p.y, S.t.x, S.t.y) < S.t.size * 0.5) {
S.hp--; addBurst(S.t.x, S.t.y, "#35ff6b"); hud = "PV " + Math.max(0, S.hp);
const sp = 4 + D * 1.2 + (S.max - S.hp);
S.t.x = rand(150, W - 150); S.t.y = rand(150, H - 120); S.t.vx = rand(-sp, sp); S.t.vy = rand(-sp, sp);
if (S.hp <= 0) win();
}
},
},
protect_center: {
setup() { S = { c: { x: W / 2, y: H / 2 + 10, size: 120 }, en: [], last: 0, sp: 1.3 + D * 0.4 }; hud = "Protège la cible !"; },
frame(now) {
if (now - S.last > 820 - D * 70) {
S.last = now;
const side = Math.floor(Math.random() * 4);
let x, y;
if (side === 0) { x = rand(40, W - 40); y = 110; }
else if (side === 1) { x = rand(40, W - 40); y = H - 30; }
else if (side === 2) { x = 40; y = rand(120, H - 40); }
else { x = W - 40; y = rand(120, H - 40); }
const ang = Math.atan2(S.c.y - y, S.c.x - x);
S.en.push({ x, y, size: 64, vx: Math.cos(ang) * S.sp, vy: Math.sin(ang) * S.sp, img: anyDecoy() });
}
drawSprite(images.target, S.c.x, S.c.y, S.c.size, 0, 1 + Math.sin(now / 200) * 0.03);
const next = [];
for (const e of S.en) {
e.x += e.vx; e.y += e.vy;
if (dist(e.x, e.y, S.c.x, S.c.y) < S.c.size * 0.5) { addBurst(S.c.x, S.c.y, "#ff3b30"); lose(); return; }
drawSprite(e.img, e.x, e.y, e.size);
next.push(e);
}
S.en = next;
},
click(p) { for (let i = 0; i < S.en.length; i++) { const e = S.en[i]; if (dist(p.x, p.y, e.x, e.y) < e.size * 0.55) { addBurst(e.x, e.y, "#35ff6b"); S.en.splice(i, 1); return; } } },
timeout() { win(); },
},
dodge_sides: {
setup() { S = { p: { x: W / 2, y: H / 2, size: 74 }, pr: [], last: 0, sp: 4 + D * 0.8 }; hud = "Esquive !"; },
frame(now) {
if (now - S.last > 620 - D * 45) {
S.last = now;
const fromLeft = Math.random() < 0.5, y = rand(130, H - 40);
S.pr.push({ x: fromLeft ? -40 : W + 40, y, vx: (fromLeft ? 1 : -1) * S.sp, size: 58, img: anyDecoy() });
}
drawSprite(images.target, S.p.x, S.p.y, S.p.size);
const next = [];
for (const pr of S.pr) {
pr.x += pr.vx;
if (pr.x < -80 || pr.x > W + 80) continue;
if (dist(pr.x, pr.y, S.p.x, S.p.y) < (pr.size + S.p.size) * 0.38) { addBurst(S.p.x, S.p.y, "#ff3b30"); lose(); return; }
drawSprite(pr.img, pr.x, pr.y, pr.size);
next.push(pr);
}
S.pr = next;
},
move(p) { S.p.x = clamp(p.x, 40, W - 40); S.p.y = clamp(p.y, 120, H - 40); },
key(e) {
if (e.key === "ArrowLeft") S.p.x = clamp(S.p.x - 40, 40, W - 40);
if (e.key === "ArrowRight") S.p.x = clamp(S.p.x + 40, 40, W - 40);
if (e.key === "ArrowUp") S.p.y = clamp(S.p.y - 40, 120, H - 40);
if (e.key === "ArrowDown") S.p.y = clamp(S.p.y + 40, 120, H - 40);
},
timeout() { win(); },
},
chase_flee: {
setup() { S = { t: { x: rand(150, W - 150), y: rand(160, H - 120), size: 96 }, sp: 2.4 + D * 0.8 }; hud = "Touche la cible !"; },
frame(now) {
const d = dist(mouse.x, mouse.y, S.t.x, S.t.y);
if (d < 240) { const ang = Math.atan2(S.t.y - mouse.y, S.t.x - mouse.x); S.t.x += Math.cos(ang) * S.sp * 1.7; S.t.y += Math.sin(ang) * S.sp * 1.7; }
S.t.x += Math.sin(now / 300) * 0.6;
S.t.x = clamp(S.t.x, 60, W - 60); S.t.y = clamp(S.t.y, 120, H - 60);
drawSprite(images.target, S.t.x, S.t.y, S.t.size, Math.sin(now / 150) * 0.2);
if (d < S.t.size * 0.5) { addBurst(S.t.x, S.t.y, "#35ff6b"); win(); }
},
},
drag_to_goal: {
setup() {
S = { t: { x: 90, y: H - 90, size: 78 }, goal: { x: W - 90, y: 130, r: 64 }, obs: [] };
const n = 1 + D, sp = 1.8 + D * 0.6;
for (let i = 0; i < n; i++) S.obs.push({ x: rand(220, W - 220), y: rand(160, H - 120), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 70, img: i % 2 ? images.decoy1 : images.decoy2 });
hud = "Amène la cible au but !";
},
frame() {
ctx.save(); ctx.setLineDash([10, 8]); ctx.strokeStyle = "#35ff6b"; ctx.lineWidth = 6;
ctx.beginPath(); ctx.arc(S.goal.x, S.goal.y, S.goal.r, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
for (const o of S.obs) {
bounceObj(o); drawSprite(o.img, o.x, o.y, o.size);
if (dist(o.x, o.y, S.t.x, S.t.y) < (o.size + S.t.size) * 0.4) { addBurst(S.t.x, S.t.y, "#ff3b30"); lose(); return; }
}
drawSprite(images.target, S.t.x, S.t.y, S.t.size);
if (dist(S.t.x, S.t.y, S.goal.x, S.goal.y) < S.goal.r) { addBurst(S.goal.x, S.goal.y, "#35ff6b"); win(); }
},
move(p) { S.t.x = clamp(p.x, 40, W - 40); S.t.y = clamp(p.y, 120, H - 40); },
},
balance: {
setup() { S = { ang: 0, vel: 0, drift: 0.0006 + D * 0.0004 }; hud = "Garde l'équilibre ! (← →)"; },
frame(now) {
S.vel += (Math.random() - 0.5) * S.drift * 60;
S.vel *= 0.985; S.ang += S.vel;
if (Math.abs(S.ang) > 0.95) { lose(); return; }
const baseY = H - 70, baseX = W / 2;
ctx.save(); ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(baseX - 90, baseY + 30); ctx.lineTo(baseX + 90, baseY + 30); ctx.stroke(); ctx.restore();
ctx.save(); ctx.translate(baseX, baseY); ctx.rotate(S.ang); drawSprite(images.target, 0, -90, 120); ctx.restore();
ctx.save(); ctx.fillStyle = Math.abs(S.ang) > 0.6 ? "#ff3b30" : "#35ff6b"; ctx.fillRect(W / 2 - 4 + S.ang * 120, 92, 8, 22); ctx.restore();
},
key(e) { if (e.key === "ArrowLeft") S.vel -= 0.06; if (e.key === "ArrowRight") S.vel += 0.06; },
timeout() { win(); },
},
mash_fill: {
setup() { S = { fill: 0, decay: 0.4 + D * 0.25, gain: 6 }; hud = "Tape vite !"; },
frame() {
S.fill = clamp(S.fill - S.decay, 0, 100);
const bw = W - 160, bx = 80, by = H - 70;
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(bx, by, bw, 36);
ctx.fillStyle = "#ffe600"; ctx.fillRect(bx, by, bw * S.fill / 100, 36);
ctx.strokeStyle = "#fff"; ctx.lineWidth = 4; ctx.strokeRect(bx, by, bw, 36);
ctx.restore();
drawSprite(images.target, W / 2, H / 2 - 30, 90 + S.fill, 0, 1, false);
if (S.fill >= 100) { addBurst(W / 2, H / 2, "#35ff6b"); win(); }
},
click() { S.fill = clamp(S.fill + S.gain, 0, 100); },
key(e) { if (e.code === "Space" || e.key === " ") S.fill = clamp(S.fill + S.gain, 0, 100); },
},
pump_inflate: {
setup() { S = { size: 70, pop: 250, bmin: 150, bmax: 205, gain: 11, deflate: 0.7 }; hud = "Gonfle… sans exploser !"; },
frame() {
S.size = Math.max(60, S.size - S.deflate);
if (S.size > S.pop) { addBurst(W / 2, H / 2 - 10, "#ff3b30"); lose(); return; }
const gx = W - 50, gy0 = 120, gy1 = H - 60, gh = gy1 - gy0;
const toY = v => gy1 - (v - 60) / (S.pop - 60) * gh;
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.4)"; ctx.fillRect(gx, gy0, 20, gh);
ctx.fillStyle = "rgba(53,255,107,0.6)"; ctx.fillRect(gx, toY(S.bmax), 20, toY(S.bmin) - toY(S.bmax));
ctx.strokeStyle = "#fff"; ctx.lineWidth = 3; ctx.strokeRect(gx, gy0, 20, gh);
ctx.fillStyle = "#ffe600"; ctx.fillRect(gx - 6, toY(S.size) - 3, 32, 6);
ctx.restore();
const inBand = S.size >= S.bmin && S.size <= S.bmax;
drawSprite(images.target, W / 2 - 10, H / 2 - 10, S.size, 0, 1, false);
if (inBand) { ctx.save(); ctx.strokeStyle = "#35ff6b"; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(W / 2 - 10, H / 2 - 10, S.size * 0.55, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); }
},
click() { S.size += S.gain; },
key(e) { if (e.code === "Space" || e.key === " ") S.size += S.gain; },
timeout() { if (S.size >= S.bmin && S.size <= S.bmax) win(); else lose(); },
},
hold_steady: {
setup() { S = { zone: { x: W / 2, y: H / 2, r: 82 - D * 6 }, grace: true }; hud = "Garde le curseur dans le rond !"; },
frame(now) {
if (now - startTime > 500) S.grace = false;
S.zone.x = W / 2 + Math.sin(now / 700) * (D * 16);
S.zone.y = H / 2 + Math.cos(now / 900) * (D * 10);
const inside = dist(mouse.x, mouse.y, S.zone.x, S.zone.y) < S.zone.r;
ctx.save(); ctx.strokeStyle = inside ? "#35ff6b" : "#ff3b30"; ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(S.zone.x, S.zone.y, S.zone.r, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
drawSprite(images.target, S.zone.x, S.zone.y, S.zone.r * 1.1, 0, 1, false);
if (!S.grace && !inside) lose();
},
timeout() { win(); },
},
rhythm_tap: {
setup() { S = { interval: 620 - D * 40, need: 3 + D, score: 0, hits: {} }; hud = "0 / " + S.need; },
frame(now) {
const phase = (now - startTime) / S.interval;
const frac = phase - Math.floor(phase);
const near = Math.min(frac, 1 - frac);
const pulse = 1 + 0.5 * Math.pow(Math.abs(Math.cos(frac * Math.PI)), 8);
drawSprite(images.target, W / 2, H / 2 - 10, 110, 0, pulse);
ctx.save(); ctx.strokeStyle = "#ffe600"; ctx.lineWidth = 5;
ctx.beginPath(); ctx.arc(W / 2, H / 2 - 10, 70 + near * 180, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
},
tap(now) {
const phase = (now - startTime) / S.interval;
const idx = Math.round(phase);
const frac = Math.abs(phase - idx);
if (frac < 0.18 && !S.hits[idx]) {
S.hits[idx] = true; S.score++; addBurst(W / 2, H / 2, "#35ff6b"); hud = S.score + " / " + S.need;
if (S.score >= S.need) win();
} else { addBurst(W / 2, H / 2, "#ff3b30"); lose(); }
},
click() { this.tap(performance.now()); },
key(e) { if (e.code === "Space" || e.key === " ") this.tap(performance.now()); },
},
stop_meter: {
setup() { const zw = 120 - D * 12; S = { pos: 60, dir: 1, sp: 4 + D * 1.1, zx: rand(160, W - 160 - zw), zw, stopped: false }; hud = "Stoppe dans le vert !"; },
frame() {
if (!S.stopped) { S.pos += S.dir * S.sp; if (S.pos < 60) { S.pos = 60; S.dir = 1; } if (S.pos > W - 60) { S.pos = W - 60; S.dir = -1; } }
const by = H / 2;
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(60, by - 18, W - 120, 36);
ctx.fillStyle = "rgba(53,255,107,0.7)"; ctx.fillRect(S.zx, by - 18, S.zw, 36);
ctx.strokeStyle = "#fff"; ctx.lineWidth = 4; ctx.strokeRect(60, by - 18, W - 120, 36);
ctx.fillStyle = "#ffe600"; ctx.fillRect(S.pos - 4, by - 30, 8, 60);
ctx.restore();
drawSprite(images.target, W / 2, by - 115, 90);
},
resolve() { if (S.stopped) return; S.stopped = true; const ok = S.pos >= S.zx && S.pos <= S.zx + S.zw; addBurst(S.pos, H / 2, ok ? "#35ff6b" : "#ff3b30"); ok ? win() : lose(); },
click() { this.resolve(); },
key(e) { if (e.code === "Space" || e.key === " ") this.resolve(); },
},
quick_draw: {
setup() { S = { fireAt: rand(900, Math.max(1300, DUR * 1000 * 0.55)), fired: false }; hud = ""; },
frame(now) {
if (now - startTime >= S.fireAt) S.fired = true;
if (!S.fired) { bigText("PRÊT…", "#ffffff"); }
else { drawSprite(images.target, W / 2, H / 2 - 40, 120, 0, 1 + Math.sin(now / 60) * 0.05); bigText("VITE !", "#35ff6b"); }
},
act() { if (S.fired) { addBurst(W / 2, H / 2, "#35ff6b"); win(); } else { addBurst(W / 2, H / 2, "#ff3b30"); lose(); } },
click() { this.act(); },
key(e) { if (e.code === "Space" || e.key === " " || e.code === "Enter") this.act(); },
},
sort_lr: {
setup() { S = { cur: null, score: 0, need: 3 + D, spd: 1.6 + D * 0.5 }; this.spawn(); hud = "0 / " + S.need; },
spawn() { const good = Math.random() < 0.5; S.cur = { x: W / 2, y: 130, size: 84, good, img: good ? images.target : anyDecoy() }; },
frame() {
ctx.save(); ctx.font = "900 28px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillStyle = "rgba(255,255,255,0.75)";
ctx.fillText("← leurre", 130, H - 28); ctx.fillText("cible →", W - 130, H - 28); ctx.restore();
if (S.cur) {
S.cur.y += S.spd;
drawSprite(S.cur.img, S.cur.x, S.cur.y, S.cur.size);
if (S.cur.y > H - 28) { addBurst(S.cur.x, S.cur.y, "#ff3b30"); lose(); return; }
}
},
judge(right) {
if (!S.cur) return;
const correct = right === S.cur.good;
if (correct) {
S.score++; addBurst(S.cur.x, S.cur.y, "#35ff6b"); hud = S.score + " / " + S.need;
if (S.score >= S.need) { win(); return; }
this.spawn();
} else { addBurst(S.cur.x, S.cur.y, "#ff3b30"); lose(); }
},
key(e) { if (e.key === "ArrowRight") this.judge(true); if (e.key === "ArrowLeft") this.judge(false); },
},
};
function currentGame() { return GAMES[SPEC.template] || GAMES.grab_target; }
function startGame() {
result.textContent = ""; particles = []; hud = ""; timer.textContent = DUR;
startBtn.style.display = "none";
running = true; startTime = performance.now();
currentGame().setup();
raf = requestAnimationFrame(drawFrame);
}
function drawFrame(now) {
drawBackground();
const left = timeRemaining(now);
timer.textContent = Math.ceil(left);
const g = currentGame();
if (running && left <= 0) {
if (g.timeout) g.timeout(); else lose();
if (!running) return;
}
if (running) g.frame(now);
drawHud();
drawParticles();
if (running) raf = requestAnimationFrame(drawFrame);
}
function canvasPoint(ev) {
const r = canvas.getBoundingClientRect();
return { x: (ev.clientX - r.left) * (W / r.width), y: (ev.clientY - r.top) * (H / r.height) };
}
canvas.addEventListener("click", e => { if (!running) return; const p = canvasPoint(e); const g = currentGame(); if (g.click) g.click(p); });
canvas.addEventListener("mousemove", e => { const p = canvasPoint(e); mouse.x = p.x; mouse.y = p.y; if (running) { const g = currentGame(); if (g.move) g.move(p); } });
canvas.addEventListener("mousedown", () => { mouse.down = true; });
window.addEventListener("mouseup", () => { mouse.down = false; });
window.addEventListener("keydown", e => {
if (!running) return;
const g = currentGame();
if (g.key) g.key(e);
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " "].indexOf(e.key) >= 0) e.preventDefault();
});
startBtn.onclick = startGame;
loadAssets().then(() => { drawBackground(); timer.textContent = DUR; });
"""
GAME_HTML_TAIL = "\n</script>\n</body>\n</html>\n"
def render_canvas_game(spec: VisualMiniGameSpec, assets: Dict[str, str]):
spec_json = json.dumps(spec.model_dump(), ensure_ascii=False)
assets_json = json.dumps(assets)
data = "const SPEC = " + spec_json + ";\nconst ASSETS = " + assets_json + ";\n"
iframe_html = GAME_HTML_HEAD + data + GAME_ENGINE_JS + GAME_HTML_TAIL
srcdoc = html.escape(iframe_html, quote=True)
return (
'<iframe srcdoc="' + srcdoc + '" '
'style="width:100%; height:620px; border:0; border-radius:18px; overflow:hidden;">'
"</iframe>"
)
# ---------------------------------------------------------------------------
# Pipeline complet (décoré ZeroGPU : GPU attaché le temps de l'appel)
# ---------------------------------------------------------------------------
@spaces.GPU(duration=120)
def make_visual_game(prompt):
spec = safe_generate_spec(prompt)
assets = generate_assets(spec)
html_game = render_canvas_game(spec, assets)
return spec.model_dump(), html_game
# ---------------------------------------------------------------------------
# Interface Gradio
# ---------------------------------------------------------------------------
import gradio as gr
with gr.Blocks() as demo:
gr.Markdown("# POC mini-jeux génératifs visuels")
gr.Markdown(
"Qwen3-4B génère la spec (20 types de mini-jeux). SDXL génère les assets. "
"Canvas exécute le mini-jeu."
)
prompt = gr.Textbox(
label="Inspiration",
value="mini-jeu absurde avec une vraie banane arcade, rapide et visuel",
)
btn = gr.Button("Générer un mini-jeu visuel")
json_out = gr.JSON(label="Spec générée")
game_out = gr.HTML(label="Mini-jeu jouable")
btn.click(
make_visual_game,
inputs=prompt,
outputs=[json_out, game_out],
)
if __name__ == "__main__":
demo.queue().launch()