Matys BIECHE Claude Opus 4.8 (1M context) commited on
Commit
5214e3b
·
1 Parent(s): a313479

Add 20 WarioWare-style microgame templates with modular canvas engine

Browse files

Replace the 3 hardcoded templates with a 20-game registry (grab/catch/whack/
find/swat/shoot/protect/dodge/chase/drag/balance/mash/pump/hold/rhythm/stop/
quickdraw/sort + timing/avoid). All share one asset contract (bg + target +
2 decoys). LLM prompt updated to pick a varied template and keep instruction
coherent with the target object. Engine rewritten as plain string (no f-string
brace escaping) with per-game setup/frame/click/move/key/timeout hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +643 -479
app.py CHANGED
@@ -13,8 +13,38 @@ from pydantic import BaseModel, Field
13
  from typing import Literal, List, Dict
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  class VisualMiniGameSpec(BaseModel):
17
- template: Literal["grab_target", "avoid_falling", "timing_hit"]
 
 
 
 
 
 
18
  instruction: str = Field(min_length=5, max_length=80)
19
  theme: str = Field(min_length=3, max_length=80)
20
  duration_seconds: int = Field(ge=3, le=8)
@@ -88,10 +118,31 @@ Aucun markdown. Aucun commentaire. Aucun texte hors JSON.
88
  Tu génères un mini-jeu visuel, pas un quiz.
89
  Les objets doivent être rendus comme des vrais assets visuels générables par un modèle image.
90
 
91
- Templates autorisés:
92
- - grab_target: cliquer/toucher rapidement l'objet cible qui bouge
93
- - avoid_falling: éviter des objets qui tombent
94
- - timing_hit: cliquer/toucher au bon moment quand l'objet passe dans une zone
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  Contraintes:
97
  - duration_seconds entre 3 et 8
@@ -112,6 +163,7 @@ COHÉRENCE OBLIGATOIRE (très important):
112
  - Les "decoy_prompts" décrivent des objets DIFFÉRENTS de la cible mais du même univers visuel,
113
  faciles à confondre en un coup d'œil rapide.
114
  - "theme" et "visual_style" doivent rester cohérents entre tous les prompts.
 
115
  """
116
 
117
  EXAMPLE_JSON = {
@@ -147,7 +199,7 @@ def generate_spec(user_prompt: str, temperature: float = 0.75) -> VisualMiniGame
147
  "content": f"""
148
  Inspiration utilisateur: {user_prompt}
149
 
150
- Génère un JSON exactement dans ce style:
151
  {json.dumps(EXAMPLE_JSON, ensure_ascii=False, indent=2)}
152
  """,
153
  },
@@ -326,126 +378,38 @@ def generate_assets(spec: VisualMiniGameSpec):
326
 
327
  # ---------------------------------------------------------------------------
328
  # Rendu du mini-jeu (canvas dans une iframe)
 
 
 
329
  # ---------------------------------------------------------------------------
330
- def render_canvas_game(spec: VisualMiniGameSpec, assets: Dict[str, str]):
331
- spec_json = json.dumps(spec.model_dump(), ensure_ascii=False)
332
- assets_json = json.dumps(assets)
333
-
334
- iframe_html = f"""
335
- <!doctype html>
336
  <html>
337
  <head>
338
  <meta charset="utf-8" />
339
  <style>
340
- html, body {{
341
- margin: 0;
342
- padding: 0;
343
- width: 100%;
344
- height: 100%;
345
- overflow: hidden;
346
- background: #111;
347
- font-family: system-ui, sans-serif;
348
- }}
349
-
350
- #wrap {{
351
- position: relative;
352
- width: 100%;
353
- height: 100vh;
354
- background: #111;
355
- }}
356
-
357
- canvas {{
358
- display: block;
359
- width: 100%;
360
- height: 100%;
361
- background: #000;
362
- }}
363
-
364
- #hud {{
365
- position: absolute;
366
- left: 16px;
367
- right: 16px;
368
- top: 14px;
369
- display: flex;
370
- justify-content: space-between;
371
- align-items: flex-start;
372
- pointer-events: none;
373
- color: white;
374
- text-shadow: 0 3px 0 #000;
375
- }}
376
-
377
- #instruction {{
378
- font-size: 32px;
379
- font-weight: 1000;
380
- line-height: 1;
381
- max-width: 72%;
382
- }}
383
-
384
- #timer {{
385
- font-size: 42px;
386
- font-weight: 1000;
387
- background: #ff2d55;
388
- border: 4px solid white;
389
- color: white;
390
- padding: 4px 16px;
391
- border-radius: 16px;
392
- box-shadow: 0 5px 0 #000;
393
- }}
394
-
395
- #center {{
396
- position: absolute;
397
- left: 50%;
398
- top: 50%;
399
- transform: translate(-50%, -50%);
400
- }}
401
-
402
- #start {{
403
- font-size: 30px;
404
- font-weight: 1000;
405
- padding: 20px 42px;
406
- border: 5px solid white;
407
- border-radius: 22px;
408
- background: #ffe600;
409
- color: #111;
410
- cursor: pointer;
411
- box-shadow: 0 8px 0 #000;
412
- }}
413
-
414
- #result {{
415
- position: absolute;
416
- left: 0;
417
- right: 0;
418
- bottom: 26px;
419
- text-align: center;
420
- color: white;
421
- font-size: 42px;
422
- font-weight: 1000;
423
- text-shadow: 0 4px 0 #000;
424
- pointer-events: none;
425
- }}
426
  </style>
427
  </head>
428
-
429
  <body>
430
  <div id="wrap">
431
  <canvas id="game" width="768" height="512"></canvas>
432
-
433
- <div id="hud">
434
- <div id="instruction"></div>
435
- <div id="timer"></div>
436
- </div>
437
-
438
- <div id="center">
439
- <button id="start">START</button>
440
- </div>
441
-
442
  <div id="result"></div>
443
  </div>
444
-
445
  <script>
446
- const spec = {spec_json};
447
- const assets = {assets_json};
448
 
 
 
449
  const canvas = document.getElementById("game");
450
  const ctx = canvas.getContext("2d");
451
  const instruction = document.getElementById("instruction");
@@ -453,406 +417,605 @@ const timer = document.getElementById("timer");
453
  const startBtn = document.getElementById("start");
454
  const result = document.getElementById("result");
455
 
456
- instruction.textContent = spec.instruction;
457
- timer.textContent = spec.duration_seconds;
458
 
459
- const W = canvas.width;
460
- const H = canvas.height;
461
 
462
- let images = {{}};
463
- let running = false;
464
- let startTime = 0;
465
- let raf = null;
466
  let particles = [];
