Spaces:
Running on Zero
Running on Zero
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 filesReplace 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>
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[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 92 |
-
-
|
| 93 |
-
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 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="
|
| 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 |
-
|
| 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 |
-
|
| 457 |
-
|
| 458 |
|
| 459 |
-
|
| 460 |
-
|
| 461 |
|
| 462 |
-
let images = {
|
| 463 |
-
let running = false;
|
| 464 |
-
let startTime = 0;
|
| 465 |
-
let raf = null;
|
| 466 |
let particles = [];
|
| 467 |
-
let
|
| 468 |
-
|
| 469 |
-
|
| 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 |
-
|
| 506 |
-
|
| 507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
ctx.fill();
|
| 513 |
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
ctx.restore();
|
| 516 |
-
}
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
for (let i = 0; i < 26; i++) {
|
| 520 |
-
|
| 521 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
| 561 |
startBtn.textContent = "REJOUER";
|
| 562 |
startBtn.style.display = "block";
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
function
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
x
|
| 626 |
-
y
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
x
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
}
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
startBtn.style.display = "none";
|
| 658 |
-
running = true;
|
| 659 |
-
|
| 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 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 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 |
-
|
| 749 |
-
|
| 750 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
|
| 752 |
-
|
| 753 |
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
return;
|
| 757 |
-
}}
|
| 758 |
-
}}
|
| 759 |
|
| 760 |
-
|
| 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 |
-
|
| 823 |
-
|
| 824 |
-
|
| 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 |
-
|
| 840 |
-
|
| 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 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 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.
|
|
|
|
| 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(
|