Spaces:
Running on Zero
Running on Zero
| # -*- 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) | |
| # --------------------------------------------------------------------------- | |
| 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() | |