467
- let gameState = null;
468
-
469
- function loadImage(src) {{
470
- return new Promise(resolve => {{
471
- const img = new Image();
472
- img.onload = () => resolve(img);
473
- img.src = "data:image/png;base64," + src;
474
- }});
475
- }}
476
-
477
- async function loadAssets() {{
478
- images.background = await loadImage(assets.background);
479
- images.target = await loadImage(assets.target);
480
- images.decoy1 = await loadImage(assets.decoy_1);
481
- images.decoy2 = await loadImage(assets.decoy_2);
482
- }}
483
-
484
- function rand(min, max) {{
485
- return min + Math.random() * (max - min);
486
- }}
487
-
488
- function clamp(v, min, max) {{
489
- return Math.max(min, Math.min(max, v));
490
- }}
491
-
492
- function drawCover(img, x, y, w, h) {{
493
- const s = Math.max(w / img.width, h / img.height);
494
- const nw = img.width * s;
495
- const nh = img.height * s;
496
- ctx.drawImage(img, x + (w - nw) / 2, y + (h - nh) / 2, nw, nh);
497
- }}
498
-
499
- function drawSprite(img, x, y, size, angle=0, pulse=1) {{
500
- ctx.save();
501
- ctx.translate(x, y);
502
- ctx.rotate(angle);
503
- ctx.scale(pulse, pulse);
504
 
505
- ctx.shadowColor = "rgba(0,0,0,0.45)";
506
- ctx.shadowBlur = 16;
507
- ctx.shadowOffsetY = 8;
 
 
 
 
 
 
 
508
 
509
- ctx.fillStyle = "white";
510
- ctx.beginPath();
511
- ctx.roundRect(-size/2, -size/2, size, size, 24);
512
- ctx.fill();
513
 
514
- ctx.drawImage(img, -size/2 + 8, -size/2 + 8, size - 16, size - 16);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  ctx.restore();
516
- }}
517
-
518
- function addBurst(x, y, color="#ffe600") {{
519
- for (let i = 0; i < 26; i++) {{
520
- particles.push({{
521
- x, y,
522
- vx: rand(-7, 7),
523
- vy: rand(-7, 7),
524
- life: rand(18, 34),
525
- color
526
- }});
527
- }}
528
- }}
529
-
530
- function drawParticles() {{
531
  particles = particles.filter(p => p.life > 0);
532
- for (const p of particles) {{
533
- p.x += p.vx;
534
- p.y += p.vy;
535
- p.vy += 0.25;
536
- p.life -= 1;
537
-
538
  ctx.globalAlpha = clamp(p.life / 30, 0, 1);
539
  ctx.fillStyle = p.color;
540
- ctx.beginPath();
541
- ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
542
- ctx.fill();
543
  ctx.globalAlpha = 1;
544
- }}
545
- }}
 
 
 
 
 
 
 
 
 
546
 
547
- function endGame(success) {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  if (!running) return;
549
  running = false;
550
  cancelAnimationFrame(raf);
551
-
552
- result.textContent = success ? spec.success_text : spec.failure_text;
553
  result.style.color = success ? "#35ff6b" : "#ff3b30";
554
-
555
- addBurst(W/2, H/2, success ? "#35ff6b" : "#ff3b30");
556
-
557
- for (let i = 0; i < 20; i++) {{
558
- drawFrame(performance.now());
559
- }}
560
-
 
561
  startBtn.textContent = "REJOUER";
562
  startBtn.style.display = "block";
563
- }}
564
-
565
- function timeRemaining(now) {{
566
- const elapsed = (now - startTime) / 1000;
567
- return Math.max(0, spec.duration_seconds - elapsed);
568
- }}
569
-
570
- function resetBase() {{
571
- result.textContent = "";
572
- particles = [];
573
- timer.textContent = spec.duration_seconds;
574
- }}
575
-
576
- function initGrabTarget() {{
577
- const speed = 2.6 + spec.difficulty * 0.8;
578
-
579
- gameState = {{
580
- target: {{
581
- x: rand(130, W - 130),
582
- y: rand(150, H - 110),
583
- vx: rand(-speed, speed),
584
- vy: rand(-speed, speed),
585
- size: 120
586
- }},
587
- decoys: [
588
- {{
589
- x: rand(100, W - 100),
590
- y: rand(150, H - 100),
591
- vx: rand(-speed, speed),
592
- vy: rand(-speed, speed),
593
- size: 105,
594
- img: images.decoy1
595
- }},
596
- {{
597
- x: rand(100, W - 100),
598
- y: rand(150, H - 100),
599
- vx: rand(-speed, speed),
600
- vy: rand(-speed, speed),
601
- size: 105,
602
- img: images.decoy2
603
- }}
604
- ]
605
- }};
606
- }}
607
-
608
- function updateMovingObject(o) {{
609
- o.x += o.vx;
610
- o.y += o.vy;
611
-
612
- if (o.x < o.size/2 || o.x > W - o.size/2) o.vx *= -1;
613
- if (o.y < 115 || o.y > H - o.size/2) o.vy *= -1;
614
-
615
- o.x = clamp(o.x, o.size/2, W - o.size/2);
616
- o.y = clamp(o.y, 115, H - o.size/2);
617
- }}
618
-
619
- function initAvoidFalling() {{
620
- const count = 4 + spec.difficulty * 2;
621
- const hazards = [];
622
-
623
- for (let i = 0; i < count; i++) {{
624
- hazards.push({{
625
- x: rand(60, W - 60),
626
- y: rand(-H, -40),
627
- size: rand(70, 105),
628
- speed: rand(2.4, 4.5) + spec.difficulty * 0.6,
629
- img: Math.random() > 0.5 ? images.target : images.decoy1
630
- }});
631
- }}
632
-
633
- gameState = {{
634
- player: {{ x: W/2, y: H - 65, size: 92 }},
635
- hazards
636
- }};
637
- }}
638
-
639
- function initTimingHit() {{
640
- const speed = 4 + spec.difficulty * 1.1;
641
-
642
- gameState = {{
643
- obj: {{
644
- x: -80,
645
- y: H / 2 + 40,
646
- size: 115,
647
- speed
648
- }},
649
- zoneX: W / 2,
650
- zoneW: 120,
651
- resolved: false
652
- }};
653
- }}
654
-
655
- function startGame() {{
656
- resetBase();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
  startBtn.style.display = "none";
658
- running = true;
659
- startTime = performance.now();
660
-
661
- if (spec.template === "grab_target") initGrabTarget();
662
- if (spec.template === "avoid_falling") initAvoidFalling();
663
- if (spec.template === "timing_hit") initTimingHit();
664
-
665
  raf = requestAnimationFrame(drawFrame);
666
- }}
667
-
668
- function drawBackground() {{
669
- drawCover(images.background, 0, 0, W, H);
670
-
671
- ctx.fillStyle = "rgba(0,0,0,0.22)";
672
- ctx.fillRect(0, 0, W, H);
673
-
674
- ctx.strokeStyle = "rgba(255,255,255,0.35)";
675
- ctx.lineWidth = 8;
676
- ctx.strokeRect(8, 8, W - 16, H - 16);
677
- }}
678
 
