# -*- 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 = """
\n\n\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 ( '" ) # --------------------------------------------------------------------------- # 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()