679
- function drawFrame(now) {{
680
  drawBackground();
681
-
682
  const left = timeRemaining(now);
683
  timer.textContent = Math.ceil(left);
 
 
 
 
 
 
 
 
 
 
684
 
685
- if (running && left <= 0) {{
686
- if (spec.template === "avoid_falling") endGame(true);
687
- else endGame(false);
688
- return;
689
- }}
690
-
691
- const t = now / 1000;
692
-
693
- if (running && spec.template === "grab_target") {{
694
- const target = gameState.target;
695
- updateMovingObject(target);
696
-
697
- for (const d of gameState.decoys) {{
698
- updateMovingObject(d);
699
- drawSprite(d.img, d.x, d.y, d.size, Math.sin(t * 4) * 0.12, 1);
700
- }}
701
-
702
- drawSprite(
703
- images.target,
704
- target.x,
705
- target.y,
706
- target.size,
707
- Math.sin(t * 5) * 0.16,
708
- 1 + Math.sin(t * 8) * 0.04
709
- );
710
- }}
711
-
712
- if (running && spec.template === "avoid_falling") {{
713
- const p = gameState.player;
714
-
715
- drawSprite(images.target, p.x, p.y, p.size, 0, 1);
716
-
717
- for (const h of gameState.hazards) {{
718
- h.y += h.speed;
719
-
720
- if (h.y > H + 80) {{
721
- h.y = rand(-220, -60);
722
- h.x = rand(60, W - 60);
723
- }}
724
-
725
- drawSprite(h.img, h.x, h.y, h.size, Math.sin(t * 6 + h.x) * 0.2, 1);
726
-
727
- const dx = h.x - p.x;
728
- const dy = h.y - p.y;
729
- const dist = Math.sqrt(dx*dx + dy*dy);
730
-
731
- if (dist < (h.size + p.size) * 0.38) {{
732
- endGame(false);
733
- return;
734
- }}
735
- }}
736
- }}
737
-
738
- if (running && spec.template === "timing_hit") {{
739
- const obj = gameState.obj;
740
- const zoneX = gameState.zoneX;
741
- const zoneW = gameState.zoneW;
742
-
743
- obj.x += obj.speed;
744
-
745
- ctx.fillStyle = "rgba(53,255,107,0.35)";
746
- ctx.fillRect(zoneX - zoneW/2, 105, zoneW, H - 125);
747
 
748
- ctx.strokeStyle = "#35ff6b";
749
- ctx.lineWidth = 6;
750
- ctx.strokeRect(zoneX - zoneW/2, 105, zoneW, H - 125);
 
 
 
 
 
 
 
751
 
752
- drawSprite(images.target, obj.x, obj.y, obj.size, Math.sin(t*7)*0.15, 1);
753
 
754
- if (obj.x > W + 100) {{
755
- endGame(false);
756
- return;
757
- }}
758
- }}
759
 
760
- drawParticles();
761
 
762
- if (running) {{
763
- raf = requestAnimationFrame(drawFrame);
764
- }}
765
- }}
766
-
767
- function canvasPoint(event) {{
768
- const rect = canvas.getBoundingClientRect();
769
- return {{
770
- x: (event.clientX - rect.left) * (W / rect.width),
771
- y: (event.clientY - rect.top) * (H / rect.height)
772
- }};
773
- }}
774
-
775
- canvas.addEventListener("mousemove", e => {{
776
- if (!running || spec.template !== "avoid_falling") return;
777
- const p = canvasPoint(e);
778
- gameState.player.x = clamp(p.x, 55, W - 55);
779
- }});
780
-
781
- canvas.addEventListener("click", e => {{
782
- if (!running) return;
783
- const p = canvasPoint(e);
784
-
785
- if (spec.template === "grab_target") {{
786
- const target = gameState.target;
787
- const dx = p.x - target.x;
788
- const dy = p.y - target.y;
789
- const dist = Math.sqrt(dx*dx + dy*dy);
790
-
791
- if (dist < target.size * 0.48) {{
792
- addBurst(target.x, target.y, "#35ff6b");
793
- endGame(true);
794
- return;
795
- }}
796
-
797
- for (const d of gameState.decoys) {{
798
- const ddx = p.x - d.x;
799
- const ddy = p.y - d.y;
800
- const ddist = Math.sqrt(ddx*ddx + ddy*ddy);
801
- if (ddist < d.size * 0.48) {{
802
- addBurst(d.x, d.y, "#ff3b30");
803
- endGame(false);
804
- return;
805
- }}
806
- }}
807
- }}
808
-
809
- if (spec.template === "timing_hit") {{
810
- const obj = gameState.obj;
811
- const zoneX = gameState.zoneX;
812
- const zoneW = gameState.zoneW;
813
- const success = obj.x > zoneX - zoneW/2 && obj.x < zoneX + zoneW/2;
814
- addBurst(obj.x, obj.y, success ? "#35ff6b" : "#ff3b30");
815
- endGame(success);
816
- }}
817
- }});
818
-
819
- window.addEventListener("keydown", e => {{
820
- if (!running) return;
821
 
822
- if (spec.template === "timing_hit" && (e.code === "Space" || e.code === "Enter")) {{
823
- const obj = gameState.obj;
824
- const zoneX = gameState.zoneX;
825
- const zoneW = gameState.zoneW;
826
- const success = obj.x > zoneX - zoneW/2 && obj.x < zoneX + zoneW/2;
827
- endGame(success);
828
- }}
829
-
830
- if (spec.template === "avoid_falling") {{
831
- const p = gameState.player;
832
- if (e.key === "ArrowLeft" || e.key === "q" || e.key === "a") p.x = clamp(p.x - 42, 55, W - 55);
833
- if (e.key === "ArrowRight" || e.key === "d") p.x = clamp(p.x + 42, 55, W - 55);
834
- }}
835
- }});
836
-
837
- startBtn.onclick = startGame;
838
 
839
- loadAssets().then(() => {{
840
- drawBackground();
841
- timer.textContent = spec.duration_seconds;
842
- }});
843
- </script>
844
- </body>
845
- </html>
846
- """
847
 
848
  srcdoc = html.escape(iframe_html, quote=True)
849
-
850
- return f"""
851
- <iframe
852
- srcdoc="{srcdoc}"
853
- style="width:100%; height:620px; border:0; border-radius:18px; overflow:hidden;"
854
- ></iframe>
855
- """
856
 
857
 
858
  # ---------------------------------------------------------------------------
@@ -874,7 +1037,8 @@ import gradio as gr
874
  with gr.Blocks() as demo:
875
  gr.Markdown("# POC mini-jeux génératifs visuels")
876
  gr.Markdown(
877
- "Qwen3-4B génère la spec. SDXL génère les assets. Canvas exécute le mini-jeu."
 
878
  )
879
 
880
  prompt = gr.Textbox(
 
13
  from typing import Literal, List, Dict
14
 
15
 
16
+ TEMPLATES = [
17
+ "grab_target", # cliquer la cible qui bouge parmi des leurres
18
+ "avoid_falling", # déplacer la cible pour éviter les objets qui tombent
19
+ "timing_hit", # cliquer/espace quand la cible passe dans la zone
20
+ "catch_falling", # déplacer un panier pour attraper les cibles, éviter les leurres
21
+ "whack_a_mole", # taper les cibles qui surgissent, éviter les leurres
22
+ "find_target", # retrouver et cliquer l'unique cible cachée parmi les leurres
23
+ "swat_all", # cliquer TOUS les objets qui bougent avant la fin
24
+ "shoot_target", # tirer (clic) plusieurs fois sur la cible qui fuit
25
+ "protect_center", # cliquer les leurres avant qu'ils n'atteignent la cible centrale
26
+ "dodge_sides", # déplacer la cible pour esquiver les projectiles latéraux
27
+ "chase_flee", # rattraper avec le curseur la cible qui s'enfuit
28
+ "drag_to_goal", # guider la cible (souris) jusqu'à la zone but sans toucher les leurres
29
+ "balance", # garder la cible en équilibre au centre (flèches gauche/droite)
30
+ "mash_fill", # marteler clic/espace pour remplir la barre
31
+ "pump_inflate", # gonfler la cible jusqu'à la bonne taille sans la faire exploser
32
+ "hold_steady", # garder le curseur dans la zone sans en sortir
33
+ "rhythm_tap", # taper en rythme quand la cible pulse
34
+ "stop_meter", # stopper l'aiguille pile dans la zone verte
35
+ "quick_draw", # attendre le signal puis cliquer le plus vite possible
36
+ "sort_lr", # trier : cible à droite (→), leurre à gauche (←)
37
+ ]
38
+
39
+
40
  class VisualMiniGameSpec(BaseModel):
41
+ template: Literal[
42
+ "grab_target", "avoid_falling", "timing_hit", "catch_falling",
43
+ "whack_a_mole", "find_target", "swat_all", "shoot_target",
44
+ "protect_center", "dodge_sides", "chase_flee", "drag_to_goal",
45
+ "balance", "mash_fill", "pump_inflate", "hold_steady",
46
+ "rhythm_tap", "stop_meter", "quick_draw", "sort_lr",
47
+ ]
48
  instruction: str = Field(min_length=5, max_length=80)
49
  theme: str = Field(min_length=3, max_length=80)
50
  duration_seconds: int = Field(ge=3, le=8)
 
118
  Tu génères un mini-jeu visuel, pas un quiz.
119
  Les objets doivent être rendus comme des vrais assets visuels générables par un modèle image.
120
 
121
+ Dans CHAQUE jeu il y a:
122
+ - "target" = l'objet HÉROS, celui que l'instruction met en avant (target_prompt le décrit)
123
+ - "decoys" = 2 autres objets du même univers (leurres, obstacles ou distracteurs)
124
+
125
+ Templates autorisés (choisis celui qui colle le mieux à l'inspiration):
126
+ - grab_target: cliquer la cible qui bouge parmi les leurres ("Attrape ...!")
127
+ - avoid_falling: déplacer la cible pour éviter les leurres qui tombent ("Évite ...!")
128
+ - timing_hit: cliquer pile quand la cible passe dans la zone ("Pile au centre !")
129
+ - catch_falling: bouger un panier pour attraper les cibles, éviter les leurres ("Attrape qui tombe !")
130
+ - whack_a_mole: taper les cibles qui surgissent, pas les leurres ("Tape les ...!")
131
+ - find_target: retrouver l'unique cible cachée parmi les leurres ("Trouve la ...!")
132
+ - swat_all: cliquer TOUS les objets qui bougent avant la fin ("Écrase tout !")
133
+ - shoot_target: tirer plusieurs fois sur la cible qui fuit ("Tire sur la ...!")
134
+ - protect_center: cliquer les leurres avant qu'ils n'atteignent la cible centrale ("Protège la ...!")
135
+ - dodge_sides: déplacer la cible pour esquiver les projectiles latéraux ("Esquive !")
136
+ - chase_flee: rattraper avec le curseur la cible qui s'enfuit ("Rattrape la ...!")
137
+ - drag_to_goal: guider la cible à la souris jusqu'au but sans toucher les leurres ("Amène la ... au but !")
138
+ - balance: garder la cible en équilibre au centre, flèches gauche/droite ("Garde l'équilibre !")
139
+ - mash_fill: marteler clic/espace pour remplir la barre ("Tape vite !")
140
+ - pump_inflate: gonfler la cible à la bonne taille sans la faire exploser ("Gonfle sans exploser !")
141
+ - hold_steady: garder le curseur dans la zone sans en sortir ("Ne bouge plus !")
142
+ - rhythm_tap: taper en rythme quand la cible pulse ("Tape en rythme !")
143
+ - stop_meter: stopper l'aiguille pile dans la zone verte ("Stoppe au bon moment !")
144
+ - quick_draw: attendre le signal puis cliquer le plus vite possible ("Dégaine !")
145
+ - sort_lr: trier la cible à droite, le leurre à gauche, avec les flèches ("Trie vite !")
146
 
147
  Contraintes:
148
  - duration_seconds entre 3 et 8
 
163
  - Les "decoy_prompts" décrivent des objets DIFFÉRENTS de la cible mais du même univers visuel,
164
  faciles à confondre en un coup d'œil rapide.
165
  - "theme" et "visual_style" doivent rester cohérents entre tous les prompts.
166
+ - Choisis un template VARIÉ et adapté à l'inspiration (ne prends pas toujours grab_target).
167
  """
168
 
169
  EXAMPLE_JSON = {
 
199
  "content": f"""
200
  Inspiration utilisateur: {user_prompt}
201
 
202
+ Génère un JSON exactement dans ce style (mais choisis le template le plus adapté):
203
  {json.dumps(EXAMPLE_JSON, ensure_ascii=False, indent=2)}
204
  """,
205
  },
 
378
 
379
  # ---------------------------------------------------------------------------
380
  # Rendu du mini-jeu (canvas dans une iframe)
381
+ #
382
+ # Moteur modulaire : un même contrat d'assets (background + target + 2 decoys)
383
+ # alimente 20 mécaniques de micro-jeu différentes (registre GAMES côté JS).
384
  # ---------------------------------------------------------------------------
385
+ GAME_HTML_HEAD = """<!doctype html>
 
 
 
 
 
386
  <html>
387
  <head>
388
  <meta charset="utf-8" />
389
  <style>
390
+ html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#111; font-family: system-ui, sans-serif; }
391
+ #wrap { position:relative; width:100%; height:100vh; background:#111; }
392
+ canvas { display:block; width:100%; height:100%; background:#000; }
393
+ #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; }
394
+ #instruction { font-size:30px; font-weight:1000; line-height:1.05; max-width:72%; }
395
+ #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; }
396
+ #center { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
397
+ #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; }
398
+ #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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  </style>
400
  </head>
 
401
  <body>
402
  <div id="wrap">
403
  <canvas id="game" width="768" height="512"></canvas>
404
+ <div id="hud"><div id="instruction"></div><div id="timer"></div></div>
405
+ <div id="center"><button id="start">START</button></div>
 
 
 
 
 
 
 
 
406
  <div id="result"></div>
407
  </div>
 
408
  <script>
409
+ """
 
410
 
411
+ # JS du moteur : chaîne BRUTE (pas de f-string) -> accolades et ${} littérales.
412
+ GAME_ENGINE_JS = r"""
413
  const canvas = document.getElementById("game");
414
  const ctx = canvas.getContext("2d");
415
  const instruction = document.getElementById("instruction");
 
417
  const startBtn = document.getElementById("start");
418
  const result = document.getElementById("result");
419
 
420
+ const W = canvas.width, H = canvas.height;
421
+ const D = SPEC.difficulty, DUR = SPEC.duration_seconds;
422
 
423
+ instruction.textContent = SPEC.instruction;
424
+ timer.textContent = DUR;
425
 
426
+ let images = {};
427
+ let running = false, startTime = 0, raf = null;
 
 
428
  let particles = [];
429
+ let S = null; // état du jeu courant
430
+ let hud = ""; // texte de progression dessiné en haut au centre
431
+ const mouse = { x: W / 2, y: H / 2, down: false };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
 
433
+ function loadImage(src) {
434
+ return new Promise(res => { const im = new Image(); im.onload = () => res(im); im.src = "data:image/png;base64," + src; });
435
+ }
436
+ async function loadAssets() {
437
+ images.bg = await loadImage(ASSETS.background);
438
+ images.target = await loadImage(ASSETS.target);
439
+ images.decoy1 = await loadImage(ASSETS.decoy_1);
440
+ images.decoy2 = await loadImage(ASSETS.decoy_2);
441
+ }
442
+ function anyDecoy() { return Math.random() < 0.5 ? images.decoy1 : images.decoy2; }
443
 
444
+ function rand(a, b) { return a + Math.random() * (b - a); }
445
+ function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
446
+ function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx * dx + dy * dy); }
 
447
 
448
+ function drawCover(img, x, y, w, h) {
449
+ const s = Math.max(w / img.width, h / img.height);
450
+ const nw = img.width * s, nh = img.height * s;
451
+ ctx.drawImage(img, x + (w - nw) / 2, y + (h - nh) / 2, nw, nh);
452
+ }
453
+ function drawSprite(img, x, y, size, angle, pulse, card) {
454
+ if (angle === undefined) angle = 0;
455
+ if (pulse === undefined) pulse = 1;
456
+ if (card === undefined) card = true;
457
+ ctx.save();
458
+ ctx.translate(x, y); ctx.rotate(angle); ctx.scale(pulse, pulse);
459
+ ctx.shadowColor = "rgba(0,0,0,0.45)"; ctx.shadowBlur = 16; ctx.shadowOffsetY = 8;
460
+ if (card) {
461
+ ctx.fillStyle = "white";
462
+ ctx.beginPath(); ctx.roundRect(-size / 2, -size / 2, size, size, 24); ctx.fill();
463
+ }
464
+ ctx.shadowColor = "transparent";
465
+ const pad = card ? 8 : 0;
466
+ ctx.drawImage(img, -size / 2 + pad, -size / 2 + pad, size - 2 * pad, size - 2 * pad);
467
  ctx.restore();
468
+ }
469
+ function addBurst(x, y, color) {
470
+ color = color || "#ffe600";
471
+ for (let i = 0; i < 26; i++) particles.push({ x, y, vx: rand(-7, 7), vy: rand(-7, 7), life: rand(18, 34), color });
472
+ }
473
+ function drawParticles() {
 
 
 
 
 
 
 
 
 
474
  particles = particles.filter(p => p.life > 0);
475
+ for (const p of particles) {
476
+ p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.life -= 1;
 
 
 
 
477
  ctx.globalAlpha = clamp(p.life / 30, 0, 1);
478
  ctx.fillStyle = p.color;
479
+ ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fill();
 
 
480
  ctx.globalAlpha = 1;
481
+ }
482
+ }
483
+ function bigText(txt, color) {
484
+ ctx.save();
485
+ ctx.fillStyle = color; ctx.font = "900 64px system-ui, sans-serif";
486
+ ctx.textAlign = "center"; ctx.textBaseline = "middle";
487
+ ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowOffsetY = 4;
488
+ ctx.fillText(txt, W / 2, H / 2 + 60);
489
+ ctx.restore();
490
+ }
491
+ function timeRemaining(now) { return Math.max(0, DUR - (now - startTime) / 1000); }
492
 
493
+ function drawBackground() {
494
+ drawCover(images.bg, 0, 0, W, H);
495
+ ctx.fillStyle = "rgba(0,0,0,0.22)"; ctx.fillRect(0, 0, W, H);
496
+ ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 8; ctx.strokeRect(8, 8, W - 16, H - 16);
497
+ }
498
+ function drawHud() {
499
+ if (!hud) return;
500
+ ctx.save();
501
+ ctx.fillStyle = "white"; ctx.font = "900 26px system-ui, sans-serif";
502
+ ctx.textAlign = "center"; ctx.shadowColor = "#000"; ctx.shadowOffsetY = 3;
503
+ ctx.fillText(hud, W / 2, 70);
504
+ ctx.restore();
505
+ }
506
+ function bounceObj(o, top) {
507
+ if (top === undefined) top = 100;
508
+ o.x += o.vx; o.y += o.vy;
509
+ const r = o.size / 2;
510
+ if (o.x < r || o.x > W - r) o.vx *= -1;
511
+ if (o.y < top + r || o.y > H - r) o.vy *= -1;
512
+ o.x = clamp(o.x, r, W - r); o.y = clamp(o.y, top + r, H - r);
513
+ }
514
+
515
+ function endGame(success) {
516
  if (!running) return;
517
  running = false;
518
  cancelAnimationFrame(raf);
519
+ result.textContent = success ? SPEC.success_text : SPEC.failure_text;
 
520
  result.style.color = success ? "#35ff6b" : "#ff3b30";
521
+ addBurst(W / 2, H / 2, success ? "#35ff6b" : "#ff3b30");
522
+ let n = 0;
523
+ const anim = () => {
524
+ drawBackground();
525
+ drawParticles();
526
+ if (n++ < 32) requestAnimationFrame(anim);
527
+ };
528
+ requestAnimationFrame(anim);
529
  startBtn.textContent = "REJOUER";
530
  startBtn.style.display = "block";
531
+ }
532
+ function win() { endGame(true); }
533
+ function lose() { endGame(false); }
534
+
535
+ // =========================================================================
536
+ // Registre des 20 mini-jeux. Chacun : setup(), frame(now) + handlers optionnels
537
+ // click(p) / move(p) / key(e) / timeout(). timeout par défaut = défaite.
538
+ // =========================================================================
539
+ const GAMES = {
540
+
541
+ grab_target: {
542
+ setup() {
543
+ const sp = 2.6 + D * 0.8;
544
+ S = {
545
+ target: { x: rand(130, W - 130), y: rand(150, H - 110), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 120 },
546
+ decoys: [
547
+ { x: rand(100, W - 100), y: rand(150, H - 100), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 105, img: images.decoy1 },
548
+ { x: rand(100, W - 100), y: rand(150, H - 100), vx: rand(-sp, sp), vy: rand(-sp, sp), size: 105, img: images.decoy2 },
549
+ ],
550
+ };
551
+ },
552
+ frame(now) {
553
+ const t = now / 1000;
554
+ for (const d of S.decoys) { bounceObj(d); drawSprite(d.img, d.x, d.y, d.size, Math.sin(t * 4) * 0.12); }
555
+ bounceObj(S.target);
556
+ 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);
557
+ },
558
+ click(p) {
559
+ const g = S.target;
560
+ if (dist(p.x, p.y, g.x, g.y) < g.size * 0.5) { addBurst(g.x, g.y, "#35ff6b"); win(); return; }
561
+ 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; }
562
+ },
563
+ },
564
+
565
+ avoid_falling: {
566
+ setup() {
567
+ const n = 4 + D * 2;
568
+ S = { p: { x: W / 2, y: H - 70, size: 88 }, hz: [] };
569
+ 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() });
570
+ },
571
+ frame(now) {
572
+ const t = now / 1000;
573
+ drawSprite(images.target, S.p.x, S.p.y, S.p.size);
574
+ for (const h of S.hz) {
575
+ h.y += h.speed;
576
+ if (h.y > H + 80) { h.y = rand(-220, -60); h.x = rand(60, W - 60); }
577
+ drawSprite(h.img, h.x, h.y, h.size, Math.sin(t * 6 + h.x) * 0.2);
578
+ 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; }
579
+ }
580
+ },
581
+ move(p) { S.p.x = clamp(p.x, 55, W - 55); },
582
+ 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); },
583
+ timeout() { win(); },
584
+ },
585
+
586
+ timing_hit: {
587
+ 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 }; },
588
+ frame(now) {
589
+ ctx.save();
590
+ ctx.fillStyle = "rgba(53,255,107,0.30)"; ctx.fillRect(S.zx, 100, S.zw, H - 130);
591
+ ctx.strokeStyle = "#35ff6b"; ctx.lineWidth = 6; ctx.strokeRect(S.zx, 100, S.zw, H - 130);
592
+ ctx.restore();
593
+ S.obj.x += S.obj.speed;
594
+ drawSprite(images.target, S.obj.x, S.obj.y, S.obj.size, Math.sin(now / 130) * 0.15);
595
+ if (S.obj.x > W + 100) lose();
596
+ },
597
+ 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(); },
598
+ click() { this.resolve(); },
599
+ key(e) { if (e.code === "Space" || e.key === "Enter" || e.key === " ") this.resolve(); },
600
+ },
601
+
602
+ catch_falling: {
603
+ 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; },
604
+ frame(now) {
605
+ if (now - S.last > 1000 - D * 60) {
606
+ S.last = now;
607
+ const good = Math.random() < 0.6;
608
+ S.items.push({ x: rand(60, W - 60), y: -40, size: 78, good, img: good ? images.target : anyDecoy() });
609
+ }
610
+ ctx.save();
611
+ ctx.fillStyle = "#ffe600"; ctx.strokeStyle = "#000"; ctx.lineWidth = 4;
612
+ ctx.beginPath(); ctx.roundRect(S.paddle.x - S.paddle.w / 2, S.paddle.y, S.paddle.w, 26, 12); ctx.fill(); ctx.stroke();
613
+ ctx.restore();
614
+ const next = [];
615
+ for (const it of S.items) {
616
+ it.y += S.spd;
617
+ if (it.y >= S.paddle.y - 24 && it.y <= S.paddle.y + 30 && Math.abs(it.x - S.paddle.x) < S.paddle.w / 2) {
618
+ if (it.good) { S.score++; addBurst(it.x, it.y, "#35ff6b"); hud = S.score + " / " + S.need; if (S.score >= S.need) { win(); return; } }
619
+ else { addBurst(it.x, it.y, "#ff3b30"); lose(); return; }
620
+ continue;
621
+ }
622
+ if (it.y > H + 60) continue;
623
+ drawSprite(it.img, it.x, it.y, it.size);
624
+ next.push(it);
625
+ }
626
+ S.items = next;
627
+ },
628
+ move(p) { S.paddle.x = clamp(p.x, S.paddle.w / 2, W - S.paddle.w / 2); },
629
+ },
630
+
631
+ whack_a_mole: {
632
+ setup() {
633
+ const cols = 4, rows = 2;
634
+ S = { holes: [], active: null, score: 0, need: 3 + D };
635
+ 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 });
636
+ hud = "0 / " + S.need;
637
+ },
638
+ frame(now) {
639
+ 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(); }
640
+ if (!S.active || now > S.active.until) {
641
+ const h = S.holes[Math.floor(Math.random() * S.holes.length)];
642
+ const good = Math.random() < 0.62;
643
+ S.active = { x: h.x, y: h.y, size: 96, good, img: good ? images.target : anyDecoy(), until: now + (950 - D * 90), born: now };
644
+ }
645
+ const a = S.active, k = Math.min(1, (now - a.born) / 120);
646
+ drawSprite(a.img, a.x, a.y - 18, a.size * k);
647
+ },
648
+ click(p) {
649
+ const a = S.active;
650
+ if (a && dist(p.x, p.y, a.x, a.y - 18) < a.size * 0.5) {
651
+ 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(); }
652
+ else { addBurst(a.x, a.y, "#ff3b30"); lose(); }
653
+ }
654
+ },
655
+ },
656
+
657
+ find_target: {
658
+ setup() {
659
+ S = { items: [] };
660
+ const n = 10 + D * 3;
661
+ 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 });
662
+ S.items.push({ x: rand(70, W - 70), y: rand(150, H - 70), size: 72, img: images.target, target: true });
663
+ 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; }
664
+ hud = "Trouve la cible !";
665
+ },
666
+ frame() { for (const it of S.items) drawSprite(it.img, it.x, it.y, it.size); },
667
+ click(p) {
668
+ for (let i = S.items.length - 1; i >= 0; i--) {
669
+ const it = S.items[i];
670
+ if (dist(p.x, p.y, it.x, it.y) < it.size * 0.5) {
671
+ if (it.target) { addBurst(it.x, it.y, "#35ff6b"); win(); } else { addBurst(it.x, it.y, "#ff3b30"); lose(); }
672
+ return;
673
+ }
674
+ }
675
+ },
676
+ },
677
+
678
+ swat_all: {
679
+ setup() {
680
+ const n = 3 + D * 2, sp = 2.2 + D * 0.7;
681
+ S = { bugs: [], left: n, total: n };
682
+ 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 });
683
+ hud = "0 / " + n;
684
+ },
685
+ frame(now) {
686
+ const t = now / 1000;
687
+ 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); }
688
+ },
689
+ click(p) {
690
+ for (const b of S.bugs) if (b.alive && dist(p.x, p.y, b.x, b.y) < b.size * 0.5) {
691
+ b.alive = false; S.left--; addBurst(b.x, b.y, "#35ff6b"); hud = (S.total - S.left) + " / " + S.total;
692
+ if (S.left <= 0) win();
693
+ return;
694
+ }
695
+ },
696
+ },
697
+
698
+ shoot_target: {
699
+ 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; },
700
+ frame(now) {
701
+ bounceObj(S.t);
702
+ drawSprite(images.target, S.t.x, S.t.y, S.t.size, Math.sin(now / 120) * 0.12);
703
+ ctx.save();
704
+ ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 3;
705
+ ctx.beginPath(); ctx.arc(mouse.x, mouse.y, 16, 0, Math.PI * 2);
706
+ ctx.moveTo(mouse.x - 22, mouse.y); ctx.lineTo(mouse.x + 22, mouse.y);
707
+ ctx.moveTo(mouse.x, mouse.y - 22); ctx.lineTo(mouse.x, mouse.y + 22);
708
+ ctx.stroke(); ctx.restore();
709
+ },
710
+ click(p) {
711
+ if (dist(p.x, p.y, S.t.x, S.t.y) < S.t.size * 0.5) {
712
+ S.hp--; addBurst(S.t.x, S.t.y, "#35ff6b"); hud = "PV " + Math.max(0, S.hp);
713
+ const sp = 4 + D * 1.2 + (S.max - S.hp);
714
+ 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);
715
+ if (S.hp <= 0) win();
716
+ }
717
+ },
718
+ },
719
+
720
+ protect_center: {
721
+ 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 !"; },
722
+ frame(now) {
723
+ if (now - S.last > 820 - D * 70) {
724
+ S.last = now;
725
+ const side = Math.floor(Math.random() * 4);
726
+ let x, y;
727
+ if (side === 0) { x = rand(40, W - 40); y = 110; }
728
+ else if (side === 1) { x = rand(40, W - 40); y = H - 30; }
729
+ else if (side === 2) { x = 40; y = rand(120, H - 40); }
730
+ else { x = W - 40; y = rand(120, H - 40); }
731
+ const ang = Math.atan2(S.c.y - y, S.c.x - x);
732
+ S.en.push({ x, y, size: 64, vx: Math.cos(ang) * S.sp, vy: Math.sin(ang) * S.sp, img: anyDecoy() });
733
+ }
734
+ drawSprite(images.target, S.c.x, S.c.y, S.c.size, 0, 1 + Math.sin(now / 200) * 0.03);
735
+ const next = [];
736
+ for (const e of S.en) {
737
+ e.x += e.vx; e.y += e.vy;
738
+ 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; }
739
+ drawSprite(e.img, e.x, e.y, e.size);
740
+ next.push(e);
741
+ }
742
+ S.en = next;
743
+ },
744
+ 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; } } },
745
+ timeout() { win(); },
746
+ },
747
+
748
+ dodge_sides: {
749
+ setup() { S = { p: { x: W / 2, y: H / 2, size: 74 }, pr: [], last: 0, sp: 4 + D * 0.8 }; hud = "Esquive !"; },
750
+ frame(now) {
751
+ if (now - S.last > 620 - D * 45) {
752
+ S.last = now;
753
+ const fromLeft = Math.random() < 0.5, y = rand(130, H - 40);
754
+ S.pr.push({ x: fromLeft ? -40 : W + 40, y, vx: (fromLeft ? 1 : -1) * S.sp, size: 58, img: anyDecoy() });
755
+ }
756
+ drawSprite(images.target, S.p.x, S.p.y, S.p.size);
757
+ const next = [];
758
+ for (const pr of S.pr) {
759
+ pr.x += pr.vx;
760
+ if (pr.x < -80 || pr.x > W + 80) continue;
761
+ 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; }
762
+ drawSprite(pr.img, pr.x, pr.y, pr.size);
763
+ next.push(pr);
764
+ }
765
+ S.pr = next;
766
+ },
767
+ move(p) { S.p.x = clamp(p.x, 40, W - 40); S.p.y = clamp(p.y, 120, H - 40); },
768
+ key(e) {
769
+ if (e.key === "ArrowLeft") S.p.x = clamp(S.p.x - 40, 40, W - 40);
770
+ if (e.key === "ArrowRight") S.p.x = clamp(S.p.x + 40, 40, W - 40);
771
+ if (e.key === "ArrowUp") S.p.y = clamp(S.p.y - 40, 120, H - 40);
772
+ if (e.key === "ArrowDown") S.p.y = clamp(S.p.y + 40, 120, H - 40);
773
+ },
774
+ timeout() { win(); },
775
+ },
776
+
777
+ chase_flee: {
778
+ 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 !"; },
779
+ frame(now) {
780
+ const d = dist(mouse.x, mouse.y, S.t.x, S.t.y);
781
+ 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; }
782
+ S.t.x += Math.sin(now / 300) * 0.6;
783
+ S.t.x = clamp(S.t.x, 60, W - 60); S.t.y = clamp(S.t.y, 120, H - 60);
784
+ drawSprite(images.target, S.t.x, S.t.y, S.t.size, Math.sin(now / 150) * 0.2);
785
+ if (d < S.t.size * 0.5) { addBurst(S.t.x, S.t.y, "#35ff6b"); win(); }
786
+ },
787
+ },
788
+
789
+ drag_to_goal: {
790
+ setup() {
791
+ S = { t: { x: 90, y: H - 90, size: 78 }, goal: { x: W - 90, y: 130, r: 64 }, obs: [] };
792
+ const n = 1 + D, sp = 1.8 + D * 0.6;
793
+ 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 });
794
+ hud = "Amène la cible au but !";
795
+ },
796
+ frame() {
797
+ ctx.save(); ctx.setLineDash([10, 8]); ctx.strokeStyle = "#35ff6b"; ctx.lineWidth = 6;
798
+ ctx.beginPath(); ctx.arc(S.goal.x, S.goal.y, S.goal.r, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
799
+ for (const o of S.obs) {
800
+ bounceObj(o); drawSprite(o.img, o.x, o.y, o.size);
801
+ 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; }
802
+ }
803
+ drawSprite(images.target, S.t.x, S.t.y, S.t.size);
804
+ 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(); }
805
+ },
806
+ move(p) { S.t.x = clamp(p.x, 40, W - 40); S.t.y = clamp(p.y, 120, H - 40); },
807
+ },
808
+
809
+ balance: {
810
+ setup() { S = { ang: 0, vel: 0, drift: 0.0006 + D * 0.0004 }; hud = "Garde l'équilibre ! (← →)"; },
811
+ frame(now) {
812
+ S.vel += (Math.random() - 0.5) * S.drift * 60;
813
+ S.vel *= 0.985; S.ang += S.vel;
814
+ if (Math.abs(S.ang) > 0.95) { lose(); return; }
815
+ const baseY = H - 70, baseX = W / 2;
816
+ 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();
817
+ ctx.save(); ctx.translate(baseX, baseY); ctx.rotate(S.ang); drawSprite(images.target, 0, -90, 120); ctx.restore();
818
+ 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();
819
+ },
820
+ key(e) { if (e.key === "ArrowLeft") S.vel -= 0.06; if (e.key === "ArrowRight") S.vel += 0.06; },
821
+ timeout() { win(); },
822
+ },
823
+
824
+ mash_fill: {
825
+ setup() { S = { fill: 0, decay: 0.4 + D * 0.25, gain: 6 }; hud = "Tape vite !"; },
826
+ frame() {
827
+ S.fill = clamp(S.fill - S.decay, 0, 100);
828
+ const bw = W - 160, bx = 80, by = H - 70;
829
+ ctx.save();
830
+ ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(bx, by, bw, 36);
831
+ ctx.fillStyle = "#ffe600"; ctx.fillRect(bx, by, bw * S.fill / 100, 36);
832
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 4; ctx.strokeRect(bx, by, bw, 36);
833
+ ctx.restore();
834
+ drawSprite(images.target, W / 2, H / 2 - 30, 90 + S.fill, 0, 1, false);
835
+ if (S.fill >= 100) { addBurst(W / 2, H / 2, "#35ff6b"); win(); }
836
+ },
837
+ click() { S.fill = clamp(S.fill + S.gain, 0, 100); },
838
+ key(e) { if (e.code === "Space" || e.key === " ") S.fill = clamp(S.fill + S.gain, 0, 100); },
839
+ },
840
+
841
+ pump_inflate: {
842
+ setup() { S = { size: 70, pop: 250, bmin: 150, bmax: 205, gain: 11, deflate: 0.7 }; hud = "Gonfle… sans exploser !"; },
843
+ frame() {
844
+ S.size = Math.max(60, S.size - S.deflate);
845
+ if (S.size > S.pop) { addBurst(W / 2, H / 2 - 10, "#ff3b30"); lose(); return; }
846
+ const gx = W - 50, gy0 = 120, gy1 = H - 60, gh = gy1 - gy0;
847
+ const toY = v => gy1 - (v - 60) / (S.pop - 60) * gh;
848
+ ctx.save();
849
+ ctx.fillStyle = "rgba(0,0,0,0.4)"; ctx.fillRect(gx, gy0, 20, gh);
850
+ ctx.fillStyle = "rgba(53,255,107,0.6)"; ctx.fillRect(gx, toY(S.bmax), 20, toY(S.bmin) - toY(S.bmax));
851
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 3; ctx.strokeRect(gx, gy0, 20, gh);
852
+ ctx.fillStyle = "#ffe600"; ctx.fillRect(gx - 6, toY(S.size) - 3, 32, 6);
853
+ ctx.restore();
854
+ const inBand = S.size >= S.bmin && S.size <= S.bmax;
855
+ drawSprite(images.target, W / 2 - 10, H / 2 - 10, S.size, 0, 1, false);
856
+ 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(); }
857
+ },
858
+ click() { S.size += S.gain; },
859
+ key(e) { if (e.code === "Space" || e.key === " ") S.size += S.gain; },
860
+ timeout() { if (S.size >= S.bmin && S.size <= S.bmax) win(); else lose(); },
861
+ },
862
+
863
+ hold_steady: {
864
+ setup() { S = { zone: { x: W / 2, y: H / 2, r: 82 - D * 6 }, grace: true }; hud = "Garde le curseur dans le rond !"; },
865
+ frame(now) {
866
+ if (now - startTime > 500) S.grace = false;
867
+ S.zone.x = W / 2 + Math.sin(now / 700) * (D * 16);
868
+ S.zone.y = H / 2 + Math.cos(now / 900) * (D * 10);
869
+ const inside = dist(mouse.x, mouse.y, S.zone.x, S.zone.y) < S.zone.r;
870
+ 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();
871
+ drawSprite(images.target, S.zone.x, S.zone.y, S.zone.r * 1.1, 0, 1, false);
872
+ if (!S.grace && !inside) lose();
873
+ },
874
+ timeout() { win(); },
875
+ },
876
+
877
+ rhythm_tap: {
878
+ setup() { S = { interval: 620 - D * 40, need: 3 + D, score: 0, hits: {} }; hud = "0 / " + S.need; },
879
+ frame(now) {
880
+ const phase = (now - startTime) / S.interval;
881
+ const frac = phase - Math.floor(phase);
882
+ const near = Math.min(frac, 1 - frac);
883
+ const pulse = 1 + 0.5 * Math.pow(Math.abs(Math.cos(frac * Math.PI)), 8);
884
+ drawSprite(images.target, W / 2, H / 2 - 10, 110, 0, pulse);
885
+ ctx.save(); ctx.strokeStyle = "#ffe600"; ctx.lineWidth = 5;
886
+ ctx.beginPath(); ctx.arc(W / 2, H / 2 - 10, 70 + near * 180, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
887
+ },
888
+ tap(now) {
889
+ const phase = (now - startTime) / S.interval;
890
+ const idx = Math.round(phase);
891
+ const frac = Math.abs(phase - idx);
892
+ if (frac < 0.18 && !S.hits[idx]) {
893
+ S.hits[idx] = true; S.score++; addBurst(W / 2, H / 2, "#35ff6b"); hud = S.score + " / " + S.need;
894
+ if (S.score >= S.need) win();
895
+ } else { addBurst(W / 2, H / 2, "#ff3b30"); lose(); }
896
+ },
897
+ click() { this.tap(performance.now()); },
898
+ key(e) { if (e.code === "Space" || e.key === " ") this.tap(performance.now()); },
899
+ },
900
+
901
+ stop_meter: {
902
+ 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 !"; },
903
+ frame() {
904
+ 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; } }
905
+ const by = H / 2;
906
+ ctx.save();
907
+ ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(60, by - 18, W - 120, 36);
908
+ ctx.fillStyle = "rgba(53,255,107,0.7)"; ctx.fillRect(S.zx, by - 18, S.zw, 36);
909
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 4; ctx.strokeRect(60, by - 18, W - 120, 36);
910
+ ctx.fillStyle = "#ffe600"; ctx.fillRect(S.pos - 4, by - 30, 8, 60);
911
+ ctx.restore();
912
+ drawSprite(images.target, W / 2, by - 115, 90);
913
+ },
914
+ 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(); },
915
+ click() { this.resolve(); },
916
+ key(e) { if (e.code === "Space" || e.key === " ") this.resolve(); },
917
+ },
918
+
919
+ quick_draw: {
920
+ setup() { S = { fireAt: rand(900, Math.max(1300, DUR * 1000 * 0.55)), fired: false }; hud = ""; },
921
+ frame(now) {
922
+ if (now - startTime >= S.fireAt) S.fired = true;
923
+ if (!S.fired) { bigText("PRÊT…", "#ffffff"); }
924
+ else { drawSprite(images.target, W / 2, H / 2 - 40, 120, 0, 1 + Math.sin(now / 60) * 0.05); bigText("VITE !", "#35ff6b"); }
925
+ },
926
+ act() { if (S.fired) { addBurst(W / 2, H / 2, "#35ff6b"); win(); } else { addBurst(W / 2, H / 2, "#ff3b30"); lose(); } },
927
+ click() { this.act(); },
928
+ key(e) { if (e.code === "Space" || e.key === " " || e.code === "Enter") this.act(); },
929
+ },
930
+
931
+ sort_lr: {
932
+ setup() { S = { cur: null, score: 0, need: 3 + D, spd: 1.6 + D * 0.5 }; this.spawn(); hud = "0 / " + S.need; },
933
+ spawn() { const good = Math.random() < 0.5; S.cur = { x: W / 2, y: 130, size: 84, good, img: good ? images.target : anyDecoy() }; },
934
+ frame() {
935
+ ctx.save(); ctx.font = "900 28px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillStyle = "rgba(255,255,255,0.75)";
936
+ ctx.fillText("← leurre", 130, H - 28); ctx.fillText("cible →", W - 130, H - 28); ctx.restore();
937
+ if (S.cur) {
938
+ S.cur.y += S.spd;
939
+ drawSprite(S.cur.img, S.cur.x, S.cur.y, S.cur.size);
940
+ if (S.cur.y > H - 28) { addBurst(S.cur.x, S.cur.y, "#ff3b30"); lose(); return; }
941
+ }
942
+ },
943
+ judge(right) {
944
+ if (!S.cur) return;
945
+ const correct = right === S.cur.good;
946
+ if (correct) {
947
+ S.score++; addBurst(S.cur.x, S.cur.y, "#35ff6b"); hud = S.score + " / " + S.need;
948
+ if (S.score >= S.need) { win(); return; }
949
+ this.spawn();
950
+ } else { addBurst(S.cur.x, S.cur.y, "#ff3b30"); lose(); }
951
+ },
952
+ key(e) { if (e.key === "ArrowRight") this.judge(true); if (e.key === "ArrowLeft") this.judge(false); },
953
+ },
954
+
955
+ };
956
+
957
+ function currentGame() { return GAMES[SPEC.template] || GAMES.grab_target; }
958
+
959
+ function startGame() {
960
+ result.textContent = ""; particles = []; hud = ""; timer.textContent = DUR;
961
  startBtn.style.display = "none";
962
+ running = true; startTime = performance.now();
963
+ currentGame().setup();
 
 
 
 
 
964
  raf = requestAnimationFrame(drawFrame);
965
+ }
 
 
 
 
 
 
 
 
 
 
 
966
 
967
+ function drawFrame(now) {
968
  drawBackground();
 
969
  const left = timeRemaining(now);
970
  timer.textContent = Math.ceil(left);
971
+ const g = currentGame();
972
+ if (running && left <= 0) {
973
+ if (g.timeout) g.timeout(); else lose();
974
+ if (!running) return;
975
+ }
976
+ if (running) g.frame(now);
977
+ drawHud();
978
+ drawParticles();
979
+ if (running) raf = requestAnimationFrame(drawFrame);
980
+ }
981
 
982
+ function canvasPoint(ev) {
983
+ const r = canvas.getBoundingClientRect();
984
+ return { x: (ev.clientX - r.left) * (W / r.width), y: (ev.clientY - r.top) * (H / r.height) };
985
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
986
 
987
+ canvas.addEventListener("click", e => { if (!running) return; const p = canvasPoint(e); const g = currentGame(); if (g.click) g.click(p); });
988
+ 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); } });
989
+ canvas.addEventListener("mousedown", () => { mouse.down = true; });
990
+ window.addEventListener("mouseup", () => { mouse.down = false; });
991
+ window.addEventListener("keydown", e => {
992
+ if (!running) return;
993
+ const g = currentGame();
994
+ if (g.key) g.key(e);
995
+ if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " "].indexOf(e.key) >= 0) e.preventDefault();
996
+ });
997
 
998
+ startBtn.onclick = startGame;
999
 
1000
+ loadAssets().then(() => { drawBackground(); timer.textContent = DUR; });
1001
+ """
 
 
 
1002
 
1003
+ GAME_HTML_TAIL = "\n</script>\n</body>\n</html>\n"
1004
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1005
 
1006
+ def render_canvas_game(spec: VisualMiniGameSpec, assets: Dict[str, str]):
1007
+ spec_json = json.dumps(spec.model_dump(), ensure_ascii=False)
1008
+ assets_json = json.dumps(assets)
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
 
1010
+ data = "const SPEC = " + spec_json + ";\nconst ASSETS = " + assets_json + ";\n"
1011
+ iframe_html = GAME_HTML_HEAD + data + GAME_ENGINE_JS + GAME_HTML_TAIL
 
 
 
 
 
 
1012
 
1013
  srcdoc = html.escape(iframe_html, quote=True)
1014
+ return (
1015
+ '<iframe srcdoc="' + srcdoc + '" '
1016
+ 'style="width:100%; height:620px; border:0; border-radius:18px; overflow:hidden;">'
1017
+ "</iframe>"
1018
+ )
 
 
1019
 
1020
 
1021
  # ---------------------------------------------------------------------------
 
1037
  with gr.Blocks() as demo:
1038
  gr.Markdown("# POC mini-jeux génératifs visuels")
1039
  gr.Markdown(
1040
+ "Qwen3-4B génère la spec (20 types de mini-jeux). SDXL génère les assets. "
1041
+ "Canvas exécute le mini-jeu."
1042
  )
1043
 
1044
  prompt = gr.Textbox(