Spaces:
Running
Running
| from __future__ import annotations | |
| import ast | |
| import base64 | |
| from difflib import SequenceMatcher | |
| import json | |
| import math | |
| import os | |
| import random | |
| import re | |
| import sqlite3 | |
| import threading | |
| import subprocess | |
| import tarfile | |
| import time | |
| import uuid | |
| import urllib.request | |
| from concurrent.futures import ThreadPoolExecutor | |
| from pathlib import Path | |
| from typing import Any | |
| import gradio as gr | |
| import regex | |
| # ========================================================= | |
| # CONFIG | |
| # ========================================================= | |
| MAX_LIVES = 5 | |
| PREVIEW_SECONDS = 4 | |
| RUNTIME_DIR = Path( | |
| os.getenv( | |
| "MATCHWISE_RUNTIME_DIR", | |
| os.getenv("MEMORY_FLIP_RUNTIME_DIR", ".runtime/matchwise"), | |
| ) | |
| ) | |
| MODEL_REPO_ID = "openbmb/MiniCPM5-1B-GGUF" | |
| MODEL_FILENAME = "MiniCPM5-1B-Q4_K_M.gguf" | |
| MODEL_CHAT_TEMPLATE_KWARGS = os.getenv("LLAMA_CHAT_TEMPLATE_KWARGS", '{"enable_thinking": false}') | |
| MODEL_OVERRIDE = os.getenv("MATCHWISE_MODEL_PATH", os.getenv("MEMORY_FLIP_MODEL_PATH")) | |
| MODEL_PATH = Path(MODEL_OVERRIDE) if MODEL_OVERRIDE else RUNTIME_DIR / "models" / MODEL_FILENAME | |
| LLAMA_RELEASE_URL = ( | |
| "https://github.com/ggml-org/llama.cpp/releases/download/b9305/" | |
| "llama-b9305-bin-ubuntu-x64.tar.gz" | |
| ) | |
| LLAMA_ARCHIVE_PATH = RUNTIME_DIR / "downloads" / "llama-b9305-bin-ubuntu-x64.tar.gz" | |
| LLAMA_EXTRACT_DIR = RUNTIME_DIR / "llama_cpp" | |
| LLAMA_SERVER_HOST = os.getenv("LLAMA_SERVER_HOST", "0.0.0.0") | |
| LLAMA_SERVER_PORT = int(os.getenv("LLAMA_SERVER_PORT", "8080")) | |
| LLAMA_SERVER_BASE_URL = f"http://{LLAMA_SERVER_HOST}:{LLAMA_SERVER_PORT}" | |
| LLAMA_SERVER_PROCESS: subprocess.Popen[str] | None = None | |
| LLAMA_SERVER_BINARY_PATH: Path | None = None | |
| N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "0")) | |
| LLAMA_REQUEST_TIMEOUT_SECONDS = int(os.getenv("LLAMA_REQUEST_TIMEOUT_SECONDS", "420")) | |
| LLAMA_MAX_PREDICT_TOKENS = int(os.getenv("LLAMA_MAX_PREDICT_TOKENS", "384")) | |
| LEVEL_GENERATION_EXECUTOR = ThreadPoolExecutor(max_workers=1) | |
| GENERATION_LOCK = threading.Lock() | |
| MIN_TRANSITION_VISIBLE_MS = 500 | |
| LOGO_PATH = Path(__file__).with_name("logo.png") | |
| LOGO_DATA_URI = "" | |
| if LOGO_PATH.exists(): | |
| LOGO_DATA_URI = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode("ascii") | |
| LEADERBOARD_DIR = Path(os.getenv("MATCHWISE_LEADERBOARD_DIR", "/data/matchwise-leaderboard")) | |
| if not LEADERBOARD_DIR.is_absolute() or not str(LEADERBOARD_DIR).startswith("/data/"): | |
| LEADERBOARD_DIR = Path("/data/matchwise-leaderboard") | |
| LEADERBOARD_DB_PATH = LEADERBOARD_DIR / "leaderboard.sqlite3" | |
| LEADERBOARD_LEGACY_JSON_PATH = LEADERBOARD_DIR / "leaderboard.json" | |
| # Mount a Hugging Face Storage Bucket at LEADERBOARD_DIR in Spaces for persistence. | |
| LEADERBOARD_LOCK = threading.Lock() | |
| MAX_RECENT_THEMES = 8 | |
| MAX_RECENT_THEME_FAMILIES = 8 | |
| MAX_RECENT_EMOJIS = 40 | |
| MAX_RECENT_CHALLENGE_QUESTIONS = 12 | |
| MAX_REPEAT_COUNT_ITEMS = 10 | |
| MAX_GENERATION_ATTEMPTS = 3 | |
| LEVEL_GENERATION_TEMPERATURE = 0.15 | |
| LEVEL_GENERATION_MAX_TOKENS = min(96, LLAMA_MAX_PREDICT_TOKENS) | |
| CHALLENGE_LEVEL_GENERATION_TEMPERATURE = 0.7 | |
| CHALLENGE_LEVEL_GENERATION_MAX_TOKENS = min(300, LLAMA_MAX_PREDICT_TOKENS) | |
| LLM_DEBUG = os.getenv("MATCHWISE_LLM_DEBUG", os.getenv("MEMORY_FLIP_LLM_DEBUG", "1")).strip().lower() not in {"0", "false", "no", "off"} | |
| LEADERBOARD_DEBUG = os.getenv("MATCHWISE_LEADERBOARD_DEBUG", "1").strip().lower() not in {"0", "false", "no", "off"} | |
| MAX_REPEAT_SUMMARY_ITEMS = MAX_REPEAT_COUNT_ITEMS | |
| THEME_PHASE_FOCUSED_LIMIT = 16 | |
| THEME_PHASE_EXPANDED_LIMIT = 48 | |
| METER_POINTS_PER_CORRECT_MATCH = 20 | |
| METER_CHALLENGE_THRESHOLD = 100 | |
| CHALLENGE_LEVEL_SEQUENCE = {3, 6, 10, 15, 21} | |
| NORMAL_LEVEL_SUBJECT_POOL = [ | |
| "Traffic Signs", | |
| "Kitchen Tools", | |
| "School Supplies", | |
| "Garden Plants", | |
| "Weather Changes", | |
| "Musical Instruments", | |
| "Ocean Life", | |
| "Space Objects", | |
| "Transport Vehicles", | |
| "Winter Clothing", | |
| "Breakfast Foods", | |
| "Farm Produce", | |
| "Bathroom Items", | |
| "Laundry Items", | |
| "Camping Gear", | |
| "Construction Tools", | |
| "Library Books", | |
| "Sports Gear", | |
| "Art Supplies", | |
| "Classroom Furniture", | |
| ] | |
| NORMAL_THEME_FAMILY_POOL = [ | |
| "Animals", | |
| "Nature", | |
| "Weather", | |
| "Food", | |
| "Music", | |
| "Space", | |
| "Transport", | |
| "School", | |
| "Sports", | |
| "Ocean", | |
| "Science", | |
| "Garden", | |
| "Insects", | |
| "Clothing", | |
| "Kitchen", | |
| ] | |
| NORMAL_LEVEL_VICTORY_MESSAGES = [ | |
| "Great memory. You cleared the {theme} board.", | |
| "Nice work. You matched all the {theme} cards.", | |
| "Well done. The {theme} pairs are all complete.", | |
| "Strong job. You remembered the {theme} cards well.", | |
| ] | |
| NORMAL_LEVEL_FAILURE_MESSAGES = [ | |
| "Take another look at the {theme} cards and try again.", | |
| "Keep going. The {theme} matches will get easier.", | |
| "Try again and watch where the {theme} cards land.", | |
| "You are close. Study the {theme} cards one more time.", | |
| ] | |
| NORMAL_LEVEL_EMOJI_POOL = [ | |
| "🧥", "🧣", "🧤", "🧢", "🥾", "👢", "🧦", "👕", "👖", "👚", | |
| "🪖", "🧺", "🛏️", "🪑", "🪟", "🧽", "🧴", "🧹", "🪠", "🪣", | |
| "🚲", "🛴", "🚌", "🚐", "🚚", "🚜", "🚒", "🚕", "🚂", "✈️", | |
| "📚", "📒", "✏️", "🖍️", "📎", "🧮", "📐", "🎨", "🎹", "🥁", | |
| "🌱", "🌼", "🌷", "🪴", "🍎", "🥕", "🫛", "🥐", "🥛", "🧀", | |
| "⚙️", "🔧", "🪛", "🔨", "🪚", "🧰", "🪜", "🧲", "🧪", "🔬", | |
| "🪐", "🌙", "⭐", "☁️", "🌧️", "🌦️", "🌤️", "❄️", "🌊", "🐚", | |
| ] | |
| NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY = { | |
| "kitchen": ["🍽️", "🥄", "🍴", "🥣", "🫙", "🫗", "🧂", "🫖", "🫕", "🧊"], | |
| "school": ["📚", "📒", "✏️", "🖍️", "🧮", "📐", "📎", "🗂️", "📝", "🪄"], | |
| "transport": ["🚲", "🚌", "🚐", "🚚", "🚜", "🚂", "✈️", "🚕", "🛴", "🛳️"], | |
| "clothing": ["🧥", "🧣", "🧤", "🧢", "🥾", "👢", "🧦", "👕", "👖", "👚"], | |
| "nature": ["🌱", "🌼", "🌷", "🪴", "🌿", "🍄", "🍀", "🌲", "🌳", "🌻"], | |
| "weather": ["☁️", "🌧️", "🌦️", "🌤️", "❄️", "🌈", "🌬️", "🌪️", "🌫️", "☀️"], | |
| "space": ["🪐", "🌙", "⭐", "☄️", "🌌", "🛰️", "🚀", "🌠", "🔭", "🛸"], | |
| "food": ["🍎", "🥕", "🫛", "🥐", "🥛", "🧀", "🍞", "🥬", "🍓", "🍇"], | |
| "science": ["⚙️", "🔧", "🪛", "🔬", "🧪", "🧲", "🧰", "🪜", "🧫", "📡"], | |
| "music": ["🎹", "🥁", "🎸", "🎺", "🎻", "🪘", "🪇", "🎼", "🎤", "🎧"], | |
| } | |
| LEVEL_GENERATION_PROMPT = """ | |
| Return one JSON object only. | |
| Level: {level} | |
| Theme family: {theme_family} | |
| Subject: {subject} | |
| Write: | |
| - level_title | |
| - victory_message | |
| - failure_message | |
| Rules: | |
| - Keep it short, concrete, and child-friendly. | |
| - Use the Subject as the only topic. | |
| - level_title must clearly name the Subject or a very close phrase. | |
| - victory_message and failure_message must also mention the Subject. | |
| - Do not introduce any other theme, object set, or concept. | |
| - No extra keys. | |
| Schema: | |
| {{ | |
| "level_title": "string", | |
| "victory_message": "string", | |
| "failure_message": "string" | |
| }} | |
| """ | |
| CHALLENGE_LEVEL_PROMPT = """ | |
| You are a child-friendly game designer. | |
| Return ONLY JSON. | |
| Invent a fresh school-knowledge topic for this challenge. | |
| The topic must be specific, concrete, and broadly taught in school across subjects such as science, geography, history, math, language, art, music, or health. | |
| Do not reuse recent topics. Make it creative but still something a school student should know. | |
| Recent challenge questions: {recent_challenge_questions} | |
| Recent challenge topics: {recent_challenge_topics} | |
| Recent challenge modes: {recent_challenge_modes} | |
| Rules: | |
| - First choose a new challenge_topic. | |
| - Then build one matching-board challenge about exactly that challenge_topic. | |
| - Choose level_mode using this mix: 50% fact_match, 50% category_match. | |
| - fact_match rules: | |
| - Match an unique unicode emoji with a short fact keyword or concept. | |
| - The keyword must clearly identify the emoji. | |
| - Keep answers extremely short, 1 to 3 words. | |
| - Example: | |
| 🐝 ↔ Honey | |
| 🌻 ↔ Sunflower | |
| 🐧 ↔ Penguin | |
| - category_match rules: | |
| - Match an emoji with its category. | |
| - Categories must be simple and child-friendly. | |
| - Example: | |
| 🦁 ↔ Mammal | |
| 🦅 ↔ Bird | |
| 🦈 ↔ Fish | |
| - Match targets must be short noun phrases, not sentences. | |
| - Match targets must be clear and educational. | |
| - Every emoji in `emoji_pairs` must have exactly one matching entry in `match_targets` and one matching entry in `pair_facts`. | |
| - Do not leave any emoji without a target or fact. | |
| - Before answering, verify that the keys in `match_targets` and `pair_facts` exactly cover every emoji in `emoji_pairs`. | |
| - Do not use placeholder text, partial phrases, or incomplete values. | |
| - Do not use boolean words like true/false. | |
| - Every item in emoji_pairs must be a plain emoji glyph only, with no text, labels, or descriptions attached. | |
| - Keep the mode_popup_title and mode_popup_message short and helpful. | |
| - Also write short victory_message and failure_message lines for the round. | |
| - Do not use normal mode or MCQ question/answer format. | |
| - Do not add extra keys. | |
| {{ | |
| "challenge_topic": "string", | |
| "level_mode": "fact_match", | |
| "mode_popup_title": "string", | |
| "mode_popup_message": "string", | |
| "victory_message": "string", | |
| "failure_message": "string", | |
| "emoji_pairs": ["emoji1", "emoji2"], | |
| "match_targets": {{"emoji1": "target"}}, | |
| "pair_facts": {{"emoji1": "fact"}} | |
| }} | |
| """ | |
| MINICPM5_LEVEL_PROMPT_PREFIX = """ | |
| Return only valid JSON and nothing else. | |
| """ | |
| # ========================================================= | |
| # RUNTIME BOOTSTRAP | |
| # ========================================================= | |
| def _download_text(url: str, timeout: int = 120) -> str: | |
| with urllib.request.urlopen(url, timeout=timeout) as response: | |
| return response.read().decode("utf-8", errors="replace") | |
| def _leaderboard_debug(message: str) -> None: | |
| if LEADERBOARD_DEBUG: | |
| _debug_log(f"[leaderboard] {message}") | |
| def _normalize_leaderboard_username(profile: Any | None) -> str: | |
| if profile is None: | |
| return "" | |
| if isinstance(profile, str): | |
| return _clean_one_line_text(profile, 80) | |
| if isinstance(profile, dict): | |
| username = profile.get("preferred_username") or profile.get("username") or "" | |
| else: | |
| username = getattr(profile, "preferred_username", "") or getattr(profile, "username", "") | |
| return _clean_one_line_text(username, 80) | |
| def _connect_leaderboard_db() -> sqlite3.Connection: | |
| LEADERBOARD_DB_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| conn = sqlite3.connect( | |
| LEADERBOARD_DB_PATH, | |
| timeout=5.0, | |
| isolation_level=None, | |
| ) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA busy_timeout = 5000") | |
| conn.execute("PRAGMA foreign_keys = ON") | |
| conn.execute("PRAGMA journal_mode = DELETE") | |
| conn.execute("PRAGMA synchronous = NORMAL") | |
| return conn | |
| def _bootstrap_leaderboard_db() -> None: | |
| with _connect_leaderboard_db() as conn: | |
| conn.execute( | |
| """ | |
| CREATE TABLE IF NOT EXISTS leaderboard ( | |
| username TEXT PRIMARY KEY, | |
| high_score INTEGER NOT NULL DEFAULT 0, | |
| updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| ) | |
| """ | |
| ) | |
| def _migrate_legacy_leaderboard_json_if_needed(conn: sqlite3.Connection) -> None: | |
| row = conn.execute("SELECT COUNT(*) AS count FROM leaderboard").fetchone() | |
| existing_count = int(row["count"] if row is not None else 0) | |
| if existing_count > 0: | |
| return | |
| if not LEADERBOARD_LEGACY_JSON_PATH.exists(): | |
| return | |
| try: | |
| raw = json.loads(LEADERBOARD_LEGACY_JSON_PATH.read_text(encoding="utf-8")) | |
| except Exception: | |
| return | |
| if not isinstance(raw, dict): | |
| return | |
| imported = 0 | |
| for username, value in raw.items(): | |
| cleaned_username = _normalize_leaderboard_username(username) | |
| if not cleaned_username: | |
| continue | |
| try: | |
| score = int(value) | |
| except Exception: | |
| continue | |
| conn.execute( | |
| """ | |
| INSERT INTO leaderboard (username, high_score, updated_at) | |
| VALUES (?, ?, CURRENT_TIMESTAMP) | |
| ON CONFLICT(username) DO UPDATE SET | |
| high_score = CASE | |
| WHEN excluded.high_score > leaderboard.high_score THEN excluded.high_score | |
| ELSE leaderboard.high_score | |
| END, | |
| updated_at = CASE | |
| WHEN excluded.high_score > leaderboard.high_score THEN CURRENT_TIMESTAMP | |
| ELSE leaderboard.updated_at | |
| END | |
| """, | |
| (cleaned_username, score), | |
| ) | |
| imported += 1 | |
| if imported: | |
| _leaderboard_debug(f"migrated legacy json path={LEADERBOARD_LEGACY_JSON_PATH} entries={imported}") | |
| def _load_leaderboard() -> dict[str, int]: | |
| _leaderboard_debug(f"load path={LEADERBOARD_DB_PATH}") | |
| with LEADERBOARD_LOCK: | |
| _bootstrap_leaderboard_db() | |
| with _connect_leaderboard_db() as conn: | |
| _migrate_legacy_leaderboard_json_if_needed(conn) | |
| rows = conn.execute( | |
| """ | |
| SELECT username, high_score | |
| FROM leaderboard | |
| ORDER BY high_score DESC, LOWER(username) ASC | |
| """ | |
| ).fetchall() | |
| leaderboard: dict[str, int] = {} | |
| for row in rows: | |
| username = _normalize_leaderboard_username(row["username"]) | |
| if not username: | |
| continue | |
| try: | |
| score = int(row["high_score"]) | |
| except Exception: | |
| continue | |
| leaderboard[username] = max(leaderboard.get(username, 0), score) | |
| return leaderboard | |
| def _apply_leaderboard_update(profile: Any | None, score: int) -> dict[str, Any]: | |
| username = _normalize_leaderboard_username(profile) | |
| _leaderboard_debug(f"apply username={username!r} score={int(score or 0)} profile_present={profile is not None}") | |
| if not username: | |
| return { | |
| "leaderboard_signed_in": False, | |
| "leaderboard_username": "", | |
| "leaderboard_high_score": 0, | |
| "leaderboard_saved": False, | |
| } | |
| current_score = int(score or 0) | |
| with LEADERBOARD_LOCK: | |
| _bootstrap_leaderboard_db() | |
| with _connect_leaderboard_db() as conn: | |
| _migrate_legacy_leaderboard_json_if_needed(conn) | |
| conn.execute("BEGIN IMMEDIATE") | |
| row = conn.execute( | |
| "SELECT high_score FROM leaderboard WHERE username = ?", | |
| (username,), | |
| ).fetchone() | |
| previous_score = int(row["high_score"]) if row is not None else 0 | |
| saved = current_score > previous_score | |
| if saved: | |
| conn.execute( | |
| """ | |
| INSERT INTO leaderboard (username, high_score, updated_at) | |
| VALUES (?, ?, CURRENT_TIMESTAMP) | |
| ON CONFLICT(username) DO UPDATE SET | |
| high_score = excluded.high_score, | |
| updated_at = CURRENT_TIMESTAMP | |
| """, | |
| (username, current_score), | |
| ) | |
| previous_score = current_score | |
| _leaderboard_debug(f"saved username={username!r} high_score={previous_score}") | |
| else: | |
| _leaderboard_debug(f"skipped username={username!r} existing={previous_score} current={current_score}") | |
| conn.commit() | |
| return { | |
| "leaderboard_signed_in": True, | |
| "leaderboard_username": username, | |
| "leaderboard_high_score": previous_score, | |
| "leaderboard_saved": saved, | |
| } | |
| def _get_leaderboard_status(profile: Any | None) -> dict[str, Any]: | |
| username = _normalize_leaderboard_username(profile) | |
| _leaderboard_debug(f"status username={username!r} profile_present={profile is not None}") | |
| if not username: | |
| return { | |
| "leaderboard_signed_in": False, | |
| "leaderboard_username": "", | |
| "leaderboard_high_score": 0, | |
| "leaderboard_saved": False, | |
| } | |
| with LEADERBOARD_LOCK: | |
| _bootstrap_leaderboard_db() | |
| with _connect_leaderboard_db() as conn: | |
| _migrate_legacy_leaderboard_json_if_needed(conn) | |
| row = conn.execute( | |
| "SELECT high_score FROM leaderboard WHERE username = ?", | |
| (username,), | |
| ).fetchone() | |
| high_score = int(row["high_score"]) if row is not None else 0 | |
| return { | |
| "leaderboard_signed_in": True, | |
| "leaderboard_username": username, | |
| "leaderboard_high_score": high_score, | |
| "leaderboard_saved": False, | |
| } | |
| def _build_leaderboard_entries(limit: int = 10) -> list[dict[str, Any]]: | |
| with LEADERBOARD_LOCK: | |
| _bootstrap_leaderboard_db() | |
| with _connect_leaderboard_db() as conn: | |
| _migrate_legacy_leaderboard_json_if_needed(conn) | |
| rows = conn.execute( | |
| """ | |
| SELECT username, high_score | |
| FROM leaderboard | |
| ORDER BY high_score DESC, LOWER(username) ASC | |
| LIMIT ? | |
| """, | |
| (max(1, int(limit)),), | |
| ).fetchall() | |
| rendered: list[dict[str, Any]] = [] | |
| for index, row in enumerate(rows, start=1): | |
| rendered.append( | |
| { | |
| "rank": index, | |
| "username": str(row["username"]), | |
| "score": int(row["high_score"]), | |
| } | |
| ) | |
| return rendered | |
| def ensure_model_downloaded() -> Path: | |
| global MODEL_PATH | |
| if MODEL_OVERRIDE: | |
| if not MODEL_PATH.exists(): | |
| raise FileNotFoundError(f"Model path does not exist: {MODEL_PATH}") | |
| return MODEL_PATH | |
| MODEL_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| if MODEL_PATH.exists(): | |
| return MODEL_PATH | |
| from huggingface_hub import hf_hub_download | |
| downloaded = hf_hub_download( | |
| repo_id=MODEL_REPO_ID, | |
| filename=MODEL_FILENAME, | |
| local_dir=str(MODEL_PATH.parent), | |
| ) | |
| MODEL_PATH = Path(downloaded) | |
| return MODEL_PATH | |
| def bootstrap_llama_runtime() -> None: | |
| ensure_model_downloaded() | |
| start_llama_server() | |
| def _safe_extract_tar(archive_path: Path, destination: Path) -> None: | |
| destination.mkdir(parents=True, exist_ok=True) | |
| def is_within_directory(directory: Path, target: Path) -> bool: | |
| abs_directory = directory.resolve() | |
| abs_target = target.resolve() | |
| return str(abs_target).startswith(str(abs_directory)) | |
| with tarfile.open(archive_path, "r:gz") as tar: | |
| for member in tar.getmembers(): | |
| member_path = destination / member.name | |
| if not is_within_directory(destination, member_path): | |
| raise RuntimeError(f"Unsafe archive path detected: {member.name}") | |
| tar.extractall(destination) | |
| def ensure_llama_server_binary() -> Path: | |
| global LLAMA_SERVER_BINARY_PATH | |
| if LLAMA_SERVER_BINARY_PATH and LLAMA_SERVER_BINARY_PATH.exists(): | |
| return LLAMA_SERVER_BINARY_PATH | |
| candidate = next((path for path in LLAMA_EXTRACT_DIR.rglob("llama-server") if path.is_file()), None) | |
| if candidate and os.access(candidate, os.X_OK): | |
| LLAMA_SERVER_BINARY_PATH = candidate | |
| return candidate | |
| if not LLAMA_ARCHIVE_PATH.exists(): | |
| LLAMA_ARCHIVE_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| _debug_log(f"[bootstrap] Downloading llama.cpp server bundle from {LLAMA_RELEASE_URL}") | |
| urllib.request.urlretrieve(LLAMA_RELEASE_URL, LLAMA_ARCHIVE_PATH) | |
| _debug_log(f"[bootstrap] Extracting llama.cpp server bundle to {LLAMA_EXTRACT_DIR}") | |
| _safe_extract_tar(LLAMA_ARCHIVE_PATH, LLAMA_EXTRACT_DIR) | |
| candidate = next((path for path in LLAMA_EXTRACT_DIR.rglob("llama-server") if path.is_file()), None) | |
| if candidate is None: | |
| raise FileNotFoundError("Could not find llama-server in extracted archive") | |
| candidate.chmod(candidate.stat().st_mode | 0o111) | |
| LLAMA_SERVER_BINARY_PATH = candidate | |
| return candidate | |
| def _pump_process_output(name: str, process: subprocess.Popen[str]) -> None: | |
| assert process.stdout is not None | |
| for line in iter(process.stdout.readline, ""): | |
| if not line: | |
| break | |
| _debug_log(f"[{name}] {line.rstrip()}") | |
| def wait_for_server(base_url: str, timeout_seconds: int = 180) -> None: | |
| deadline = time.time() + timeout_seconds | |
| checks = ("/v1/health", "/health", "/v1/models") | |
| last_error: str | None = None | |
| while time.time() < deadline: | |
| for suffix in checks: | |
| try: | |
| with urllib.request.urlopen(base_url + suffix, timeout=2) as response: | |
| if 200 <= getattr(response, "status", 200) < 500: | |
| return | |
| except Exception as exc: | |
| last_error = str(exc) | |
| time.sleep(1) | |
| raise RuntimeError(f"llama-server did not become ready at {base_url}: {last_error}") | |
| def start_llama_server() -> None: | |
| global LLAMA_SERVER_PROCESS | |
| if LLAMA_SERVER_PROCESS and LLAMA_SERVER_PROCESS.poll() is None: | |
| return | |
| model_path = ensure_model_downloaded() | |
| binary_path = ensure_llama_server_binary() | |
| cmd = [ | |
| str(binary_path), | |
| "-m", | |
| str(model_path), | |
| "--host", | |
| LLAMA_SERVER_HOST, | |
| "--port", | |
| str(LLAMA_SERVER_PORT), | |
| "-c", | |
| os.getenv("LLAMA_CONTEXT_SIZE", "4096"), | |
| "-n", | |
| os.getenv("LLAMA_MAX_PREDICT_TOKENS", "384"), | |
| "-ngl", | |
| os.getenv("LLAMA_GPU_LAYERS", str(N_GPU_LAYERS)), | |
| "-fa", | |
| os.getenv("LLAMA_ARG_FLASH_ATTN", "auto"), | |
| ] | |
| if MODEL_CHAT_TEMPLATE_KWARGS.strip(): | |
| cmd.extend(["--chat-template-kwargs", MODEL_CHAT_TEMPLATE_KWARGS.strip()]) | |
| _debug_log("[bootstrap] Starting llama-server: " + " ".join(cmd)) | |
| LLAMA_SERVER_PROCESS = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| bufsize=1, | |
| ) | |
| thread = threading.Thread( | |
| target=_pump_process_output, | |
| args=("llama-server", LLAMA_SERVER_PROCESS), | |
| daemon=True, | |
| ) | |
| thread.start() | |
| try: | |
| wait_for_server(LLAMA_SERVER_BASE_URL) | |
| _debug_log(f"[bootstrap] llama-server is ready at {LLAMA_SERVER_BASE_URL}") | |
| except Exception: | |
| if LLAMA_SERVER_PROCESS and LLAMA_SERVER_PROCESS.poll() is None: | |
| LLAMA_SERVER_PROCESS.terminate() | |
| raise | |
| # ========================================================= | |
| # GRID PROGRESSION | |
| # ========================================================= | |
| def get_grid_config(completed_challenge_levels: int) -> dict[str, int]: | |
| challenge_count = max(0, int(completed_challenge_levels or 0)) | |
| if challenge_count <= 0: | |
| return {"rows": 2, "cols": 2, "pairs": 2} | |
| if challenge_count == 1: | |
| return {"rows": 2, "cols": 3, "pairs": 3} | |
| if challenge_count == 2: | |
| return {"rows": 3, "cols": 4, "pairs": 6} | |
| if challenge_count == 3: | |
| return {"rows": 4, "cols": 4, "pairs": 8} | |
| if challenge_count == 4: | |
| return {"rows": 4, "cols": 5, "pairs": 10} | |
| pairs = min(18, 10 + 2 * (challenge_count - 4)) | |
| cols = 6 | |
| rows = math.ceil((pairs * 2) / cols) | |
| return {"rows": min(6, rows), "cols": cols, "pairs": pairs} | |
| def is_challenge_level(level: int, meter_value: int = 0, challenge_due: bool = False) -> bool: | |
| return bool(challenge_due) or int(meter_value) >= METER_CHALLENGE_THRESHOLD | |
| # ========================================================= | |
| # AI LEVEL GENERATION | |
| # ========================================================= | |
| def _normalize_emoji_token(emoji: str) -> str: | |
| return emoji.strip() | |
| EMOJI_ALIAS_RE = re.compile(r"^:[A-Za-z0-9_+\-]+:$") | |
| def _is_actual_emoji_token(token: Any) -> bool: | |
| if not isinstance(token, str): | |
| return False | |
| token = token.strip() | |
| if not token: | |
| return False | |
| if token.startswith(":") or token.endswith(":") or EMOJI_ALIAS_RE.fullmatch(token): | |
| return False | |
| if re.search(r"[A-Za-z0-9_\s]", token): | |
| return False | |
| return bool(regex.search(r"\p{Emoji}", token)) | |
| def _clean_one_line_text(value: Any, max_length: int) -> str: | |
| text = re.sub(r"[\r\n]+", " ", str(value or "")).strip() | |
| text = re.sub(r"\s+", " ", text) | |
| if len(text) <= max_length: | |
| return text | |
| truncated = text[:max_length].rstrip() | |
| last_space = truncated.rfind(" ") | |
| if last_space >= 24: | |
| truncated = truncated[:last_space].rstrip() | |
| return truncated[:max_length].rstrip() | |
| def normalize_label(value: Any) -> str: | |
| text = _clean_one_line_text(value, 80).lower() | |
| text = re.sub(r"[^a-z0-9]+", " ", text).strip() | |
| return text | |
| def _theme_phrase(theme: str) -> str: | |
| phrase = _clean_one_line_text(theme, 120) | |
| return phrase.lower() if phrase else "the theme" | |
| def _is_vague_theme_text(value: Any) -> bool: | |
| text = normalize_label(value) | |
| if not text: | |
| return True | |
| vague_phrases = { | |
| "memory", | |
| "learning", | |
| "education", | |
| "educational", | |
| "game", | |
| "theme", | |
| "level", | |
| "fun", | |
| "general", | |
| "random", | |
| "mixed", | |
| "various", | |
| "simple", | |
| "basic", | |
| "kids", | |
| "child friendly", | |
| "child safe", | |
| } | |
| if text in vague_phrases: | |
| return True | |
| if len(text) < 3: | |
| return True | |
| return any(phrase in text for phrase in ("memory game", "a game", "themed", "lesson", "topic")) | |
| def _concrete_theme_from_family(theme_family: str, emoji_pairs: list[str] | None = None) -> str: | |
| family = normalize_label(theme_family) | |
| if "animal" in family: | |
| return "Animal Shapes" | |
| if "nature" in family: | |
| return "Forest Plants" | |
| if "flower" in family: | |
| return "Garden Flowers" | |
| if "vehicle" in family or "transport" in family: | |
| return "Travel Vehicles" | |
| if "science" in family: | |
| return "Science Tools" | |
| if "space" in family: | |
| return "Space Objects" | |
| if "ocean" in family or "sea" in family: | |
| return "Ocean Animals" | |
| if "food" in family: | |
| return "Healthy Foods" | |
| if "music" in family: | |
| return "Music Instruments" | |
| if "weather" in family: | |
| return "Weather Signs" | |
| if "farm" in family: | |
| return "Farm Animals" | |
| if "school" in family: | |
| return "School Supplies" | |
| if "insect" in family or "bug" in family: | |
| return "Insects" | |
| if "dinosaur" in family: | |
| return "Dinosaurs" | |
| if emoji_pairs: | |
| return "Picture Match" | |
| return _clean_one_line_text(theme_family, 80).title() or "Picture Match" | |
| def _theme_family_from_theme(theme: str) -> str: | |
| label = normalize_label(theme) | |
| if not label: | |
| return "General" | |
| if any(token in label for token in ("animal", "cat", "dog", "bird", "fish", "bug", "insect", "dinosaur", "pet")): | |
| return "Animals" | |
| if any(token in label for token in ("kitchen", "cook", "cooking", "utensil", "spoon", "fork", "plate", "bowl", "pan", "pot", "whisk")): | |
| return "Kitchen" | |
| if any(token in label for token in ("school", "class", "book", "pencil", "desk", "paper", "classroom", "notebook")): | |
| return "School" | |
| if any(token in label for token in ("flower", "garden", "tree", "plant", "leaf", "forest")): | |
| return "Nature" | |
| if any(token in label for token in ("vehicle", "car", "train", "plane", "bus", "boat", "truck", "transport")): | |
| return "Transport" | |
| if any(token in label for token in ("space", "rocket", "planet", "star", "moon", "galaxy")): | |
| return "Space" | |
| if any(token in label for token in ("food", "fruit", "vegetable", "snack", "meal", "dessert")): | |
| return "Food" | |
| if any(token in label for token in ("music", "instrument", "song", "drum", "guitar", "piano")): | |
| return "Music" | |
| if any(token in label for token in ("science", "tool", "lab", "experiment", "machine", "gadget")): | |
| return "Science" | |
| if any(token in label for token in ("weather", "rain", "snow", "wind", "cloud", "storm", "sun")): | |
| return "Weather" | |
| return _clean_one_line_text(theme, 80).title() or "General" | |
| def _theme_key(value: Any) -> str: | |
| return normalize_label(value) | |
| def _question_key(value: Any) -> str: | |
| return normalize_label(value) | |
| def _is_similar_text(a: str, b: str) -> bool: | |
| a_key = _question_key(a) | |
| b_key = _question_key(b) | |
| if not a_key or not b_key: | |
| return False | |
| if a_key == b_key: | |
| return True | |
| return SequenceMatcher(None, a_key, b_key).ratio() >= 0.86 | |
| def _extend_unique_theme_history(items: list[str], value: str) -> None: | |
| candidate = _clean_one_line_text(value, 120) | |
| if not candidate: | |
| return | |
| candidate_key = _theme_key(candidate) | |
| if not candidate_key: | |
| return | |
| existing_keys = {_theme_key(item) for item in items} | |
| if candidate_key not in existing_keys: | |
| items.append(candidate) | |
| def _theme_phase_for_session(memory: dict[str, Any] | None, level: int) -> str: | |
| memory = memory or {} | |
| used_themes = list(memory.get("used_themes", [])) | |
| used_theme_families = list(memory.get("used_theme_families", [])) | |
| diversity = max(len(used_themes), len(used_theme_families)) | |
| if level <= 6 or diversity < THEME_PHASE_FOCUSED_LIMIT: | |
| return "focused" | |
| if diversity < THEME_PHASE_EXPANDED_LIMIT: | |
| return "expanded" | |
| return "mixed" | |
| def _compact_tail(values: list[str], limit: int) -> list[str]: | |
| return list(values[-limit:]) | |
| def _compact_counts(values: dict[str, Any], limit: int) -> dict[str, int]: | |
| items: list[tuple[str, int]] = [] | |
| for key, value in values.items(): | |
| try: | |
| count = int(value) | |
| except Exception: | |
| continue | |
| if count > 0: | |
| items.append((str(key), count)) | |
| items.sort(key=lambda item: (-item[1], item[0])) | |
| return dict(items[:limit]) | |
| def _extract_director_memory_snapshot(memory: dict[str, Any] | None) -> dict[str, Any]: | |
| memory = memory or {} | |
| return { | |
| "recent_themes": _compact_tail(list(memory.get("recent_themes", [])), MAX_RECENT_THEMES), | |
| "recent_theme_families": _compact_tail(list(memory.get("recent_theme_families", [])), MAX_RECENT_THEME_FAMILIES), | |
| "recent_challenge_questions": _compact_tail(list(memory.get("recent_challenge_questions", [])), MAX_RECENT_CHALLENGE_QUESTIONS), | |
| "recent_challenge_topics": _compact_tail(list(memory.get("recent_challenge_topics", [])), MAX_RECENT_CHALLENGE_QUESTIONS), | |
| "recent_challenge_modes": _compact_tail(list(memory.get("recent_challenge_modes", [])), MAX_RECENT_CHALLENGE_QUESTIONS), | |
| "used_themes": list(memory.get("used_themes", [])), | |
| "used_theme_families": list(memory.get("used_theme_families", [])), | |
| "recent_emojis": _compact_tail(list(memory.get("recent_emojis", [])), MAX_RECENT_EMOJIS), | |
| "theme_repeat_counts": _compact_counts(dict(memory.get("theme_repeat_counts", {})), MAX_REPEAT_COUNT_ITEMS), | |
| "family_repeat_counts": _compact_counts(dict(memory.get("family_repeat_counts", {})), MAX_REPEAT_COUNT_ITEMS), | |
| } | |
| def _make_director_memory() -> dict[str, Any]: | |
| return { | |
| "recent_themes": [], | |
| "recent_theme_families": [], | |
| "recent_challenge_questions": [], | |
| "recent_challenge_topics": [], | |
| "recent_challenge_modes": [], | |
| "used_themes": [], | |
| "used_theme_families": [], | |
| "recent_emojis": [], | |
| "theme_repeat_counts": {}, | |
| "family_repeat_counts": {}, | |
| } | |
| def _build_theme_specific_focus(theme: str) -> str: | |
| phrase = _theme_phrase(theme) | |
| return _clean_one_line_text(f"Recognizing {phrase} symbols.", 120) | |
| def _build_theme_specific_victory(theme: str) -> str: | |
| phrase = _theme_phrase(theme) | |
| template = random.choice(NORMAL_LEVEL_VICTORY_MESSAGES) | |
| return _clean_one_line_text(template.format(theme=phrase), 160) | |
| def _build_theme_specific_failure(theme: str) -> str: | |
| phrase = _theme_phrase(theme) | |
| template = random.choice(NORMAL_LEVEL_FAILURE_MESSAGES) | |
| return _clean_one_line_text(template.format(theme=phrase), 160) | |
| def _record_challenge_memory(memory: dict[str, Any], topic: str, question: str) -> None: | |
| recent_questions = memory.setdefault("recent_challenge_questions", []) | |
| recent_topics = memory.setdefault("recent_challenge_topics", []) | |
| topic_text = _clean_one_line_text(topic, 120) | |
| question_text = _clean_one_line_text(question, 120) | |
| if topic_text: | |
| _extend_unique_theme_history(recent_topics, topic_text) | |
| recent_topics[:] = _compact_tail(recent_topics, MAX_RECENT_CHALLENGE_QUESTIONS) | |
| if question_text: | |
| if _question_key(question_text) not in {_question_key(item) for item in recent_questions}: | |
| recent_questions.append(question_text) | |
| recent_questions[:] = _compact_tail(recent_questions, MAX_RECENT_CHALLENGE_QUESTIONS) | |
| def _pick_challenge_level_mode(level: int) -> str: | |
| return "fact_match" if random.random() < 0.5 else "category_match" | |
| def _record_challenge_mode(memory: dict[str, Any], mode: str) -> None: | |
| recent_modes = memory.setdefault("recent_challenge_modes", []) | |
| mode_text = _clean_one_line_text(mode, 40) | |
| if not mode_text: | |
| return | |
| if mode_text not in recent_modes: | |
| recent_modes.append(mode_text) | |
| recent_modes[:] = _compact_tail(recent_modes, MAX_RECENT_CHALLENGE_QUESTIONS) | |
| def _repair_blueprint_output( | |
| data: Any, | |
| pair_count: int, | |
| rows: int, | |
| cols: int, | |
| director_memory: dict[str, Any], | |
| performance_for_prompt: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| parsed = dict(data) if isinstance(data, dict) else {} | |
| theme_family = _clean_one_line_text(parsed.get("theme_family", ""), 80) | |
| theme = _clean_one_line_text(parsed.get("theme", ""), 100) | |
| if not theme or _is_vague_theme_text(theme): | |
| theme = _concrete_theme_from_family(theme_family, parsed.get("emoji_pairs") if isinstance(parsed.get("emoji_pairs"), list) else None) | |
| theme = _clean_one_line_text(theme, 100) | |
| if not theme: | |
| theme = "School Objects" | |
| if theme and theme[0].islower(): | |
| theme = theme[:1].upper() + theme[1:] | |
| if not theme_family: | |
| theme_family = _theme_family_from_theme(theme) | |
| victory_message = _clean_one_line_text(parsed.get("victory_message", ""), 160) | |
| if not victory_message or _is_vague_theme_text(victory_message): | |
| victory_message = _build_theme_specific_victory(theme) | |
| failure_message = _clean_one_line_text(parsed.get("failure_message", ""), 160) | |
| if not failure_message or _is_vague_theme_text(failure_message): | |
| failure_message = _build_theme_specific_failure(theme) | |
| grid_advice = { | |
| "recommended_rows": rows, | |
| "recommended_cols": cols, | |
| "pair_count": pair_count, | |
| } | |
| return { | |
| "theme_family": theme_family, | |
| "theme": theme, | |
| "grid_advice": grid_advice, | |
| "victory_message": victory_message, | |
| "failure_message": failure_message, | |
| } | |
| def collect_level_blueprint_violations( | |
| data: Any, | |
| pair_count: int, | |
| rows: int, | |
| cols: int, | |
| recent_emojis: list[str] | None = None, | |
| used_themes: list[str] | None = None, | |
| used_theme_families: list[str] | None = None, | |
| ) -> list[str]: | |
| violations: list[str] = [] | |
| if not isinstance(data, dict): | |
| return ["Response must be a JSON object."] | |
| allowed_keys = {"theme", "grid_advice"} | |
| extra_keys = sorted(set(data.keys()) - allowed_keys) | |
| if extra_keys: | |
| violations.append(f"Unexpected keys are not allowed: {', '.join(extra_keys)}.") | |
| theme = _clean_one_line_text(data.get("theme", ""), 100) | |
| if not theme or _is_vague_theme_text(theme): | |
| violations.append("theme must be a clear, specific string.") | |
| grid_advice = data.get("grid_advice") | |
| if not isinstance(grid_advice, dict): | |
| violations.append("grid_advice must be an object.") | |
| else: | |
| allowed_grid_keys = {"recommended_rows", "recommended_cols", "pair_count"} | |
| extra_grid_keys = sorted(set(grid_advice.keys()) - allowed_grid_keys) | |
| if extra_grid_keys: | |
| violations.append(f"grid_advice has unexpected keys: {', '.join(extra_grid_keys)}.") | |
| if "recommended_rows" not in grid_advice or "recommended_cols" not in grid_advice or "pair_count" not in grid_advice: | |
| violations.append("grid_advice must include recommended_rows, recommended_cols, and pair_count.") | |
| else: | |
| try: | |
| grid_rows = int(grid_advice.get("recommended_rows")) | |
| grid_cols = int(grid_advice.get("recommended_cols")) | |
| count = int(grid_advice.get("pair_count")) | |
| except Exception: | |
| violations.append("grid_advice values must be integers.") | |
| else: | |
| if grid_rows != rows: | |
| violations.append(f"grid_advice.recommended_rows must be {rows}.") | |
| if grid_cols != cols: | |
| violations.append(f"grid_advice.recommended_cols must be {cols}.") | |
| if grid_rows <= 0 or grid_cols <= 0: | |
| violations.append("grid_advice rows and cols must be positive integers.") | |
| if count != pair_count: | |
| violations.append(f"grid_advice pair_count must be {pair_count}.") | |
| return violations | |
| def validate_level_blueprint( | |
| data: Any, | |
| pair_count: int, | |
| rows: int, | |
| cols: int, | |
| recent_emojis: list[str] | None = None, | |
| used_themes: list[str] | None = None, | |
| used_theme_families: list[str] | None = None, | |
| ) -> bool: | |
| return not collect_level_blueprint_violations(data, pair_count, rows, cols, recent_emojis, used_themes, used_theme_families) | |
| def parse_level_blueprint(data: Any) -> dict[str, Any] | None: | |
| if not isinstance(data, dict): | |
| return None | |
| return dict(data) | |
| def _normalize_output(value: object) -> str: | |
| if value is None: | |
| return "" | |
| if isinstance(value, str): | |
| return value.strip() | |
| if isinstance(value, dict): | |
| if "parsed" in value: | |
| return _normalize_output(value.get("parsed")) | |
| if "content" in value or "text" in value: | |
| normalized = _normalize_output(value.get("content") or value.get("text")) | |
| if normalized: | |
| return normalized | |
| return json.dumps(value, ensure_ascii=False) | |
| if isinstance(value, list): | |
| parts: list[str] = [] | |
| for item in value: | |
| if isinstance(item, dict): | |
| parts.append((item.get("text") or item.get("content") or "").strip()) | |
| else: | |
| parts.append(str(item).strip()) | |
| return "".join(part for part in parts if part).strip() | |
| return str(value).strip() | |
| def _extract_response_text(response: object) -> str: | |
| choices = [] | |
| if isinstance(response, dict): | |
| choices = response.get("choices") or [] | |
| if not choices: | |
| return "" | |
| choice = choices[0] | |
| message = choice.get("message") if isinstance(choice, dict) else None | |
| if isinstance(choice, dict): | |
| for candidate in ( | |
| choice.get("text"), | |
| choice.get("content"), | |
| (message or {}).get("content") if isinstance(message, dict) else None, | |
| ): | |
| text = _normalize_output(candidate) | |
| if text: | |
| return text | |
| return "" | |
| def _extract_json_candidate(text: str) -> str: | |
| stripped = text.strip() | |
| if not stripped: | |
| return "" | |
| stripped = re.sub(r"<think>.*?</think>", "", stripped, flags=re.IGNORECASE | re.DOTALL) | |
| if stripped.startswith("```"): | |
| stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE) | |
| stripped = re.sub(r"\s*```$", "", stripped) | |
| start = stripped.find("{") | |
| end = stripped.rfind("}") | |
| if 0 <= start < end: | |
| return stripped[start : end + 1].strip() | |
| return stripped | |
| def _debug_preview(value: Any, limit: int = 2000) -> str: | |
| try: | |
| if isinstance(value, str): | |
| text = value | |
| else: | |
| text = json.dumps(value, ensure_ascii=False, default=str) | |
| except Exception: | |
| text = str(value) | |
| text = text.replace("\n", "\\n") | |
| if len(text) > limit: | |
| return text[:limit] + "...<truncated>" | |
| return text | |
| def _debug_log(message: str) -> None: | |
| if LLM_DEBUG: | |
| return | |
| def build_level_blueprint_response_schema(pair_count: int, rows: int, cols: int) -> dict[str, Any]: | |
| return { | |
| "type": "object", | |
| "additionalProperties": False, | |
| "required": [ | |
| "level_title", | |
| "victory_message", | |
| "failure_message", | |
| ], | |
| "properties": { | |
| "level_title": {"type": "string", "minLength": 1, "maxLength": 100}, | |
| "victory_message": {"type": "string", "minLength": 1, "maxLength": 160}, | |
| "failure_message": {"type": "string", "minLength": 1, "maxLength": 160}, | |
| }, | |
| } | |
| def build_challenge_blueprint_response_schema() -> dict[str, Any]: | |
| return { | |
| "type": "object", | |
| "additionalProperties": False, | |
| "required": [ | |
| "challenge_topic", | |
| "level_mode", | |
| "mode_popup_title", | |
| "mode_popup_message", | |
| "victory_message", | |
| "failure_message", | |
| "emoji_pairs", | |
| "match_targets", | |
| "pair_facts", | |
| ], | |
| "properties": { | |
| "challenge_topic": {"type": "string", "minLength": 1, "maxLength": 80}, | |
| "level_mode": { | |
| "type": "string", | |
| "enum": ["fact_match", "category_match"], | |
| }, | |
| "mode_popup_title": {"type": "string", "minLength": 1, "maxLength": 80}, | |
| "mode_popup_message": {"type": "string", "minLength": 1, "maxLength": 180}, | |
| "victory_message": {"type": "string", "minLength": 1, "maxLength": 160}, | |
| "failure_message": {"type": "string", "minLength": 1, "maxLength": 160}, | |
| "emoji_pairs": { | |
| "type": "array", | |
| "minItems": 2, | |
| "maxItems": 10, | |
| "uniqueItems": True, | |
| "items": {"type": "string", "minLength": 1, "maxLength": 16}, | |
| }, | |
| "match_targets": { | |
| "type": "object", | |
| "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 24}, | |
| }, | |
| "pair_facts": { | |
| "type": "object", | |
| "additionalProperties": {"type": "string", "minLength": 1, "maxLength": 160}, | |
| }, | |
| }, | |
| } | |
| def build_response_format(schema: dict[str, Any]) -> dict[str, Any] | None: | |
| return {"type": "json_object", "schema": schema} | |
| def build_featured_fact_response_schema(pair_count: int) -> dict[str, Any]: | |
| return { | |
| "type": "object", | |
| "additionalProperties": False, | |
| "required": ["featured_fact", "emoji_pairs"], | |
| "properties": { | |
| "featured_fact": {"type": "string", "minLength": 1, "maxLength": 220}, | |
| "emoji_pairs": { | |
| "type": "array", | |
| "minItems": pair_count, | |
| "maxItems": pair_count, | |
| "uniqueItems": True, | |
| "items": {"type": "string", "minLength": 1, "maxLength": 16}, | |
| }, | |
| }, | |
| } | |
| def build_featured_fact_prompt( | |
| theme_family: str, | |
| level_title: str, | |
| subject: str, | |
| pair_count: int, | |
| ) -> str: | |
| return ( | |
| "Return one JSON object only.\n" | |
| f"Theme family: {theme_family}\n" | |
| f"Level title: {level_title}\n" | |
| f"Subject: {subject}\n" | |
| f"Choose exactly {pair_count} unique single Unicode emojis.\n" | |
| "Every emoji must clearly match the Level title and the Subject.\n" | |
| "Do not repeat any emoji, do not use near-duplicates, and do not use the same emoji twice.\n" | |
| "If you cannot find enough distinct emojis for this exact title, choose a different emoji set that still fits the title.\n" | |
| "Write one short objective fact about the same Subject.\n" | |
| "The fact must mention the Subject by name or a very close phrase.\n" | |
| "No aliases. No extra text.\n\n" | |
| "Schema:\n" | |
| "{\n" | |
| ' "featured_fact": "short fact sentence",\n' | |
| f' "emoji_pairs": ["emoji1", "emoji2"{", ..." if pair_count > 2 else ""}]\n' | |
| "}" | |
| ) | |
| def collect_featured_fact_violations(data: Any, pair_count: int) -> list[str]: | |
| violations: list[str] = [] | |
| if not isinstance(data, dict): | |
| return ["Response must be a JSON object."] | |
| allowed_keys = {"featured_fact", "emoji_pairs"} | |
| extra_keys = set(data.keys()) - allowed_keys | |
| if extra_keys: | |
| violations.append(f"No extra keys allowed: {sorted(extra_keys)}") | |
| featured_fact = str(data.get("featured_fact", "")).strip() | |
| if not featured_fact: | |
| violations.append("featured_fact must be a non-empty string.") | |
| return violations | |
| sentence_count = len([part for part in re.split(r"[.!?]+", featured_fact) if part.strip()]) | |
| if sentence_count > 2: | |
| violations.append("featured_fact must be at most 2 sentences.") | |
| if len(featured_fact) > 220: | |
| violations.append("featured_fact must be 220 characters or less.") | |
| if re.search(r"\b(i|me|my|mine|we|our|ours)\b", featured_fact, flags=re.IGNORECASE): | |
| violations.append("featured_fact must not use first-person language.") | |
| emoji_pairs = data.get("emoji_pairs") | |
| if not isinstance(emoji_pairs, list): | |
| violations.append("emoji_pairs must be an array.") | |
| else: | |
| normalized_pairs = [_normalize_emoji_token(item) for item in emoji_pairs if isinstance(item, str)] | |
| if len(normalized_pairs) != len(emoji_pairs): | |
| violations.append("emoji_pairs must contain only strings.") | |
| if len(normalized_pairs) != pair_count: | |
| violations.append(f"emoji_pairs must contain exactly {pair_count} emojis.") | |
| if len(set(normalized_pairs)) != len(normalized_pairs): | |
| violations.append("emoji_pairs must contain unique emojis.") | |
| invalid_emojis = [emoji for emoji in normalized_pairs if not _is_actual_emoji_token(emoji)] | |
| if invalid_emojis: | |
| violations.append( | |
| "emoji_pairs must contain real Unicode emoji glyphs only: " | |
| + ", ".join(repr(item) for item in invalid_emojis[:5]) | |
| ) | |
| return violations | |
| def _build_fallback_featured_fact(theme_family: str, level_title: str, subject: str) -> str: | |
| phrase = _clean_one_line_text(level_title, 100) or _clean_one_line_text(subject, 100) or _clean_one_line_text(theme_family, 80) or "this level" | |
| fact_map = { | |
| "animals": "Animals live in many habitats and have body parts that help them survive.", | |
| "nature": "Nature includes living things like plants and animals, plus weather and landforms.", | |
| "weather": "Weather can change from day to day because air, water, and sunlight keep moving around Earth.", | |
| "food": "Foods can come from plants or animals and give the body energy to grow and move.", | |
| "music": "Musical instruments make sound when they are hit, blown, shaken, or played with strings.", | |
| "space": "Space contains stars, planets, moons, and many objects that move through the universe.", | |
| "transport": "Vehicles are designed to help people and goods move from one place to another.", | |
| "school": "School tools help people read, write, measure, organize, and learn new ideas.", | |
| "sports": "Sports use rules, movement, and practice to build teamwork and coordination.", | |
| "ocean": "The ocean is home to many animals and covers most of Earth's surface.", | |
| "science": "Science tools help people observe, measure, test, and learn how the world works.", | |
| "garden": "Gardens can grow flowers, fruits, and vegetables with sunlight, soil, and water.", | |
| "insects": "Insects have six legs and three main body parts.", | |
| "clothing": "Clothing helps protect the body in different seasons and activities.", | |
| "kitchen": "Kitchen tools are made to help prepare, cook, and serve food safely.", | |
| } | |
| family_key = normalize_label(theme_family) | |
| for key, fact in fact_map.items(): | |
| if key in family_key: | |
| return fact | |
| return _clean_one_line_text(f"{phrase} can be sorted by shape, use, color, or where it is found.", 220) | |
| def _subject_theme_key(theme_family: str, subject: str) -> str: | |
| text = normalize_label(f"{theme_family} {subject}") | |
| for key in NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY.keys(): | |
| if key in text: | |
| return key | |
| if "clothing" in text or "winter" in text: | |
| return "clothing" | |
| if "transport" in text or "vehicle" in text: | |
| return "transport" | |
| if "kitchen" in text or "cook" in text: | |
| return "kitchen" | |
| if "school" in text or "class" in text: | |
| return "school" | |
| if "weather" in text or "rain" in text or "snow" in text: | |
| return "weather" | |
| if "music" in text or "instrument" in text: | |
| return "music" | |
| if "space" in text: | |
| return "space" | |
| if "food" in text or "fruit" in text or "vegetable" in text: | |
| return "food" | |
| if "science" in text or "tool" in text or "machine" in text: | |
| return "science" | |
| if "nature" in text or "garden" in text: | |
| return "nature" | |
| return "" | |
| def _pick_repair_emoji_pool(theme_family: str, subject: str) -> list[str]: | |
| family_key = _subject_theme_key(theme_family, subject) | |
| if family_key and family_key in NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY: | |
| return list(NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY[family_key]) | |
| return list(NORMAL_LEVEL_EMOJI_POOL) | |
| def _repair_unique_emoji_pairs( | |
| emoji_pairs: list[str] | None, | |
| theme_family: str, | |
| subject: str, | |
| pair_count: int, | |
| ) -> list[str]: | |
| pool = _pick_repair_emoji_pool(theme_family, subject) | |
| repaired: list[str] = [] | |
| seen: set[str] = set() | |
| for emoji in emoji_pairs or []: | |
| if not isinstance(emoji, str): | |
| continue | |
| token = _normalize_emoji_token(emoji) | |
| if not _is_actual_emoji_token(token): | |
| continue | |
| if token in seen: | |
| continue | |
| seen.add(token) | |
| repaired.append(token) | |
| if len(repaired) >= pair_count: | |
| return repaired | |
| for emoji in pool: | |
| token = _normalize_emoji_token(emoji) | |
| if not _is_actual_emoji_token(token): | |
| continue | |
| if token in seen: | |
| continue | |
| seen.add(token) | |
| repaired.append(token) | |
| if len(repaired) >= pair_count: | |
| break | |
| return repaired[:pair_count] | |
| def collect_challenge_blueprint_violations( | |
| data: Any, | |
| used_themes: list[str] | None = None, | |
| recent_themes: list[str] | None = None, | |
| challenge_theme: str | None = None, | |
| ) -> list[str]: | |
| if not isinstance(data, dict): | |
| return ["Response must be a JSON object."] | |
| # Challenge mode is intentionally permissive now so the backend can | |
| # accept the model's JSON without rejecting repeats, extra fields, or | |
| # imperfect challenge wording. | |
| return [] | |
| def _parse_strict_json_object(text: str) -> dict[str, Any] | None: | |
| if not text: | |
| return None | |
| if isinstance(text, dict): | |
| return text | |
| if isinstance(text, list): | |
| if len(text) == 1 and isinstance(text[0], dict): | |
| return text[0] | |
| return None | |
| candidate = _extract_json_candidate(str(text)) | |
| try: | |
| parsed = json.loads(candidate) | |
| except json.JSONDecodeError: | |
| try: | |
| parsed = ast.literal_eval(candidate) | |
| except (ValueError, SyntaxError): | |
| return None | |
| return parsed if isinstance(parsed, dict) else None | |
| def generate_answer( | |
| user_prompt: str, | |
| response_format: dict[str, Any] | None = None, | |
| temperature: float = 0.45, | |
| max_tokens: int = 384, | |
| system_prompt: str | None = None, | |
| ) -> str: | |
| _debug_log("[llm] calling llama-server") | |
| messages = [] | |
| system_text = (system_prompt or "").strip() | |
| if system_text: | |
| messages.append( | |
| { | |
| "role": "system", | |
| "content": system_text, | |
| } | |
| ) | |
| else: | |
| messages.append( | |
| { | |
| "role": "system", | |
| "content": ( | |
| "Return exactly one valid JSON object and nothing else. " | |
| "Do not wrap it in markdown, code fences, or explanations." | |
| ), | |
| } | |
| ) | |
| payload = { | |
| "model": Path(MODEL_PATH).name, | |
| "messages": messages + [{"role": "user", "content": user_prompt}], | |
| "temperature": temperature, | |
| "max_tokens": max_tokens, | |
| "response_format": response_format or {"type": "json_object"}, | |
| "stream": False, | |
| } | |
| try: | |
| request = urllib.request.Request( | |
| f"{LLAMA_SERVER_BASE_URL}/v1/chat/completions", | |
| data=json.dumps(payload).encode("utf-8"), | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(request, timeout=LLAMA_REQUEST_TIMEOUT_SECONDS) as response: | |
| raw_response_text = response.read().decode("utf-8", errors="replace") | |
| _debug_log(f"[llm] llama-server raw http response={_debug_preview(raw_response_text, 6000)}") | |
| body = json.loads(raw_response_text) | |
| except Exception as exc: | |
| _debug_log(f"[llm] llama-server call failed: {type(exc).__name__}: {exc}") | |
| raise | |
| output = _extract_response_text(body) | |
| if not output: | |
| raise RuntimeError("llama-server returned an empty response") | |
| _debug_log(f"[llm] llama-server response body={_debug_preview(body)}") | |
| _debug_log(f"[llm] llama-server extracted text={_debug_preview(output)}") | |
| return output | |
| def _call_llama_server( | |
| prompt: str, | |
| response_format: dict[str, Any] | None = None, | |
| temperature: float = 0.0, | |
| max_tokens: int = 384, | |
| system_prompt: str | None = None, | |
| ) -> dict[str, Any] | None: | |
| try: | |
| text = generate_answer( | |
| prompt, | |
| response_format=response_format, | |
| temperature=temperature, | |
| max_tokens=max_tokens, | |
| system_prompt=system_prompt, | |
| ) | |
| except Exception as exc: | |
| _debug_log(f"[llm] llama-server call exception={type(exc).__name__}: {exc}") | |
| return None | |
| _debug_log(f"[llm] llama-server raw text={_debug_preview(text)}") | |
| parsed = _parse_strict_json_object(text) | |
| if parsed is None: | |
| _debug_log(f"[llm] llama-server parse failed candidate={_debug_preview(_extract_json_candidate(text))}") | |
| return parsed | |
| def _generate_featured_fact( | |
| theme_family: str, | |
| level_title: str, | |
| subject: str, | |
| pair_count: int, | |
| ) -> dict[str, Any]: | |
| prompt = build_featured_fact_prompt( | |
| theme_family=theme_family, | |
| level_title=level_title, | |
| subject=subject, | |
| pair_count=pair_count, | |
| ) | |
| response_format = build_response_format(build_featured_fact_response_schema(pair_count)) | |
| validation_feedback = "" | |
| for attempt in range(1, MAX_GENERATION_ATTEMPTS + 1): | |
| attempt_prompt = prompt if not validation_feedback else ( | |
| prompt | |
| + "\n\nIMPORTANT FIXES NEEDED:\n" | |
| + validation_feedback | |
| + "\nReturn a corrected JSON object only." | |
| ) | |
| ai_data = _call_llama_server( | |
| attempt_prompt, | |
| response_format=response_format, | |
| temperature=LEVEL_GENERATION_TEMPERATURE, | |
| max_tokens=72, | |
| ) | |
| if ai_data is None: | |
| validation_feedback = "The response was not parseable as JSON." | |
| continue | |
| featured_fact = _clean_one_line_text(str(ai_data.get("featured_fact", "")).strip(), 220) | |
| if not featured_fact: | |
| validation_feedback = "featured_fact must be a non-empty string." | |
| _debug_log(f"[llm] featured fact validation failed: {validation_feedback}") | |
| continue | |
| if len(featured_fact) > 220: | |
| validation_feedback = "featured_fact must be 220 characters or less." | |
| _debug_log(f"[llm] featured fact validation failed: {validation_feedback}") | |
| continue | |
| if re.search(r"\b(i|me|my|mine|we|our|ours)\b", featured_fact, flags=re.IGNORECASE): | |
| validation_feedback = "featured_fact must not use first-person language." | |
| _debug_log(f"[llm] featured fact validation failed: {validation_feedback}") | |
| continue | |
| emoji_pairs = _repair_unique_emoji_pairs( | |
| ai_data.get("emoji_pairs") if isinstance(ai_data.get("emoji_pairs"), list) else [], | |
| theme_family, | |
| subject, | |
| pair_count, | |
| ) | |
| if len(emoji_pairs) != pair_count: | |
| validation_feedback = f"Could not repair emoji_pairs to exactly {pair_count} unique emojis." | |
| _debug_log(f"[llm] featured fact validation failed: {validation_feedback}") | |
| continue | |
| if emoji_pairs != list(ai_data.get("emoji_pairs", [])): | |
| _debug_log( | |
| f"[llm] featured fact repaired emoji_pairs level_title={level_title!r} " | |
| f"raw={_debug_preview(ai_data.get('emoji_pairs', []))} repaired={_debug_preview(emoji_pairs)}" | |
| ) | |
| return { | |
| "featured_fact": featured_fact, | |
| "emoji_pairs": emoji_pairs, | |
| } | |
| fallback_fact = _build_fallback_featured_fact(theme_family, level_title, subject) | |
| _debug_log(f"[llm] featured fact fallback used for title={level_title!r}") | |
| return { | |
| "featured_fact": fallback_fact, | |
| "emoji_pairs": [], | |
| } | |
| def build_challenge_generation_payload(level: int, director_memory: dict[str, Any]) -> dict[str, Any]: | |
| snapshot = _extract_director_memory_snapshot(director_memory) | |
| return { | |
| "level": level, | |
| "challenge_topic": "", | |
| "level_mode": _pick_challenge_level_mode(level), | |
| "recent_challenge_questions": json.dumps(snapshot.get("recent_challenge_questions", []), ensure_ascii=False), | |
| "recent_challenge_topics": json.dumps(snapshot.get("recent_challenge_topics", []), ensure_ascii=False), | |
| "recent_challenge_modes": json.dumps(snapshot.get("recent_challenge_modes", []), ensure_ascii=False), | |
| } | |
| def build_challenge_generation_prompt(payload: dict[str, Any]) -> str: | |
| return ( | |
| MINICPM5_LEVEL_PROMPT_PREFIX | |
| + "\n" | |
| + CHALLENGE_LEVEL_PROMPT.format( | |
| recent_challenge_questions=payload["recent_challenge_questions"], | |
| recent_challenge_topics=payload["recent_challenge_topics"], | |
| recent_challenge_modes=payload["recent_challenge_modes"], | |
| ) | |
| ) | |
| def _infer_challenge_theme(question: str, ans_fact: str) -> str: | |
| text = normalize_label(f"{question} {ans_fact}") | |
| theme_keywords = [ | |
| ("Traffic Signs", ["traffic sign", "stop sign", "road sign", "speed limit", "crosswalk"]), | |
| ("Musical Instruments", ["musical instrument", "instrument", "piano", "guitar", "drum", "violin"]), | |
| ("Space", ["planet", "space", "moon", "star", "rocket", "astronaut"]), | |
| ("Animals", ["animal", "mammal", "bird", "fish", "reptile", "insect"]), | |
| ("Nature", ["flower", "tree", "plant", "leaf", "garden", "nature"]), | |
| ("Geography", ["country", "capital", "map", "continent", "ocean", "mountain"]), | |
| ("Mathematics", ["math", "number", "shape", "triangle", "circle", "equation"]), | |
| ("Technology", ["computer", "device", "technology", "internet", "keyboard", "phone"]), | |
| ("History", ["history", "ancient", "war", "king", "queen", "past"]), | |
| ("Literature", ["book", "story", "poem", "author", "novel", "literature"]), | |
| ("Language", ["word", "letter", "grammar", "sentence", "language"]), | |
| ("Art", ["paint", "art", "drawing", "color", "brush", "canvas"]), | |
| ("Sports", ["sport", "ball", "score", "player", "team", "game"]), | |
| ("Health", ["health", "body", "exercise", "diet", "sleep"]), | |
| ("Human Body", ["heart", "brain", "bone", "skin", "muscle"]), | |
| ("Environment", ["environment", "recycle", "pollution", "earth", "nature"]), | |
| ("Inventions", ["invention", "invented", "inventor", "machine"]), | |
| ("Famous Landmarks", ["landmark", "tower", "statue", "temple", "bridge"]), | |
| ("World Cultures", ["culture", "festival", "country", "tradition"]), | |
| ] | |
| for theme, keywords in theme_keywords: | |
| if any(keyword in text for keyword in keywords): | |
| return theme | |
| if text: | |
| return "General Knowledge" | |
| return "General Knowledge" | |
| def _challenge_grid_dimensions(pair_count: int) -> tuple[int, int]: | |
| total_cards = max(4, pair_count * 2) | |
| if total_cards <= 4: | |
| return 2, 2 | |
| if total_cards <= 6: | |
| return 2, 3 | |
| if total_cards <= 8: | |
| return 2, 4 | |
| if total_cards <= 12: | |
| return 3, 4 | |
| if total_cards <= 16: | |
| return 4, 4 | |
| cols = 5 | |
| rows = math.ceil(total_cards / cols) | |
| return rows, cols | |
| def _build_llm_challenge_cards( | |
| emoji_pairs: list[str], | |
| match_targets: dict[str, Any], | |
| pair_facts: dict[str, Any], | |
| ) -> list[dict[str, Any]]: | |
| cards: list[dict[str, Any]] = [] | |
| for emoji in emoji_pairs: | |
| target = match_targets.get(emoji, "") | |
| if not isinstance(target, str) or not target.strip(): | |
| target = pair_facts.get(emoji, "") | |
| if not isinstance(target, str): | |
| target = str(target) | |
| target = _clean_one_line_text(target, 24) | |
| if not target: | |
| target = _clean_one_line_text(emoji, 24) | |
| fact = pair_facts.get(emoji, "") | |
| if not isinstance(fact, str): | |
| fact = str(fact) | |
| fact = _clean_one_line_text(fact, 160) | |
| cards.append({ | |
| "display": emoji, | |
| "match_key": target, | |
| "card_type": "emoji", | |
| "pair_fact": fact, | |
| }) | |
| cards.append({ | |
| "display": target, | |
| "match_key": target, | |
| "card_type": "text", | |
| "pair_fact": fact, | |
| }) | |
| random.shuffle(cards) | |
| return cards | |
| def generate_challenge_content( | |
| level: int, | |
| director_memory: dict[str, Any], | |
| fallback_theme: str = "", | |
| ) -> dict[str, Any]: | |
| payload = build_challenge_generation_payload(level, director_memory) | |
| challenge_topic = _clean_one_line_text(payload.get("challenge_topic") or fallback_theme or "Challenge", 80) | |
| level_mode = payload["level_mode"] | |
| _debug_log(f"[llm] challenge generator level={level} topic={challenge_topic!r} mode={level_mode!r}") | |
| prompt = build_challenge_generation_prompt(payload) | |
| response_format = build_response_format(build_challenge_blueprint_response_schema()) | |
| ai_data = _call_llama_server( | |
| prompt, | |
| response_format=response_format, | |
| temperature=CHALLENGE_LEVEL_GENERATION_TEMPERATURE, | |
| max_tokens=CHALLENGE_LEVEL_GENERATION_MAX_TOKENS, | |
| ) | |
| if ai_data is None: | |
| raise RuntimeError("llama-server returned an invalid challenge blueprint") | |
| parsed = parse_level_blueprint(ai_data) | |
| if parsed is None: | |
| raise RuntimeError("llama-server returned an invalid challenge blueprint") | |
| challenge_topic = str(parsed.get("challenge_topic", "")).strip() | |
| if not challenge_topic: | |
| raise RuntimeError("llama-server returned a challenge blueprint without challenge_topic") | |
| level_mode = str(parsed.get("level_mode", level_mode)).strip() | |
| mode_popup_title = parsed.get("mode_popup_title", "") | |
| mode_popup_message = parsed.get("mode_popup_message", "") | |
| victory_message = parsed.get("victory_message", "") | |
| failure_message = parsed.get("failure_message", "") | |
| emoji_pairs = list(parsed.get("emoji_pairs", [])) | |
| match_targets_raw = parsed.get("match_targets", {}) | |
| pair_facts_raw = parsed.get("pair_facts", {}) | |
| if not isinstance(match_targets_raw, dict) or not isinstance(pair_facts_raw, dict): | |
| raise RuntimeError("llama-server returned an invalid challenge blueprint") | |
| match_targets = dict(match_targets_raw) | |
| pair_facts = dict(pair_facts_raw) | |
| cards = _build_llm_challenge_cards(emoji_pairs, match_targets, pair_facts) | |
| pair_count = len(emoji_pairs) | |
| rows, cols = _challenge_grid_dimensions(pair_count) | |
| theme_family = _theme_family_from_theme(challenge_topic) | |
| if isinstance(director_memory, dict): | |
| memory_modes = director_memory.setdefault("recent_challenge_modes", []) | |
| mode_text = str(level_mode).strip() | |
| if mode_text and mode_text not in memory_modes: | |
| memory_modes.append(mode_text) | |
| memory_modes[:] = _compact_tail(memory_modes, MAX_RECENT_CHALLENGE_QUESTIONS) | |
| _record_challenge_memory(director_memory, challenge_topic, mode_popup_title or challenge_topic) | |
| featured_fact = "" | |
| for emoji in emoji_pairs: | |
| fact = pair_facts.get(emoji, "") | |
| if not isinstance(fact, str): | |
| fact = str(fact) | |
| fact = fact.strip() | |
| if fact: | |
| featured_fact = fact | |
| break | |
| parsed["level_type"] = "challenge" | |
| parsed["level_mode"] = level_mode | |
| parsed["mode_popup_title"] = mode_popup_title | |
| parsed["mode_popup_message"] = mode_popup_message | |
| parsed["victory_message"] = victory_message | |
| parsed["failure_message"] = failure_message | |
| parsed["emoji_pairs"] = emoji_pairs | |
| parsed["match_targets"] = match_targets | |
| parsed["pair_facts"] = pair_facts | |
| parsed["cards"] = cards | |
| parsed["pair_count"] = pair_count | |
| parsed["rows"] = rows | |
| parsed["cols"] = cols | |
| parsed["grid_advice"] = { | |
| "recommended_rows": rows, | |
| "recommended_cols": cols, | |
| "pair_count": pair_count, | |
| } | |
| parsed["theme_family"] = theme_family | |
| parsed["theme"] = challenge_topic | |
| parsed["challenge_theme"] = challenge_topic | |
| parsed["level_title"] = mode_popup_title or f"{challenge_topic} Challenge" | |
| parsed["educational_focus"] = mode_popup_message or f"Matching {challenge_topic}." | |
| parsed["featured_fact_emoji"] = emoji_pairs[0] if emoji_pairs else "" | |
| parsed["featured_fact"] = featured_fact | |
| parsed["challenge_modal_title"] = mode_popup_title or "Challenge Level" | |
| parsed["challenge_modal_message"] = mode_popup_message | |
| parsed["challenge_question"] = "" | |
| parsed["challenge_options"] = [] | |
| parsed["challenge_correct_index"] = None | |
| parsed["theme_family"] = theme_family | |
| if isinstance(director_memory, dict): | |
| _record_challenge_mode(director_memory, str(parsed.get("level_mode", ""))) | |
| _debug_log(f"[llm] final challenge blueprint level={level} provider=llama-server data={_debug_preview(parsed)}") | |
| _debug_log(f"[llm] accepted generated challenge for level={level}") | |
| return parsed | |
| def _cycle_index(names: list[str], fallback: str) -> str: | |
| if names: | |
| return names[-1] | |
| return fallback | |
| def _pick_theme_family_hint(director_memory: dict[str, Any]) -> str: | |
| snapshot = _extract_director_memory_snapshot(director_memory) | |
| recent_families = { | |
| _theme_key(item) | |
| for item in ( | |
| list(snapshot.get("recent_theme_families", []))[-4:] | |
| + list(snapshot.get("used_theme_families", []))[-4:] | |
| ) | |
| if str(item).strip() | |
| } | |
| candidates = [ | |
| family | |
| for family in NORMAL_THEME_FAMILY_POOL | |
| if _theme_key(family) not in recent_families | |
| ] | |
| if not candidates: | |
| candidates = list(NORMAL_THEME_FAMILY_POOL) | |
| return random.choice(candidates) | |
| def build_player_performance( | |
| state: dict[str, Any] | None, | |
| level: int, | |
| ) -> dict[str, Any]: | |
| state = state or {} | |
| director_memory = state.get("director_memory", {}) or {} | |
| return { | |
| "current_level": level, | |
| "score": int(state.get("score", 0)), | |
| "lives_remaining": int(state.get("lives", MAX_LIVES)), | |
| "performance_meter": int(state.get("performance_meter", 0)), | |
| "challenge_due": bool(state.get("challenge_due", False)), | |
| "win_streak": int(state.get("win_streak", 0)), | |
| "previous_level_moves": int(state.get("moves", 0)), | |
| "previous_level_perfect_moves": int(state.get("total_pairs", 0)), | |
| "previous_level_wrong_attempts": int(state.get("wrong_attempts", 0)), | |
| "previous_level_time_seconds": int(state.get("previous_level_time_seconds", 0)), | |
| "previous_level_completed": bool(state.get("previous_level_completed", False)), | |
| "previous_theme": str(state.get("theme", "")), | |
| "recent_themes": list(director_memory.get("recent_themes", []))[-MAX_RECENT_THEMES:], | |
| "recent_theme_families": list(director_memory.get("recent_theme_families", []))[-MAX_RECENT_THEME_FAMILIES:], | |
| "recent_emojis": list(director_memory.get("recent_emojis", []))[-MAX_RECENT_EMOJIS:], | |
| "player_strengths": list(state.get("player_strengths", [])), | |
| "player_weaknesses": list(state.get("player_weaknesses", [])), | |
| "theme_repeat_counts": dict(director_memory.get("theme_repeat_counts", {})), | |
| "family_repeat_counts": dict(director_memory.get("family_repeat_counts", {})), | |
| } | |
| def build_level_generation_payload( | |
| level: int, | |
| pair_count: int, | |
| rows: int, | |
| cols: int, | |
| director_memory: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| snapshot = _extract_director_memory_snapshot(director_memory) | |
| theme_family = _pick_theme_family_hint(director_memory) | |
| used_themes = { | |
| _theme_key(item) | |
| for item in snapshot["used_themes"] | |
| if _theme_key(item) | |
| } | |
| subject_candidates = [subject for subject in NORMAL_LEVEL_SUBJECT_POOL if _theme_key(subject) not in used_themes] | |
| subject = random.choice(subject_candidates or NORMAL_LEVEL_SUBJECT_POOL) | |
| return { | |
| "level": level, | |
| "theme_family": theme_family, | |
| "subject": subject, | |
| "rows": rows, | |
| "cols": cols, | |
| "pair_count": pair_count, | |
| "used_themes_list": snapshot["used_themes"], | |
| "used_themes": json.dumps(snapshot["used_themes"], ensure_ascii=False), | |
| } | |
| def build_level_generation_prompt(payload: dict[str, Any]) -> str: | |
| return ( | |
| MINICPM5_LEVEL_PROMPT_PREFIX | |
| + "\n" | |
| + LEVEL_GENERATION_PROMPT.format( | |
| level=payload["level"], | |
| theme_family=payload["theme_family"], | |
| subject=payload["subject"], | |
| ) | |
| ) | |
| def collect_level_title_violations(data: Any) -> list[str]: | |
| violations: list[str] = [] | |
| if not isinstance(data, dict): | |
| return ["Response must be a JSON object."] | |
| level_title = _clean_one_line_text(data.get("level_title", ""), 100) | |
| if not level_title: | |
| violations.append("level_title must be a non-empty string.") | |
| elif _is_vague_theme_text(level_title): | |
| violations.append("level_title must be concrete and specific.") | |
| victory_message = _clean_one_line_text(data.get("victory_message", ""), 160) | |
| if not victory_message: | |
| violations.append("victory_message must be a non-empty string.") | |
| failure_message = _clean_one_line_text(data.get("failure_message", ""), 160) | |
| if not failure_message: | |
| violations.append("failure_message must be a non-empty string.") | |
| return violations | |
| def generate_level_content( | |
| level: int, | |
| pair_count: int, | |
| rows: int, | |
| cols: int, | |
| performance_for_prompt: dict[str, Any], | |
| director_memory: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| payload = build_level_generation_payload( | |
| level=level, | |
| pair_count=pair_count, | |
| rows=rows, | |
| cols=cols, | |
| director_memory=director_memory, | |
| ) | |
| prompt = build_level_generation_prompt(payload) | |
| subject = payload["subject"] | |
| used_themes_list = payload["used_themes_list"] | |
| _debug_log( | |
| f"[llm] prompt summary level={level} rows={rows} cols={cols} pairs={pair_count} " | |
| f"subject={subject!r} used_themes={payload['used_themes_list']} " | |
| f"provider=llama-server" | |
| ) | |
| response_format = build_response_format( | |
| build_level_blueprint_response_schema(pair_count, rows, cols), | |
| ) | |
| validation_feedback = "" | |
| parsed_title_data = None | |
| for attempt in range(1, MAX_GENERATION_ATTEMPTS + 1): | |
| attempt_prompt = prompt if not validation_feedback else ( | |
| prompt | |
| + "\n\nIMPORTANT FIXES NEEDED:\n" | |
| + validation_feedback | |
| + "\nReturn a corrected JSON object only." | |
| ) | |
| _debug_log( | |
| f"[llm] title attempt prompt level={level} attempt={attempt} chars={len(attempt_prompt)} " | |
| f"prompt={_debug_preview(attempt_prompt, 1800)}" | |
| ) | |
| ai_data = _call_llama_server( | |
| attempt_prompt, | |
| response_format=response_format, | |
| temperature=LEVEL_GENERATION_TEMPERATURE, | |
| max_tokens=LEVEL_GENERATION_MAX_TOKENS, | |
| ) | |
| if ai_data is None: | |
| validation_feedback = "The response was not parseable as JSON." | |
| _debug_log(f"[llm] title parse failed level={level} attempt={attempt} provider=llama-server") | |
| continue | |
| parsed_preview = parse_level_blueprint(ai_data) | |
| if parsed_preview is None: | |
| validation_feedback = "The response shape was invalid. Return the exact JSON schema." | |
| _debug_log( | |
| f"[llm] title shape failed level={level} attempt={attempt} provider=llama-server " | |
| f"raw={_debug_preview(ai_data)}" | |
| ) | |
| continue | |
| _debug_log( | |
| f"[llm] title parsed level={level} attempt={attempt} data={_debug_preview(parsed_preview)}" | |
| ) | |
| violations = collect_level_title_violations(parsed_preview) | |
| if violations: | |
| validation_feedback = " ".join(violations) | |
| _debug_log(f"[llm] title validation failed level={level}: {validation_feedback}") | |
| continue | |
| parsed_title_data = parsed_preview | |
| break | |
| if parsed_title_data is None: | |
| raise RuntimeError("llama-server returned an invalid level title blueprint") | |
| level_title = _clean_one_line_text(parsed_title_data.get("level_title", ""), 100) | |
| victory_message = _clean_one_line_text(parsed_title_data.get("victory_message", ""), 160) | |
| failure_message = _clean_one_line_text(parsed_title_data.get("failure_message", ""), 160) | |
| if not level_title: | |
| level_title = f"{subject} Match" | |
| if not victory_message: | |
| victory_message = _build_theme_specific_victory(subject) | |
| if not failure_message: | |
| failure_message = _build_theme_specific_failure(subject) | |
| theme_family = _theme_family_from_theme(subject) | |
| featured_fact_bundle = _generate_featured_fact( | |
| theme_family=theme_family, | |
| level_title=level_title, | |
| subject=subject, | |
| pair_count=pair_count, | |
| ) | |
| emoji_pairs = list(featured_fact_bundle.get("emoji_pairs", [])) | |
| parsed = { | |
| "theme_family": theme_family, | |
| "theme": subject, | |
| "level_title": level_title, | |
| "educational_focus": _build_theme_specific_focus(subject), | |
| "emoji_pairs": emoji_pairs, | |
| "grid_advice": { | |
| "recommended_rows": rows, | |
| "recommended_cols": cols, | |
| "pair_count": pair_count, | |
| "reason": "Fits the current board size.", | |
| }, | |
| "victory_message": victory_message, | |
| "failure_message": failure_message, | |
| "featured_fact_emoji": emoji_pairs[0] if emoji_pairs else "", | |
| "featured_fact": featured_fact_bundle["featured_fact"], | |
| } | |
| _debug_log(f"[llm] final blueprint level={level} provider=llama-server data={_debug_preview(parsed)}") | |
| _debug_log(f"[llm] accepted llama-server blueprint for level={level}") | |
| return parsed | |
| def generate_challenge_safely( | |
| level_number: int, | |
| director_memory: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| _debug_log(f"[llm] generate_challenge_safely level={level_number}") | |
| fallback_theme = "" | |
| if isinstance(director_memory, dict): | |
| recent_themes = list(director_memory.get("recent_themes", [])) | |
| if recent_themes: | |
| fallback_theme = str(recent_themes[-1]) | |
| blueprint = generate_challenge_content( | |
| level=level_number, | |
| director_memory=director_memory, | |
| fallback_theme=fallback_theme, | |
| ) | |
| level_instance_id = f"challenge-{level_number}-{uuid.uuid4().hex[:8]}" | |
| _debug_log( | |
| f"[llm] generated challenge level={level_number} instance={level_instance_id} theme={blueprint.get('theme', '')}" | |
| ) | |
| return { | |
| "level_number": level_number, | |
| "level_type": "challenge", | |
| "blueprint": blueprint, | |
| "cards": list(blueprint.get("cards", [])), | |
| "pair_count": int(blueprint.get("pair_count", 0) or 0), | |
| "rows": int(blueprint.get("rows", 2) or 2), | |
| "cols": int(blueprint.get("cols", 2) or 2), | |
| "level_instance_id": level_instance_id, | |
| } | |
| def create_cards(emojis: list[str]) -> list[str]: | |
| cards = list(emojis) * 2 | |
| random.shuffle(cards) | |
| return cards | |
| def generate_level_safely( | |
| level_number: int, | |
| performance_for_prompt: dict[str, Any], | |
| director_memory: dict[str, Any], | |
| completed_challenge_levels: int = 0, | |
| force_challenge: bool = False, | |
| ) -> dict[str, Any]: | |
| _debug_log(f"[llm] generate_level_safely level={level_number}") | |
| if force_challenge: | |
| return generate_challenge_safely(level_number, director_memory) | |
| config = get_grid_config(completed_challenge_levels) | |
| rows = config["rows"] | |
| cols = config["cols"] | |
| pair_count = config["pairs"] | |
| blueprint = generate_level_content( | |
| level=level_number, | |
| pair_count=pair_count, | |
| rows=rows, | |
| cols=cols, | |
| performance_for_prompt=performance_for_prompt, | |
| director_memory=director_memory, | |
| ) | |
| level_instance_id = f"level-{level_number}-{uuid.uuid4().hex[:8]}" | |
| cards = create_cards(blueprint["emoji_pairs"]) | |
| _debug_log( | |
| f"[llm] generated level={level_number} instance={level_instance_id} " | |
| f"theme={blueprint.get('theme', '')} pairs={len(blueprint.get('emoji_pairs', []))}" | |
| ) | |
| return { | |
| "level_number": level_number, | |
| "level_type": "normal", | |
| "blueprint": blueprint, | |
| "cards": cards, | |
| "pair_count": pair_count, | |
| "rows": rows, | |
| "cols": cols, | |
| "level_instance_id": level_instance_id, | |
| } | |
| def build_predicted_performance(state: dict[str, Any]) -> dict[str, Any]: | |
| active = state.get("active_level", {}) or {} | |
| director_memory = state.get("director_memory", {}) or {} | |
| return { | |
| "generation_mode": "predictive_next_level", | |
| "current_level": active.get("level_number", 1), | |
| "lives_remaining": state.get("lives", MAX_LIVES), | |
| "win_streak": state.get("win_streak", 0), | |
| "score": state.get("score", 0), | |
| "performance_meter": int(state.get("performance_meter", 0)), | |
| "challenge_due": bool(state.get("challenge_due", False)), | |
| "recent_average_wrong_attempts": state.get("last_performance", {}).get("wrong_attempts", 0), | |
| "recent_average_moves_ratio": state.get("last_performance", {}).get("moves_ratio", 0), | |
| "recent_themes": director_memory.get("recent_themes", []), | |
| "recent_theme_families": director_memory.get("recent_theme_families", []), | |
| "recent_emojis": director_memory.get("recent_emojis", []), | |
| "theme_repeat_counts": director_memory.get("theme_repeat_counts", {}), | |
| "family_repeat_counts": director_memory.get("family_repeat_counts", {}), | |
| "note": "This is predictive generation while current level is being played.", | |
| } | |
| def calculate_level_performance(event: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: | |
| active = state.get("active_level", {}) or {} | |
| director_memory = state.get("director_memory", {}) or {} | |
| pair_count = int(active.get("pair_count", 0)) | |
| moves = int(event.get("moves", 0)) | |
| wrong_attempts = int(event.get("wrong_attempts", 0)) | |
| if pair_count <= 0: | |
| pair_count = max(1, len(active.get("cards", [])) // 2) | |
| return { | |
| "generation_mode": "actual_performance_adjusted", | |
| "completed_level": active.get("level_number", 1), | |
| "score": state.get("score", 0), | |
| "lives_remaining": state.get("lives", MAX_LIVES), | |
| "win_streak": state.get("win_streak", 0), | |
| "moves": moves, | |
| "perfect_moves": pair_count, | |
| "wrong_attempts": wrong_attempts, | |
| "time_seconds": int(event.get("time_seconds", 0)), | |
| "previous_theme": active.get("blueprint", {}).get("theme", ""), | |
| "recent_themes": director_memory.get("recent_themes", []), | |
| "recent_theme_families": director_memory.get("recent_theme_families", []), | |
| "recent_emojis": director_memory.get("recent_emojis", []), | |
| "theme_repeat_counts": director_memory.get("theme_repeat_counts", {}), | |
| "family_repeat_counts": director_memory.get("family_repeat_counts", {}), | |
| "moves_ratio": round(moves / max(1, pair_count), 3), | |
| } | |
| def calculate_challenge_performance(state: dict[str, Any], selected_index: int, is_correct: bool) -> dict[str, Any]: | |
| active = state.get("active_level", {}) or {} | |
| blueprint = active.get("blueprint", {}) or {} | |
| return { | |
| "generation_mode": "challenge_response", | |
| "completed_level": active.get("level_number", 1), | |
| "level_type": "challenge", | |
| "theme": blueprint.get("theme", ""), | |
| "challenge_selected_index": int(selected_index), | |
| "challenge_correct_index": int(blueprint.get("challenge_correct_index", -1)) | |
| if isinstance(blueprint.get("challenge_correct_index", -1), int) | |
| else -1, | |
| "challenge_correct": bool(is_correct), | |
| "score": state.get("score", 0), | |
| "lives_remaining": state.get("lives", MAX_LIVES), | |
| "win_streak": state.get("win_streak", 0), | |
| "recent_themes": (state.get("director_memory", {}) or {}).get("recent_themes", []), | |
| "recent_theme_families": (state.get("director_memory", {}) or {}).get("recent_theme_families", []), | |
| "recent_emojis": (state.get("director_memory", {}) or {}).get("recent_emojis", []), | |
| "theme_repeat_counts": (state.get("director_memory", {}) or {}).get("theme_repeat_counts", {}), | |
| "family_repeat_counts": (state.get("director_memory", {}) or {}).get("family_repeat_counts", {}), | |
| } | |
| def update_director_memory_from_active_level(state: dict[str, Any]) -> None: | |
| active = state.get("active_level", {}) or {} | |
| blueprint = active.get("blueprint", {}) or {} | |
| memory = state.setdefault("director_memory", {}) | |
| used_themes = memory.setdefault("used_themes", []) | |
| theme = str(blueprint.get("theme", "")).strip() | |
| if theme: | |
| _extend_unique_theme_history(used_themes, theme) | |
| memory["used_themes"] = used_themes | |
| def build_shell_state() -> dict[str, Any]: | |
| return { | |
| "game_id": str(uuid.uuid4()), | |
| "game_started": False, | |
| "score": 0, | |
| "performance_meter": 0, | |
| "challenge_due": False, | |
| "completed_challenge_levels": 0, | |
| "lives": MAX_LIVES, | |
| "max_lives": MAX_LIVES, | |
| "hints": 1, | |
| "max_hints": 5, | |
| "win_streak": 0, | |
| "last_performance": {}, | |
| "history": [], | |
| "leaderboard_signed_in": False, | |
| "leaderboard_username": "", | |
| "leaderboard_high_score": 0, | |
| "leaderboard_saved": False, | |
| "director_memory": { | |
| "recent_themes": [], | |
| "recent_theme_families": [], | |
| "recent_challenge_questions": [], | |
| "recent_challenge_topics": [], | |
| "recent_challenge_modes": [], | |
| "used_themes": [], | |
| "used_theme_families": [], | |
| "recent_emojis": [], | |
| "theme_repeat_counts": {}, | |
| "family_repeat_counts": {}, | |
| }, | |
| "active_level": { | |
| "level_number": 1, | |
| "level_type": "normal", | |
| "blueprint": { | |
| "theme_family": "", | |
| "level_title": "", | |
| "theme": "", | |
| "educational_focus": "", | |
| "featured_fact_emoji": "", | |
| "featured_fact": "", | |
| "challenge_modal_title": "", | |
| "challenge_modal_message": "", | |
| "challenge_question": "", | |
| "challenge_options": [], | |
| "challenge_correct_index": None, | |
| "level_mode": "normal", | |
| "mode_popup_title": "", | |
| "mode_popup_message": "", | |
| "match_targets": {}, | |
| "pair_facts": {}, | |
| "grid_advice": {}, | |
| "victory_message": "", | |
| "failure_message": "", | |
| }, | |
| "cards": [], | |
| "pair_count": 0, | |
| "rows": 2, | |
| "cols": 2, | |
| "level_instance_id": "", | |
| }, | |
| "generation_status": {"level_number": None, "status": "idle", "error": ""}, | |
| "game_over": False, | |
| "level_complete": False, | |
| "transitioning_level": False, | |
| "status": "preview", | |
| } | |
| def build_initial_game_state( | |
| game_id: str | None = None, | |
| ) -> dict[str, Any]: | |
| bootstrap_llama_runtime() | |
| base_state = { | |
| "game_id": game_id or str(uuid.uuid4()), | |
| "game_started": True, | |
| "score": 0, | |
| "performance_meter": 0, | |
| "challenge_due": False, | |
| "completed_challenge_levels": 0, | |
| "lives": MAX_LIVES, | |
| "max_lives": MAX_LIVES, | |
| "hints": 1, | |
| "max_hints": 5, | |
| "win_streak": 0, | |
| "last_performance": {}, | |
| "history": [], | |
| "leaderboard_signed_in": False, | |
| "leaderboard_username": "", | |
| "leaderboard_high_score": 0, | |
| "leaderboard_saved": False, | |
| "director_memory": { | |
| "recent_themes": [], | |
| "recent_theme_families": [], | |
| "recent_challenge_questions": [], | |
| "recent_challenge_topics": [], | |
| "recent_challenge_modes": [], | |
| "used_themes": [], | |
| "used_theme_families": [], | |
| "recent_emojis": [], | |
| "theme_repeat_counts": {}, | |
| "family_repeat_counts": {}, | |
| }, | |
| "active_level": None, | |
| "generation_status": {"level_number": None, "status": "idle", "error": ""}, | |
| "game_over": False, | |
| "level_complete": False, | |
| "transitioning_level": False, | |
| "challenge_started": False, | |
| "challenge_result": "", | |
| } | |
| active_level = generate_level_safely( | |
| 1, | |
| { | |
| "generation_mode": "initial_level", | |
| "lives_remaining": MAX_LIVES, | |
| "win_streak": 0, | |
| "score": 0, | |
| "recent_themes": [], | |
| "recent_theme_families": [], | |
| "recent_emojis": [], | |
| "theme_repeat_counts": {}, | |
| "family_repeat_counts": {}, | |
| }, | |
| base_state["director_memory"], | |
| completed_challenge_levels=0, | |
| ) | |
| base_state["active_level"] = active_level | |
| base_state["level_complete"] = False | |
| base_state["game_over"] = False | |
| update_director_memory_from_active_level(base_state) | |
| return base_state | |
| def public_game_state(state: dict[str, Any]) -> dict[str, Any]: | |
| payload = dict(state) | |
| return payload | |
| def _extract_session_hash(incoming: dict[str, Any] | None = None) -> str: | |
| incoming = incoming or {} | |
| session_hash = str(incoming.get("session_hash") or "").strip() | |
| if session_hash: | |
| return session_hash | |
| game_id = str(incoming.get("game_id") or "").strip() | |
| if game_id: | |
| return game_id | |
| return "" | |
| # ========================================================= | |
| # GAME MANAGER | |
| # ========================================================= | |
| class GameManager: | |
| def __init__(self) -> None: | |
| self.sessions: dict[str, dict[str, Any]] = {} | |
| self.game_state: dict[str, Any] = {} | |
| def _compute_card_size(self, cols: int) -> int: | |
| if cols <= 2: | |
| return 160 | |
| if cols == 3: | |
| return 150 | |
| if cols == 4: | |
| return 132 | |
| return 118 | |
| def _merge_session_state(self, state_json: str) -> dict[str, Any]: | |
| incoming = json.loads(state_json) | |
| game_id = incoming.get("game_id") | |
| existing = self.sessions.get(game_id, {}) if game_id else {} | |
| merged = dict(existing) | |
| for key, value in incoming.items(): | |
| if value is not None: | |
| merged[key] = value | |
| if game_id: | |
| merged["game_id"] = game_id | |
| elif "game_id" not in merged: | |
| merged["game_id"] = str(uuid.uuid4()) | |
| if "director_memory" not in merged: | |
| merged["director_memory"] = { | |
| "recent_themes": [], | |
| "recent_theme_families": [], | |
| "recent_challenge_questions": [], | |
| "recent_challenge_topics": [], | |
| "recent_challenge_modes": [], | |
| "used_themes": [], | |
| "used_theme_families": [], | |
| "recent_emojis": [], | |
| "theme_repeat_counts": {}, | |
| "family_repeat_counts": {}, | |
| } | |
| if "history" not in merged: | |
| merged["history"] = [] | |
| if "leaderboard_signed_in" not in merged: | |
| merged["leaderboard_signed_in"] = False | |
| if "leaderboard_username" not in merged: | |
| merged["leaderboard_username"] = "" | |
| if "leaderboard_high_score" not in merged: | |
| merged["leaderboard_high_score"] = 0 | |
| if "leaderboard_saved" not in merged: | |
| merged["leaderboard_saved"] = False | |
| if "last_performance" not in merged: | |
| merged["last_performance"] = {} | |
| if "generation_status" not in merged: | |
| merged["generation_status"] = {"level_number": None, "status": "idle", "error": ""} | |
| if "score" not in merged: | |
| merged["score"] = 0 | |
| if "performance_meter" not in merged: | |
| merged["performance_meter"] = 0 | |
| if "challenge_due" not in merged: | |
| merged["challenge_due"] = False | |
| if "completed_challenge_levels" not in merged: | |
| merged["completed_challenge_levels"] = 0 | |
| if "lives" not in merged: | |
| merged["lives"] = MAX_LIVES | |
| if "max_lives" not in merged: | |
| merged["max_lives"] = MAX_LIVES | |
| if "hints" not in merged: | |
| merged["hints"] = 1 | |
| if "max_hints" not in merged: | |
| merged["max_hints"] = 5 | |
| if "win_streak" not in merged: | |
| merged["win_streak"] = 0 | |
| if "challenge_started" not in merged: | |
| merged["challenge_started"] = False | |
| if "challenge_result" not in merged: | |
| merged["challenge_result"] = "" | |
| if "active_level" not in merged: | |
| merged["active_level"] = None | |
| elif isinstance(merged["active_level"], dict): | |
| merged["active_level"].setdefault("level_type", "normal") | |
| blueprint = merged["active_level"].setdefault("blueprint", {}) | |
| blueprint.setdefault("theme_family", "") | |
| blueprint.setdefault("level_title", "") | |
| blueprint.setdefault("theme", "") | |
| blueprint.setdefault("educational_focus", "") | |
| blueprint.setdefault("emoji_pairs", []) | |
| blueprint.setdefault("featured_fact_emoji", "") | |
| blueprint.setdefault("featured_fact", "") | |
| blueprint.setdefault("challenge_modal_title", "") | |
| blueprint.setdefault("challenge_modal_message", "") | |
| blueprint.setdefault("challenge_question", "") | |
| blueprint.setdefault("challenge_options", []) | |
| blueprint.setdefault("challenge_correct_index", None) | |
| blueprint.setdefault("level_mode", "normal") | |
| blueprint.setdefault("mode_popup_title", "") | |
| blueprint.setdefault("mode_popup_message", "") | |
| blueprint.setdefault("match_targets", {}) | |
| blueprint.setdefault("pair_facts", {}) | |
| blueprint.setdefault("grid_advice", {}) | |
| blueprint.setdefault("victory_message", "") | |
| blueprint.setdefault("failure_message", "") | |
| self.sessions[merged["game_id"]] = merged | |
| self.game_state = merged | |
| return merged | |
| def bootstrap_game(self) -> dict[str, Any]: | |
| state = build_shell_state() | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return public_game_state(state) | |
| def load_level_state(self, state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| incoming = json.loads(state_json or "{}") | |
| session_hash = _extract_session_hash(incoming) | |
| if not session_hash: | |
| return { | |
| "ok": False, | |
| "error": "missing_session", | |
| } | |
| existing = self.sessions.get(session_hash) | |
| if isinstance(existing, dict) and existing.get("active_level"): | |
| if profile is not None: | |
| existing.update(_get_leaderboard_status(profile)) | |
| self.game_state = existing | |
| return { | |
| "ok": True, | |
| "session_hash": session_hash, | |
| "state": public_game_state(existing), | |
| "ui_action": "load_level_and_start_preview", | |
| "html_payload": existing["active_level"], | |
| } | |
| state = build_initial_game_state( | |
| session_hash, | |
| ) | |
| if profile is not None: | |
| state.update(_get_leaderboard_status(profile)) | |
| self.sessions[session_hash] = state | |
| self.game_state = state | |
| return { | |
| "ok": True, | |
| "session_hash": session_hash, | |
| "state": public_game_state(state), | |
| "ui_action": "load_level_and_start_preview", | |
| "html_payload": state["active_level"], | |
| } | |
| def start_game(self, state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| incoming = json.loads(state_json) | |
| state = build_initial_game_state( | |
| incoming.get("game_id"), | |
| ) | |
| state.update(_get_leaderboard_status(profile)) | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return { | |
| "state": public_game_state(state), | |
| "ui_action": "load_level_and_start_preview", | |
| "html_payload": state["active_level"], | |
| } | |
| def sync_state(self, state_json: str) -> dict[str, Any]: | |
| state = self._merge_session_state(state_json) | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return {"ok": True} | |
| def reset_game(self, state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| state = build_initial_game_state( | |
| self.game_state.get("game_id"), | |
| ) | |
| if profile is not None: | |
| state.update(_get_leaderboard_status(profile)) | |
| else: | |
| state["leaderboard_signed_in"] = bool(self.game_state.get("leaderboard_signed_in", False)) | |
| state["leaderboard_username"] = str(self.game_state.get("leaderboard_username", "")) | |
| state["leaderboard_high_score"] = int(self.game_state.get("leaderboard_high_score", 0) or 0) | |
| state["leaderboard_saved"] = bool(self.game_state.get("leaderboard_saved", False)) | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return public_game_state(state) | |
| def on_hint_used(self, state_json: str) -> dict[str, Any]: | |
| state = self._merge_session_state(state_json) | |
| hints_remaining = int(state.get("hints", 1)) | |
| state["hints"] = max(0, min(int(state.get("max_hints", 5)), hints_remaining)) | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return public_game_state(state) | |
| def on_challenge_answer(self, state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| state = self._merge_session_state(state_json) | |
| active = state.get("active_level", {}) or {} | |
| blueprint = active.get("blueprint", {}) or {} | |
| selected_index = state.get("challenge_selected_index") | |
| try: | |
| selected_index = int(selected_index) | |
| except Exception: | |
| selected_index = -1 | |
| correct_index = blueprint.get("challenge_correct_index") | |
| try: | |
| correct_index = int(correct_index) | |
| except Exception: | |
| correct_index = -1 | |
| is_correct = selected_index == correct_index and correct_index >= 0 | |
| state["challenge_last_selected_index"] = selected_index | |
| state["challenge_last_result"] = "correct" if is_correct else "wrong" | |
| state["challenge_started"] = True | |
| state["challenge_attempts"] = int(state.get("challenge_attempts", 0)) + 1 | |
| state["completed_challenge_levels"] = int(state.get("completed_challenge_levels", 0)) + 1 | |
| if is_correct: | |
| update_director_memory_from_active_level(state) | |
| state["level_complete"] = True | |
| state["game_over"] = False | |
| state["status"] = "complete" | |
| state["score"] = int(state.get("score", 0)) + 20 | |
| state["win_streak"] = int(state.get("win_streak", 0)) + 1 | |
| state["performance_meter"] = 0 | |
| state["challenge_due"] = False | |
| state["last_performance"] = calculate_challenge_performance(state, selected_index, True) | |
| state.setdefault("history", []).append(state["last_performance"]) | |
| state["generation_status"] = {"level_number": None, "status": "idle", "error": ""} | |
| else: | |
| state["lives"] = max(0, int(state.get("lives", MAX_LIVES)) - 1) | |
| state["level_complete"] = True | |
| state["game_over"] = False | |
| state["status"] = "complete" | |
| state["performance_meter"] = 100 | |
| state["challenge_due"] = False | |
| state["last_performance"] = calculate_challenge_performance(state, selected_index, False) | |
| state.update(_apply_leaderboard_update(profile, int(state.get("score", 0)))) | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return public_game_state(state) | |
| def on_wrong_match(self, state_json: str) -> dict[str, Any]: | |
| state = self._merge_session_state(state_json) | |
| active = state.get("active_level", {}) or {} | |
| is_challenge = active.get("level_type") == "challenge" | |
| state["lives"] = max(0, int(state.get("lives", MAX_LIVES)) - 1) | |
| if is_challenge: | |
| state["performance_meter"] = 100 | |
| state["challenge_due"] = False | |
| else: | |
| state["performance_meter"] = max( | |
| 0, | |
| int(state.get("performance_meter", 0)) - METER_POINTS_PER_CORRECT_MATCH, | |
| ) | |
| state["challenge_due"] = bool(state.get("performance_meter", 0) >= METER_CHALLENGE_THRESHOLD) | |
| state["level_complete"] = False | |
| if state["lives"] <= 0: | |
| state["game_over"] = True | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return public_game_state(state) | |
| def on_level_complete(self, state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| state = self._merge_session_state(state_json) | |
| active = state.get("active_level", {}) or {} | |
| is_challenge_level = active.get("level_type") == "challenge" | |
| event = { | |
| "moves": int(state.get("moves", 0)), | |
| "wrong_attempts": int(state.get("wrong_attempts", 0)), | |
| "time_seconds": int(state.get("previous_level_time_seconds", 0)), | |
| } | |
| performance = calculate_level_performance(event, state) | |
| state["last_performance"] = performance | |
| state.setdefault("history", []).append(performance) | |
| state["level_complete"] = True | |
| state["game_over"] = False | |
| state["transitioning_level"] = False | |
| update_director_memory_from_active_level(state) | |
| if is_challenge_level: | |
| state["completed_challenge_levels"] = int(state.get("completed_challenge_levels", 0)) + 1 | |
| state["score"] = int(state.get("score", 0)) | |
| state["win_streak"] = int(state.get("win_streak", 0)) + 1 | |
| state["generation_status"] = {"level_number": None, "status": "idle", "error": ""} | |
| state.update(_apply_leaderboard_update(profile, int(state.get("score", 0)))) | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return public_game_state(state) | |
| def on_next_level_click(self, state_json: str) -> dict[str, Any]: | |
| state = self._merge_session_state(state_json) | |
| expected_next_level = int(state["active_level"]["level_number"]) + 1 | |
| force_challenge = bool(state.get("challenge_due")) or int(state.get("performance_meter", 0)) >= METER_CHALLENGE_THRESHOLD | |
| state["transitioning_level"] = True | |
| performance_for_prompt = state.get("last_performance") or build_predicted_performance(state) | |
| prepared = generate_level_safely( | |
| expected_next_level, | |
| performance_for_prompt, | |
| state.setdefault("director_memory", {}), | |
| completed_challenge_levels=int(state.get("completed_challenge_levels", 0)), | |
| force_challenge=force_challenge, | |
| ) | |
| state["active_level"] = prepared | |
| state["level_complete"] = False | |
| state["game_over"] = False | |
| state["transitioning_level"] = False | |
| state["moves"] = 0 | |
| state["wrong_attempts"] = 0 | |
| state["challenge_started"] = False | |
| state["challenge_result"] = "" | |
| state["challenge_selected_index"] = None | |
| state["challenge_last_selected_index"] = None | |
| state["challenge_last_result"] = "" | |
| state["challenge_attempts"] = 0 | |
| state["active_level"]["blueprint"]["preview_seconds"] = PREVIEW_SECONDS | |
| if force_challenge: | |
| state["challenge_due"] = False | |
| state["generation_status"] = {"level_number": None, "status": "idle", "error": ""} | |
| self.sessions[state["game_id"]] = state | |
| self.game_state = state | |
| return { | |
| "state": public_game_state(state), | |
| "ui_action": "load_level_and_start_preview", | |
| "html_payload": state["active_level"], | |
| } | |
| manager = GameManager() | |
| def load_level_state(state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| return manager.load_level_state(state_json, profile) | |
| def sync_state(state_json: str) -> dict[str, Any]: | |
| return manager.sync_state(state_json) | |
| def on_wrong_match(state_json: str) -> dict[str, Any]: | |
| return manager.on_wrong_match(state_json) | |
| def on_level_complete(state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| return manager.on_level_complete(state_json, profile) | |
| def on_hint_used(state_json: str) -> dict[str, Any]: | |
| return manager.on_hint_used(state_json) | |
| def on_challenge_answer(state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| return manager.on_challenge_answer(state_json, profile) | |
| def start_game(state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| return manager.start_game(state_json, profile) | |
| def on_next_level_click(state_json: str) -> dict[str, Any]: | |
| return manager.on_next_level_click(state_json) | |
| def reset_game(state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| return manager.reset_game(state_json, profile) | |
| def get_leaderboard_data(state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]: | |
| return { | |
| "current_user": _get_leaderboard_status(profile), | |
| "entries": _build_leaderboard_entries(10), | |
| } | |
| # ========================================================= | |
| # HTML TEMPLATE | |
| # ========================================================= | |
| GRID_HTML = """ | |
| <div class="memory-container" data-game-id="${game_id}"> | |
| <div class="start-overlay" id="start-overlay" aria-hidden="false"> | |
| <div class="start-stage"> | |
| <div class="start-orbit"></div> | |
| <div class="start-rings"></div> | |
| <div class="start-stars"></div> | |
| <div class="start-hero"> | |
| <div class="start-mark" aria-hidden="true"> | |
| <img class="start-mark-logo" src="__LOGO_DATA_URI__" alt="" aria-hidden="true"> | |
| </div> | |
| <div class="start-title-wrap"> | |
| <div class="start-title">MatchWise</div> | |
| <div class="start-tagline"> | |
| <span class="tag-green">Remember.</span> | |
| <span class="tag-blue">Match.</span> | |
| <span class="tag-purple">Progress.</span> | |
| </div> | |
| </div> | |
| <div class="start-subtitle"> | |
| Match pairs, discover facts, and see how far your memory can go. | |
| </div> | |
| <div class="start-preview" aria-hidden="true"> | |
| <div class="preview-card preview-flower"> | |
| <div class="preview-emoji">🌼</div> | |
| </div> | |
| <div class="preview-card preview-flower"> | |
| <div class="preview-emoji">🌼</div> | |
| </div> | |
| <div class="preview-card preview-blossom"> | |
| <div class="preview-emoji">🌸</div> | |
| </div> | |
| <div class="preview-card preview-blossom"> | |
| <div class="preview-emoji">🌸</div> | |
| </div> | |
| </div> | |
| <div class="start-utility-row"> | |
| <button type="button" class="start-help-button" id="how-to-play-btn"> | |
| <span class="start-help-icon">?</span> | |
| <span class="start-help-text">How to Play</span> | |
| </button> | |
| <button type="button" class="music-toggle-button start-music-button" id="music-toggle-start-btn"> | |
| <span class="music-toggle-icon">♫</span> | |
| <span class="music-toggle-text">Sound: ON</span> | |
| </button> | |
| <div class="high-score-pill" id="high-score-pill"> | |
| <span class="high-score-icon">⭐</span> | |
| <span class="high-score-copy"> | |
| <span class="high-score-label" id="high-score-label">My High Score</span> | |
| <span class="high-score-user" id="high-score-user">Guest</span> | |
| </span> | |
| <span class="high-score-value" id="high-score-value">0</span> | |
| </div> | |
| <button type="button" class="leaderboard-button start-leaderboard-button" id="leaderboard-btn"> | |
| <span class="leaderboard-icon">🏆</span> | |
| <span class="leaderboard-text">Leaderboard</span> | |
| </button> | |
| </div> | |
| <div class="start-footer-row" aria-label="Start screen footer"> | |
| <div class="start-footer-item"> | |
| <span class="start-footer-value"><a href="https://huggingface.co/build-small-hackathon" target="_blank" rel="noopener noreferrer">The Build Small Hackathon</a></span> | |
| </div> | |
| <div class="start-footer-item"> | |
| <span class="start-footer-value">TRACK:🍄 An Adventure in Thousand Token Wood</span> | |
| </div> | |
| </div> | |
| <div class="start-attribution"> | |
| Built with ❤️ by <a href="https://huggingface.co/tejasashinde" target="_blank" rel="noopener noreferrer">tejasashinde</a> with Gradio, MiniCPM5-1B via Llama.cpp | |
| </div> | |
| <button type="button" class="start-button" id="start-game-btn"> | |
| <span class="start-button-icon">▶</span> | |
| <span class="start-button-text">START GAME</span> | |
| </button> | |
| <div class="start-note" id="start-note">Press Start to generate your first level.</div> | |
| <div class="dialog-overlay hidden start-transition-overlay" id="start-transition-overlay" aria-hidden="true"> | |
| </div> | |
| </div> | |
| <div class="dialog-overlay hidden start-transition-overlay" id="start-transition-overlay" aria-hidden="true"> | |
| <div class="dialog-card dialog-start-loading"> | |
| <div class="dialog-title" id="start-transition-title">Preparing Game</div> | |
| <div class="dialog-text" id="start-transition-message">Generating your first level...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="dialog-overlay hidden" id="how-to-play-overlay" aria-hidden="true"> | |
| <div class="dialog-card dialog-howto"> | |
| <button type="button" class="howto-close-btn" id="how-to-play-close-btn" aria-label="Close how to play modal">×</button> | |
| <div class="howto-logo-wrap" aria-hidden="true"> | |
| <img class="howto-logo" src="__LOGO_DATA_URI__" alt=""> | |
| </div> | |
| <div class="howto-kicker">HOW TO PLAY</div> | |
| <div class="howto-headline">Remember cards. Match pairs. Survive the run.</div> | |
| <div class="howto-steps"> | |
| <div class="howto-step-card purple"> | |
| <div class="howto-step-icon">👀</div> | |
| <div class="howto-step-copy"> | |
| <div class="howto-step-title">1. Preview</div> | |
| <div class="howto-step-text">Memorize the board during the preview.</div> | |
| </div> | |
| </div> | |
| <div class="howto-step-arrow">→</div> | |
| <div class="howto-step-card blue"> | |
| <div class="howto-step-icon">🃏</div> | |
| <div class="howto-step-copy"> | |
| <div class="howto-step-title">2. Match</div> | |
| <div class="howto-step-text">Find matching pairs to score.</div> | |
| </div> | |
| </div> | |
| <div class="howto-step-arrow">→</div> | |
| <div class="howto-step-card green"> | |
| <div class="howto-step-icon">🏆</div> | |
| <div class="howto-step-copy"> | |
| <div class="howto-step-title">3. Progress</div> | |
| <div class="howto-step-text">Beat challenges and survive the run.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="howto-main"> | |
| <div class="howto-left"> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon blue">⏱️</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">1. No Time Limit</div> | |
| <div class="howto-rule-text">Play at your own pace. There is no countdown timer.</div> | |
| </div> | |
| </div> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon purple">🧠</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">2. Memorize First</div> | |
| <div class="howto-rule-text">Each level shows the board briefly before the cards flip back.</div> | |
| </div> | |
| </div> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon red">❤️</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">3. Lives</div> | |
| <div class="howto-rule-text">Start with <span class="howto-chip danger">5 lives</span>. Wrong matches cost 1 life, and challenge wins can add more lives.</div> | |
| </div> | |
| </div> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon amber">💡</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">4. Peeks</div> | |
| <div class="howto-rule-text">Use <strong>PEEK</strong> to reveal one matching pair for a moment. You start with <span class="howto-chip warning">1 peek</span> and can earn more on normal level clears.</div> | |
| </div> | |
| </div> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon gold">🏅</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">5. Scoring</div> | |
| <div class="howto-rule-text">Correct matches raise score. Wrong matches reduce score by 10.</div> | |
| </div> | |
| </div> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon orange">🔥</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">6. Challenge Level</div> | |
| <div class="howto-rule-text">Some levels switch to a matching challenge about a school topic. Match emoji cards to clue words or categories. Correct answers give <span class="howto-chip success">+20 score</span> and <span class="howto-chip success">+1 life</span>, while wrong answers remove <span class="howto-chip danger">1 life</span>.</div> | |
| </div> | |
| </div> | |
| <div class="howto-rule"> | |
| <div class="howto-rule-icon purple">🤖</div> | |
| <div class="howto-rule-copy"> | |
| <div class="howto-rule-title">7. Endless AI Levels</div> | |
| <div class="howto-rule-text">New LLM-driven levels keep coming until your lives reach 0.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="howto-right"> | |
| <div class="howto-panel-title">Reward Table</div> | |
| <table class="howto-rewards-table"> | |
| <thead> | |
| <tr> | |
| <th>Action</th> | |
| <th>Reward</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td>Normal pair matched</td> | |
| <td class="reward-good">+20 score</td> | |
| </tr> | |
| <tr> | |
| <td>Normal wrong match</td> | |
| <td class="reward-bad">-10 score, -1 life</td> | |
| </tr> | |
| <tr> | |
| <td>Normal level cleared</td> | |
| <td class="reward-warn">Can earn +1 peek</td> | |
| </tr> | |
| <tr> | |
| <td>Challenge answer correct</td> | |
| <td class="reward-good">+20 score, +1 life</td> | |
| </tr> | |
| <tr> | |
| <td>Challenge answer wrong</td> | |
| <td class="reward-bad">-1 life</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="dialog-overlay hidden" id="leaderboard-overlay" aria-hidden="true"> | |
| <div class="dialog-card dialog-leaderboard"> | |
| <button type="button" class="howto-close-btn leaderboard-close-btn" id="leaderboard-close-btn" aria-label="Close leaderboard modal">×</button> | |
| <div class="leaderboard-logo-wrap" aria-hidden="true"> | |
| <img class="leaderboard-logo" src="__LOGO_DATA_URI__" alt=""> | |
| </div> | |
| <div class="leaderboard-kicker">LEADERBOARD</div> | |
| <div class="leaderboard-headline">See the highest MatchWise scores.</div> | |
| <div class="leaderboard-my-score"> | |
| <div class="leaderboard-my-score-label">My High Score</div> | |
| <div class="leaderboard-my-score-row"> | |
| <span class="leaderboard-my-score-user" id="leaderboard-my-score-user">Guest</span> | |
| <span class="leaderboard-my-score-value" id="leaderboard-my-score-value">0</span> | |
| </div> | |
| </div> | |
| <div class="leaderboard-list" id="leaderboard-list"></div> | |
| </div> | |
| </div> | |
| <div class="game-brand" aria-label="MatchWise"> | |
| <div class="game-brand-mark" aria-hidden="true"> | |
| <img class="game-brand-logo" src="__LOGO_DATA_URI__" alt="" aria-hidden="true"> | |
| </div> | |
| <div class="game-brand-copy"> | |
| <div class="game-brand-title">MatchWise</div> | |
| <div class="game-brand-subtitle">Match pairs, discover facts, and see how far your memory can go.</div> | |
| </div> | |
| </div> | |
| <div class="board-frame"> | |
| <div class="top-utility-bar"> | |
| <button type="button" class="utility-button home-button" id="home-btn"> | |
| <span class="utility-button-icon">⌂</span> | |
| <span class="utility-button-text">Main menu</span> | |
| </button> | |
| <button type="button" class="music-toggle-button top-music-button" id="music-toggle-top-btn"> | |
| <span class="music-toggle-icon">♫</span> | |
| <span class="music-toggle-text">Sound: ON</span> | |
| </button> | |
| <div class="high-score-pill top-high-score-pill" id="high-score-pill"> | |
| <span class="high-score-icon">⭐</span> | |
| <span class="high-score-copy"> | |
| <span class="high-score-label" id="high-score-label">My High Score</span> | |
| <span class="high-score-user" id="high-score-user">Guest</span> | |
| </span> | |
| <span class="high-score-value" id="high-score-value">0</span> | |
| </div> | |
| </div> | |
| <div class="game-header"> | |
| <div class="performance-meter-shell"> | |
| <div class="performance-meter-title">PERFORMANCE METER</div> | |
| <div class="meter-card"> | |
| <div class="side-label easy"> | |
| <div class="icon-box">🍃</div> | |
| EASY | |
| </div> | |
| <div class="meter" id="meter"> | |
| <div class="meter-fill-spark" id="spark"></div> | |
| <div class="ticks"> | |
| <span></span><span></span><span></span><span></span><span></span> | |
| <span></span><span></span><span></span><span></span><span></span> | |
| </div> | |
| <div class="knob" id="knob"> | |
| <div class="knob-core"></div> | |
| </div> | |
| </div> | |
| <div class="side-label challenge"> | |
| <div class="icon-box">🔥</div> | |
| Challenge Me | |
| </div> | |
| </div> | |
| </div> | |
| <div class="game-header-row"> | |
| <div class="game-stats"> | |
| <div class="stat-block stat-level"> | |
| <span class="stat-icon">🏁</span> | |
| <span class="stat-label">LEVEL</span> | |
| <span class="stat-value" id="level-display">${level}</span> | |
| </div> | |
| <div class="stat-block stat-moves"> | |
| <span class="stat-icon">⇄</span> | |
| <span class="stat-label">MOVES</span> | |
| <span class="stat-value" id="moves-display">0</span> | |
| </div> | |
| <div class="stat-block stat-matches"> | |
| <span class="stat-icon">👥</span> | |
| <span class="stat-label">MATCHES</span> | |
| <span class="stat-value" id="matches-display">0 / <span id="total-pairs">0</span></span> | |
| </div> | |
| <div class="stat-block stat-score"> | |
| <span class="stat-icon">🏆</span> | |
| <span class="stat-label">SCORE</span> | |
| <span class="stat-value" id="score-display">${score}</span> | |
| </div> | |
| <div class="stat-block stat-lives"> | |
| <span class="stat-icon">♥</span> | |
| <span class="stat-label">LIVES</span> | |
| <span class="stat-value" id="lives-display">${lives}</span> | |
| </div> | |
| </div> | |
| <div class="game-state-badge" id="game-state-badge">MEMORIZE</div> | |
| </div> | |
| </div> | |
| <div id="status-layout"> | |
| <div class="game-status" id="game-status-area"> | |
| <div class="status-text" id="status-text">${level_title}</div> | |
| <div class="status-subtext" id="status-subtext">${educational_focus}</div> | |
| </div> | |
| <div class="transition-overlay hidden" id="transition-overlay" aria-hidden="true"> | |
| <div class="transition-card"> | |
| <div class="transition-title" id="transition-title">Loading</div> | |
| <div class="transition-subtitle" id="transition-subtitle">Please wait...</div> | |
| </div> | |
| </div> | |
| <div class="dialog-overlay hidden" id="reset-confirm-overlay" aria-hidden="true"> | |
| <div class="dialog-card dialog-warning"> | |
| <div class="dialog-title">Start a new game?</div> | |
| <div class="dialog-text">Are you sure, starting new game will remove current progress?</div> | |
| <div class="dialog-actions"> | |
| <button type="button" class="dialog-button dialog-secondary" id="reset-cancel-btn">Cancel</button> | |
| <button type="button" class="dialog-button dialog-primary" id="reset-confirm-btn">Yes,Start New Game</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="dialog-overlay hidden" id="gameover-overlay" aria-hidden="true"> | |
| <div class="dialog-card dialog-gameover"> | |
| <div class="gameover-icon-wrap" aria-hidden="true"> | |
| <div class="gameover-icon-halo"></div> | |
| <div class="gameover-icon">🎮</div> | |
| </div> | |
| <div class="gameover-title">Game Over</div> | |
| <div class="gameover-divider" aria-hidden="true"> | |
| <span></span> | |
| <span class="gameover-diamond">◆</span> | |
| <span></span> | |
| </div> | |
| <div class="gameover-subtitle"> | |
| You finished with a score of <span id="gameover-score">0</span>. | |
| </div> | |
| <div class="gameover-score-pill" aria-label="Score"> | |
| <span class="gameover-score-icon">🏆</span> | |
| <span class="gameover-score-label">SCORE</span> | |
| <span class="gameover-score-sep"></span> | |
| <span class="gameover-score-value" id="gameover-score-pill">0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="dialog-overlay hidden" id="challenge-intro-overlay" aria-hidden="true"> | |
| <div class="dialog-card dialog-challenge dialog-challenge-polished"> | |
| <button type="button" class="challenge-close-btn" id="challenge-close-btn" aria-label="Close challenge dialog">×</button> | |
| <div class="challenge-hero" aria-hidden="true"> | |
| <div class="challenge-hero-ring"> | |
| <div class="challenge-hero-icon">🔥</div> | |
| </div> | |
| </div> | |
| <div class="challenge-title" id="challenge-modal-title">Challenge Level</div> | |
| <div class="challenge-subtitle" id="challenge-modal-subtitle">This is a matching challenge. Match the cards, then tap OK to begin.</div> | |
| <div class="challenge-divider" aria-hidden="true"><span></span><i></i><span></span></div> | |
| <div class="challenge-points"> | |
| <div class="challenge-point"><span class="challenge-point-icon">?</span><span>Match an emoji to a word or its identical pair.</span></div> | |
| <div class="challenge-point"><span class="challenge-point-icon">◎</span><span>Keep matching until the board is clear.</span></div> | |
| </div> | |
| <div class="challenge-rewards"> | |
| <div class="challenge-reward good"><span class="challenge-reward-icon">❤</span><span>+1 life for a cleared challenge</span></div> | |
| <div class="challenge-reward bad"><span class="challenge-reward-icon">💔</span><span>-1 life for wrong match</span></div> | |
| </div> | |
| <div class="challenge-note"><span class="challenge-note-icon">i</span><span>Challenge boards can use clue words or categories.</span></div> | |
| <div class="dialog-actions challenge-actions"> | |
| <button type="button" class="challenge-start-btn" id="challenge-ok-btn"><span class="challenge-start-icon">▶</span><span>Start Challenge</span></button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="game-layout"> | |
| <div class="level-panel"> | |
| <div class="level-meta"> | |
| <div class="level-title" id="level-title">${level_title}</div> | |
| <div class="level-chips"> | |
| <span class="theme-chip" id="theme-chip">${theme}</span> | |
| </div> | |
| <div class="level-focus" id="focus-line">${educational_focus}</div> | |
| </div> | |
| </div> | |
| <div class="challenge-panel hidden" id="challenge-panel"> | |
| <div class="challenge-card"> | |
| <div class="challenge-label">Challenge Question</div> | |
| <div class="challenge-theme" id="challenge-theme"></div> | |
| <div class="challenge-question" id="challenge-question"></div> | |
| <div class="challenge-options" id="challenge-options"></div> | |
| <div class="challenge-focus" id="challenge-focus"></div> | |
| </div> | |
| </div> | |
| <div class="cards-panel"> | |
| <div class="preview-overlay" id="preview-overlay"> | |
| <div class="preview-content"> | |
| <div class="preview-countdown" id="preview-countdown">3</div> | |
| <div class="preview-subtitle">Memorize the cards</div> | |
| </div> | |
| </div> | |
| <div class="memory-grid" id="memory-grid" style="--grid-cols:${grid_cols}; --grid-rows:${grid_rows}; --card-size:${card_size}px;"> | |
| @children | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="controls-row" id="controls-row" style="display:none;"> | |
| <button type="button" class="hint-button" id="hint-btn"> | |
| <span class="hint-prefix">🔍 PEEK</span> | |
| <span class="hint-count" id="hint-count">${hints} / ${max_hints}</span> | |
| </button> | |
| <button type="button" class="next-level-button" id="next-btn" disabled>⬆ NEXT LEVEL</button> | |
| <button type="button" class="reset-button" id="reset-btn">🔄 START NEW GAME</button> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| GRID_HTML = GRID_HTML.replace("__LOGO_DATA_URI__", LOGO_DATA_URI) | |
| _LEVEL_SPLIT_INDEX = GRID_HTML.index('<div class="game-brand"') | |
| START_HTML = GRID_HTML[:_LEVEL_SPLIT_INDEX] + "</div>\n" | |
| LEVEL_HTML = '<div class="memory-container" data-game-id="${game_id}">\n' + GRID_HTML[_LEVEL_SPLIT_INDEX:] | |
| # ========================================================= | |
| # CSS | |
| # ========================================================= | |
| CSS = """ | |
| html, | |
| body { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| width: 100% !important; | |
| } | |
| .gradio-container { | |
| width: 100vw !important; | |
| } | |
| #game-shell { | |
| width: 100% !important; | |
| } | |
| footer { | |
| display: none !important; | |
| } | |
| .memory-container { | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: flex-start; | |
| gap: clamp(12px, 1.5vw, 18px); | |
| padding: clamp(10px, 1.6vw, 22px); | |
| background: | |
| radial-gradient(circle at top left, rgba(99,102,241,0.28), transparent 32%), | |
| radial-gradient(circle at bottom right, rgba(14,165,233,0.18), transparent 30%), | |
| linear-gradient(135deg, #0b1020 0%, #111827 52%, #020617 100%); | |
| border-radius: 28px; | |
| position: relative; | |
| width: 100%; | |
| max-width: 100vw; | |
| min-height: 0; | |
| height: auto; | |
| max-height: none; | |
| color: #f8fafc; | |
| box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45); | |
| box-sizing: border-box; | |
| } | |
| .memory-container.level-loading .board-frame { | |
| visibility: hidden; | |
| } | |
| .game-brand { | |
| width: min(100%, 1480px); | |
| z-index: 1; | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| justify-content: flex-start; | |
| gap: 14px; | |
| padding: 4px 4px 0; | |
| box-sizing: border-box; | |
| } | |
| .game-brand-mark { | |
| width: clamp(54px, 4.8vw, 72px); | |
| height: clamp(54px, 4.8vw, 72px); | |
| flex: 0 0 auto; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 18px; | |
| background: | |
| radial-gradient(circle at 35% 35%, rgba(255,255,255,0.18), transparent 42%), | |
| radial-gradient(circle at 50% 50%, rgba(168, 85, 247, 0.2), rgba(168, 85, 247, 0.04) 70%); | |
| box-shadow: | |
| 0 0 0 1px rgba(196, 181, 253, 0.18) inset, | |
| 0 0 18px rgba(168, 85, 247, 0.22); | |
| } | |
| .game-brand-logo { | |
| width: 70%; | |
| height: 70%; | |
| object-fit: contain; | |
| display: block; | |
| } | |
| .game-brand-copy { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| min-width: 0; | |
| } | |
| .game-brand-title { | |
| font-size: clamp(28px, 2.9vw, 42px); | |
| font-weight: 900; | |
| letter-spacing: -0.03em; | |
| color: #f8fafc; | |
| } | |
| .game-brand-subtitle { | |
| font-size: clamp(14px, 1.05vw, 17px); | |
| line-height: 1.45; | |
| color: #cbd5e1; | |
| max-width: 72ch; | |
| } | |
| .memory-container::before { | |
| content: ""; | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| background-image: | |
| linear-gradient(rgba(148,163,184,0.04) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(148,163,184,0.04) 1px, transparent 1px); | |
| background-size: 32px 32px; | |
| pointer-events: none; | |
| opacity: 0.35; | |
| } | |
| .board-frame { | |
| width: min(100%, 1480px); | |
| max-width: 100%; | |
| height: auto; | |
| max-height: none; | |
| position: relative; | |
| z-index: 1; | |
| --board-frame-padding: clamp(14px, 1.8vw, 26px); | |
| border-radius: 28px; | |
| border: 1px solid rgba(148, 163, 184, 0.18); | |
| background: linear-gradient(180deg, rgba(15, 23, 42, 0.72), rgba(2, 6, 23, 0.84)); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 28px 90px rgba(2, 6, 23, 0.42); | |
| padding: var(--board-frame-padding); | |
| display: flex; | |
| flex-direction: column; | |
| gap: clamp(16px, 2vw, 24px); | |
| box-sizing: border-box; | |
| overflow: visible; | |
| } | |
| #status-layout { | |
| position: relative; | |
| width: 100%; | |
| min-height: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: clamp(14px, 1.6vw, 20px); | |
| overflow: visible; | |
| } | |
| .preview-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(2, 6, 23, 0.46); | |
| backdrop-filter: blur(3px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 5; | |
| border-radius: 28px; | |
| transition: opacity 0.45s ease; | |
| pointer-events: none; | |
| } | |
| .memory-container.preview-wobbling .preview-overlay { | |
| background: rgba(2, 6, 23, 0.18); | |
| backdrop-filter: blur(1px); | |
| } | |
| .memory-container.preview-wobbling .preview-content { | |
| transform: scale(0.96); | |
| transition: transform 0.25s ease, opacity 0.25s ease; | |
| opacity: 0.95; | |
| } | |
| .preview-overlay.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .transition-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(2, 6, 23, 0.82); | |
| backdrop-filter: blur(10px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 45; | |
| border-radius: 28px; | |
| transition: opacity 0.25s ease; | |
| } | |
| .transition-overlay.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .dialog-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(2, 6, 23, 0.78); | |
| backdrop-filter: blur(12px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 55; | |
| border-radius: 28px; | |
| transition: opacity 0.25s ease; | |
| } | |
| #leaderboard-overlay { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 85; | |
| } | |
| .dialog-overlay.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .start-overlay { | |
| position: fixed; | |
| inset: 0; | |
| width: 100vw; | |
| height: 100dvh; | |
| min-height: 100vh; | |
| z-index: 60; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: | |
| radial-gradient(circle at 50% 28%, rgba(139, 92, 246, 0.16), transparent 18%), | |
| radial-gradient(circle at 50% 64%, rgba(14, 165, 233, 0.24), transparent 20%), | |
| radial-gradient(circle at 50% 64%, rgba(168, 85, 247, 0.18), transparent 30%), | |
| linear-gradient(180deg, #040714 0%, #06122a 55%, #020617 100%); | |
| backdrop-filter: blur(8px); | |
| overflow-x: hidden; | |
| overflow-y: auto; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| .start-overlay.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .start-stage { | |
| position: relative; | |
| width: 200vw; | |
| height: 200dvh; | |
| min-height: 200vh; | |
| margin: 0 auto; | |
| padding: clamp(18px, 3vw, 48px) clamp(18px, 5vw, 64px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-sizing: border-box; | |
| transform: scale(0.5); | |
| transform-origin: center center; | |
| } | |
| .start-hero { | |
| position: relative; | |
| z-index: 2; | |
| width: min(100%, 820px); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| text-align: center; | |
| gap: clamp(14px, 1.6vw, 22px); | |
| } | |
| .start-mark { | |
| width: clamp(58px, 7vw, 86px); | |
| height: clamp(58px, 7vw, 86px); | |
| display: grid; | |
| place-items: center; | |
| border-radius: 50%; | |
| background: | |
| radial-gradient(circle at 35% 35%, rgba(255,255,255,0.2), transparent 40%), | |
| radial-gradient(circle at 50% 50%, rgba(168, 85, 247, 0.24), rgba(168, 85, 247, 0.02) 70%); | |
| box-shadow: | |
| 0 0 0 1px rgba(196, 181, 253, 0.22) inset, | |
| 0 0 18px rgba(168, 85, 247, 0.35), | |
| 0 0 40px rgba(168, 85, 247, 0.2); | |
| } | |
| .start-mark-icon { | |
| font-size: clamp(28px, 3.4vw, 42px); | |
| filter: drop-shadow(0 0 14px rgba(168, 85, 247, 0.65)); | |
| } | |
| .start-mark-logo { | |
| width: 70%; | |
| height: 70%; | |
| object-fit: contain; | |
| display: block; | |
| filter: drop-shadow(0 0 14px rgba(168, 85, 247, 0.45)); | |
| } | |
| .start-title-wrap { | |
| display: flex; | |
| flex-direction: column; | |
| gap: clamp(8px, 1vw, 12px); | |
| align-items: center; | |
| } | |
| .start-title { | |
| margin-top: 0; | |
| font-size: clamp(48px, 10vw, 118px); | |
| line-height: 0.92; | |
| font-weight: 1000; | |
| letter-spacing: -0.06em; | |
| color: #f7f2ff; | |
| text-shadow: | |
| 0 1px 0 #ffffff, | |
| 0 2px 0 #d7cfff, | |
| 0 5px 0 rgba(102, 0, 255, 0.85), | |
| 0 18px 34px rgba(110, 29, 255, 0.35), | |
| 0 0 28px rgba(168, 85, 247, 0.3); | |
| -webkit-text-stroke: 1px rgba(255,255,255,0.35); | |
| } | |
| .start-tagline { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| gap: 0.3em 0.45em; | |
| font-size: clamp(18px, 2.4vw, 34px); | |
| font-weight: 900; | |
| letter-spacing: -0.02em; | |
| } | |
| .tag-green { color: #86efac; text-shadow: 0 0 16px rgba(34,197,94,0.45); } | |
| .tag-blue { color: #67e8f9; text-shadow: 0 0 16px rgba(6,182,212,0.45); } | |
| .tag-purple { color: #d8b4fe; text-shadow: 0 0 16px rgba(168,85,247,0.45); } | |
| .start-subtitle { | |
| margin-top: 0; | |
| color: #eef2ff; | |
| font-size: clamp(17px, 2vw, 34px); | |
| line-height: 1.45; | |
| text-wrap: balance; | |
| text-shadow: 0 4px 18px rgba(2, 6, 23, 0.65); | |
| } | |
| .start-preview { | |
| width: min(100%, 400px); | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: clamp(10px, 1.7vw, 16px); | |
| padding: clamp(4px, 1vw, 10px); | |
| margin-top: clamp(4px, 1vw, 8px); | |
| } | |
| .preview-card { | |
| aspect-ratio: 1 / 1; | |
| border-radius: clamp(20px, 2.6vw, 32px); | |
| background: | |
| radial-gradient(circle at 24% 18%, rgba(255,255,255,0.34), transparent 22%), | |
| linear-gradient(145deg, #b3f9d1 0%, #a5f3a0 100%); | |
| box-shadow: | |
| 0 10px 0 rgba(16, 185, 129, 0.5), | |
| 0 24px 34px rgba(0, 0, 0, 0.36); | |
| display: grid; | |
| place-items: center; | |
| border: 1px solid rgba(168, 255, 211, 0.4); | |
| } | |
| .preview-emoji { | |
| font-size: clamp(34px, 5.2vw, 68px); | |
| filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.18)); | |
| } | |
| .start-help-button { | |
| width: min(100%, 560px); | |
| margin-top: clamp(8px, 1.2vw, 14px); | |
| padding: 16px 20px; | |
| border: 1px solid rgba(125, 211, 252, 0.28); | |
| border-radius: 24px; | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.12), transparent 28%), | |
| linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(15, 23, 42, 0.96)); | |
| color: #e0f2fe; | |
| font-size: clamp(17px, 2vw, 24px); | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.06), | |
| 0 18px 40px rgba(2, 6, 23, 0.35); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 14px; | |
| } | |
| .start-help-button:hover { | |
| transform: translateY(-1px); | |
| border-color: rgba(125, 211, 252, 0.46); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.1), | |
| 0 20px 44px rgba(2, 6, 23, 0.42); | |
| } | |
| .start-utility-row { | |
| width: min(100%, 760px); | |
| display: flex; | |
| align-items: stretch; | |
| justify-content: center; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| margin-top: clamp(8px, 1.2vw, 14px); | |
| } | |
| .start-utility-row .start-help-button, | |
| .start-utility-row .music-toggle-button { | |
| width: auto; | |
| flex: 1 1 260px; | |
| margin-top: 0; | |
| } | |
| .start-utility-row .start-help-button { | |
| min-width: 240px; | |
| } | |
| .start-utility-row .music-toggle-button { | |
| min-width: 220px; | |
| } | |
| .start-utility-row .leaderboard-button { | |
| min-width: 220px; | |
| } | |
| .start-utility-row .high-score-pill { | |
| min-width: 240px; | |
| position: relative; | |
| overflow: hidden; | |
| padding: 15px 18px; | |
| border-radius: 24px; | |
| border-color: rgba(34, 211, 238, 0.42); | |
| background: | |
| radial-gradient(circle at 16% 18%, rgba(255,255,255,0.18), transparent 24%), | |
| radial-gradient(circle at 84% 18%, rgba(34, 211, 238, 0.18), transparent 22%), | |
| radial-gradient(circle at 50% 110%, rgba(16, 185, 129, 0.2), transparent 34%), | |
| linear-gradient(135deg, rgba(5, 92, 68, 0.98), rgba(12, 18, 35, 0.99)); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.08), | |
| 0 0 0 1px rgba(34, 211, 238, 0.08) inset, | |
| 0 18px 44px rgba(2, 6, 23, 0.44), | |
| 0 0 28px rgba(34, 211, 238, 0.16); | |
| } | |
| .start-utility-row .high-score-pill::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 1px; | |
| border-radius: inherit; | |
| background: linear-gradient(120deg, rgba(255,255,255,0.14), transparent 34%, rgba(255,255,255,0.05)); | |
| pointer-events: none; | |
| } | |
| .start-utility-row .high-score-icon { | |
| width: 30px; | |
| height: 30px; | |
| font-size: 17px; | |
| color: #fef3c7; | |
| background: | |
| radial-gradient(circle at 30% 30%, rgba(255,255,255,0.24), transparent 42%), | |
| linear-gradient(135deg, rgba(250, 204, 21, 0.24), rgba(34, 197, 94, 0.14)); | |
| box-shadow: | |
| inset 0 0 0 1px rgba(251, 191, 36, 0.24), | |
| 0 0 18px rgba(250, 204, 21, 0.12); | |
| } | |
| .start-help-icon { | |
| width: 34px; | |
| height: 34px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 50%; | |
| color: #22d3ee; | |
| background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.18), transparent 42%), rgba(34, 211, 238, 0.12); | |
| box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.22); | |
| font-size: 20px; | |
| line-height: 1; | |
| } | |
| .start-help-text { | |
| line-height: 1; | |
| } | |
| .music-toggle-button, | |
| .utility-button { | |
| appearance: none; | |
| border: 1px solid rgba(125, 211, 252, 0.28); | |
| border-radius: 24px; | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.12), transparent 28%), | |
| linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(15, 23, 42, 0.96)); | |
| color: #e0f2fe; | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.06), | |
| 0 18px 40px rgba(2, 6, 23, 0.35); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| min-width: 0; | |
| transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease; | |
| } | |
| .leaderboard-button { | |
| appearance: none; | |
| border: 1px solid rgba(245, 158, 11, 0.34); | |
| border-radius: 24px; | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1), transparent 28%), | |
| linear-gradient(135deg, rgba(78, 32, 0, 0.96), rgba(15, 23, 42, 0.96)); | |
| color: #fde68a; | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.06), | |
| 0 18px 40px rgba(2, 6, 23, 0.35); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| min-width: 0; | |
| padding: 16px 20px; | |
| font-size: clamp(15px, 1.8vw, 22px); | |
| width: auto; | |
| flex: 1 1 260px; | |
| } | |
| .leaderboard-button:hover { | |
| transform: translateY(-1px); | |
| border-color: rgba(251, 191, 36, 0.5); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.1), | |
| 0 20px 44px rgba(2, 6, 23, 0.42); | |
| } | |
| .leaderboard-button .leaderboard-icon { | |
| width: 34px; | |
| height: 34px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 50%; | |
| color: #fbbf24; | |
| background: rgba(251, 191, 36, 0.12); | |
| box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.22); | |
| font-size: 20px; | |
| line-height: 1; | |
| flex: 0 0 auto; | |
| } | |
| .leaderboard-text { | |
| line-height: 1; | |
| } | |
| .high-score-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| min-width: 0; | |
| padding: 14px 18px; | |
| border-radius: 24px; | |
| border: 1px solid rgba(52, 211, 153, 0.28); | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.08), transparent 28%), | |
| linear-gradient(135deg, rgba(6, 95, 70, 0.94), rgba(15, 23, 42, 0.96)); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.06), | |
| 0 18px 40px rgba(2, 6, 23, 0.35); | |
| color: #d1fae5; | |
| flex: 1 1 260px; | |
| } | |
| .high-score-icon { | |
| width: 34px; | |
| height: 34px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 50%; | |
| background: rgba(34, 197, 94, 0.14); | |
| box-shadow: inset 0 0 0 1px rgba(110, 231, 183, 0.22); | |
| font-size: 19px; | |
| line-height: 1; | |
| flex: 0 0 auto; | |
| } | |
| .high-score-copy { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| min-width: 0; | |
| flex: 1 1 auto; | |
| } | |
| .high-score-label { | |
| font-size: 11px; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| color: rgba(209, 250, 229, 0.78); | |
| line-height: 1; | |
| } | |
| .high-score-user { | |
| font-size: 12px; | |
| font-weight: 800; | |
| line-height: 1; | |
| color: #e2e8f0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| max-width: 18ch; | |
| } | |
| .high-score-value { | |
| font-size: clamp(18px, 1.8vw, 24px); | |
| font-weight: 1000; | |
| letter-spacing: -0.03em; | |
| color: #86efac; | |
| line-height: 1; | |
| flex: 0 0 auto; | |
| } | |
| .music-toggle-button:hover, | |
| .utility-button:hover { | |
| transform: translateY(-1px); | |
| border-color: rgba(125, 211, 252, 0.46); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.1), | |
| 0 20px 44px rgba(2, 6, 23, 0.42); | |
| } | |
| .music-toggle-button { | |
| width: min(100%, 280px); | |
| padding: 16px 20px; | |
| font-size: clamp(15px, 1.8vw, 22px); | |
| } | |
| .utility-button { | |
| padding: 12px 18px; | |
| font-size: clamp(13px, 1.4vw, 18px); | |
| border-color: rgba(167, 139, 250, 0.34); | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1), transparent 28%), | |
| linear-gradient(135deg, rgba(41, 26, 84, 0.96), rgba(15, 23, 42, 0.96)); | |
| } | |
| .utility-button:hover { | |
| border-color: rgba(196, 181, 253, 0.5); | |
| } | |
| .utility-button-icon, | |
| .music-toggle-icon { | |
| width: 28px; | |
| height: 28px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 50%; | |
| font-size: 18px; | |
| line-height: 1; | |
| flex: 0 0 auto; | |
| } | |
| .utility-button-icon { | |
| color: #d8b4fe; | |
| background: rgba(168, 85, 247, 0.14); | |
| box-shadow: inset 0 0 0 1px rgba(196, 181, 253, 0.22); | |
| } | |
| .music-toggle-icon { | |
| color: #67e8f9; | |
| background: rgba(34, 211, 238, 0.12); | |
| box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.22); | |
| } | |
| .music-toggle-off { | |
| border-color: rgba(248, 113, 113, 0.36); | |
| color: #ffe4e6; | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1), transparent 28%), | |
| linear-gradient(135deg, rgba(69, 10, 10, 0.96), rgba(15, 23, 42, 0.96)); | |
| } | |
| .music-toggle-off .music-toggle-icon { | |
| color: #fca5a5; | |
| background: rgba(248, 113, 113, 0.12); | |
| box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.22); | |
| } | |
| .top-utility-bar { | |
| width: 100%; | |
| display: flex; | |
| justify-content: flex-end; | |
| align-items: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| margin-bottom: 2px; | |
| } | |
| .top-utility-bar .utility-button, | |
| .top-utility-bar .music-toggle-button { | |
| padding: 10px 14px; | |
| font-size: 13px; | |
| border-radius: 18px; | |
| } | |
| .top-utility-bar .utility-button { | |
| min-width: 120px; | |
| } | |
| .top-utility-bar .music-toggle-button { | |
| min-width: 180px; | |
| } | |
| .top-utility-bar .high-score-pill { | |
| min-width: 230px; | |
| flex: 0 1 250px; | |
| padding: 10px 14px; | |
| border-radius: 18px; | |
| } | |
| .top-utility-bar .high-score-icon { | |
| width: 28px; | |
| height: 28px; | |
| font-size: 16px; | |
| } | |
| .top-utility-bar .high-score-label { | |
| font-size: 10px; | |
| } | |
| .top-utility-bar .high-score-user { | |
| font-size: 11px; | |
| max-width: 14ch; | |
| } | |
| .top-utility-bar .high-score-value { | |
| font-size: 20px; | |
| } | |
| .start-utility-row .high-score-copy { | |
| gap: 3px; | |
| } | |
| .start-utility-row .high-score-label { | |
| font-size: 10px; | |
| letter-spacing: 0.2em; | |
| color: rgba(209, 250, 229, 0.9); | |
| } | |
| .start-utility-row .high-score-user { | |
| font-size: clamp(16px, 1.45vw, 20px); | |
| font-weight: 1000; | |
| color: #ffffff; | |
| max-width: 18ch; | |
| line-height: 1.05; | |
| letter-spacing: -0.03em; | |
| text-shadow: 0 0 10px rgba(255,255,255,0.08); | |
| } | |
| .start-utility-row .high-score-value { | |
| font-size: clamp(22px, 2.2vw, 28px); | |
| color: #86efac; | |
| text-shadow: 0 0 12px rgba(52, 211, 153, 0.28); | |
| } | |
| .start-button { | |
| width: min(100%, 680px); | |
| margin-top: clamp(10px, 1.6vw, 18px); | |
| padding: 18px 22px; | |
| border: 1px solid rgba(255,255,255,0.25); | |
| border-radius: 26px; | |
| background: linear-gradient(135deg, #1463ff 0%, #2c53ff 34%, #b52cff 100%); | |
| color: #fff; | |
| font-size: clamp(19px, 2.4vw, 31px); | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| box-shadow: | |
| 0 8px 0 rgba(77, 12, 156, 0.55), | |
| 0 16px 36px rgba(119, 31, 255, 0.34), | |
| 0 0 34px rgba(126, 34, 206, 0.18); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 18px; | |
| } | |
| .start-button-icon { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.92em; | |
| transform: translateY(-1px); | |
| } | |
| .start-note { | |
| margin-top: 10px; | |
| color: #94a3b8; | |
| font-size: 12px; | |
| line-height: 1.4; | |
| max-width: 64ch; | |
| } | |
| .start-footer-row { | |
| width: min(100%, 900px); | |
| margin-top: 8px; | |
| margin-bottom: 4px; | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 10px; | |
| align-items: stretch; | |
| } | |
| .start-footer-item { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 6px; | |
| min-width: 0; | |
| padding: 10px 14px; | |
| border-radius: 18px; | |
| border: 1px solid rgba(125, 211, 252, 0.18); | |
| background: rgba(8, 15, 35, 0.55); | |
| color: #dbeafe; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| } | |
| .start-footer-label { | |
| color: #94a3b8; | |
| font-size: 11px; | |
| font-weight: 800; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| } | |
| .start-footer-value { | |
| color: #f8fafc; | |
| font-size: 12px; | |
| font-weight: 700; | |
| line-height: 1.35; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| } | |
| .start-attribution { | |
| width: min(100%, 900px); | |
| margin-top: 6px; | |
| color: #dbeafe; | |
| font-size: clamp(12px, 1vw, 14px); | |
| font-weight: 700; | |
| line-height: 1.45; | |
| text-align: center; | |
| } | |
| .start-attribution a { | |
| color: #67e8f9; | |
| text-decoration: none; | |
| } | |
| .start-attribution a:hover { | |
| text-decoration: underline; | |
| } | |
| .start-orbit { | |
| position: absolute; | |
| inset: 6% 4% auto; | |
| height: 64%; | |
| border-radius: 50%; | |
| background: | |
| radial-gradient(circle at center, rgba(34, 211, 238, 0.22) 0%, rgba(34, 211, 238, 0.06) 20%, transparent 42%), | |
| radial-gradient(circle at center, transparent 44%, rgba(99, 102, 241, 0.2) 45%, transparent 48%), | |
| radial-gradient(circle at center, transparent 53%, rgba(168, 85, 247, 0.16) 54%, transparent 57%); | |
| filter: blur(0.3px); | |
| opacity: 0.7; | |
| pointer-events: none; | |
| } | |
| .start-rings { | |
| position: absolute; | |
| inset: 45% 8% 7%; | |
| border-radius: 50%; | |
| background: | |
| radial-gradient(circle at center, transparent 0 63%, rgba(34, 211, 238, 0.08) 64%, transparent 65%), | |
| radial-gradient(circle at center, transparent 0 72%, rgba(168, 85, 247, 0.08) 73%, transparent 74%), | |
| radial-gradient(circle at center, transparent 0 81%, rgba(59, 130, 246, 0.08) 82%, transparent 83%); | |
| opacity: 0.7; | |
| pointer-events: none; | |
| } | |
| .start-stars { | |
| position: absolute; | |
| inset: 0; | |
| background-image: | |
| radial-gradient(circle, rgba(255,255,255,0.55) 0 1px, transparent 1px), | |
| radial-gradient(circle, rgba(168,85,247,0.6) 0 1px, transparent 1px), | |
| radial-gradient(circle, rgba(34,197,94,0.35) 0 1px, transparent 1px); | |
| background-size: 36px 36px, 54px 54px, 78px 78px; | |
| background-position: 0 0, 18px 18px, 30px 12px; | |
| opacity: 0.12; | |
| pointer-events: none; | |
| animation: starDrift 18s linear infinite; | |
| } | |
| @keyframes starDrift { | |
| from { transform: translateY(0); } | |
| to { transform: translateY(18px); } | |
| } | |
| .dialog-card { | |
| width: min(100%, 420px); | |
| margin: 0 18px; | |
| padding: 22px 24px; | |
| border-radius: 24px; | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| background: rgba(15, 23, 42, 0.96); | |
| box-shadow: 0 24px 80px rgba(2, 6, 23, 0.56); | |
| } | |
| .dialog-warning { | |
| border-color: rgba(248, 180, 57, 0.36); | |
| box-shadow: 0 24px 80px rgba(120, 53, 15, 0.42); | |
| } | |
| .dialog-gameover { | |
| position: relative; | |
| width: min(82vw, 720px); | |
| max-width: none; | |
| min-height: min(54dvh, 540px); | |
| padding: clamp(22px, 2.2vw, 34px) clamp(20px, 2.4vw, 40px); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| overflow: hidden; | |
| text-align: center; | |
| border-radius: 30px; | |
| border: 2px solid rgba(255, 90, 145, 0.58); | |
| background: | |
| radial-gradient(circle at 50% 22%, rgba(255, 74, 112, 0.20), transparent 18%), | |
| radial-gradient(circle at 20% 0%, rgba(96, 89, 255, 0.18), transparent 28%), | |
| radial-gradient(circle at 80% 100%, rgba(95, 122, 255, 0.10), transparent 26%), | |
| linear-gradient(180deg, rgba(12, 20, 52, 0.98), rgba(6, 12, 34, 0.98)); | |
| box-shadow: | |
| 0 0 0 1px rgba(255, 255, 255, 0.05) inset, | |
| 0 0 0 1px rgba(255, 96, 167, 0.20), | |
| 0 0 26px rgba(255, 71, 114, 0.18), | |
| 0 0 42px rgba(120, 100, 255, 0.16), | |
| 0 24px 90px rgba(2, 6, 23, 0.68); | |
| } | |
| .dialog-gameover::before { | |
| content: ""; | |
| position: absolute; | |
| inset: auto 0 0; | |
| height: 20px; | |
| background: linear-gradient(90deg, transparent 0%, rgba(110, 104, 255, 0.48) 18%, rgba(255, 95, 160, 0.46) 52%, rgba(110, 104, 255, 0.48) 82%, transparent 100%); | |
| filter: blur(16px); | |
| opacity: 0.7; | |
| pointer-events: none; | |
| } | |
| .dialog-gameover::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| background: | |
| radial-gradient(circle at 50% 20%, rgba(255,255,255,0.05), transparent 14%), | |
| radial-gradient(circle at 50% 32%, rgba(255, 78, 119, 0.10), transparent 24%); | |
| opacity: 0.8; | |
| } | |
| .gameover-icon-wrap { | |
| position: relative; | |
| width: 96px; | |
| height: 96px; | |
| display: grid; | |
| place-items: center; | |
| margin-top: 2px; | |
| z-index: 1; | |
| } | |
| .gameover-icon-halo { | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 50%; | |
| border: 3px solid rgba(255, 63, 114, 0.92); | |
| box-shadow: | |
| 0 0 24px rgba(255, 56, 109, 0.55), | |
| 0 0 46px rgba(255, 56, 109, 0.30), | |
| inset 0 0 12px rgba(255, 56, 109, 0.18); | |
| background: | |
| radial-gradient(circle at 50% 50%, rgba(255, 80, 120, 0.08), transparent 58%); | |
| } | |
| .gameover-icon { | |
| position: relative; | |
| font-size: 46px; | |
| line-height: 1; | |
| filter: drop-shadow(0 0 14px rgba(255, 66, 120, 0.75)); | |
| } | |
| .gameover-title { | |
| position: relative; | |
| z-index: 1; | |
| color: #f8fafc; | |
| font-size: clamp(42px, 4.8vw, 66px); | |
| font-weight: 1000; | |
| letter-spacing: -0.06em; | |
| line-height: 0.95; | |
| text-shadow: | |
| 0 1px 0 rgba(255,255,255,0.35), | |
| 0 8px 24px rgba(0, 0, 0, 0.35); | |
| } | |
| .gameover-divider { | |
| position: relative; | |
| z-index: 1; | |
| width: min(100%, 280px); | |
| display: grid; | |
| grid-template-columns: 1fr auto 1fr; | |
| align-items: center; | |
| gap: 10px; | |
| margin-top: 2px; | |
| } | |
| .gameover-divider span { | |
| height: 2px; | |
| border-radius: 999px; | |
| background: linear-gradient(90deg, transparent, rgba(255, 80, 120, 0.96), transparent); | |
| box-shadow: 0 0 18px rgba(255, 80, 120, 0.28); | |
| } | |
| .gameover-diamond { | |
| color: #ff577b; | |
| font-size: 18px; | |
| text-shadow: 0 0 16px rgba(255, 80, 120, 0.68); | |
| } | |
| .gameover-subtitle { | |
| position: relative; | |
| z-index: 1; | |
| color: #d2d7e6; | |
| font-size: clamp(16px, 1.7vw, 24px); | |
| line-height: 1.35; | |
| font-weight: 500; | |
| max-width: 30ch; | |
| } | |
| .gameover-score-pill { | |
| position: relative; | |
| z-index: 1; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 14px 18px; | |
| margin-top: 8px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| background: rgba(10, 18, 42, 0.76); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.05), | |
| 0 0 0 1px rgba(255, 255, 255, 0.04), | |
| 0 18px 38px rgba(2, 6, 23, 0.34); | |
| } | |
| .gameover-score-icon { | |
| font-size: 34px; | |
| filter: drop-shadow(0 0 10px rgba(255, 191, 53, 0.34)); | |
| } | |
| .gameover-score-label { | |
| color: #c7cedd; | |
| font-size: 22px; | |
| font-weight: 700; | |
| letter-spacing: 0.03em; | |
| } | |
| .gameover-score-sep { | |
| width: 1px; | |
| height: 30px; | |
| background: rgba(148, 163, 184, 0.34); | |
| } | |
| .gameover-score-value { | |
| min-width: 28px; | |
| color: #ff5574; | |
| font-size: 46px; | |
| font-weight: 900; | |
| letter-spacing: -0.06em; | |
| line-height: 1; | |
| text-shadow: 0 0 18px rgba(255, 85, 116, 0.25); | |
| } | |
| .dialog-howto { | |
| position: relative; | |
| width: min(94vw, 1520px); | |
| max-width: none; | |
| max-height: min(90dvh, 880px); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| scrollbar-gutter: stable; | |
| padding: clamp(20px, 1.8vw, 30px); | |
| border-radius: 28px; | |
| border: 1px solid rgba(164, 94, 255, 0.55); | |
| background: | |
| radial-gradient(circle at 18% 0%, rgba(87, 52, 255, 0.14), transparent 24%), | |
| radial-gradient(circle at 82% 8%, rgba(255, 78, 173, 0.10), transparent 22%), | |
| linear-gradient(180deg, rgba(6, 14, 35, 0.99), rgba(4, 10, 28, 0.99)); | |
| box-shadow: | |
| 0 0 0 1px rgba(255, 255, 255, 0.04) inset, | |
| 0 0 0 1px rgba(255, 111, 196, 0.18), | |
| 0 0 40px rgba(114, 74, 255, 0.18), | |
| 0 20px 80px rgba(2, 6, 23, 0.7); | |
| } | |
| .dialog-howto::-webkit-scrollbar { | |
| width: 10px; | |
| } | |
| .dialog-howto::-webkit-scrollbar-track { | |
| background: rgba(10, 18, 40, 0.55); | |
| border-radius: 999px; | |
| } | |
| .dialog-howto::-webkit-scrollbar-thumb { | |
| background: linear-gradient(180deg, rgba(173, 114, 255, 0.86), rgba(79, 196, 255, 0.86)); | |
| border-radius: 999px; | |
| border: 2px solid rgba(4, 10, 28, 0.95); | |
| } | |
| #how-to-play-overlay { | |
| position: fixed; | |
| inset: 0; | |
| width: 100vw; | |
| height: 100dvh; | |
| z-index: 80; | |
| padding: clamp(12px, 1.6vw, 22px); | |
| box-sizing: border-box; | |
| } | |
| .howto-close-btn { | |
| position: absolute; | |
| top: 16px; | |
| right: 16px; | |
| width: 58px; | |
| height: 58px; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 120, 200, 0.55); | |
| background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.08), transparent 42%), rgba(10, 18, 40, 0.96); | |
| color: #fff; | |
| font-size: 42px; | |
| line-height: 1; | |
| cursor: pointer; | |
| box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03) inset, 0 0 22px rgba(255, 118, 199, 0.18); | |
| } | |
| .howto-logo-wrap { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| margin: 2px 0 4px; | |
| } | |
| .howto-logo { | |
| width: clamp(26px, 2.6vw, 36px); | |
| height: clamp(26px, 2.6vw, 36px); | |
| object-fit: contain; | |
| display: block; | |
| filter: drop-shadow(0 0 12px rgba(173, 114, 255, 0.35)); | |
| } | |
| .howto-kicker { | |
| color: #ad72ff; | |
| font-size: clamp(15px, 1.2vw, 20px); | |
| font-weight: 900; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| text-align: center; | |
| } | |
| .howto-headline { | |
| margin-top: 8px; | |
| color: #f4f4f7; | |
| font-size: clamp(18px, 1.95vw, 30px); | |
| line-height: 1.08; | |
| font-weight: 800; | |
| letter-spacing: -0.04em; | |
| text-align: center; | |
| text-wrap: balance; | |
| } | |
| .howto-steps { | |
| margin-top: clamp(14px, 1.6vw, 22px); | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); | |
| align-items: stretch; | |
| gap: 12px; | |
| min-height: 0; | |
| } | |
| .howto-step-card { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 14px 16px; | |
| border-radius: 20px; | |
| border: 1px solid rgba(90, 141, 255, 0.24); | |
| background: | |
| radial-gradient(circle at 20% 0%, rgba(255,255,255,0.04), transparent 28%), | |
| rgba(8, 15, 35, 0.72); | |
| min-height: 104px; | |
| } | |
| .howto-step-card.purple { | |
| border-color: rgba(173, 114, 255, 0.45); | |
| box-shadow: 0 0 0 1px rgba(173, 114, 255, 0.08) inset; | |
| } | |
| .howto-step-card.blue { | |
| border-color: rgba(56, 165, 255, 0.45); | |
| } | |
| .howto-step-card.green { | |
| border-color: rgba(38, 210, 120, 0.45); | |
| } | |
| .howto-step-icon { | |
| width: 58px; | |
| height: 58px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| flex: 0 0 auto; | |
| font-size: 30px; | |
| background: rgba(255,255,255,0.04); | |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05); | |
| } | |
| .howto-step-card.purple .howto-step-icon { | |
| border: 1px solid rgba(173, 114, 255, 0.42); | |
| box-shadow: 0 0 0 4px rgba(173, 114, 255, 0.08), inset 0 0 0 1px rgba(173, 114, 255, 0.18); | |
| } | |
| .howto-step-card.blue .howto-step-icon { | |
| border: 1px solid rgba(56, 165, 255, 0.42); | |
| box-shadow: 0 0 0 4px rgba(56, 165, 255, 0.08), inset 0 0 0 1px rgba(56, 165, 255, 0.18); | |
| } | |
| .howto-step-card.green .howto-step-icon { | |
| border: 1px solid rgba(38, 210, 120, 0.42); | |
| box-shadow: 0 0 0 4px rgba(38, 210, 120, 0.08), inset 0 0 0 1px rgba(38, 210, 120, 0.18); | |
| } | |
| .howto-step-copy { | |
| min-width: 0; | |
| } | |
| .howto-step-title { | |
| font-size: clamp(15px, 1.1vw, 18px); | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| } | |
| .howto-step-card.purple .howto-step-title { color: #bf8cff; } | |
| .howto-step-card.blue .howto-step-title { color: #4fc4ff; } | |
| .howto-step-card.green .howto-step-title { color: #39eb8b; } | |
| .howto-step-text { | |
| margin-top: 6px; | |
| color: #d9e1f2; | |
| font-size: clamp(11px, 0.75vw, 13px); | |
| line-height: 1.26; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| } | |
| .howto-step-arrow { | |
| display: grid; | |
| place-items: center; | |
| color: #cfe0ff; | |
| font-size: clamp(34px, 2.5vw, 50px); | |
| font-weight: 300; | |
| align-self: center; | |
| text-shadow: 0 0 16px rgba(145, 189, 255, 0.6); | |
| } | |
| .howto-main { | |
| margin-top: clamp(14px, 1.6vw, 22px); | |
| display: grid; | |
| grid-template-columns: 1.05fr 1fr; | |
| gap: 12px; | |
| min-height: 0; | |
| flex: 1 1 auto; | |
| overflow: hidden; | |
| } | |
| .howto-left, | |
| .howto-right { | |
| border-radius: 20px; | |
| border: 1px solid rgba(104, 139, 255, 0.16); | |
| background: rgba(7, 14, 34, 0.68); | |
| padding: 11px; | |
| min-width: 0; | |
| min-height: 0; | |
| } | |
| .howto-left { | |
| display: grid; | |
| gap: 6px; | |
| } | |
| .howto-rule { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| padding: 8px 9px; | |
| border-radius: 14px; | |
| border: 1px solid rgba(149, 166, 201, 0.12); | |
| background: rgba(255,255,255,0.015); | |
| } | |
| .howto-rule-icon { | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| flex: 0 0 auto; | |
| font-size: 20px; | |
| background: rgba(255,255,255,0.04); | |
| } | |
| .howto-rule-icon.blue { border: 1px solid rgba(56, 165, 255, 0.4); box-shadow: 0 0 0 4px rgba(56,165,255,0.08); } | |
| .howto-rule-icon.purple { border: 1px solid rgba(173, 114, 255, 0.4); box-shadow: 0 0 0 4px rgba(173,114,255,0.08); } | |
| .howto-rule-icon.red { border: 1px solid rgba(255, 90, 90, 0.4); box-shadow: 0 0 0 4px rgba(255,90,90,0.08); } | |
| .howto-rule-icon.amber { border: 1px solid rgba(255, 200, 74, 0.4); box-shadow: 0 0 0 4px rgba(255,200,74,0.08); } | |
| .howto-rule-icon.gold { border: 1px solid rgba(255, 194, 56, 0.4); box-shadow: 0 0 0 4px rgba(255,194,56,0.08); } | |
| .howto-rule-icon.orange { border: 1px solid rgba(255, 142, 58, 0.4); box-shadow: 0 0 0 4px rgba(255,142,58,0.08); } | |
| .howto-rule-copy { | |
| min-width: 0; | |
| } | |
| .howto-rule-title { | |
| color: #f4f7ff; | |
| font-size: clamp(14px, 0.95vw, 16px); | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| } | |
| .howto-rule-text { | |
| margin-top: 4px; | |
| color: #c5d2ea; | |
| font-size: clamp(10px, 0.75vw, 12px); | |
| line-height: 1.22; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| } | |
| .howto-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| padding: 2px 10px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255,255,255,0.12); | |
| font-size: 0.92em; | |
| white-space: nowrap; | |
| } | |
| .howto-chip.danger { color: #ff7b7b; border-color: rgba(255, 94, 94, 0.3); background: rgba(255, 77, 77, 0.08); } | |
| .howto-chip.warning { color: #f7c53d; border-color: rgba(247, 197, 61, 0.3); background: rgba(247, 197, 61, 0.08); } | |
| .howto-chip.success { color: #42db7f; border-color: rgba(66, 219, 127, 0.3); background: rgba(66, 219, 127, 0.08); } | |
| .howto-right { | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 0; | |
| overflow: hidden; | |
| } | |
| .howto-panel-title { | |
| color: #b573ff; | |
| font-size: clamp(13px, 0.95vw, 16px); | |
| font-weight: 900; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| margin: 2px 0 12px; | |
| } | |
| .howto-rewards-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| table-layout: fixed; | |
| color: #dbeafe; | |
| font-size: clamp(10px, 0.75vw, 12px); | |
| line-height: 1.2; | |
| overflow: hidden; | |
| border-radius: 18px; | |
| border: 1px solid rgba(149, 166, 201, 0.16); | |
| } | |
| .howto-rewards-table th, | |
| .howto-rewards-table td { | |
| padding: 9px 10px; | |
| border-bottom: 1px solid rgba(149, 166, 201, 0.12); | |
| border-right: 1px solid rgba(149, 166, 201, 0.12); | |
| vertical-align: middle; | |
| text-align: left; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| white-space: normal; | |
| } | |
| .howto-rewards-table th { | |
| color: #f4f7ff; | |
| font-size: 11px; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| font-weight: 900; | |
| } | |
| .howto-rewards-table tbody tr:last-child td { | |
| border-bottom: 0; | |
| } | |
| .howto-rewards-table th:last-child, | |
| .howto-rewards-table td:last-child { | |
| border-right: 0; | |
| } | |
| .howto-rewards-table td:last-child { | |
| width: 40%; | |
| } | |
| .reward-good { color: #43db7f; } | |
| .reward-bad { color: #ff6d6d; } | |
| .reward-warn { color: #ffc94b; } | |
| .howto-actions { | |
| justify-content: flex-end; | |
| gap: 0; | |
| margin-top: clamp(12px, 1.2vw, 16px); | |
| padding-top: 4px; | |
| } | |
| .howto-secondary-btn { | |
| min-width: 0; | |
| width: min(100%, 240px); | |
| padding: 12px 18px; | |
| border-radius: 16px; | |
| font-size: clamp(13px, 0.95vw, 16px); | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| line-height: 1; | |
| } | |
| .howto-secondary-btn { | |
| border: 1px solid rgba(85, 164, 255, 0.82); | |
| background: rgba(15, 24, 47, 0.92); | |
| color: #f4f7ff; | |
| box-shadow: 0 0 0 1px rgba(85, 164, 255, 0.16) inset, 0 0 20px rgba(85, 164, 255, 0.14); | |
| } | |
| .dialog-leaderboard { | |
| position: relative; | |
| width: min(94vw, 920px); | |
| max-width: none; | |
| max-height: min(90dvh, 860px); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| scrollbar-gutter: stable; | |
| padding: clamp(20px, 2vw, 30px); | |
| border-radius: 28px; | |
| border: 1px solid rgba(245, 158, 11, 0.42); | |
| background: | |
| radial-gradient(circle at 18% 0%, rgba(245, 158, 11, 0.16), transparent 24%), | |
| radial-gradient(circle at 82% 8%, rgba(16, 185, 129, 0.10), transparent 22%), | |
| linear-gradient(180deg, rgba(6, 14, 35, 0.99), rgba(4, 10, 28, 0.99)); | |
| box-shadow: | |
| 0 0 0 1px rgba(255, 255, 255, 0.04) inset, | |
| 0 0 0 1px rgba(245, 158, 11, 0.16), | |
| 0 0 40px rgba(245, 158, 11, 0.12), | |
| 0 20px 80px rgba(2, 6, 23, 0.7); | |
| } | |
| .dialog-leaderboard::-webkit-scrollbar { | |
| width: 10px; | |
| } | |
| .dialog-leaderboard::-webkit-scrollbar-track { | |
| background: rgba(10, 18, 40, 0.55); | |
| border-radius: 999px; | |
| } | |
| .dialog-leaderboard::-webkit-scrollbar-thumb { | |
| background: linear-gradient(180deg, rgba(245, 158, 11, 0.88), rgba(34, 197, 94, 0.82)); | |
| border-radius: 999px; | |
| border: 2px solid rgba(4, 10, 28, 0.95); | |
| } | |
| .leaderboard-logo-wrap { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| margin: 2px 0 4px; | |
| } | |
| .leaderboard-logo { | |
| width: clamp(26px, 2.6vw, 36px); | |
| height: clamp(26px, 2.6vw, 36px); | |
| object-fit: contain; | |
| display: block; | |
| filter: drop-shadow(0 0 12px rgba(245, 158, 11, 0.32)); | |
| } | |
| .leaderboard-kicker { | |
| color: #f59e0b; | |
| font-size: clamp(15px, 1.2vw, 20px); | |
| font-weight: 900; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| text-align: center; | |
| } | |
| .leaderboard-headline { | |
| margin-top: 8px; | |
| color: #f4f4f7; | |
| font-size: clamp(18px, 1.95vw, 28px); | |
| line-height: 1.08; | |
| font-weight: 800; | |
| letter-spacing: -0.04em; | |
| text-align: center; | |
| text-wrap: balance; | |
| } | |
| .leaderboard-my-score { | |
| margin-top: 18px; | |
| padding: 16px 18px; | |
| border-radius: 20px; | |
| border: 1px solid rgba(245, 158, 11, 0.18); | |
| background: rgba(8, 15, 35, 0.58); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .leaderboard-my-score-label { | |
| color: #fcd34d; | |
| font-size: 12px; | |
| font-weight: 900; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| } | |
| .leaderboard-my-score-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| } | |
| .leaderboard-my-score-user { | |
| color: #dbeafe; | |
| font-size: 14px; | |
| font-weight: 800; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .leaderboard-my-score-value { | |
| color: #86efac; | |
| font-size: clamp(22px, 2vw, 34px); | |
| font-weight: 1000; | |
| letter-spacing: -0.04em; | |
| } | |
| .leaderboard-list { | |
| margin-top: 18px; | |
| display: block; | |
| } | |
| .leaderboard-table-wrap { | |
| width: 100%; | |
| overflow: hidden; | |
| border-radius: 18px; | |
| border: 1px solid rgba(148, 163, 184, 0.14); | |
| background: rgba(8, 15, 35, 0.58); | |
| } | |
| .leaderboard-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| table-layout: fixed; | |
| } | |
| .leaderboard-table thead th { | |
| padding: 14px 16px; | |
| text-align: left; | |
| font-size: 12px; | |
| font-weight: 900; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: #fcd34d; | |
| background: rgba(15, 23, 42, 0.76); | |
| border-bottom: 1px solid rgba(148, 163, 184, 0.12); | |
| } | |
| .leaderboard-table tbody td { | |
| padding: 14px 16px; | |
| border-top: 1px solid rgba(148, 163, 184, 0.08); | |
| color: #e2e8f0; | |
| font-size: 15px; | |
| font-weight: 700; | |
| vertical-align: middle; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .leaderboard-table tbody tr:first-child td { | |
| border-top: none; | |
| } | |
| .leaderboard-table tbody tr.current-user td { | |
| background: | |
| radial-gradient(circle at 18% 0%, rgba(34, 197, 94, 0.12), transparent 22%), | |
| rgba(8, 15, 35, 0.72); | |
| } | |
| .leaderboard-table .leaderboard-rank-cell { | |
| width: 96px; | |
| color: #fbbf24; | |
| font-weight: 1000; | |
| } | |
| .leaderboard-table .leaderboard-score-cell { | |
| width: 120px; | |
| color: #86efac; | |
| font-weight: 1000; | |
| text-align: right; | |
| } | |
| .leaderboard-row { | |
| display: grid; | |
| grid-template-columns: 72px minmax(0, 1fr) auto; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 14px 16px; | |
| border-radius: 18px; | |
| border: 1px solid rgba(148, 163, 184, 0.14); | |
| background: rgba(8, 15, 35, 0.58); | |
| } | |
| .leaderboard-row.current-user { | |
| border-color: rgba(34, 197, 94, 0.36); | |
| background: | |
| radial-gradient(circle at 18% 0%, rgba(34, 197, 94, 0.12), transparent 22%), | |
| rgba(8, 15, 35, 0.72); | |
| } | |
| .leaderboard-rank { | |
| width: 56px; | |
| height: 56px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 16px; | |
| background: rgba(245, 158, 11, 0.12); | |
| color: #fbbf24; | |
| font-size: 20px; | |
| font-weight: 1000; | |
| } | |
| .leaderboard-name { | |
| min-width: 0; | |
| color: #f8fafc; | |
| font-size: 15px; | |
| font-weight: 800; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .leaderboard-score { | |
| color: #86efac; | |
| font-size: 18px; | |
| font-weight: 1000; | |
| letter-spacing: -0.03em; | |
| } | |
| .leaderboard-empty { | |
| padding: 18px 16px; | |
| text-align: center; | |
| color: #cbd5e1; | |
| border: 1px dashed rgba(148, 163, 184, 0.22); | |
| border-radius: 18px; | |
| background: rgba(8, 15, 35, 0.45); | |
| } | |
| .dialog-challenge { | |
| border-color: rgba(14, 165, 233, 0.34); | |
| box-shadow: 0 24px 80px rgba(14, 165, 233, 0.32); | |
| text-align: center; | |
| } | |
| .dialog-challenge.dialog-challenge-polished { | |
| position: relative; | |
| width: min(90vw, 430px); | |
| padding: clamp(20px, 2.4vw, 28px) clamp(18px, 2.2vw, 24px) clamp(20px, 2.4vw, 24px); | |
| border-radius: 28px; | |
| border: 1px solid rgba(50, 215, 255, 0.84); | |
| background: | |
| radial-gradient(circle at 50% 0%, rgba(56, 189, 248, 0.18), transparent 22%), | |
| radial-gradient(circle at 50% 100%, rgba(2, 132, 199, 0.12), transparent 34%), | |
| linear-gradient(180deg, rgba(12, 18, 35, 0.94), rgba(8, 13, 28, 0.94)); | |
| box-shadow: | |
| 0 0 0 1px rgba(56, 189, 248, 0.06) inset, | |
| 0 0 0 1px rgba(255, 255, 255, 0.02), | |
| 0 20px 80px rgba(2, 6, 23, 0.64), | |
| 0 0 26px rgba(56, 189, 248, 0.12); | |
| text-align: center; | |
| overflow: hidden; | |
| } | |
| .dialog-challenge.dialog-challenge-polished::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| border-radius: inherit; | |
| padding: 1px; | |
| background: linear-gradient(180deg, rgba(56, 189, 248, 0.52), rgba(59, 130, 246, 0.10), rgba(245, 158, 11, 0.22)); | |
| -webkit-mask: | |
| linear-gradient(#000 0 0) content-box, | |
| linear-gradient(#000 0 0); | |
| -webkit-mask-composite: xor; | |
| mask-composite: exclude; | |
| pointer-events: none; | |
| } | |
| .dialog-challenge.dialog-challenge-polished::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 8px; | |
| border-radius: 22px; | |
| border: 1px solid rgba(255,255,255,0.03); | |
| pointer-events: none; | |
| } | |
| .challenge-close-btn { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| width: 34px; | |
| height: 34px; | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 50%; | |
| background: rgba(11, 17, 34, 0.62); | |
| color: rgba(255,255,255,0.88); | |
| font-size: 28px; | |
| line-height: 1; | |
| display: grid; | |
| place-items: center; | |
| cursor: pointer; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| z-index: 2; | |
| } | |
| .challenge-close-btn:hover { | |
| border-color: rgba(255,255,255,0.22); | |
| background: rgba(16, 22, 40, 0.76); | |
| } | |
| .challenge-hero { | |
| display: flex; | |
| justify-content: center; | |
| margin-top: 2px; | |
| margin-bottom: 8px; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .challenge-hero-ring { | |
| width: 72px; | |
| height: 72px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| background: | |
| radial-gradient(circle at 50% 50%, rgba(255,255,255,0.10), transparent 54%), | |
| linear-gradient(180deg, rgba(16, 185, 255, 0.26), rgba(59, 130, 246, 0.20)); | |
| border: 2px solid rgba(56, 189, 248, 0.96); | |
| box-shadow: | |
| 0 0 0 5px rgba(56, 189, 248, 0.08), | |
| 0 0 28px rgba(56, 189, 248, 0.36); | |
| } | |
| .challenge-hero-icon { | |
| width: 52px; | |
| height: 52px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| background: rgba(24, 38, 71, 0.92); | |
| font-size: 30px; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.06), | |
| 0 0 0 1px rgba(255,255,255,0.04); | |
| } | |
| .challenge-pill { | |
| width: fit-content; | |
| margin: 0 auto 10px; | |
| padding: 7px 12px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(56, 189, 248, 0.34); | |
| background: rgba(11, 27, 48, 0.76); | |
| color: #1ed5ff; | |
| font-size: 11px; | |
| font-weight: 900; | |
| letter-spacing: 0.09em; | |
| text-transform: uppercase; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .challenge-title { | |
| position: relative; | |
| z-index: 1; | |
| margin: 0; | |
| font-size: clamp(24px, 5.6vw, 34px); | |
| line-height: 1.04; | |
| font-weight: 1000; | |
| letter-spacing: -0.04em; | |
| color: #f4f7fb; | |
| text-shadow: 0 4px 18px rgba(2, 6, 23, 0.5); | |
| } | |
| .challenge-subtitle { | |
| position: relative; | |
| z-index: 1; | |
| margin: 8px auto 0; | |
| width: min(100%, 30ch); | |
| color: #39aef8; | |
| font-size: clamp(13px, 3.8vw, 16px); | |
| font-weight: 800; | |
| letter-spacing: -0.03em; | |
| line-height: 1.22; | |
| } | |
| .challenge-divider { | |
| position: relative; | |
| z-index: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| margin: 12px 0 14px; | |
| } | |
| .challenge-divider span { | |
| width: 34px; | |
| height: 1px; | |
| background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.55), transparent); | |
| } | |
| .challenge-divider i { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: #2fb5ff; | |
| box-shadow: 0 0 12px rgba(47, 181, 255, 0.9); | |
| } | |
| .challenge-points { | |
| position: relative; | |
| z-index: 1; | |
| display: grid; | |
| gap: 10px; | |
| width: 100%; | |
| margin: 0 auto; | |
| text-align: left; | |
| } | |
| .challenge-point { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| color: #e7eef8; | |
| font-size: clamp(12px, 3.2vw, 14px); | |
| line-height: 1.3; | |
| } | |
| .challenge-point-icon, | |
| .challenge-note-icon { | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| flex: 0 0 auto; | |
| color: #38bdf8; | |
| border: 1px solid rgba(56, 189, 248, 0.35); | |
| background: rgba(11, 27, 48, 0.85); | |
| font-weight: 900; | |
| } | |
| .challenge-rewards { | |
| position: relative; | |
| z-index: 1; | |
| display: flex; | |
| justify-content: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| margin: 14px auto 10px; | |
| } | |
| .challenge-reward { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 9px 12px; | |
| min-height: 38px; | |
| border-radius: 999px; | |
| font-size: clamp(11px, 3vw, 13px); | |
| font-weight: 700; | |
| letter-spacing: -0.01em; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| } | |
| .challenge-reward.good { | |
| color: #dfffe8; | |
| border: 1px solid rgba(34, 197, 94, 0.34); | |
| background: rgba(10, 42, 30, 0.74); | |
| } | |
| .challenge-reward.bad { | |
| color: #ffd6d8; | |
| border: 1px solid rgba(239, 68, 68, 0.34); | |
| background: rgba(51, 15, 22, 0.74); | |
| } | |
| .challenge-reward-icon { | |
| font-size: 16px; | |
| line-height: 1; | |
| } | |
| .challenge-note { | |
| position: relative; | |
| z-index: 1; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin: 0 auto 16px; | |
| color: #dce6f2; | |
| font-size: clamp(11px, 2.8vw, 13px); | |
| line-height: 1.3; | |
| } | |
| .challenge-actions { | |
| justify-content: center; | |
| margin-top: 0; | |
| } | |
| .challenge-start-btn { | |
| appearance: none; | |
| border: 1px solid rgba(63, 224, 255, 0.42); | |
| background: linear-gradient(90deg, #16d8c8 0%, #2fb1ff 100%); | |
| color: #f9fdff; | |
| border-radius: 24px; | |
| min-width: 100%; | |
| padding: 14px 20px; | |
| min-height: 52px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 14px; | |
| font-size: clamp(16px, 4vw, 20px); | |
| font-weight: 900; | |
| letter-spacing: -0.02em; | |
| cursor: pointer; | |
| box-shadow: | |
| 0 0 0 1px rgba(255,255,255,0.04) inset, | |
| 0 0 18px rgba(16, 216, 200, 0.28), | |
| 0 16px 34px rgba(3, 105, 161, 0.32); | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .challenge-start-btn:hover { | |
| transform: translateY(-1px); | |
| box-shadow: | |
| 0 0 0 1px rgba(255,255,255,0.04) inset, | |
| 0 0 26px rgba(16, 216, 200, 0.36), | |
| 0 20px 42px rgba(3, 105, 161, 0.38); | |
| } | |
| .challenge-start-icon { | |
| font-size: 0.96em; | |
| } | |
| } | |
| .dialog-title { | |
| font-size: 22px; | |
| font-weight: 900; | |
| letter-spacing: -0.02em; | |
| color: #f8fafc; | |
| } | |
| .dialog-challenge .dialog-title { | |
| color: #ef4444; | |
| font-size: 24px; | |
| text-align: center; | |
| } | |
| .dialog-text { | |
| margin-top: 10px; | |
| color: #cbd5e1; | |
| font-size: 15px; | |
| line-height: 1.5; | |
| } | |
| .dialog-challenge .dialog-text { | |
| text-align: center; | |
| margin-top: 12px; | |
| color: #dbeafe; | |
| font-size: 16px; | |
| } | |
| .dialog-actions { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 10px; | |
| margin-top: 18px; | |
| flex-wrap: wrap; | |
| } | |
| .dialog-button { | |
| padding: 12px 18px; | |
| border-radius: 999px; | |
| border: none; | |
| font-size: 13px; | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| } | |
| .dialog-secondary { | |
| background: rgba(51, 65, 85, 0.9); | |
| color: #e2e8f0; | |
| } | |
| .dialog-primary { | |
| background: linear-gradient(135deg, #fb7185 0%, #f97316 100%); | |
| color: white; | |
| box-shadow: 0 12px 30px rgba(249, 115, 22, 0.28); | |
| } | |
| .dialog-gameover .dialog-primary { | |
| background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%); | |
| box-shadow: 0 12px 30px rgba(14, 165, 233, 0.28); | |
| } | |
| .dialog-challenge .dialog-primary { | |
| background: linear-gradient(135deg, #22c55e 0%, #0ea5e9 100%); | |
| box-shadow: 0 12px 30px rgba(34, 197, 94, 0.24); | |
| } | |
| .dialog-button:hover { | |
| transform: translateY(-1px); | |
| } | |
| .transition-card { | |
| padding: 24px 28px; | |
| border-radius: 22px; | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| background: rgba(15, 23, 42, 0.88); | |
| box-shadow: 0 20px 60px rgba(2, 6, 23, 0.5); | |
| text-align: center; | |
| min-width: min(320px, calc(100% - 48px)); | |
| } | |
| .transition-title { | |
| font-size: 26px; | |
| font-weight: 900; | |
| letter-spacing: -0.02em; | |
| color: #f8fafc; | |
| } | |
| .transition-subtitle { | |
| margin-top: 8px; | |
| color: #cbd5e1; | |
| font-size: 15px; | |
| line-height: 1.45; | |
| } | |
| .preview-content { | |
| text-align: center; | |
| animation: previewFadeIn 0.35s ease; | |
| } | |
| @keyframes previewFadeIn { | |
| from { opacity: 0; transform: scale(0.94); } | |
| to { opacity: 1; transform: scale(1); } | |
| } | |
| .preview-countdown { | |
| font-size: clamp(72px, 12vw, 132px); | |
| font-weight: 900; | |
| line-height: 1; | |
| background: white; | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| animation: countPulse 1s ease infinite; | |
| } | |
| @keyframes countPulse { | |
| 0% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(1.12); opacity: 0.86; } | |
| 100% { transform: scale(1); opacity: 1; } | |
| } | |
| .preview-subtitle { | |
| margin-top: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.24em; | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: #cbd5e1; | |
| } | |
| .game-header { | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| gap: clamp(12px, 1.4vw, 16px); | |
| z-index: 1; | |
| } | |
| .game-header-row { | |
| width: 100%; | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) auto; | |
| align-items: start; | |
| gap: clamp(12px, 1.4vw, 16px); | |
| } | |
| .game-stats { | |
| display: grid; | |
| grid-template-columns: repeat(6, minmax(0, 1fr)); | |
| gap: clamp(8px, 1vw, 14px); | |
| align-items: stretch; | |
| min-width: 0; | |
| } | |
| .stat-block { | |
| display: grid; | |
| grid-template-columns: 44px 1fr; | |
| align-items: center; | |
| gap: 6px 10px; | |
| min-width: 0; | |
| padding: clamp(12px, 1.2vw, 16px) clamp(12px, 1.3vw, 18px); | |
| border-radius: clamp(14px, 1.5vw, 18px); | |
| border: 1px solid rgba(148,163,184,0.18); | |
| background: rgba(15, 23, 42, 0.45); | |
| backdrop-filter: blur(10px); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| box-sizing: border-box; | |
| } | |
| .stat-icon { | |
| grid-row: span 2; | |
| width: clamp(34px, 3vw, 44px); | |
| height: clamp(34px, 3vw, 44px); | |
| border-radius: clamp(11px, 1.2vw, 14px); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: clamp(16px, 1.6vw, 20px); | |
| background: rgba(255,255,255,0.08); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); | |
| } | |
| .stat-label { | |
| font-size: clamp(9px, 0.85vw, 11px); | |
| font-weight: 800; | |
| color: #7c8599; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| line-height: 1; | |
| } | |
| .stat-value { | |
| font-size: clamp(16px, 1.5vw, 20px); | |
| font-weight: 900; | |
| color: #f8fafc; | |
| margin-top: -1px; | |
| line-height: 1.1; | |
| grid-column: 2; | |
| } | |
| .stat-level { border-color: rgba(34,197,94,0.28); box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset; } | |
| .stat-level .stat-icon { background: linear-gradient(135deg, rgba(74,222,128,0.18), rgba(34,197,94,0.16)); color: #86efac; } | |
| .stat-moves { border-color: rgba(59,130,246,0.24); } | |
| .stat-moves .stat-icon { background: linear-gradient(135deg, rgba(96,165,250,0.18), rgba(14,165,233,0.16)); color: #7dd3fc; } | |
| .stat-matches { border-color: rgba(168,85,247,0.24); } | |
| .stat-matches .stat-icon { background: linear-gradient(135deg, rgba(192,132,252,0.18), rgba(139,92,246,0.16)); color: #c4b5fd; } | |
| .stat-score { border-color: rgba(245,158,11,0.24); } | |
| .stat-score .stat-icon { background: linear-gradient(135deg, rgba(251,191,36,0.18), rgba(245,158,11,0.16)); color: #fbbf24; } | |
| .stat-lives { border-color: rgba(248,113,113,0.24); } | |
| .stat-lives .stat-icon { background: linear-gradient(135deg, rgba(248,113,113,0.18), rgba(239,68,68,0.16)); color: #fca5a5; } | |
| .game-state-badge { | |
| justify-self: end; | |
| align-self: center; | |
| padding: 8px 14px 9px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(251, 191, 36, 0.35); | |
| background: rgba(251, 191, 36, 0.12); | |
| color: #fbbf24; | |
| font-size: clamp(11px, 0.9vw, 12px); | |
| font-weight: 900; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| display: inline-flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| line-height: 1; | |
| gap: 4px; | |
| min-width: 92px; | |
| } | |
| .game-state-badge .badge-main { | |
| font-size: 0.95em; | |
| line-height: 1; | |
| } | |
| .game-state-badge .badge-sub { | |
| font-size: 0.8em; | |
| letter-spacing: 0.08em; | |
| line-height: 1; | |
| color: #fff7cc; | |
| text-shadow: 0 0 8px rgba(253, 230, 138, 0.18); | |
| } | |
| .game-state-badge.playing { | |
| border-color: rgba(52, 211, 153, 0.35); | |
| background: rgba(52, 211, 153, 0.12); | |
| color: #34d399; | |
| } | |
| .game-state-badge.complete { | |
| border-color: rgba(250, 204, 21, 0.38); | |
| background: rgba(250, 204, 21, 0.16); | |
| color: #6CF527; | |
| text-shadow: 0 0 10px rgba(253, 230, 138, 0.24); | |
| } | |
| .game-state-badge.gameover { | |
| border-color: rgba(248, 113, 113, 0.35); | |
| background: rgba(248, 113, 113, 0.14); | |
| color: #f87171; | |
| } | |
| .level-meta { | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| gap: clamp(8px, 1vw, 10px); | |
| z-index: 1; | |
| } | |
| .level-title { | |
| font-size: clamp(26px, 3vw, 38px); | |
| font-weight: 900; | |
| letter-spacing: -0.03em; | |
| color: #fff; | |
| } | |
| .level-personality { | |
| color: #f8fafc; | |
| opacity: 0.9; | |
| font-size: clamp(13px, 1.1vw, 15px); | |
| font-weight: 700; | |
| letter-spacing: 0.02em; | |
| } | |
| .level-chips { | |
| display: flex; | |
| flex-wrap: nowrap; | |
| gap: 10px; | |
| min-width: 0; | |
| } | |
| .theme-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| border-radius: 999px; | |
| padding: 7px 12px; | |
| font-size: clamp(10px, 0.85vw, 12px); | |
| font-weight: 800; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| white-space: nowrap; | |
| min-width: 0; | |
| } | |
| .theme-chip { | |
| background: rgba(14, 165, 233, 0.15); | |
| color: #7dd3fc; | |
| border: 1px solid rgba(14, 165, 233, 0.26); | |
| } | |
| .level-focus { | |
| color: #cbd5e1; | |
| font-size: clamp(13px, 1.1vw, 15px); | |
| line-height: 1.5; | |
| max-width: 72ch; | |
| } | |
| .memory-grid { | |
| --grid-gap: clamp(8px, 1vw, 16px); | |
| display: grid; | |
| grid-template-columns: repeat(var(--grid-cols, 2), minmax(0, var(--card-fit-size, var(--card-size, 150px)))); | |
| gap: var(--grid-gap); | |
| width: fit-content; | |
| max-width: 100%; | |
| z-index: 1; | |
| margin-top: 0; | |
| justify-content: center; | |
| align-content: start; | |
| } | |
| .game-layout { | |
| width: 100%; | |
| position: relative; | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr); | |
| gap: clamp(14px, 1.8vw, 24px); | |
| align-items: start; | |
| overflow: visible; | |
| } | |
| .level-panel { | |
| display: flex; | |
| justify-content: center; | |
| } | |
| .challenge-panel { | |
| display: none; | |
| justify-content: center; | |
| align-items: stretch; | |
| width: 100%; | |
| } | |
| .memory-container.challenge-mode .challenge-panel { | |
| display: flex; | |
| } | |
| .challenge-card { | |
| width: min(100%, 920px); | |
| padding: clamp(18px, 2vw, 28px); | |
| border-radius: 24px; | |
| border: 1px solid rgba(96, 165, 250, 0.26); | |
| background: | |
| radial-gradient(circle at 20% 10%, rgba(14, 165, 233, 0.14), transparent 34%), | |
| linear-gradient(180deg, rgba(15, 23, 42, 0.88), rgba(2, 6, 23, 0.92)); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 70px rgba(2, 6, 23, 0.48); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .challenge-label { | |
| font-size: 12px; | |
| font-weight: 900; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| color: #7dd3fc; | |
| } | |
| .challenge-theme { | |
| color: #f8fafc; | |
| font-size: clamp(14px, 1.25vw, 18px); | |
| font-weight: 800; | |
| letter-spacing: 0.02em; | |
| } | |
| .challenge-question { | |
| color: #f8fafc; | |
| font-size: clamp(22px, 2.3vw, 34px); | |
| line-height: 1.25; | |
| font-weight: 900; | |
| letter-spacing: -0.03em; | |
| } | |
| .challenge-options { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 12px; | |
| } | |
| .challenge-option { | |
| appearance: none; | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| background: rgba(15, 23, 42, 0.78); | |
| color: #f8fafc; | |
| border-radius: 18px; | |
| padding: 16px 18px; | |
| min-height: 70px; | |
| font-size: clamp(16px, 1.4vw, 20px); | |
| font-weight: 800; | |
| letter-spacing: 0.01em; | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); | |
| } | |
| .challenge-option:hover:not(:disabled) { | |
| transform: translateY(-1px); | |
| border-color: rgba(125, 211, 252, 0.45); | |
| } | |
| .challenge-option:disabled { | |
| cursor: default; | |
| opacity: 0.72; | |
| } | |
| .challenge-option.correct-choice { | |
| border-color: rgba(34, 197, 94, 0.75); | |
| background: linear-gradient(135deg, rgba(22, 163, 74, 0.24), rgba(34, 197, 94, 0.34)); | |
| box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.16) inset, 0 0 26px rgba(34, 197, 94, 0.28); | |
| } | |
| .challenge-option.wrong-choice { | |
| border-color: rgba(248, 113, 113, 0.75); | |
| background: linear-gradient(135deg, rgba(185, 28, 28, 0.22), rgba(248, 113, 113, 0.34)); | |
| box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.16) inset, 0 0 26px rgba(248, 113, 113, 0.22); | |
| } | |
| .challenge-option.reveal-correct { | |
| border-color: rgba(250, 204, 21, 0.7); | |
| background: linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(245, 158, 11, 0.28)); | |
| box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.14) inset, 0 0 22px rgba(250, 204, 21, 0.24); | |
| } | |
| .challenge-focus { | |
| color: #cbd5e1; | |
| font-size: 14px; | |
| line-height: 1.45; | |
| } | |
| .game-state-badge.challenge { | |
| border-color: rgba(14, 165, 233, 0.35); | |
| background: rgba(14, 165, 233, 0.12); | |
| color: #7dd3fc; | |
| } | |
| .cards-panel { | |
| display: flex; | |
| justify-content: center; | |
| align-items: flex-start; | |
| padding-top: 0; | |
| position: relative; | |
| z-index: 1; | |
| width: 100%; | |
| } | |
| .memory-card { | |
| width: var(--card-fit-size, var(--card-size, 150px)); | |
| aspect-ratio: 1 / 1; | |
| height: auto; | |
| perspective: 1200px; | |
| cursor: pointer; | |
| user-select: none; | |
| transform-origin: center center; | |
| } | |
| .memory-inner { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| transition: transform 0.55s cubic-bezier(0.2, 0.8, 0.2, 1); | |
| transform-style: preserve-3d; | |
| } | |
| .memory-card.flipped .memory-inner { | |
| transform: rotateY(180deg); | |
| } | |
| .memory-front, | |
| .memory-back { | |
| position: absolute; | |
| inset: 0; | |
| border-radius: clamp(18px, 1.8vw, 24px); | |
| backface-visibility: hidden; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: clamp(32px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.34), 64px); | |
| box-shadow: 0 18px 30px rgba(2, 6, 23, 0.32); | |
| } | |
| .memory-front { | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255,255,255,0.35), transparent 24%), | |
| linear-gradient(135deg, #334155 0%, #6366f1 55%, #8b5cf6 100%); | |
| color: white; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| overflow: hidden; | |
| } | |
| .memory-container.challenge-mode .memory-front { | |
| background: | |
| radial-gradient(circle at 20% 20%, rgba(255, 237, 213, 0.38), transparent 24%), | |
| radial-gradient(circle at 80% 18%, rgba(255, 107, 53, 0.26), transparent 26%), | |
| linear-gradient(135deg, #7f1d1d 0%, #dc2626 52%, #fb923c 100%); | |
| border-color: rgba(251, 146, 60, 0.24); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.08), | |
| 0 0 0 1px rgba(251, 146, 60, 0.08), | |
| 0 18px 30px rgba(69, 10, 10, 0.36); | |
| } | |
| .memory-front-logo { | |
| width: clamp(34px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.38), 76px); | |
| height: auto; | |
| object-fit: contain; | |
| display: block; | |
| filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.22)); | |
| } | |
| .memory-back { | |
| background: | |
| radial-gradient(circle at 25% 20%, rgba(255,255,255,0.38), transparent 22%), | |
| linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); | |
| transform: rotateY(180deg); | |
| color: #0f172a; | |
| } | |
| .memory-card.text-card .memory-back { | |
| color: #020617; | |
| font-size: clamp(12px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.19), 28px); | |
| font-weight: 800; | |
| line-height: 1.12; | |
| letter-spacing: -0.02em; | |
| text-align: center; | |
| padding: clamp(8px, 1vw, 14px); | |
| box-sizing: border-box; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| hyphens: auto; | |
| } | |
| .memory-card:hover:not(.preview-locked):not(.matched) .memory-front { | |
| transform: scale(1.025); | |
| } | |
| .memory-card.matched { | |
| cursor: default; | |
| pointer-events: none; | |
| } | |
| .memory-card.matched .memory-back { | |
| background: | |
| radial-gradient(circle at 25% 20%, rgba(255,255,255,0.42), transparent 22%), | |
| linear-gradient(135deg, #bbf7d0 0%, #86efac 100%); | |
| animation: matchGlow 0.8s ease; | |
| } | |
| @keyframes matchGlow { | |
| 0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.4); } | |
| 60% { box-shadow: 0 0 0 14px rgba(52,211,153,0); } | |
| 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); } | |
| } | |
| .memory-card.wrong .memory-back { | |
| background: | |
| radial-gradient(circle at 25% 20%, rgba(255,255,255,0.24), transparent 24%), | |
| linear-gradient(135deg, #fca5a5 0%, #fb7185 100%); | |
| animation: shake 0.45s ease; | |
| } | |
| @keyframes shake { | |
| 0%, 100% { transform: rotateY(180deg) translateX(0); } | |
| 20% { transform: rotateY(180deg) translateX(-7px); } | |
| 40% { transform: rotateY(180deg) translateX(7px); } | |
| 60% { transform: rotateY(180deg) translateX(-4px); } | |
| 80% { transform: rotateY(180deg) translateX(4px); } | |
| } | |
| .memory-card.preview-locked { | |
| cursor: default; | |
| pointer-events: none; | |
| } | |
| .memory-card.hint-peek .memory-back { | |
| background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); | |
| box-shadow: 0 0 0 8px rgba(250, 204, 21, 0.2); | |
| } | |
| .game-status { | |
| width: 100%; | |
| margin-top: 0; | |
| padding: clamp(14px, 1.4vw, 18px) clamp(16px, 1.8vw, 22px); | |
| background: rgba(15, 23, 42, 0.55); | |
| border-radius: clamp(16px, 1.5vw, 18px); | |
| border: 1px solid rgba(148, 163, 184, 0.14); | |
| color: white; | |
| backdrop-filter: blur(8px); | |
| z-index: 1; | |
| box-sizing: border-box; | |
| } | |
| .game-status.show { | |
| border-color: rgba(250, 204, 21, 0.34); | |
| background: rgba(113, 63, 18, 0.45); | |
| } | |
| .game-status.error { | |
| border-color: rgba(248, 113, 113, 0.34); | |
| background: rgba(127, 29, 29, 0.45); | |
| } | |
| .game-status.win { | |
| border-color: rgba(52, 211, 153, 0.34); | |
| background: rgba(6, 78, 59, 0.45); | |
| } | |
| .status-text { | |
| font-size: clamp(15px, 1.3vw, 18px); | |
| font-weight: 800; | |
| } | |
| .status-subtext { | |
| margin-top: 6px; | |
| color: #cbd5e1; | |
| font-size: clamp(12px, 1vw, 14px); | |
| line-height: 1.45; | |
| } | |
| .performance-meter-shell { | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| min-width: 0; | |
| padding: 18px 18px 20px; | |
| border-radius: 30px; | |
| border: 1px solid rgba(255, 255, 255, 0.10); | |
| background: rgba(7, 11, 24, 0.36); | |
| box-shadow: | |
| inset 0 1px 0 rgba(255, 255, 255, 0.03), | |
| 0 20px 40px rgba(2, 6, 23, 0.22); | |
| box-sizing: border-box; | |
| } | |
| .performance-meter-title { | |
| text-align: center; | |
| font-size: 14px; | |
| font-weight: 900; | |
| letter-spacing: 0.20em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.58); | |
| line-height: 1; | |
| } | |
| .meter-card { | |
| width: 100%; | |
| padding: 18px 24px; | |
| border-radius: 999px; | |
| background: rgba(8, 12, 23, 0.78); | |
| border: 1px solid rgba(255,255,255,0.06); | |
| box-shadow: | |
| 0 18px 45px rgba(0, 0, 0, 0.12), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.04); | |
| display: grid; | |
| grid-template-columns: auto 1fr auto; | |
| align-items: center; | |
| gap: 20px; | |
| box-sizing: border-box; | |
| min-width: 0; | |
| } | |
| .side-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-size: clamp(15px, 1.65vw, 22px); | |
| font-weight: 900; | |
| white-space: nowrap; | |
| flex: 0 0 auto; | |
| min-width: max-content; | |
| } | |
| .easy { | |
| color: #4ced7f; | |
| } | |
| .challenge { | |
| color: #ff684f; | |
| } | |
| .icon-box { | |
| width: 40px; | |
| height: 40px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 16px; | |
| font-size: 24px; | |
| box-shadow: | |
| inset 0 1px 0 rgba(255,255,255,0.16), | |
| 0 0 0 1px rgba(255,255,255,0.05); | |
| } | |
| .easy .icon-box { | |
| background: rgba(34,197,94,0.14); | |
| } | |
| .challenge .icon-box { | |
| background: rgba(239,68,68,0.14); | |
| } | |
| .meter { | |
| --meter-value: 0%; | |
| --meter-side-padding: 18px; | |
| --meter-knob-size: 42px; | |
| --knob-color: #17a34a; | |
| --knob-glow: rgba(23, 163, 74, 0.65); | |
| position: relative; | |
| width: 100%; | |
| min-width: 0; | |
| height: 28px; | |
| padding-inline: var(--meter-side-padding); | |
| box-sizing: border-box; | |
| border-radius: 999px; | |
| background: linear-gradient( | |
| 90deg, | |
| #17a34a 0%, | |
| #97c51e 28%, | |
| #ffd522 50%, | |
| #ff981f 72%, | |
| #ff2e25 100% | |
| ); | |
| overflow: visible; | |
| box-shadow: | |
| inset 0 3px 8px rgba(0, 0, 0, 0.26), | |
| 0 0 0 1px rgba(255,255,255,0.08), | |
| 0 0 24px rgba(255, 147, 31, 0.18); | |
| } | |
| .meter::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| border-radius: inherit; | |
| background: linear-gradient( | |
| 180deg, | |
| rgba(255,255,255,0.38), | |
| rgba(255,255,255,0.02) | |
| ); | |
| pointer-events: none; | |
| } | |
| .ticks { | |
| position: absolute; | |
| inset: 0 var(--meter-side-padding); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| pointer-events: none; | |
| } | |
| .ticks span { | |
| width: 3px; | |
| height: 12px; | |
| border-radius: 99px; | |
| background: rgba(255, 255, 255, 0.88); | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.18); | |
| } | |
| .meter-fill-spark { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: var(--meter-value); | |
| height: 100%; | |
| border-radius: inherit; | |
| background: linear-gradient( | |
| 90deg, | |
| rgba(255,255,255,0.04), | |
| rgba(255,255,255,0.35), | |
| rgba(255,255,255,0.06) | |
| ); | |
| transition: width 720ms cubic-bezier(.2, .9, .2, 1.15); | |
| pointer-events: none; | |
| } | |
| .knob { | |
| position: absolute; | |
| top: 50%; | |
| left: clamp( | |
| var(--meter-side-padding), | |
| calc(var(--meter-side-padding) + var(--meter-value)), | |
| calc(100% - var(--meter-side-padding)) | |
| ); | |
| width: var(--meter-knob-size); | |
| height: var(--meter-knob-size); | |
| transform: translate(-50%, -50%); | |
| border-radius: 50%; | |
| z-index: 5; | |
| display: grid; | |
| place-items: center; | |
| transition: | |
| left 720ms cubic-bezier(.2, .9, .2, 1.15), | |
| transform 180ms ease; | |
| filter: | |
| drop-shadow(0 8px 14px rgba(0, 0, 0, 0.24)) | |
| drop-shadow(0 0 18px var(--knob-glow)); | |
| } | |
| .knob::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 50%; | |
| padding: 3px; | |
| background: | |
| conic-gradient( | |
| from 120deg, | |
| rgba(255,255,255,0.95), | |
| var(--knob-color), | |
| rgba(255,255,255,0.95), | |
| var(--knob-color), | |
| rgba(255,255,255,0.95) | |
| ); | |
| box-shadow: | |
| 0 0 10px var(--knob-glow), | |
| 0 0 20px var(--knob-glow), | |
| inset 0 0 8px rgba(255,255,255,0.9); | |
| -webkit-mask: | |
| linear-gradient(#000 0 0) content-box, | |
| linear-gradient(#000 0 0); | |
| -webkit-mask-composite: xor; | |
| mask-composite: exclude; | |
| animation: neonSpin 1.4s linear infinite; | |
| } | |
| @keyframes neonSpin { | |
| from { | |
| rotate: 0deg; | |
| } | |
| to { | |
| rotate: 360deg; | |
| } | |
| } | |
| .knob-core { | |
| position: relative; | |
| width: calc(var(--meter-knob-size) * 0.76); | |
| height: calc(var(--meter-knob-size) * 0.76); | |
| border-radius: 50%; | |
| background: linear-gradient(180deg, #ffffff, #f3f4f6); | |
| box-shadow: | |
| inset 0 3px 7px rgba(255, 255, 255, 1), | |
| inset 0 -5px 10px rgba(0, 0, 0, 0.08); | |
| display: grid; | |
| place-items: center; | |
| } | |
| .knob-core::after { | |
| content: ""; | |
| width: calc(var(--meter-knob-size) * 0.07); | |
| height: calc(var(--meter-knob-size) * 0.30); | |
| border-radius: 99px; | |
| background: var(--knob-color); | |
| box-shadow: | |
| 0 0 10px var(--knob-glow), | |
| 0 0 18px var(--knob-glow); | |
| transition: | |
| background 720ms ease, | |
| box-shadow 720ms ease; | |
| } | |
| .knob.bump { | |
| animation: knobBump 420ms ease; | |
| } | |
| @keyframes knobBump { | |
| 0% { | |
| transform: translate(-50%, -50%) scale(1); | |
| } | |
| 35% { | |
| transform: translate(-50%, -50%) scale(1.14); | |
| } | |
| 65% { | |
| transform: translate(-50%, -50%) scale(0.96); | |
| } | |
| 100% { | |
| transform: translate(-50%, -50%) scale(1); | |
| } | |
| } | |
| .meter.flash { | |
| animation: meterFlash 520ms ease; | |
| } | |
| @keyframes meterFlash { | |
| 0% { | |
| filter: brightness(1); | |
| } | |
| 40% { | |
| filter: brightness(1.25) saturate(1.28); | |
| } | |
| 100% { | |
| filter: brightness(1); | |
| } | |
| } | |
| .controls-row { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| justify-content: center; | |
| gap: clamp(10px, 1.2vw, 14px); | |
| z-index: 1; | |
| margin-top: 0; | |
| width: min(100%, 760px); | |
| margin-inline: auto; | |
| box-sizing: border-box; | |
| } | |
| .memory-container:not(.game-playing) .controls-row { display: none !important; } | |
| .memory-container.game-playing .controls-row { display: grid !important; } | |
| .reset-button, | |
| .hint-button, | |
| .next-level-button { | |
| width: 100%; | |
| min-width: 0; | |
| padding: clamp(10px, 1vw, 12px) clamp(14px, 1.4vw, 20px); | |
| font-size: clamp(11px, 0.9vw, 13px); | |
| font-weight: 900; | |
| color: white; | |
| border: none; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.25s ease; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| #controls-row .reset-button, | |
| #controls-row .reset-button:disabled { | |
| background: linear-gradient(135deg, #ef4444 0%, #f97316 100%) !important; | |
| box-shadow: 0 12px 30px rgba(239,68,68,0.32) !important; | |
| color: #ffffff !important; | |
| } | |
| #controls-row .hint-button, | |
| #controls-row .hint-button:disabled { | |
| background: linear-gradient(135deg, #fde68a 0%, #facc15 100%) !important; | |
| box-shadow: 0 10px 30px rgba(250, 204, 21, 0.25) !important; | |
| color: #1e293b !important; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| line-height: 1; | |
| } | |
| .hint-prefix, | |
| .hint-count { | |
| display: inline-block; | |
| } | |
| .hint-count { | |
| font-weight: 1000; | |
| letter-spacing: 0.04em; | |
| } | |
| .hint-button:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| box-shadow: 0 14px 36px rgba(250, 204, 21, 0.35); | |
| } | |
| #controls-row .next-level-button, | |
| #controls-row .next-level-button:disabled { | |
| background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%) !important; | |
| box-shadow: 0 12px 30px rgba(14,165,233,0.28) !important; | |
| color: #f5f8ff !important; | |
| } | |
| .reset-button:hover:not(:disabled), | |
| .next-level-button:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| } | |
| .reset-button:disabled, | |
| .hint-button:disabled, | |
| .next-level-button:disabled { | |
| opacity: 0.9; | |
| cursor: not-allowed; | |
| filter: saturate(0.95) brightness(0.96); | |
| } | |
| .hud-glow-peek, | |
| .hud-glow-lives, | |
| .hud-glow-lives-gain { | |
| animation: hudPulseGlow 0.8s ease-out; | |
| } | |
| .hud-glow-peek { | |
| text-shadow: | |
| 0 0 0 rgba(134, 239, 172, 0), | |
| 0 0 10px rgba(34, 197, 94, 0.65), | |
| 0 0 18px rgba(134, 239, 172, 0.75), | |
| 0 0 28px rgba(34, 197, 94, 0.45); | |
| box-shadow: | |
| 0 0 0 1px rgba(34, 197, 94, 0.15) inset, | |
| 0 0 18px rgba(34, 197, 94, 0.24), | |
| 0 0 30px rgba(134, 239, 172, 0.14); | |
| } | |
| .hud-glow-lives { | |
| text-shadow: | |
| 0 0 0 rgba(248, 113, 113, 0), | |
| 0 0 10px rgba(248, 113, 113, 0.68), | |
| 0 0 18px rgba(252, 165, 165, 0.75), | |
| 0 0 28px rgba(248, 113, 113, 0.46); | |
| box-shadow: | |
| 0 0 0 1px rgba(248, 113, 113, 0.15) inset, | |
| 0 0 18px rgba(248, 113, 113, 0.24), | |
| 0 0 30px rgba(252, 165, 165, 0.14); | |
| } | |
| .hud-glow-lives-gain { | |
| text-shadow: | |
| 0 0 0 rgba(74, 222, 128, 0), | |
| 0 0 10px rgba(34, 197, 94, 0.68), | |
| 0 0 18px rgba(134, 239, 172, 0.78), | |
| 0 0 28px rgba(74, 222, 128, 0.46); | |
| box-shadow: | |
| 0 0 0 1px rgba(34, 197, 94, 0.15) inset, | |
| 0 0 18px rgba(34, 197, 94, 0.24), | |
| 0 0 30px rgba(134, 239, 172, 0.14); | |
| } | |
| @keyframes hudPulseGlow { | |
| 0% { | |
| filter: brightness(1); | |
| transform: translateY(0) scale(1); | |
| } | |
| 35% { | |
| filter: brightness(1.18); | |
| transform: translateY(-1px) scale(1.015); | |
| } | |
| 100% { | |
| filter: brightness(1); | |
| transform: translateY(0) scale(1); | |
| } | |
| } | |
| @media (max-width: 900px) { | |
| .board-frame { | |
| padding: 14px; | |
| gap: 14px; | |
| --board-frame-padding: 14px; | |
| } | |
| .performance-meter-shell { | |
| padding: 12px 12px 14px; | |
| gap: 8px; | |
| } | |
| .performance-meter-title { | |
| font-size: 11px; | |
| } | |
| .meter-card { | |
| padding: 15px 18px; | |
| gap: 14px; | |
| } | |
| .game-stats { | |
| gap: 8px; | |
| } | |
| .dialog-gameover { | |
| width: min(88vw, 660px); | |
| min-height: min(50dvh, 500px); | |
| padding: clamp(20px, 2.2vw, 30px) clamp(18px, 2.4vw, 34px); | |
| border-radius: 28px; | |
| gap: 12px; | |
| } | |
| .gameover-icon-wrap { | |
| width: 88px; | |
| height: 88px; | |
| } | |
| .gameover-icon { | |
| font-size: 42px; | |
| } | |
| .gameover-title { | |
| font-size: clamp(38px, 5vw, 60px); | |
| } | |
| .gameover-subtitle { | |
| font-size: clamp(15px, 1.7vw, 22px); | |
| } | |
| .gameover-score-pill { | |
| padding: 13px 16px; | |
| gap: 12px; | |
| } | |
| .gameover-score-icon { | |
| font-size: 30px; | |
| } | |
| .gameover-score-label { | |
| font-size: 18px; | |
| } | |
| .gameover-score-value { | |
| font-size: 40px; | |
| } | |
| .dialog-challenge.dialog-challenge-polished { | |
| width: min(94vw, 560px); | |
| padding: 24px 18px 22px; | |
| border-radius: 28px; | |
| } | |
| .dialog-howto { | |
| width: min(98vw, 1200px); | |
| max-height: min(94dvh, 900px); | |
| padding: 18px; | |
| overflow-y: auto; | |
| overscroll-behavior: contain; | |
| } | |
| .howto-close-btn { | |
| width: 50px; | |
| height: 50px; | |
| font-size: 34px; | |
| top: 12px; | |
| right: 12px; | |
| } | |
| .howto-logo { | |
| width: 30px; | |
| height: 30px; | |
| } | |
| .howto-headline { | |
| font-size: clamp(17px, 3.4vw, 24px); | |
| } | |
| .howto-steps { | |
| grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); | |
| gap: 8px; | |
| } | |
| .howto-step-arrow { | |
| display: grid; | |
| font-size: clamp(20px, 3vw, 28px); | |
| } | |
| .howto-main { | |
| grid-template-columns: 1fr; | |
| gap: 10px; | |
| } | |
| .howto-step-card { | |
| min-height: 96px; | |
| padding: 9px 10px; | |
| gap: 8px; | |
| flex-direction: column; | |
| justify-content: flex-start; | |
| align-items: center; | |
| text-align: center; | |
| } | |
| .howto-step-icon { | |
| width: 48px; | |
| height: 48px; | |
| font-size: 24px; | |
| } | |
| .howto-step-title { | |
| font-size: 12px; | |
| } | |
| .howto-step-text { | |
| font-size: 9.5px; | |
| line-height: 1.16; | |
| text-align: center; | |
| max-width: 12ch; | |
| } | |
| .howto-step-copy { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .howto-left, | |
| .howto-right { | |
| padding: 9px; | |
| border-radius: 16px; | |
| } | |
| .howto-rule { | |
| padding: 7px 8px; | |
| gap: 7px; | |
| border-radius: 12px; | |
| } | |
| .howto-rule-icon { | |
| width: 32px; | |
| height: 32px; | |
| font-size: 16px; | |
| } | |
| .howto-rule-title { | |
| font-size: 10px; | |
| } | |
| .howto-rule-text { | |
| font-size: 9px; | |
| line-height: 1.16; | |
| } | |
| .howto-panel-title { | |
| font-size: 11px; | |
| margin: 1px 0 8px; | |
| } | |
| .howto-rewards-table { | |
| font-size: 9px; | |
| line-height: 1.16; | |
| table-layout: fixed; | |
| width: 100%; | |
| } | |
| .howto-rewards-table th, | |
| .howto-rewards-table td { | |
| padding: 6px 6px; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| white-space: normal; | |
| } | |
| .howto-rewards-table th { | |
| font-size: 7px; | |
| letter-spacing: 0.08em; | |
| } | |
| .howto-rewards-table td:last-child { | |
| width: 38%; | |
| } | |
| .howto-secondary-btn { | |
| width: min(100%, 220px); | |
| padding: 12px 16px; | |
| font-size: 13px; | |
| border-radius: 14px; | |
| } | |
| .dialog-leaderboard { | |
| width: min(98vw, 1200px); | |
| max-height: min(94dvh, 900px); | |
| padding: 18px; | |
| overflow-y: auto; | |
| overscroll-behavior: contain; | |
| } | |
| .leaderboard-row { | |
| grid-template-columns: 58px minmax(0, 1fr) auto; | |
| padding: 10px 12px; | |
| gap: 10px; | |
| } | |
| .leaderboard-rank { | |
| width: 46px; | |
| height: 46px; | |
| border-radius: 14px; | |
| font-size: 16px; | |
| } | |
| .leaderboard-name { | |
| font-size: 13px; | |
| } | |
| .leaderboard-score { | |
| font-size: 16px; | |
| } | |
| .challenge-close-btn { | |
| top: 14px; | |
| right: 14px; | |
| width: 38px; | |
| height: 38px; | |
| font-size: 24px; | |
| } | |
| .challenge-hero-ring { | |
| width: 82px; | |
| height: 82px; | |
| } | |
| .challenge-hero-icon { | |
| width: 60px; | |
| height: 60px; | |
| font-size: 34px; | |
| } | |
| .challenge-pill { | |
| margin-bottom: 12px; | |
| padding: 8px 14px; | |
| font-size: 12px; | |
| } | |
| .challenge-title { | |
| font-size: clamp(28px, 5.5vw, 40px); | |
| } | |
| .challenge-subtitle { | |
| font-size: clamp(15px, 3.4vw, 20px); | |
| max-width: 34ch; | |
| } | |
| .challenge-divider { | |
| margin: 14px 0 16px; | |
| } | |
| .challenge-points { | |
| gap: 10px; | |
| width: min(100%, 360px); | |
| } | |
| .challenge-point { | |
| gap: 10px; | |
| font-size: 14px; | |
| } | |
| .challenge-point-icon, | |
| .challenge-note-icon { | |
| width: 26px; | |
| height: 26px; | |
| } | |
| .challenge-rewards { | |
| gap: 10px; | |
| margin: 18px auto 14px; | |
| } | |
| .challenge-reward { | |
| min-height: 48px; | |
| padding: 10px 14px; | |
| font-size: 14px; | |
| } | |
| .challenge-note { | |
| margin-bottom: 20px; | |
| font-size: 13px; | |
| gap: 10px; | |
| } | |
| .challenge-start-btn { | |
| min-width: min(100%, 440px); | |
| min-height: 64px; | |
| padding: 16px 22px; | |
| font-size: clamp(18px, 4.4vw, 24px); | |
| border-radius: 20px; | |
| } | |
| } | |
| @media (max-width: 820px) { | |
| .memory-container { | |
| padding: 8px; | |
| padding-bottom: calc(20px + env(safe-area-inset-bottom)); | |
| gap: 8px; | |
| border-radius: 16px; | |
| } | |
| .memory-container:not(.game-playing) .board-frame { | |
| display: none !important; | |
| } | |
| .game-brand { | |
| width: 100%; | |
| justify-content: center; | |
| gap: 10px; | |
| padding-top: 2px; | |
| } | |
| .game-brand-mark { | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 14px; | |
| } | |
| .game-brand-title { | |
| font-size: clamp(22px, 6vw, 30px); | |
| } | |
| .game-brand-subtitle { | |
| font-size: 12px; | |
| line-height: 1.35; | |
| max-width: 44ch; | |
| } | |
| .board-frame { | |
| padding: 12px; | |
| padding-bottom: 18px; | |
| border-radius: 18px; | |
| gap: 12px; | |
| --board-frame-padding: 12px; | |
| } | |
| .performance-meter-shell { | |
| padding: 10px 10px 12px; | |
| gap: 8px; | |
| } | |
| .performance-meter-title { | |
| font-size: 9px; | |
| letter-spacing: 0.16em; | |
| } | |
| #status-layout, | |
| .game-layout, | |
| .level-panel, | |
| .cards-panel { | |
| flex: 0 0 auto; | |
| } | |
| .game-header-row { | |
| grid-template-columns: 1fr; | |
| gap: 6px; | |
| } | |
| .game-stats { | |
| grid-template-columns: repeat(5, minmax(0, 1fr)); | |
| gap: 4px; | |
| } | |
| .meter-card { | |
| padding: 11px 12px; | |
| gap: 10px; | |
| border-radius: 28px; | |
| } | |
| .meter { | |
| --meter-side-padding: 10px; | |
| --meter-knob-size: 32px; | |
| height: 22px; | |
| } | |
| .ticks span { | |
| height: 9px; | |
| } | |
| .side-label { | |
| font-size: 8px; | |
| gap: 5px; | |
| } | |
| .icon-box { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 6px; | |
| font-size: 9px; | |
| } | |
| .game-state-badge { | |
| display: inline-flex; | |
| grid-column: 1; | |
| justify-self: center; | |
| align-self: start; | |
| margin-top: 2px; | |
| padding: 6px 10px; | |
| min-width: 0; | |
| width: fit-content; | |
| max-width: 100%; | |
| flex-direction: row; | |
| gap: 6px; | |
| white-space: nowrap; | |
| letter-spacing: 0.12em; | |
| font-size: 10px; | |
| } | |
| .stat-block { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto auto auto; | |
| justify-items: center; | |
| align-items: center; | |
| text-align: center; | |
| gap: 1px; | |
| padding: 7px 4px 6px; | |
| } | |
| .stat-icon { | |
| width: 22px; | |
| height: 22px; | |
| font-size: 11px; | |
| grid-row: 1; | |
| grid-column: 1; | |
| justify-self: center; | |
| } | |
| .stat-label { | |
| grid-column: 1; | |
| grid-row: 2; | |
| align-self: center; | |
| letter-spacing: 0.08em; | |
| font-size: 6px; | |
| line-height: 1; | |
| } | |
| .stat-value { | |
| grid-column: 1; | |
| grid-row: 3; | |
| align-self: center; | |
| margin-top: 0; | |
| font-size: 10px; | |
| line-height: 1; | |
| } | |
| .memory-grid { | |
| --grid-gap: 10px; | |
| width: 100%; | |
| } | |
| .game-layout { | |
| gap: 12px; | |
| } | |
| .start-overlay { | |
| position: relative; | |
| inset: auto; | |
| width: 100%; | |
| height: auto; | |
| min-height: auto; | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: center; | |
| overflow: visible; | |
| } | |
| .level-panel { | |
| justify-content: stretch; | |
| } | |
| .level-chips { | |
| flex-wrap: wrap; | |
| } | |
| .start-card { | |
| width: min(100%, calc(100vw - 24px)); | |
| padding: 20px 18px 18px; | |
| border-radius: 22px; | |
| } | |
| .start-key-row { | |
| margin-top: 12px; | |
| } | |
| .top-utility-bar { | |
| gap: 8px; | |
| } | |
| .top-utility-bar .utility-button, | |
| .top-utility-bar .music-toggle-button { | |
| padding: 9px 12px; | |
| font-size: 12px; | |
| border-radius: 16px; | |
| } | |
| .top-utility-bar .utility-button { | |
| min-width: 92px; | |
| } | |
| .top-utility-bar .music-toggle-button { | |
| min-width: 150px; | |
| } | |
| .top-utility-bar .high-score-pill { | |
| min-width: 160px; | |
| flex: 1 1 160px; | |
| } | |
| .start-utility-row { | |
| width: min(100%, 680px); | |
| gap: 8px; | |
| } | |
| .start-utility-row .start-help-button, | |
| .start-utility-row .music-toggle-button { | |
| min-width: 0; | |
| flex: 1 1 0; | |
| padding: 14px 16px; | |
| font-size: 14px; | |
| } | |
| .start-utility-row .leaderboard-button, | |
| .start-utility-row .high-score-pill { | |
| min-width: 0; | |
| flex: 1 1 0; | |
| padding: 14px 16px; | |
| font-size: 14px; | |
| } | |
| .start-utility-row .start-help-icon, | |
| .start-utility-row .music-toggle-icon { | |
| width: 28px; | |
| height: 28px; | |
| font-size: 16px; | |
| } | |
| .start-utility-row .leaderboard-icon, | |
| .start-utility-row .high-score-icon { | |
| width: 28px; | |
| height: 28px; | |
| font-size: 16px; | |
| } | |
| .start-utility-row .high-score-copy { | |
| gap: 2px; | |
| } | |
| .start-utility-row .high-score-label { | |
| font-size: 8px; | |
| letter-spacing: 0.16em; | |
| } | |
| .start-utility-row .high-score-user { | |
| font-size: 15px; | |
| font-weight: 1000; | |
| max-width: 12ch; | |
| } | |
| .start-utility-row .high-score-value { | |
| font-size: 22px; | |
| } | |
| .cards-panel { | |
| width: 100%; | |
| } | |
| .controls-row { | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 8px; | |
| margin-top: 2px; | |
| width: min(100%, 560px); | |
| margin-inline: auto; | |
| } | |
| .next-level-button { | |
| grid-column: auto; | |
| justify-self: stretch; | |
| width: 100%; | |
| } | |
| .reset-button, | |
| .hint-button, | |
| .next-level-button { | |
| padding: 10px 10px; | |
| font-size: 10px; | |
| letter-spacing: 0.06em; | |
| } | |
| .hint-button { | |
| gap: 6px; | |
| } | |
| .level-title { | |
| font-size: clamp(22px, 6vw, 30px); | |
| } | |
| .level-personality { | |
| font-size: 13px; | |
| } | |
| .level-focus { | |
| font-size: 14px; | |
| } | |
| .status-text { | |
| font-size: 15px; | |
| } | |
| .status-subtext { | |
| font-size: 12px; | |
| } | |
| .memory-front, | |
| .memory-back { | |
| border-radius: 18px; | |
| } | |
| .start-stage { | |
| width: 100%; | |
| height: auto; | |
| min-height: 0; | |
| padding: 18px 18px 24px; | |
| transform: none; | |
| align-items: flex-start; | |
| } | |
| .start-hero { | |
| width: min(100%, 680px); | |
| gap: 14px; | |
| } | |
| .start-title { | |
| font-size: clamp(40px, 11vw, 86px); | |
| } | |
| .start-tagline { | |
| font-size: clamp(16px, 3vw, 24px); | |
| } | |
| .start-subtitle { | |
| max-width: 28ch; | |
| font-size: clamp(16px, 2.8vw, 24px); | |
| } | |
| .start-preview { | |
| width: min(100%, 520px); | |
| gap: 10px; | |
| } | |
| .start-help-button { | |
| width: min(100%, 680px); | |
| font-size: clamp(15px, 2.8vw, 20px); | |
| padding: 14px 16px; | |
| } | |
| .start-button { | |
| width: min(100%, 560px); | |
| font-size: clamp(17px, 3.2vw, 24px); | |
| padding: 16px 18px; | |
| } | |
| } | |
| @media (max-width: 420px) { | |
| .memory-container { | |
| padding-bottom: calc(24px + env(safe-area-inset-bottom)); | |
| } | |
| .performance-meter-shell { | |
| padding: 8px 8px 10px; | |
| gap: 6px; | |
| } | |
| .performance-meter-title { | |
| font-size: 8px; | |
| letter-spacing: 0.14em; | |
| } | |
| .meter-card { | |
| padding: 9px 10px; | |
| gap: 8px; | |
| border-radius: 24px; | |
| } | |
| .meter { | |
| --meter-side-padding: 8px; | |
| --meter-knob-size: 28px; | |
| height: 20px; | |
| } | |
| .ticks span { | |
| height: 8px; | |
| } | |
| .knob-core::after { | |
| height: calc(var(--meter-knob-size) * 0.30); | |
| } | |
| .side-label { | |
| font-size: 8px; | |
| gap: 3px; | |
| } | |
| .icon-box { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 5px; | |
| font-size: 9px; | |
| } | |
| .game-brand { | |
| flex-direction: column; | |
| align-items: center; | |
| text-align: center; | |
| gap: 8px; | |
| } | |
| .game-brand-mark { | |
| width: 42px; | |
| height: 42px; | |
| } | |
| .game-brand-title { | |
| font-size: 21px; | |
| } | |
| .game-brand-subtitle { | |
| font-size: 11px; | |
| line-height: 1.3; | |
| max-width: 36ch; | |
| } | |
| .dialog-gameover { | |
| width: min(92vw, 460px); | |
| min-height: auto; | |
| padding: 16px 14px 14px; | |
| border-radius: 22px; | |
| gap: 10px; | |
| } | |
| .gameover-icon-wrap { | |
| width: 72px; | |
| height: 72px; | |
| } | |
| .gameover-icon { | |
| font-size: 34px; | |
| } | |
| .gameover-title { | |
| font-size: clamp(30px, 10vw, 44px); | |
| } | |
| .gameover-divider { | |
| width: min(100%, 180px); | |
| gap: 6px; | |
| } | |
| .gameover-diamond { | |
| font-size: 16px; | |
| } | |
| .gameover-subtitle { | |
| font-size: 14px; | |
| max-width: 24ch; | |
| } | |
| .gameover-score-pill { | |
| padding: 11px 13px; | |
| gap: 10px; | |
| } | |
| .gameover-score-icon { | |
| font-size: 24px; | |
| } | |
| .gameover-score-label { | |
| font-size: 14px; | |
| } | |
| .gameover-score-sep { | |
| height: 22px; | |
| } | |
| .gameover-score-value { | |
| font-size: 30px; | |
| } | |
| .start-utility-row { | |
| width: min(100%, 560px); | |
| gap: 8px; | |
| } | |
| .start-utility-row .start-help-button, | |
| .start-utility-row .music-toggle-button { | |
| padding: 13px 14px; | |
| font-size: 13px; | |
| border-radius: 20px; | |
| } | |
| .start-utility-row .start-help-icon, | |
| .start-utility-row .music-toggle-icon { | |
| width: 26px; | |
| height: 26px; | |
| font-size: 15px; | |
| } | |
| .top-utility-bar { | |
| gap: 6px; | |
| } | |
| .top-utility-bar .utility-button, | |
| .top-utility-bar .music-toggle-button { | |
| padding: 8px 10px; | |
| font-size: 11px; | |
| border-radius: 16px; | |
| } | |
| .top-utility-bar .utility-button { | |
| min-width: 84px; | |
| } | |
| .top-utility-bar .music-toggle-button { | |
| min-width: 138px; | |
| } | |
| .top-utility-bar .high-score-pill { | |
| min-width: 150px; | |
| flex: 1 1 150px; | |
| padding: 8px 10px; | |
| } | |
| .top-utility-bar .high-score-user { | |
| max-width: 10ch; | |
| } | |
| .dialog-challenge.dialog-challenge-polished { | |
| width: min(calc(100vw - 20px), 460px); | |
| padding: 20px 14px 18px; | |
| border-radius: 24px; | |
| } | |
| .challenge-close-btn { | |
| top: 12px; | |
| right: 12px; | |
| width: 34px; | |
| height: 34px; | |
| font-size: 21px; | |
| } | |
| .challenge-hero { | |
| margin-top: 0; | |
| margin-bottom: 8px; | |
| } | |
| .challenge-hero-ring { | |
| width: 74px; | |
| height: 74px; | |
| } | |
| .challenge-hero-icon { | |
| width: 54px; | |
| height: 54px; | |
| font-size: 30px; | |
| } | |
| .challenge-pill { | |
| margin-bottom: 10px; | |
| padding: 7px 12px; | |
| font-size: 11px; | |
| letter-spacing: 0.08em; | |
| } | |
| .challenge-title { | |
| font-size: clamp(24px, 8vw, 34px); | |
| } | |
| .challenge-subtitle { | |
| margin-top: 8px; | |
| font-size: clamp(13px, 4.2vw, 16px); | |
| max-width: 30ch; | |
| } | |
| .challenge-divider { | |
| margin: 12px 0 14px; | |
| gap: 8px; | |
| } | |
| .challenge-divider span { | |
| width: 36px; | |
| } | |
| .challenge-points { | |
| gap: 8px; | |
| width: min(100%, 100%); | |
| } | |
| .challenge-point { | |
| gap: 8px; | |
| font-size: 13px; | |
| } | |
| .challenge-point-icon, | |
| .challenge-note-icon { | |
| width: 22px; | |
| height: 22px; | |
| font-size: 13px; | |
| } | |
| .challenge-rewards { | |
| gap: 8px; | |
| margin: 16px auto 12px; | |
| } | |
| .challenge-reward { | |
| min-height: 42px; | |
| padding: 9px 12px; | |
| font-size: 12px; | |
| } | |
| .challenge-reward-icon { | |
| font-size: 18px; | |
| } | |
| .challenge-note { | |
| margin-bottom: 16px; | |
| gap: 8px; | |
| font-size: 12px; | |
| } | |
| .challenge-start-btn { | |
| min-width: 100%; | |
| min-height: 58px; | |
| padding: 14px 18px; | |
| font-size: 18px; | |
| border-radius: 18px; | |
| } | |
| .dialog-card { | |
| width: min(100%, calc(100vw - 24px)); | |
| padding: 18px 18px 16px; | |
| border-radius: 20px; | |
| } | |
| #how-to-play-overlay { | |
| padding: 8px; | |
| } | |
| .dialog-howto { | |
| width: min(100%, calc(100vw - 16px)); | |
| max-height: calc(100dvh - 16px); | |
| padding: 14px 12px 12px; | |
| overflow-y: auto; | |
| overscroll-behavior: contain; | |
| } | |
| .howto-logo-wrap { | |
| margin: 0 0 2px; | |
| } | |
| .howto-logo { | |
| width: 24px; | |
| height: 24px; | |
| } | |
| .dialog-title { | |
| font-size: 19px; | |
| } | |
| .dialog-text { | |
| font-size: 13px; | |
| } | |
| .howto-kicker { | |
| font-size: 12px; | |
| letter-spacing: 0.14em; | |
| } | |
| .howto-headline { | |
| font-size: clamp(15px, 4vw, 18px); | |
| line-height: 1.08; | |
| } | |
| .howto-step-card { | |
| padding: 8px 10px; | |
| gap: 7px; | |
| min-height: 0; | |
| flex-direction: column; | |
| justify-content: flex-start; | |
| align-items: center; | |
| text-align: center; | |
| } | |
| .howto-step-icon { | |
| width: 36px; | |
| height: 36px; | |
| font-size: 18px; | |
| } | |
| .howto-step-title { | |
| font-size: 11px; | |
| } | |
| .howto-step-text, | |
| .howto-rule-text, | |
| .howto-rewards-table { | |
| font-size: 8.5px; | |
| line-height: 1.15; | |
| } | |
| .howto-step-text { | |
| text-align: center; | |
| max-width: 13ch; | |
| } | |
| .howto-step-copy { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .howto-rewards-table { | |
| width: 100%; | |
| } | |
| .howto-rule { | |
| padding: 6px 7px; | |
| gap: 6px; | |
| } | |
| .howto-rule-icon { | |
| width: 28px; | |
| height: 28px; | |
| font-size: 15px; | |
| } | |
| .howto-rule-title { | |
| font-size: 9px; | |
| } | |
| .howto-panel-title { | |
| font-size: 10px; | |
| margin: 1px 0 7px; | |
| } | |
| .howto-left, | |
| .howto-right { | |
| padding: 7px; | |
| border-radius: 14px; | |
| } | |
| .howto-rewards-table th, | |
| .howto-rewards-table td { | |
| padding: 5px 4px; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| white-space: normal; | |
| } | |
| .howto-actions { | |
| justify-content: flex-end; | |
| gap: 0; | |
| margin-top: 6px; | |
| } | |
| .howto-secondary-btn { | |
| width: min(100%, 136px); | |
| padding: 7px 9px; | |
| font-size: 9px; | |
| } | |
| .dialog-leaderboard { | |
| width: min(100%, calc(100vw - 16px)); | |
| max-height: calc(100dvh - 16px); | |
| padding: 14px 12px 12px; | |
| overflow-y: auto; | |
| overscroll-behavior: contain; | |
| } | |
| .leaderboard-logo-wrap { | |
| margin: 0 0 2px; | |
| } | |
| .leaderboard-logo { | |
| width: 24px; | |
| height: 24px; | |
| } | |
| .leaderboard-headline { | |
| font-size: clamp(15px, 4vw, 18px); | |
| } | |
| .leaderboard-my-score { | |
| margin-top: 12px; | |
| padding: 12px 14px; | |
| gap: 8px; | |
| } | |
| .leaderboard-my-score-label { | |
| font-size: 10px; | |
| letter-spacing: 0.14em; | |
| } | |
| .leaderboard-my-score-user { | |
| font-size: 11px; | |
| } | |
| .leaderboard-my-score-value { | |
| font-size: 22px; | |
| } | |
| .leaderboard-list { | |
| margin-top: 12px; | |
| display: block; | |
| } | |
| .leaderboard-table thead th { | |
| padding: 10px 10px; | |
| font-size: 10px; | |
| } | |
| .leaderboard-table tbody td { | |
| padding: 10px 10px; | |
| font-size: 12px; | |
| } | |
| .leaderboard-name { | |
| font-size: 11px; | |
| } | |
| .leaderboard-score { | |
| font-size: 14px; | |
| } | |
| .start-stage { | |
| width: 100%; | |
| height: auto; | |
| min-height: 0; | |
| padding: 16px 12px 18px; | |
| transform: none; | |
| align-items: flex-start; | |
| } | |
| .start-hero { | |
| width: min(100%, 560px); | |
| gap: 12px; | |
| } | |
| .start-mark { | |
| width: 54px; | |
| height: 54px; | |
| } | |
| .start-mark-icon { | |
| font-size: 28px; | |
| } | |
| .start-title { | |
| font-size: clamp(34px, 15vw, 60px); | |
| letter-spacing: -0.05em; | |
| } | |
| .start-tagline { | |
| font-size: 15px; | |
| gap: 0.25em 0.35em; | |
| } | |
| .start-subtitle { | |
| max-width: 28ch; | |
| font-size: 15px; | |
| } | |
| .start-preview { | |
| width: min(100%, 320px); | |
| gap: 8px; | |
| } | |
| .preview-card { | |
| border-radius: 20px; | |
| box-shadow: | |
| 0 8px 0 rgba(16, 185, 129, 0.48), | |
| 0 16px 24px rgba(0, 0, 0, 0.3); | |
| } | |
| .preview-emoji { | |
| font-size: 30px; | |
| } | |
| .start-help-button { | |
| width: 100%; | |
| padding: 13px 14px; | |
| font-size: 15px; | |
| border-radius: 20px; | |
| gap: 10px; | |
| } | |
| .start-help-icon { | |
| width: 28px; | |
| height: 28px; | |
| font-size: 16px; | |
| } | |
| .start-utility-row { | |
| width: 100%; | |
| gap: 8px; | |
| } | |
| .start-utility-row .start-help-button, | |
| .start-utility-row .music-toggle-button, | |
| .start-utility-row .leaderboard-button, | |
| .start-utility-row .high-score-pill { | |
| flex: 1 1 100%; | |
| width: 100%; | |
| min-width: 0; | |
| padding: 12px 14px; | |
| font-size: 13px; | |
| border-radius: 18px; | |
| } | |
| .start-utility-row .start-help-icon, | |
| .start-utility-row .music-toggle-icon, | |
| .start-utility-row .leaderboard-icon, | |
| .start-utility-row .high-score-icon { | |
| width: 24px; | |
| height: 24px; | |
| font-size: 14px; | |
| } | |
| .start-utility-row .high-score-copy { | |
| gap: 1px; | |
| } | |
| .start-utility-row .high-score-label { | |
| font-size: 8px; | |
| letter-spacing: 0.18em; | |
| } | |
| .start-utility-row .high-score-user { | |
| font-size: 12px; | |
| font-weight: 900; | |
| max-width: 12ch; | |
| } | |
| .start-utility-row .high-score-value { | |
| font-size: 18px; | |
| } | |
| .section-title-row { | |
| gap: 10px; | |
| } | |
| .section-title { | |
| font-size: 14px; | |
| } | |
| .start-select { | |
| padding: 16px 16px; | |
| font-size: 15px; | |
| border-radius: 16px; | |
| } | |
| .start-button { | |
| width: 100%; | |
| font-size: 16px; | |
| padding: 14px 16px; | |
| gap: 12px; | |
| border-radius: 22px; | |
| } | |
| .start-button-icon { | |
| font-size: 0.9em; | |
| } | |
| .start-note { | |
| font-size: 11px; | |
| line-height: 1.35; | |
| text-align: center; | |
| } | |
| .start-footer-row { | |
| grid-template-columns: 1fr; | |
| width: min(100%, 560px); | |
| gap: 8px; | |
| } | |
| .start-footer-item { | |
| justify-content: center; | |
| text-align: center; | |
| padding: 8px 10px; | |
| border-radius: 14px; | |
| } | |
| .start-footer-label { | |
| font-size: 9px; | |
| } | |
| .start-footer-value { | |
| font-size: 10px; | |
| } | |
| .game-stats { | |
| gap: 3px; | |
| } | |
| .stat-block { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto auto auto; | |
| justify-items: center; | |
| align-items: center; | |
| text-align: center; | |
| gap: 1px; | |
| padding: 6px 3px 5px; | |
| } | |
| .stat-icon { | |
| width: 20px; | |
| height: 20px; | |
| font-size: 10px; | |
| grid-row: 1; | |
| grid-column: 1; | |
| justify-self: center; | |
| } | |
| .stat-label { | |
| letter-spacing: 0.1em; | |
| font-size: 6px; | |
| grid-column: 1; | |
| grid-row: 2; | |
| align-self: center; | |
| line-height: 1; | |
| } | |
| .stat-value { | |
| font-size: 10px; | |
| grid-column: 1; | |
| grid-row: 3; | |
| margin-top: 0; | |
| align-self: center; | |
| line-height: 1; | |
| } | |
| .game-state-badge { | |
| padding: 5px 8px; | |
| font-size: 9px; | |
| gap: 4px; | |
| letter-spacing: 0.1em; | |
| } | |
| .game-state-badge .badge-sub { | |
| display: none; | |
| } | |
| .controls-row { | |
| grid-template-columns: 1fr; | |
| width: min(100%, 520px); | |
| margin-inline: auto; | |
| } | |
| .next-level-button { | |
| width: 100%; | |
| grid-column: auto; | |
| } | |
| .memory-grid { | |
| --grid-gap: 6px; | |
| } | |
| .memory-front, | |
| .memory-back { | |
| border-radius: 16px; | |
| } | |
| } | |
| """ | |
| # ========================================================= | |
| # JAVASCRIPT | |
| # ========================================================= | |
| JS = r""" | |
| const PREVIEW_SECONDS = 4; | |
| class MatchWiseApp { | |
| constructor() { | |
| this.root = element; | |
| this.grid = element.querySelector('#memory-grid'); | |
| this.cardsPanel = element.querySelector('.cards-panel'); | |
| this.overlay = element.querySelector('#preview-overlay'); | |
| this.transitionOverlay = element.querySelector('#transition-overlay'); | |
| this.transitionTitle = element.querySelector('#transition-title'); | |
| this.transitionSubtitle = element.querySelector('#transition-subtitle'); | |
| this.performanceMeterEl = element.querySelector('#meter'); | |
| this.performanceMeterSparkEl = element.querySelector('#spark'); | |
| this.performanceMeterKnobEl = element.querySelector('#knob'); | |
| this.boardFrame = element.querySelector('#board-frame') || element.querySelector('.board-frame'); | |
| this.memoryContainer = element.querySelector('.memory-container') || element; | |
| this.popupHost = element.querySelector('.game-layout') || this.memoryContainer; | |
| this.topUtilityBar = element.querySelector('.top-utility-bar'); | |
| this.startUtilityRow = element.querySelector('.start-utility-row'); | |
| this.countdown = element.querySelector('#preview-countdown'); | |
| this.movesEl = element.querySelector('#moves-display'); | |
| this.matchesEl = element.querySelector('#matches-display'); | |
| this.scoreEl = element.querySelector('#score-display'); | |
| this.livesEl = element.querySelector('#lives-display'); | |
| this.levelEl = element.querySelector('#level-display'); | |
| this.themeChip = element.querySelector('#theme-chip'); | |
| this.controlsRow = element.querySelector('#controls-row'); | |
| this.startOverlay = element.querySelector('#start-overlay'); | |
| this.startGameBtn = element.querySelector('#start-game-btn'); | |
| this.startNote = element.querySelector('#start-note'); | |
| this.howToPlayBtn = element.querySelector('#how-to-play-btn'); | |
| this.howToPlayOverlay = element.querySelector('#how-to-play-overlay'); | |
| this.howToPlayCloseBtn = element.querySelector('#how-to-play-close-btn'); | |
| this.controlsRow = element.querySelector('#controls-row'); | |
| this.hintCountEl = element.querySelector('#hint-count'); | |
| this.stateBadge = element.querySelector('#game-state-badge'); | |
| this.statusEl = element.querySelector('#game-status-area'); | |
| this.statusTextEl = element.querySelector('#status-text'); | |
| this.statusSubtextEl = element.querySelector('#status-subtext'); | |
| this.resetBtn = element.querySelector('#reset-btn'); | |
| this.hintBtn = element.querySelector('#hint-btn'); | |
| this.nextBtn = element.querySelector('#next-btn'); | |
| this.resetConfirmOverlay = element.querySelector('#reset-confirm-overlay'); | |
| this.resetConfirmBtn = element.querySelector('#reset-confirm-btn'); | |
| this.resetCancelBtn = element.querySelector('#reset-cancel-btn'); | |
| this.gameOverOverlay = element.querySelector('#gameover-overlay'); | |
| this.gameOverScore = element.querySelector('#gameover-score'); | |
| this.gameOverCloseBtn = element.querySelector('#gameover-close-btn'); | |
| this.challengeIntroOverlay = element.querySelector('#challenge-intro-overlay'); | |
| this.challengeCloseBtn = element.querySelector('#challenge-close-btn'); | |
| this.challengeModalTitleEl = element.querySelector('#challenge-modal-title'); | |
| this.challengeModalMessageEl = element.querySelector('#challenge-modal-message'); | |
| this.challengeModalSubtitleEl = element.querySelector('#challenge-modal-subtitle'); | |
| this.challengeOkBtn = element.querySelector('#challenge-ok-btn'); | |
| this.challengePanel = element.querySelector('#challenge-panel'); | |
| this.challengeThemeEl = element.querySelector('#challenge-theme'); | |
| this.challengeQuestionEl = element.querySelector('#challenge-question'); | |
| this.challengeOptionsEl = element.querySelector('#challenge-options'); | |
| this.challengeFocusEl = element.querySelector('#challenge-focus'); | |
| this.leaderboardBtn = element.querySelector('#leaderboard-btn'); | |
| this.leaderboardOverlay = element.querySelector('#leaderboard-overlay'); | |
| this.leaderboardCloseBtn = element.querySelector('#leaderboard-close-btn'); | |
| this.leaderboardListEl = element.querySelector('#leaderboard-list'); | |
| this.leaderboardMyScoreUserEl = element.querySelector('#leaderboard-my-score-user'); | |
| this.leaderboardMyScoreValueEl = element.querySelector('#leaderboard-my-score-value'); | |
| this.highScoreLabelEl = element.querySelector('#high-score-label'); | |
| this.highScoreUserEl = element.querySelector('#high-score-user'); | |
| this.highScoreValueEl = element.querySelector('#high-score-value'); | |
| this.homeBtn = element.querySelector('#home-btn'); | |
| this.pageMode = this.grid ? 'level' : 'start'; | |
| this.sessionHash = this.getSessionHashFromUrl(); | |
| this.state = JSON.parse(props.state_json || '{}'); | |
| this.cards = Array.isArray(this.state.cards) ? [...this.state.cards] : []; | |
| this.flipped = []; | |
| this.matched = []; | |
| const initialHints = Number.parseInt(props.hints ?? this.state.hints ?? '1', 10); | |
| const initialMaxHints = Number.parseInt(props.max_hints ?? this.state.max_hints ?? '5', 10); | |
| this.hints = Number.isNaN(initialHints) ? 1 : initialHints; | |
| this.maxHints = Number.isNaN(initialMaxHints) ? 5 : initialMaxHints; | |
| this.hinting = false; | |
| this.hintedPairs = new Set(); | |
| this.moves = 0; | |
| this.locked = false; | |
| this.previewTimer = null; | |
| this.previewShuffleTimer = null; | |
| this.previewShuffleAnimationMs = 520; | |
| this.previewFogTimers = []; | |
| this.previewHideTimers = []; | |
| this.previewCount = PREVIEW_SECONDS; | |
| this.pendingTransition = false; | |
| this.levelStartTime = null; | |
| this.transitionShownAt = 0; | |
| this.transitionHideTimer = null; | |
| this.comboStreak = 0; | |
| this.lastRenderedHints = null; | |
| this.lastRenderedLives = null; | |
| this.lastRenderedPerformanceMeter = null; | |
| this.peekGlowTimer = null; | |
| this.livesGlowTimer = null; | |
| this.wrongMatchTimer = null; | |
| this.distractorSparkleTimer = null; | |
| this.distractorSparkleClearTimer = null; | |
| this.recentlyFlippedCardIndexes = []; | |
| this.peekCooldownMoves = Number(this.state.peek_cooldown_moves || 0); | |
| this.challengeStarted = false; | |
| this.challengeSelectionLocked = false; | |
| this.challengeResultIndex = null; | |
| this.challengeCorrectIndex = null; | |
| this.challengeOptions = []; | |
| this.leaderboardData = { entries: [], current_user: {} }; | |
| this.leaderboardRefreshTimer = null; | |
| this.loginButtonObserver = null; | |
| this.loginButtonRefreshEl = null; | |
| this.musicToggleStartBtn = element.querySelector('#music-toggle-start-btn'); | |
| this.musicToggleTopBtn = element.querySelector('#music-toggle-top-btn'); | |
| this.musicToggleButtons = []; | |
| this.musicEnabled = this.loadMusicPreference(); | |
| this.audioContext = null; | |
| this.injectPopupRuntimeStyles(); | |
| this.ensurePopupRuntimeContainer(); | |
| this.installEvents(); | |
| this.installButtonHandlers(); | |
| this.syncMusicToggleButtons(); | |
| this.attachLoginButtonToTopBar(); | |
| requestAnimationFrame(() => this.attachLoginButtonToTopBar()); | |
| this.installLeaderboardRefreshHooks(); | |
| void this.refreshLeaderboardData(); | |
| this.installViewportHandlers(); | |
| this.hideResetConfirm(); | |
| this.hideGameOverModal(); | |
| this.stopDistractorSparkles(); | |
| this.hideHowToPlayModal(); | |
| if (this.pageMode === 'start') { | |
| if (this.startNote) { | |
| this.startNote.textContent = 'Press Start to generate your first level.'; | |
| } | |
| return; | |
| } | |
| this.root.classList.add('level-loading'); | |
| if (this.memoryContainer && this.memoryContainer !== this.root) { | |
| this.memoryContainer.classList.add('level-loading'); | |
| } | |
| this.injectPopupRuntimeStyles(); | |
| this.ensurePopupRuntimeContainer(); | |
| void this.bootstrapLevelPage(); | |
| } | |
| syncControlsVisibility() { | |
| if (!this.controlsRow) return; | |
| this.controlsRow.style.setProperty('display', this.state.game_started ? 'grid' : 'none', 'important'); | |
| } | |
| resetComboFeedback() { | |
| this.comboStreak = 0; | |
| } | |
| clearWrongMatchTimer() { | |
| if (this.wrongMatchTimer) { | |
| clearTimeout(this.wrongMatchTimer); | |
| this.wrongMatchTimer = null; | |
| } | |
| } | |
| getSessionHashFromUrl() { | |
| try { | |
| const url = new URL(window.location.href); | |
| return String(url.searchParams.get('session_hash') || '').trim(); | |
| } catch (err) { | |
| return ''; | |
| } | |
| } | |
| injectPopupRuntimeStyles() { | |
| if (document.getElementById('pop-runtime-style')) { | |
| return; | |
| } | |
| const style = document.createElement('style'); | |
| style.id = 'pop-runtime-style'; | |
| style.textContent = ` | |
| .pop-wrapper-runtime { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 999999; | |
| width: 100%; | |
| height: 100%; | |
| overflow: visible; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .pop-text-runtime { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| transform: translate(-50%, -50%) scale(0) rotate(-15deg); | |
| opacity: 0; | |
| font-family: Impact, "Arial Black", system-ui, sans-serif; | |
| font-size: clamp(28px, 5.6vw, 72px); | |
| font-weight: 900; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| white-space: pre-line; | |
| line-height: 1; | |
| text-align: center; | |
| max-width: min(92vw, 720px); | |
| -webkit-text-stroke: 3px #ffffff; | |
| filter: | |
| drop-shadow(0 4px 0px rgba(0, 0, 0, 0.45)) | |
| drop-shadow(0 10px 16px rgba(0, 0, 0, 0.35)); | |
| will-change: transform, opacity; | |
| } | |
| .pop-text-runtime.animate { | |
| animation: popBounceRuntime 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; | |
| } | |
| .pop-good-runtime { | |
| background: linear-gradient(to bottom, #ffffff 18%, #6CF527 58%, #15803d 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .pop-win-runtime { | |
| background: linear-gradient(to bottom, #ffffff 18%, #ffe66d 42%, #fb5607 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .pop-bad-runtime { | |
| background: linear-gradient(to bottom, #ffffff 18%, #f87171 58%, #dc2626 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .memory-card.distractor-sparkle .memory-front::after { | |
| content: '✦'; | |
| position: absolute; | |
| right: 9%; | |
| top: 8%; | |
| width: 28%; | |
| height: 28%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 999px; | |
| color: rgba(255, 255, 255, 0.95); | |
| background: radial-gradient(circle, rgba(255,255,255,0.78), rgba(255,255,255,0.12) 58%, transparent 72%); | |
| filter: drop-shadow(0 0 10px rgba(255,255,255,0.85)); | |
| font-size: clamp(14px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.18), 28px); | |
| pointer-events: none; | |
| animation: distractorSparkleRuntime 680ms ease-out forwards; | |
| z-index: 4; | |
| } | |
| .memory-card.distractor-sparkle .memory-back::after { | |
| content: '✦'; | |
| position: absolute; | |
| left: 9%; | |
| top: 8%; | |
| width: 28%; | |
| height: 28%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 999px; | |
| color: rgba(255, 255, 255, 0.95); | |
| background: radial-gradient(circle, rgba(255,255,255,0.7), rgba(255,255,255,0.1) 58%, transparent 72%); | |
| filter: drop-shadow(0 0 10px rgba(255,255,255,0.75)); | |
| font-size: clamp(14px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.16), 26px); | |
| pointer-events: none; | |
| animation: distractorSparkleRuntime 680ms ease-out forwards; | |
| z-index: 4; | |
| } | |
| .memory-container.preview-fog-blink .memory-card.preview-locked .memory-back, | |
| .matchwise-root.preview-fog-blink .memory-card.preview-locked .memory-back { | |
| animation: previewFogBlinkRuntime 260ms ease-in-out forwards; | |
| } | |
| .memory-card.preview-hidden-card .memory-back { | |
| animation: previewCardHideRuntime 620ms ease-in-out forwards; | |
| } | |
| .memory-card.preview-hidden-card::after { | |
| content: '☁️'; | |
| position: absolute; | |
| inset: 10%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 24px; | |
| font-size: clamp(24px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.28), 46px); | |
| background: radial-gradient(circle, rgba(255,255,255,0.95), rgba(255,255,255,0.62) 55%, rgba(255,255,255,0.14)); | |
| filter: drop-shadow(0 10px 18px rgba(132, 90, 255, 0.2)); | |
| pointer-events: none; | |
| z-index: 8; | |
| animation: previewCloudCoverRuntime 620ms ease-in-out forwards; | |
| } | |
| @keyframes distractorSparkleRuntime { | |
| 0% { transform: scale(0.35) rotate(-18deg); opacity: 0; } | |
| 28% { transform: scale(1.18) rotate(10deg); opacity: 1; } | |
| 100% { transform: scale(0.65) rotate(24deg); opacity: 0; } | |
| } | |
| @keyframes previewFogBlinkRuntime { | |
| 0% { filter: blur(0px) brightness(1); opacity: 1; } | |
| 45% { filter: blur(5px) brightness(0.94); opacity: 0.72; } | |
| 100% { filter: blur(0px) brightness(1); opacity: 1; } | |
| } | |
| @keyframes previewCardHideRuntime { | |
| 0% { filter: blur(0px); opacity: 1; } | |
| 18% { filter: blur(3px); opacity: 0.18; } | |
| 82% { filter: blur(3px); opacity: 0.18; } | |
| 100% { filter: blur(0px); opacity: 1; } | |
| } | |
| @keyframes previewCloudCoverRuntime { | |
| 0% { transform: scale(0.86); opacity: 0; } | |
| 18% { transform: scale(1); opacity: 1; } | |
| 82% { transform: scale(1); opacity: 1; } | |
| 100% { transform: scale(1.06); opacity: 0; } | |
| } | |
| @keyframes popBounceRuntime { | |
| 0% { | |
| transform: translate(-50%, -50%) scale(0) rotate(-15deg); | |
| opacity: 0; | |
| } | |
| 15% { | |
| transform: translate(-50%, -50%) scale(1.35) rotate(5deg); | |
| opacity: 1; | |
| } | |
| 30% { | |
| transform: translate(-50%, -50%) scale(0.92) rotate(-4deg); | |
| opacity: 1; | |
| } | |
| 45% { | |
| transform: translate(-50%, -50%) scale(1.08) rotate(3deg); | |
| opacity: 1; | |
| } | |
| 60% { | |
| transform: translate(-50%, -50%) scale(0.98) rotate(-1deg); | |
| opacity: 1; | |
| } | |
| 72% { | |
| transform: translate(-50%, -50%) scale(1) rotate(0deg); | |
| opacity: 1; | |
| } | |
| 100% { | |
| transform: translate(-50%, -85%) scale(0.78) rotate(-5deg); | |
| opacity: 0; | |
| } | |
| } | |
| @media (max-width: 520px) { | |
| .pop-text-runtime { | |
| font-size: clamp(12px, 4.6vw, 24px); | |
| letter-spacing: 0.2px; | |
| max-width: calc(100vw - 24px); | |
| -webkit-text-stroke: 1px #ffffff; | |
| } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| ensurePopupRuntimeContainer() { | |
| const host = this.popupHost || this.memoryContainer; | |
| if (!host) return; | |
| let popupContainer = host.querySelector('.pop-wrapper-runtime'); | |
| if (!popupContainer) { | |
| popupContainer = document.createElement('div'); | |
| popupContainer.className = 'pop-wrapper-runtime'; | |
| host.appendChild(popupContainer); | |
| } | |
| this.popupContainer = popupContainer; | |
| } | |
| loadMusicPreference() { | |
| try { | |
| const stored = window.localStorage.getItem('matchwise_music_enabled'); | |
| if (stored === null) return true; | |
| return stored !== '0' && stored !== 'false'; | |
| } catch (err) { | |
| return true; | |
| } | |
| } | |
| saveMusicPreference(enabled) { | |
| try { | |
| window.localStorage.setItem('matchwise_music_enabled', enabled ? '1' : '0'); | |
| } catch (err) { | |
| // ignore storage failures | |
| } | |
| } | |
| syncMusicToggleButtons() { | |
| this.musicToggleButtons = [ | |
| this.musicToggleStartBtn, | |
| this.musicToggleTopBtn, | |
| ].filter(Boolean); | |
| this.musicToggleButtons.forEach((button) => { | |
| const textEl = button.querySelector('.music-toggle-text'); | |
| if (textEl) { | |
| textEl.textContent = this.musicEnabled ? 'Sound: ON' : 'Sound: OFF'; | |
| } | |
| button.classList.toggle('music-toggle-off', !this.musicEnabled); | |
| button.setAttribute('aria-pressed', this.musicEnabled ? 'true' : 'false'); | |
| }); | |
| } | |
| setMusicEnabled(enabled) { | |
| this.musicEnabled = Boolean(enabled); | |
| this.saveMusicPreference(this.musicEnabled); | |
| this.syncMusicToggleButtons(); | |
| } | |
| toggleMusic() { | |
| this.setMusicEnabled(!this.musicEnabled); | |
| } | |
| getAudioContext() { | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| if (!AudioContextClass) return null; | |
| if (!this.audioContext) { | |
| this.audioContext = new AudioContextClass(); | |
| } | |
| if (this.audioContext.state === 'suspended') { | |
| void this.audioContext.resume(); | |
| } | |
| return this.audioContext; | |
| } | |
| playCorrectSound() { | |
| if (!this.musicEnabled) return; | |
| const audioCtx = this.getAudioContext(); | |
| if (!audioCtx) return; | |
| const now = audioCtx.currentTime; | |
| const osc1 = audioCtx.createOscillator(); | |
| const gain1 = audioCtx.createGain(); | |
| osc1.type = 'sine'; | |
| osc1.frequency.setValueAtTime(659, now); | |
| gain1.gain.setValueAtTime(0.3, now); | |
| gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.12); | |
| osc1.connect(gain1); | |
| gain1.connect(audioCtx.destination); | |
| const osc2 = audioCtx.createOscillator(); | |
| const gain2 = audioCtx.createGain(); | |
| osc2.type = 'sine'; | |
| osc2.frequency.setValueAtTime(880, now + 0.08); | |
| gain2.gain.setValueAtTime(0.3, now + 0.08); | |
| gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35); | |
| osc2.connect(gain2); | |
| gain2.connect(audioCtx.destination); | |
| osc1.start(now); | |
| osc1.stop(now + 0.12); | |
| osc2.start(now + 0.08); | |
| osc2.stop(now + 0.35); | |
| } | |
| playWrongSound() { | |
| if (!this.musicEnabled) return; | |
| const audioCtx = this.getAudioContext(); | |
| if (!audioCtx) return; | |
| const now = audioCtx.currentTime; | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.type = 'triangle'; | |
| osc.frequency.setValueAtTime(150, now); | |
| osc.frequency.linearRampToValueAtTime(70, now + 0.25); | |
| gain.gain.setValueAtTime(0.4, now); | |
| gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| osc.start(now); | |
| osc.stop(now + 0.25); | |
| } | |
| async bootstrapLevelPage() { | |
| const requestedSessionHash = this.getSessionHashFromUrl() || this.sessionHash; | |
| if (!requestedSessionHash) { | |
| window.location.replace('/'); | |
| return; | |
| } | |
| this.sessionHash = requestedSessionHash; | |
| try { | |
| const response = this.normalizeServerResponse( | |
| await server.load_level_state(JSON.stringify({ | |
| session_hash: this.sessionHash, | |
| })) | |
| ); | |
| if (!response || !response.state) { | |
| if (response && response.error === 'missing_session') { | |
| window.location.replace('/'); | |
| return; | |
| } | |
| throw new Error(response?.error || 'Unable to load session'); | |
| } | |
| if (response.session_hash) { | |
| this.sessionHash = String(response.session_hash); | |
| const nextUrl = new URL(window.location.href); | |
| nextUrl.pathname = '/level'; | |
| nextUrl.searchParams.set('session_hash', this.sessionHash); | |
| window.history.replaceState({}, '', nextUrl.toString()); | |
| } | |
| this.state = { | |
| ...this.state, | |
| ...response.state, | |
| game_started: true, | |
| }; | |
| this.renderState(response.state, true); | |
| this.lastRenderedHints = this.hints; | |
| this.lastRenderedLives = Number(this.state.lives ?? 0); | |
| this.syncControlsVisibility(); | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| this.root.classList.remove('level-loading'); | |
| if (this.memoryContainer && this.memoryContainer !== this.root) { | |
| this.memoryContainer.classList.remove('level-loading'); | |
| } | |
| this.startPreview(); | |
| }); | |
| }); | |
| } catch (err) { | |
| console.error('load_level_state failed', err); | |
| this.root.classList.remove('level-loading'); | |
| if (this.memoryContainer && this.memoryContainer !== this.root) { | |
| this.memoryContainer.classList.remove('level-loading'); | |
| } | |
| this.setStatus('Could not load the level.', 'error', 'Please return to the start page and try again.'); | |
| } | |
| } | |
| triggerPopup(text, styleClass = 'pop-good-runtime') { | |
| if (!this.popupContainer) { | |
| this.ensurePopupRuntimeContainer(); | |
| } | |
| if (!this.popupContainer) return; | |
| const popup = document.createElement('div'); | |
| popup.className = `pop-text-runtime ${styleClass}`; | |
| popup.textContent = String(text || '').trim() || 'Tasty!'; | |
| this.popupContainer.appendChild(popup); | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| popup.classList.add('animate'); | |
| }); | |
| }); | |
| window.setTimeout(() => { | |
| if (popup && popup.parentNode) { | |
| popup.parentNode.removeChild(popup); | |
| } | |
| }, 1300); | |
| } | |
| showWrongMatchPopup() { | |
| this.triggerPopup('Wrong Match:\n -1 lives!', 'pop-bad-runtime'); | |
| } | |
| showLevelCompletePopup(message = 'Level Complete!') { | |
| this.triggerPopup(message, 'pop-win-runtime'); | |
| } | |
| showFeedbackOverlay(kicker = '', text = '', type = 'positive', lock = false) { | |
| if (type === 'negative') { | |
| this.showWrongMatchPopup(); | |
| return; | |
| } | |
| if (type === 'level') { | |
| this.showLevelCompletePopup(text || 'Level Complete!'); | |
| return; | |
| } | |
| if (type === 'combo' || type === 'positive') { | |
| this.triggerPopup('Matched!', 'pop-good-runtime'); | |
| return; | |
| } | |
| this.triggerPopup('Matched!', 'pop-good-runtime'); | |
| } | |
| showComboFeedback() { | |
| this.triggerPopup('Combo!', 'pop-good-runtime'); | |
| } | |
| hexToRgb(hex) { | |
| const clean = String(hex || '').replace('#', ''); | |
| return { | |
| r: parseInt(clean.substring(0, 2), 16), | |
| g: parseInt(clean.substring(2, 4), 16), | |
| b: parseInt(clean.substring(4, 6), 16), | |
| }; | |
| } | |
| rgbToHex(r, g, b) { | |
| return `#${[r, g, b].map((value) => Number(value).toString(16).padStart(2, '0')).join('')}`; | |
| } | |
| mixColor(colorA, colorB, amount) { | |
| const a = this.hexToRgb(colorA); | |
| const b = this.hexToRgb(colorB); | |
| const mix = Math.max(0, Math.min(1, Number(amount))); | |
| const r = Math.round(a.r + (b.r - a.r) * mix); | |
| const g = Math.round(a.g + (b.g - a.g) * mix); | |
| const blue = Math.round(a.b + (b.b - a.b) * mix); | |
| return this.rgbToHex(r, g, blue); | |
| } | |
| hexToRgba(hex, alpha) { | |
| const rgb = this.hexToRgb(hex); | |
| return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; | |
| } | |
| getMeterGradientColor(value) { | |
| const safeValue = Math.max(0, Math.min(100, Number(value))); | |
| const gradientStops = [ | |
| { point: 0, color: '#17a34a' }, | |
| { point: 28, color: '#97c51e' }, | |
| { point: 50, color: '#ffd522' }, | |
| { point: 72, color: '#ff981f' }, | |
| { point: 100, color: '#ff2e25' }, | |
| ]; | |
| for (let i = 0; i < gradientStops.length - 1; i += 1) { | |
| const start = gradientStops[i]; | |
| const end = gradientStops[i + 1]; | |
| if (safeValue >= start.point && safeValue <= end.point) { | |
| const localAmount = (safeValue - start.point) / (end.point - start.point); | |
| return this.mixColor(start.color, end.color, localAmount); | |
| } | |
| } | |
| return gradientStops[gradientStops.length - 1].color; | |
| } | |
| updatePerformanceMeter(value, animate = false) { | |
| const safeValue = Math.max(0, Math.min(100, Number(value || 0))); | |
| const dynamicColor = this.getMeterGradientColor(safeValue); | |
| const dynamicGlow = this.hexToRgba(dynamicColor, 0.72); | |
| if (this.performanceMeterEl) { | |
| this.performanceMeterEl.style.setProperty('--meter-value', `${safeValue}%`); | |
| this.performanceMeterEl.style.setProperty('--knob-color', dynamicColor); | |
| this.performanceMeterEl.style.setProperty('--knob-glow', dynamicGlow); | |
| } | |
| if (!animate) { | |
| return; | |
| } | |
| if (this.performanceMeterKnobEl) { | |
| this.performanceMeterKnobEl.classList.remove('bump'); | |
| void this.performanceMeterKnobEl.offsetWidth; | |
| this.performanceMeterKnobEl.classList.add('bump'); | |
| } | |
| if (this.performanceMeterEl) { | |
| this.performanceMeterEl.classList.remove('flash'); | |
| void this.performanceMeterEl.offsetWidth; | |
| this.performanceMeterEl.classList.add('flash'); | |
| } | |
| } | |
| installEvents() { | |
| this.root.addEventListener('click', async (e) => { | |
| const card = e.target.closest('.memory-card'); | |
| if (card && !this.locked) { | |
| await this.handleCardClick(card); | |
| return; | |
| } | |
| }); | |
| } | |
| installButtonHandlers() { | |
| if (this.resetBtn) { | |
| this.resetBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (this.state.game_over || this.state.status === 'game_over') { | |
| await this.resetGame(); | |
| return; | |
| } | |
| this.showResetConfirm(); | |
| }); | |
| } | |
| if (this.resetConfirmBtn) { | |
| this.resetConfirmBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.hideResetConfirm(); | |
| await this.resetGame(); | |
| }); | |
| } | |
| if (this.resetCancelBtn) { | |
| this.resetCancelBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.hideResetConfirm(); | |
| }); | |
| } | |
| if (this.homeBtn) { | |
| this.homeBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| window.location.assign('/'); | |
| }); | |
| } | |
| if (this.gameOverCloseBtn) { | |
| this.gameOverCloseBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.hideGameOverModal(); | |
| }); | |
| } | |
| if (this.challengeCloseBtn) { | |
| this.challengeCloseBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await this.openChallengeQuestion(); | |
| }); | |
| } | |
| if (this.challengeOkBtn) { | |
| this.challengeOkBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await this.openChallengeQuestion(); | |
| }); | |
| } | |
| if (this.hintBtn) { | |
| this.hintBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await this.useHint(); | |
| }); | |
| } | |
| if (this.nextBtn) { | |
| this.nextBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await this.advanceLevel(); | |
| }); | |
| } | |
| if (this.startGameBtn) { | |
| this.startGameBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await this.startGame(); | |
| }); | |
| } | |
| if (this.howToPlayBtn) { | |
| this.howToPlayBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.showHowToPlayModal(); | |
| }); | |
| } | |
| if (this.musicToggleStartBtn) { | |
| this.musicToggleStartBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.toggleMusic(); | |
| }); | |
| } | |
| if (this.musicToggleTopBtn) { | |
| this.musicToggleTopBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.toggleMusic(); | |
| }); | |
| } | |
| this.installLeaderboardRefreshHooks(); | |
| if (this.leaderboardBtn) { | |
| this.leaderboardBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await this.openLeaderboardModal(); | |
| }); | |
| } | |
| if (this.howToPlayCloseBtn) { | |
| this.howToPlayCloseBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.hideHowToPlayModal(); | |
| }); | |
| } | |
| if (this.howToPlayOverlay) { | |
| this.howToPlayOverlay.addEventListener('click', (e) => { | |
| if (e.target === this.howToPlayOverlay) { | |
| this.hideHowToPlayModal(); | |
| } | |
| }); | |
| } | |
| if (this.leaderboardCloseBtn) { | |
| this.leaderboardCloseBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| this.hideLeaderboardModal(); | |
| }); | |
| } | |
| if (this.leaderboardOverlay) { | |
| this.leaderboardOverlay.addEventListener('click', (e) => { | |
| if (e.target === this.leaderboardOverlay) { | |
| this.hideLeaderboardModal(); | |
| } | |
| }); | |
| } | |
| if (this.challengeOptionsEl) { | |
| this.challengeOptionsEl.addEventListener('click', async (e) => { | |
| const button = e.target.closest('.challenge-option'); | |
| if (!button) return; | |
| await this.handleChallengeChoice(button); | |
| }); | |
| } | |
| } | |
| installViewportHandlers() { | |
| const rerenderCardSize = () => { | |
| this.updateCardSize(); | |
| }; | |
| window.addEventListener('resize', rerenderCardSize, { passive: true }); | |
| window.addEventListener('orientationchange', () => { | |
| setTimeout(rerenderCardSize, 150); | |
| }, { passive: true }); | |
| } | |
| attachLoginButtonToTopBar() { | |
| if (!this.topUtilityBar) return; | |
| const loginButton = document.getElementById('hf-login-button'); | |
| if (!loginButton) return; | |
| if (loginButton.parentElement === this.topUtilityBar) return; | |
| this.topUtilityBar.appendChild(loginButton); | |
| } | |
| scheduleLeaderboardRefresh(delayMs = 0) { | |
| if (this.leaderboardRefreshTimer) { | |
| clearTimeout(this.leaderboardRefreshTimer); | |
| this.leaderboardRefreshTimer = null; | |
| } | |
| this.leaderboardRefreshTimer = setTimeout(() => { | |
| this.leaderboardRefreshTimer = null; | |
| void this.refreshLeaderboardData(); | |
| }, Math.max(0, Number(delayMs) || 0)); | |
| } | |
| installLeaderboardRefreshHooks() { | |
| const loginButton = document.getElementById('hf-login-button'); | |
| if (!loginButton || loginButton === this.loginButtonRefreshEl) return; | |
| if (this.loginButtonObserver) { | |
| this.loginButtonObserver.disconnect(); | |
| this.loginButtonObserver = null; | |
| } | |
| this.loginButtonRefreshEl = loginButton; | |
| loginButton.addEventListener('click', () => { | |
| this.scheduleLeaderboardRefresh(900); | |
| }); | |
| this.loginButtonObserver = new MutationObserver(() => { | |
| this.scheduleLeaderboardRefresh(250); | |
| }); | |
| this.loginButtonObserver.observe(loginButton, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| attributes: true, | |
| }); | |
| } | |
| updateLeaderboardDisplay() { | |
| const signedIn = Boolean(this.state.leaderboard_signed_in); | |
| const username = String(this.state.leaderboard_username || '').trim(); | |
| const highScore = Number(this.state.leaderboard_high_score || 0); | |
| if (this.highScoreLabelEl) { | |
| this.highScoreLabelEl.textContent = signedIn ? 'My High Score' : 'Guest Score'; | |
| } | |
| if (this.highScoreUserEl) { | |
| this.highScoreUserEl.textContent = username || (signedIn ? 'Signed in' : 'Guest'); | |
| } | |
| if (this.highScoreValueEl) { | |
| this.highScoreValueEl.textContent = String(highScore); | |
| } | |
| if (this.leaderboardMyScoreUserEl) { | |
| this.leaderboardMyScoreUserEl.textContent = username || (signedIn ? 'Signed in' : 'Guest'); | |
| } | |
| if (this.leaderboardMyScoreValueEl) { | |
| this.leaderboardMyScoreValueEl.textContent = String(highScore); | |
| } | |
| } | |
| renderLeaderboardEntries(entries) { | |
| if (!this.leaderboardListEl) return; | |
| const rows = this.getRankedLeaderboardEntries(entries); | |
| if (!rows.length) { | |
| this.leaderboardListEl.innerHTML = '<div class="leaderboard-empty">No scores yet. Be the first to play.</div>'; | |
| return; | |
| } | |
| const currentUsername = String(this.state.leaderboard_username || '').trim().toLowerCase(); | |
| this.leaderboardListEl.innerHTML = ` | |
| <div class="leaderboard-table-wrap"> | |
| <table class="leaderboard-table"> | |
| <thead> | |
| <tr> | |
| <th>Rank</th> | |
| <th>HF Username</th> | |
| <th>High Score</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${rows.map((entry) => { | |
| const username = String(entry.username || 'Guest').trim(); | |
| const score = Number(entry.score || 0); | |
| const isCurrentUser = currentUsername && username.toLowerCase() === currentUsername; | |
| return ` | |
| <tr class="${isCurrentUser ? 'current-user' : ''}"> | |
| <td class="leaderboard-rank-cell">#${Number(entry.rank || 0)}</td> | |
| <td class="leaderboard-user-cell">${this.escapeHtml(username)}${isCurrentUser ? ' (You)' : ''}</td> | |
| <td class="leaderboard-score-cell">${score}</td> | |
| </tr> | |
| `; | |
| }).join('')} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| getRankedLeaderboardEntries(entries) { | |
| const rows = Array.isArray(entries) ? entries : []; | |
| return rows | |
| .map((entry) => ({ | |
| username: String(entry?.username || 'Guest').trim(), | |
| score: Number(entry?.score || 0), | |
| })) | |
| .filter((entry) => entry.username) | |
| .sort((a, b) => { | |
| if (b.score !== a.score) return b.score - a.score; | |
| return a.username.localeCompare(b.username, undefined, { sensitivity: 'base' }); | |
| }) | |
| .map((entry, index) => ({ | |
| ...entry, | |
| rank: index + 1, | |
| })); | |
| } | |
| async refreshLeaderboardData() { | |
| try { | |
| const response = this.normalizeServerResponse( | |
| await server.get_leaderboard_data(JSON.stringify({ | |
| game_id: this.state.game_id || '', | |
| })) | |
| ); | |
| if (!response) return null; | |
| const currentUser = response.current_user || {}; | |
| this.state.leaderboard_signed_in = Boolean(currentUser.leaderboard_signed_in); | |
| this.state.leaderboard_username = String(currentUser.leaderboard_username || ''); | |
| this.state.leaderboard_high_score = Number(currentUser.leaderboard_high_score || 0); | |
| this.state.leaderboard_saved = Boolean(currentUser.leaderboard_saved); | |
| this.leaderboardData = { | |
| entries: Array.isArray(response.entries) ? response.entries : [], | |
| current_user: currentUser, | |
| }; | |
| this.updateLeaderboardDisplay(); | |
| this.renderLeaderboardEntries(this.leaderboardData.entries); | |
| return this.leaderboardData; | |
| } catch (err) { | |
| return null; | |
| } | |
| } | |
| async openLeaderboardModal() { | |
| this.showLeaderboardModal(); | |
| if (this.leaderboardListEl) { | |
| this.leaderboardListEl.innerHTML = '<div class="leaderboard-empty">Loading leaderboard...</div>'; | |
| } | |
| await this.refreshLeaderboardData(); | |
| this.showLeaderboardModal(); | |
| } | |
| snapshot() { | |
| const activeLevel = { | |
| ...(this.activeLevel || {}), | |
| blueprint: { | |
| ...((this.activeLevel && this.activeLevel.blueprint) || {}), | |
| }, | |
| cards: [...(this.cards || [])], | |
| }; | |
| return { | |
| ...this.state, | |
| active_level: activeLevel, | |
| cards: [...this.cards], | |
| flipped: [...this.flipped], | |
| matched: [...this.matched], | |
| moves: this.moves, | |
| hints: this.hints, | |
| max_hints: this.maxHints, | |
| performance_meter: this.state.performance_meter, | |
| challenge_due: this.state.challenge_due, | |
| game_started: this.state.game_started, | |
| status: this.state.status, | |
| level_complete: this.state.level_complete, | |
| game_over: this.state.game_over, | |
| preview_seconds: PREVIEW_SECONDS, | |
| }; | |
| } | |
| async syncBackend() { | |
| try { | |
| await server.sync_state(JSON.stringify(this.snapshot())); | |
| } catch (err) { | |
| return; | |
| } | |
| } | |
| setBadge(text, kind, subtext = '') { | |
| if (!this.stateBadge) return; | |
| if (kind === 'complete' && subtext) { | |
| this.stateBadge.innerHTML = `<span class="badge-main">${text}</span><span class="badge-sub">${subtext}</span>`; | |
| } else { | |
| this.stateBadge.textContent = text; | |
| } | |
| this.stateBadge.classList.remove('playing', 'complete', 'gameover', 'challenge'); | |
| if (kind) this.stateBadge.classList.add(kind); | |
| } | |
| getCompletionStarCount() { | |
| const totalPairs = Math.max(1, Number(this.state.total_pairs || this.cards.length / 2 || 1)); | |
| const moves = Math.max(0, Number(this.state.moves ?? this.moves ?? 0)); | |
| const wrongAttempts = Math.max(0, Number(this.state.wrong_attempts || 0)); | |
| if (wrongAttempts === 0 && moves === totalPairs) { | |
| return 3; | |
| } | |
| if (wrongAttempts <= 1 && moves <= totalPairs + 2) { | |
| return 2; | |
| } | |
| return 1; | |
| } | |
| getCompletionBadgeText() { | |
| return '⭐'.repeat(this.getCompletionStarCount()); | |
| } | |
| getCompletionRatingLabel() { | |
| const stars = this.getCompletionStarCount(); | |
| if (stars >= 3) return 'Perfect'; | |
| if (stars === 2) return 'Good'; | |
| return 'Average'; | |
| } | |
| setStatus(text, kind = '', subtext = '') { | |
| if (this.statusTextEl) this.statusTextEl.textContent = text; | |
| if (this.statusSubtextEl) this.statusSubtextEl.textContent = subtext; | |
| if (!this.statusEl) return; | |
| this.statusEl.classList.remove('show', 'error', 'win'); | |
| if (kind) this.statusEl.classList.add(kind); | |
| } | |
| pulseHudGlow(target, glowClass, timerKey) { | |
| if (!target) return; | |
| const timerName = `${timerKey}GlowTimer`; | |
| if (this[timerName]) { | |
| clearTimeout(this[timerName]); | |
| this[timerName] = null; | |
| } | |
| target.classList.remove(glowClass); | |
| void target.offsetWidth; | |
| target.classList.add(glowClass); | |
| this[timerName] = setTimeout(() => { | |
| target.classList.remove(glowClass); | |
| this[timerName] = null; | |
| }, 850); | |
| } | |
| getEffectiveMaxHints() { | |
| const baseMax = Math.max(1, Number(this.maxHints || this.state.max_hints || 5)); | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| // Hint cap gently tightens as levels progress. | |
| // L1-5: normal cap, L6-10: -1, L11-16: -2, L17+: -3. | |
| const reduction = level <= 5 ? 0 : level <= 10 ? 1 : level <= 16 ? 2 : 3; | |
| return Math.max(1, baseMax - reduction); | |
| } | |
| syncProgressiveHintLimit(announce = false) { | |
| const effectiveMaxHints = this.getEffectiveMaxHints(); | |
| const previousHints = Number(this.hints || 0); | |
| this.hints = Math.max(0, Math.min(effectiveMaxHints, previousHints)); | |
| this.state.hints = this.hints; | |
| if (announce && previousHints > this.hints) { | |
| this.setStatus( | |
| 'Harder level: peeks are rarer now.', | |
| 'show', | |
| `Peek limit for this level: ${this.hints} / ${effectiveMaxHints}` | |
| ); | |
| } | |
| return effectiveMaxHints; | |
| } | |
| getHintCooldownLengthMoves() { | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| // Early levels stay friendly. Later levels make Peek a small decision. | |
| if (level < 7) return 0; | |
| if (level < 14) return 1; | |
| return 2; | |
| } | |
| decrementPeekCooldownAfterMove() { | |
| if (this.peekCooldownMoves > 0) { | |
| this.peekCooldownMoves = Math.max(0, this.peekCooldownMoves - 1); | |
| this.state.peek_cooldown_moves = this.peekCooldownMoves; | |
| this.updateHintsDisplay(); | |
| } | |
| } | |
| getWrongFlipBackDelayMs() { | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| // Level 1-5: 900ms, Level 6-12: 700ms, Level 13+: 550ms. | |
| if (level <= 5) return 900; | |
| if (level <= 12) return 700; | |
| return 550; | |
| } | |
| getCompletedChallengeLevels() { | |
| return Math.max(0, Number(this.state.completed_challenge_levels || 0)); | |
| } | |
| rememberRecentlyFlipped(index) { | |
| const value = Number(index); | |
| if (!Number.isFinite(value)) return; | |
| this.recentlyFlippedCardIndexes = [ | |
| value, | |
| ...this.recentlyFlippedCardIndexes.filter((item) => item !== value), | |
| ].slice(0, 6); | |
| } | |
| getDistractorSparkleIntervalMs() { | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| // Very light distraction: slower early, slightly quicker later. | |
| return Math.max(1700, 3900 - Math.min(1700, (level - 1) * 95)); | |
| } | |
| triggerDistractorSparkleOnce() { | |
| if (!this.grid || this.state.status !== 'playing' || this.locked || this.state.game_over || this.state.level_complete) { | |
| return; | |
| } | |
| const faceDownCandidates = Array.from(this.grid.querySelectorAll('.memory-card:not(.matched):not(.flipped):not(.preview-locked)')); | |
| const recentlyFlippedCandidates = this.recentlyFlippedCardIndexes | |
| .map((index) => this.grid.querySelector(`[data-index="${index}"]`)) | |
| .filter((card) => card && !card.classList.contains('matched') && !card.classList.contains('preview-locked')); | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| const canUseRecentDecoy = level >= 8 && recentlyFlippedCandidates.length > 0; | |
| const useRecentDecoy = canUseRecentDecoy && Math.random() < 0.34; | |
| const candidates = useRecentDecoy ? recentlyFlippedCandidates : faceDownCandidates; | |
| if (!candidates.length) return; | |
| const card = candidates[Math.floor(Math.random() * candidates.length)]; | |
| if (!card) return; | |
| card.classList.remove('distractor-sparkle'); | |
| void card.offsetWidth; | |
| card.classList.add('distractor-sparkle'); | |
| if (this.distractorSparkleClearTimer) { | |
| clearTimeout(this.distractorSparkleClearTimer); | |
| this.distractorSparkleClearTimer = null; | |
| } | |
| this.distractorSparkleClearTimer = setTimeout(() => { | |
| card.classList.remove('distractor-sparkle'); | |
| this.distractorSparkleClearTimer = null; | |
| }, 740); | |
| } | |
| startDistractorSparkles() { | |
| if (this.distractorSparkleTimer || !this.grid || this.state.status !== 'playing') { | |
| return; | |
| } | |
| const interval = this.getDistractorSparkleIntervalMs(); | |
| this.distractorSparkleTimer = setInterval(() => { | |
| if (window.__activeMemoryLevelId !== this.levelId || this.state.status !== 'playing') { | |
| this.stopDistractorSparkles(); | |
| return; | |
| } | |
| this.triggerDistractorSparkleOnce(); | |
| }, interval); | |
| } | |
| stopDistractorSparkles() { | |
| if (this.distractorSparkleTimer) { | |
| clearInterval(this.distractorSparkleTimer); | |
| this.distractorSparkleTimer = null; | |
| } | |
| if (this.distractorSparkleClearTimer) { | |
| clearTimeout(this.distractorSparkleClearTimer); | |
| this.distractorSparkleClearTimer = null; | |
| } | |
| if (this.grid) { | |
| this.grid.querySelectorAll('.memory-card.distractor-sparkle').forEach((card) => { | |
| card.classList.remove('distractor-sparkle'); | |
| }); | |
| } | |
| } | |
| updateHintsDisplay() { | |
| const effectiveMaxHints = this.syncProgressiveHintLimit(false); | |
| if (this.hintCountEl) { | |
| this.hintCountEl.textContent = `${this.hints} / ${effectiveMaxHints}`; | |
| } | |
| const currentHints = Number(this.hints || 0); | |
| if (this.lastRenderedHints !== null && currentHints > this.lastRenderedHints) { | |
| this.pulseHudGlow(this.hintCountEl || this.hintBtn, 'hud-glow-peek', 'peek'); | |
| } | |
| this.lastRenderedHints = currentHints; | |
| if (this.hintBtn) { | |
| const oddFaceUp = this.flipped.length % 2 === 1; | |
| const disabled = | |
| this.state.level_type === 'challenge' || | |
| this.hints <= 0 || | |
| this.state.status !== 'playing' || | |
| this.hinting || | |
| this.peekCooldownMoves > 0 || | |
| oddFaceUp || | |
| this.state.game_over || | |
| this.state.level_complete; | |
| this.hintBtn.disabled = disabled; | |
| } | |
| } | |
| showResetConfirm() { | |
| if (this.resetConfirmOverlay) { | |
| this.resetConfirmOverlay.classList.remove('hidden'); | |
| this.resetConfirmOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideResetConfirm() { | |
| if (this.resetConfirmOverlay) { | |
| this.resetConfirmOverlay.classList.add('hidden'); | |
| this.resetConfirmOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| } | |
| showGameOverModal(score) { | |
| if (this.gameOverScore) { | |
| this.gameOverScore.textContent = String(score ?? this.state.score ?? 0); | |
| } | |
| const scorePillValue = this.root.querySelector('#gameover-score-pill'); | |
| if (scorePillValue) { | |
| scorePillValue.textContent = String(score ?? this.state.score ?? 0); | |
| } | |
| this.disableControlsForGameOver(); | |
| if (this.gameOverOverlay) { | |
| this.gameOverOverlay.classList.remove('hidden'); | |
| this.gameOverOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideGameOverModal() { | |
| if (this.gameOverOverlay) { | |
| this.gameOverOverlay.classList.add('hidden'); | |
| this.gameOverOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| this.restoreControlsAfterGameOver(); | |
| } | |
| disableControlsForGameOver() { | |
| if (this.resetBtn) this.resetBtn.disabled = false; | |
| if (this.hintBtn) this.hintBtn.disabled = true; | |
| if (this.nextBtn) this.nextBtn.disabled = true; | |
| if (this.howToPlayBtn) this.howToPlayBtn.disabled = true; | |
| if (this.homeBtn) this.homeBtn.disabled = true; | |
| if (this.musicToggleStartBtn) this.musicToggleStartBtn.disabled = true; | |
| if (this.musicToggleTopBtn) this.musicToggleTopBtn.disabled = true; | |
| if (this.challengeOkBtn) this.challengeOkBtn.disabled = true; | |
| if (this.challengeCloseBtn) this.challengeCloseBtn.disabled = true; | |
| if (this.gameOverCloseBtn) this.gameOverCloseBtn.disabled = true; | |
| } | |
| restoreControlsAfterGameOver() { | |
| if (this.howToPlayBtn) this.howToPlayBtn.disabled = false; | |
| if (this.homeBtn) this.homeBtn.disabled = false; | |
| if (this.musicToggleStartBtn) this.musicToggleStartBtn.disabled = false; | |
| if (this.musicToggleTopBtn) this.musicToggleTopBtn.disabled = false; | |
| if (this.challengeOkBtn) this.challengeOkBtn.disabled = false; | |
| if (this.challengeCloseBtn) this.challengeCloseBtn.disabled = false; | |
| if (this.gameOverCloseBtn) this.gameOverCloseBtn.disabled = false; | |
| this.updateHintsDisplay(); | |
| } | |
| showHowToPlayModal() { | |
| if (this.howToPlayOverlay) { | |
| this.howToPlayOverlay.classList.remove('hidden'); | |
| this.howToPlayOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideHowToPlayModal() { | |
| if (this.howToPlayOverlay) { | |
| this.howToPlayOverlay.classList.add('hidden'); | |
| this.howToPlayOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| } | |
| showLeaderboardModal() { | |
| if (this.leaderboardOverlay) { | |
| this.leaderboardOverlay.classList.remove('hidden'); | |
| this.leaderboardOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideLeaderboardModal() { | |
| if (this.leaderboardOverlay) { | |
| this.leaderboardOverlay.classList.add('hidden'); | |
| this.leaderboardOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| } | |
| showChallengeIntroModal() { | |
| if (this.challengeIntroOverlay) { | |
| this.challengeIntroOverlay.classList.remove('hidden'); | |
| this.challengeIntroOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideChallengeIntroModal() { | |
| if (this.challengeIntroOverlay) { | |
| this.challengeIntroOverlay.classList.add('hidden'); | |
| this.challengeIntroOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| } | |
| showChallengePanel() { | |
| if (this.challengePanel) { | |
| this.challengePanel.classList.remove('hidden'); | |
| } | |
| if (this.memoryContainer) { | |
| this.memoryContainer.classList.add('challenge-mode'); | |
| } | |
| } | |
| hideChallengePanel() { | |
| if (this.challengePanel) { | |
| this.challengePanel.classList.add('hidden'); | |
| } | |
| if (this.memoryContainer) { | |
| this.memoryContainer.classList.remove('challenge-mode'); | |
| } | |
| } | |
| getChallengeIntroCopy() { | |
| const title = String(this.state.mode_popup_title || this.blueprint.mode_popup_title || this.state.challenge_modal_title || 'Challenge Level').trim(); | |
| const message = String(this.state.mode_popup_message || this.blueprint.mode_popup_message || this.state.challenge_modal_message || '').trim(); | |
| return { | |
| title, | |
| message: message || 'Match the cards to continue.', | |
| }; | |
| } | |
| renderChallengePanel() { | |
| if (!this.challengePanel) return; | |
| const theme = String(this.state.theme || this.blueprint.theme || '').trim(); | |
| const modeLabel = String(this.state.level_mode || this.blueprint.level_mode || 'normal').trim(); | |
| const title = String(this.state.mode_popup_title || this.blueprint.mode_popup_title || theme || 'Challenge').trim(); | |
| const focus = String(this.state.mode_popup_message || this.blueprint.mode_popup_message || this.state.featured_fact || this.blueprint.featured_fact || theme || '').trim(); | |
| if (this.challengeThemeEl) { | |
| this.challengeThemeEl.textContent = modeLabel ? `Mode: ${modeLabel.replace(/_/g, ' ')}` : ''; | |
| } | |
| if (this.challengeQuestionEl) { | |
| this.challengeQuestionEl.textContent = title; | |
| } | |
| if (this.challengeFocusEl) { | |
| this.challengeFocusEl.textContent = focus; | |
| } | |
| if (this.challengeOptionsEl) { | |
| this.challengeOptionsEl.innerHTML = ''; | |
| } | |
| } | |
| enableChallengeOptions(enabled) { | |
| if (!this.challengeOptionsEl) return; | |
| this.challengeOptionsEl.querySelectorAll('.challenge-option').forEach((button) => { | |
| button.disabled = !enabled || this.state.status === 'complete' || this.state.game_over || this.challengeSelectionLocked; | |
| }); | |
| } | |
| setChallengeOptionState(selectedIndex, correctIndex, isCorrect) { | |
| if (!this.challengeOptionsEl) return; | |
| const buttons = Array.from(this.challengeOptionsEl.querySelectorAll('.challenge-option')); | |
| const lockAll = this.challengeSelectionLocked || this.state.status === 'complete' || this.state.game_over || Boolean(this.state.challenge_result); | |
| buttons.forEach((button) => { | |
| const index = Number(button.dataset.index); | |
| button.classList.remove('correct-choice', 'wrong-choice', 'reveal-correct'); | |
| if (index === correctIndex) { | |
| button.classList.add('reveal-correct'); | |
| } | |
| if (index === selectedIndex) { | |
| button.classList.add(isCorrect ? 'correct-choice' : 'wrong-choice'); | |
| } | |
| button.disabled = lockAll || (isCorrect && index !== correctIndex); | |
| }); | |
| } | |
| async openChallengeQuestion() { | |
| this.hideChallengeIntroModal(); | |
| await this.startChallengePreview(); | |
| } | |
| async handleChallengeChoice(button) { | |
| if (this.state.level_type !== 'challenge') return; | |
| if (!this.challengeStarted || this.challengeSelectionLocked || this.state.status === 'complete') return; | |
| const selectedIndex = Number(button.dataset.index); | |
| if (Number.isNaN(selectedIndex)) return; | |
| const correctIndex = Number(this.challengeCorrectIndex); | |
| const isCorrect = selectedIndex === correctIndex; | |
| this.state.challenge_selected_index = selectedIndex; | |
| this.challengeResultIndex = selectedIndex; | |
| this.challengeSelectionLocked = true; | |
| this.state.challenge_result = isCorrect ? 'correct' : 'wrong'; | |
| this.state.level_complete = true; | |
| this.state.previous_level_completed = true; | |
| this.state.status = 'complete'; | |
| this.state.performance_meter = isCorrect ? 0 : 100; | |
| this.state.challenge_due = false; | |
| this.setChallengeOptionState(selectedIndex, correctIndex, isCorrect); | |
| if (isCorrect) { | |
| this.state.win_streak = Number(this.state.win_streak || 0) + 1; | |
| this.state.lives = Number(this.state.lives || this.lives || MAX_LIVES) + 1; | |
| this.playCorrectSound(); | |
| this.lives = Number(this.state.lives); | |
| this.state.performance_rating = this.getCompletionBadgeText(); | |
| this.state.performance_label = this.getCompletionRatingLabel(); | |
| this.updateHud(); | |
| this.setStatus( | |
| this.state.victory_message || 'Great answer! You cleared the challenge.', | |
| 'win', | |
| `Correct: ${String(this.challengeOptions[correctIndex] || '').trim()}` | |
| ); | |
| this.setBadge('COMPLETE', 'complete', `${this.getCompletionRatingLabel()} ${this.getCompletionBadgeText()}`); | |
| if (this.nextBtn) this.nextBtn.disabled = false; | |
| this.enableChallengeOptions(false); | |
| await this.syncBackendEvent('challenge'); | |
| return; | |
| } | |
| this.setStatus( | |
| this.state.failure_message || 'Not quite. Try again.', | |
| 'error', | |
| `Correct answer: ${String(this.challengeOptions[correctIndex] || '').trim()}` | |
| ); | |
| this.playWrongSound(); | |
| if (this.nextBtn) this.nextBtn.disabled = false; | |
| this.enableChallengeOptions(false); | |
| await this.syncBackendEvent('challenge'); | |
| } | |
| showStartOverlay() { | |
| if (this.startOverlay) { | |
| this.startOverlay.classList.remove('hidden'); | |
| this.startOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideStartOverlay() { | |
| if (this.startOverlay) { | |
| this.startOverlay.classList.add('hidden'); | |
| this.startOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| } | |
| showTransitionOverlay(title, subtitle) { | |
| if (this.transitionHideTimer) { | |
| clearTimeout(this.transitionHideTimer); | |
| this.transitionHideTimer = null; | |
| } | |
| this.transitionShownAt = performance.now ? performance.now() : Date.now(); | |
| if (this.transitionTitle) this.transitionTitle.textContent = title; | |
| if (this.transitionSubtitle) this.transitionSubtitle.textContent = subtitle; | |
| if (this.transitionOverlay) { | |
| this.transitionOverlay.classList.remove('hidden'); | |
| this.transitionOverlay.setAttribute('aria-hidden', 'false'); | |
| } | |
| } | |
| hideTransitionOverlay(force = false) { | |
| const now = performance.now ? performance.now() : Date.now(); | |
| const elapsed = now - (this.transitionShownAt || 0); | |
| if (!force && elapsed < MIN_TRANSITION_VISIBLE_MS) { | |
| if (this.transitionHideTimer) { | |
| clearTimeout(this.transitionHideTimer); | |
| } | |
| this.transitionHideTimer = setTimeout(() => { | |
| this.transitionHideTimer = null; | |
| this.hideTransitionOverlay(true); | |
| }, MIN_TRANSITION_VISIBLE_MS - elapsed); | |
| return; | |
| } | |
| if (this.transitionHideTimer) { | |
| clearTimeout(this.transitionHideTimer); | |
| this.transitionHideTimer = null; | |
| } | |
| if (this.transitionOverlay) { | |
| this.transitionOverlay.classList.add('hidden'); | |
| this.transitionOverlay.setAttribute('aria-hidden', 'true'); | |
| } | |
| } | |
| normalizeServerResponse(result) { | |
| if (!result) return null; | |
| if (typeof result === 'string') { | |
| try { | |
| return JSON.parse(result); | |
| } catch (err) { | |
| return null; | |
| } | |
| } | |
| if (typeof result === 'object') { | |
| return result; | |
| } | |
| return null; | |
| } | |
| getFeaturedFact() { | |
| const fact = this.blueprint.featured_fact || this.state.featured_fact || ''; | |
| return typeof fact === 'string' ? fact.trim() : ''; | |
| } | |
| formatFeaturedFact(fact) { | |
| const text = String(fact || '').trim(); | |
| if (!text) return ''; | |
| if (/^did you know\b/i.test(text)) { | |
| return text.toUpperCase().startsWith('DID YOU KNOW:') ? text : `DID YOU KNOW: ${text.replace(/^did you know[:\s-]*/i, '').trim()}`; | |
| } | |
| return text.toUpperCase().startsWith('FACT:') ? text : `FACT: ${text}`; | |
| } | |
| escapeHtml(value) { | |
| return String(value || '') | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| getCardDisplayText(card) { | |
| if (card && typeof card === 'object') { | |
| return String(card.display ?? card.value ?? card.text ?? '').trim(); | |
| } | |
| return String(card ?? '').trim(); | |
| } | |
| getCardMatchKey(card) { | |
| if (card && typeof card === 'object') { | |
| const key = card.match_key ?? card.matchKey ?? card.key ?? card.display ?? card.value ?? card.text ?? ''; | |
| return String(key).trim(); | |
| } | |
| return String(card ?? '').trim(); | |
| } | |
| getCardType(card) { | |
| if (card && typeof card === 'object') { | |
| return String(card.card_type ?? card.type ?? 'emoji').trim(); | |
| } | |
| return 'emoji'; | |
| } | |
| getCardPairFact(card) { | |
| if (card && typeof card === 'object') { | |
| return String(card.pair_fact ?? card.fact ?? '').trim(); | |
| } | |
| return ''; | |
| } | |
| shuffleBoardCards(cards) { | |
| const shuffled = Array.isArray(cards) ? [...cards] : []; | |
| for (let i = shuffled.length - 1; i > 0; i -= 1) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| if (j !== i) { | |
| [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; | |
| } | |
| } | |
| return shuffled; | |
| } | |
| isSpecialChallengeMode() { | |
| return this.state.level_type === 'challenge' && ['fact_match', 'category_match'].includes(String(this.state.level_mode || this.blueprint.level_mode || '').trim()); | |
| } | |
| bindActiveLevelState() { | |
| const previousLevelId = this.levelId || ''; | |
| this.activeLevel = this.state.active_level || {}; | |
| this.blueprint = this.activeLevel.blueprint || {}; | |
| this.levelId = this.activeLevel.level_instance_id || this.levelId || ''; | |
| this.levelNumber = Number(this.activeLevel.level_number || this.levelNumber || 1); | |
| if (this.levelId && this.levelId !== previousLevelId) { | |
| this.activeLevel.cards = this.shuffleBoardCards(this.activeLevel.cards); | |
| this.challengeStarted = false; | |
| this.challengeSelectionLocked = false; | |
| this.challengeResultIndex = null; | |
| this.challengeCorrectIndex = null; | |
| } | |
| this.cards = Array.isArray(this.activeLevel.cards) ? [...this.activeLevel.cards] : []; | |
| this.state.level = this.levelNumber; | |
| this.state.total_pairs = Number(this.activeLevel.pair_count || this.state.total_pairs || 0); | |
| this.state.grid_rows = Number(this.activeLevel.rows || this.state.grid_rows || 2); | |
| this.state.grid_cols = Number(this.activeLevel.cols || this.state.grid_cols || 2); | |
| this.state.hints = Number.isFinite(Number(this.state.hints)) ? Number(this.state.hints) : this.hints; | |
| this.state.max_hints = Number.isFinite(Number(this.state.max_hints)) ? Number(this.state.max_hints) : this.maxHints; | |
| this.state.max_lives = Number.isFinite(Number(this.state.max_lives)) ? Number(this.state.max_lives) : MAX_LIVES; | |
| this.state.level_title = this.blueprint.level_title || this.state.level_title || ''; | |
| this.state.theme = this.blueprint.theme || this.state.theme || ''; | |
| this.state.educational_focus = this.blueprint.educational_focus || this.state.educational_focus || ''; | |
| this.state.featured_fact_emoji = this.blueprint.featured_fact_emoji || this.state.featured_fact_emoji || ''; | |
| this.state.featured_fact = this.blueprint.featured_fact || this.state.featured_fact || ''; | |
| this.state.level_type = this.activeLevel.level_type || this.state.level_type || 'normal'; | |
| this.state.challenge_modal_title = this.blueprint.challenge_modal_title || this.state.challenge_modal_title || ''; | |
| this.state.challenge_modal_message = this.blueprint.challenge_modal_message || this.state.challenge_modal_message || ''; | |
| this.state.challenge_question = this.blueprint.challenge_question || this.state.challenge_question || ''; | |
| this.state.challenge_options = Array.isArray(this.blueprint.challenge_options) ? [...this.blueprint.challenge_options] : (this.state.challenge_options || []); | |
| this.state.challenge_correct_index = Number.isFinite(Number(this.blueprint.challenge_correct_index)) | |
| ? Number(this.blueprint.challenge_correct_index) | |
| : this.state.challenge_correct_index; | |
| this.state.level_mode = this.blueprint.level_mode || this.state.level_mode || 'normal'; | |
| this.state.mode_popup_title = this.blueprint.mode_popup_title || this.state.mode_popup_title || ''; | |
| this.state.mode_popup_message = this.blueprint.mode_popup_message || this.state.mode_popup_message || ''; | |
| this.state.match_targets = this.blueprint.match_targets || this.state.match_targets || {}; | |
| this.state.pair_facts = this.blueprint.pair_facts || this.state.pair_facts || {}; | |
| this.state.victory_message = this.blueprint.victory_message || this.state.victory_message || ''; | |
| this.state.failure_message = this.blueprint.failure_message || this.state.failure_message || ''; | |
| this.state.grid_advice = this.blueprint.grid_advice || this.state.grid_advice || {}; | |
| this.hints = Number.isFinite(Number(this.state.hints)) ? Number(this.state.hints) : this.hints; | |
| this.maxHints = Number.isFinite(Number(this.state.max_hints)) ? Number(this.state.max_hints) : this.maxHints; | |
| this.challengeOptions = Array.isArray(this.state.challenge_options) ? [...this.state.challenge_options] : []; | |
| this.challengeCorrectIndex = Number.isFinite(Number(this.state.challenge_correct_index)) | |
| ? Number(this.state.challenge_correct_index) | |
| : null; | |
| this.challengeLevelMode = String(this.state.level_mode || this.blueprint.level_mode || 'normal').trim(); | |
| } | |
| isChallengeMode() { | |
| return this.state.level_type === 'challenge'; | |
| } | |
| updateHud() { | |
| if (this.movesEl) this.movesEl.textContent = String(this.moves); | |
| const matchedPairs = Math.floor(this.matched.length / 2); | |
| if (this.matchesEl) this.matchesEl.textContent = `${matchedPairs} / ${this.state.total_pairs}`; | |
| if (this.scoreEl) this.scoreEl.textContent = String(this.state.score); | |
| const currentMeter = Math.max(0, Math.min(100, Number(this.state.performance_meter || 0))); | |
| const meterChanged = this.lastRenderedPerformanceMeter !== null && currentMeter !== this.lastRenderedPerformanceMeter; | |
| this.updatePerformanceMeter(currentMeter, meterChanged); | |
| this.lastRenderedPerformanceMeter = currentMeter; | |
| const currentLives = Number(this.state.lives || 0); | |
| if (this.livesEl) this.livesEl.textContent = String(currentLives); | |
| const livesBlock = this.livesEl?.closest('.stat-block') || this.livesEl; | |
| if (this.lastRenderedLives !== null && currentLives < this.lastRenderedLives) { | |
| this.pulseHudGlow(livesBlock, 'hud-glow-lives', 'lives'); | |
| this.pulseHudGlow(this.livesEl, 'hud-glow-lives', 'livesValue'); | |
| } else if (this.lastRenderedLives !== null && currentLives > this.lastRenderedLives) { | |
| this.pulseHudGlow(livesBlock, 'hud-glow-lives-gain', 'lives'); | |
| this.pulseHudGlow(this.livesEl, 'hud-glow-lives-gain', 'livesValue'); | |
| } | |
| this.lastRenderedLives = currentLives; | |
| this.updateHintsDisplay(); | |
| if (this.levelEl) this.levelEl.textContent = String(this.state.level); | |
| if (this.levelTitleEl) this.levelTitleEl.textContent = this.state.level_title || ''; | |
| if (this.themeChip) this.themeChip.textContent = this.state.theme || ''; | |
| if (this.focusLine) this.focusLine.textContent = this.state.educational_focus || this.state.featured_fact || ''; | |
| this.updateLeaderboardDisplay(); | |
| } | |
| updateCardSize() { | |
| if (!this.grid) return; | |
| const cols = Math.max(2, Number(this.state.grid_cols || 2)); | |
| const rows = Math.max(2, Number(this.state.grid_rows || 2)); | |
| const mobileViewport = window.innerWidth <= 760; | |
| const gap = mobileViewport ? (cols > 4 ? 3 : cols > 3 ? 4 : 5) : (cols > 4 ? 8 : cols > 3 ? 10 : 12); | |
| const safeWidth = Math.max(0, window.innerWidth - (mobileViewport ? 22 : 44)); | |
| const safeHeight = Math.max(0, window.innerHeight - (mobileViewport ? 320 : 280)); | |
| const widthBased = Math.floor((safeWidth - gap * (cols - 1)) / cols); | |
| const heightBased = Math.floor((safeHeight - gap * (rows - 1)) / rows); | |
| const rowPenalty = rows >= 5 ? 0.72 : rows === 4 ? 0.8 : rows === 3 ? 0.9 : 1; | |
| const mobileTightness = mobileViewport ? 0.82 : 1; | |
| const size = Math.max( | |
| mobileViewport ? 42 : 92, | |
| Math.floor(Math.min(widthBased, heightBased) * rowPenalty * mobileTightness) | |
| ); | |
| const maxCard = mobileViewport | |
| ? (cols <= 2 ? 76 : cols === 3 ? 56 : cols === 4 ? 44 : 34) | |
| : (cols <= 2 ? 160 : cols === 3 ? 150 : cols === 4 ? 132 : 118); | |
| const minCard = mobileViewport | |
| ? (cols <= 2 ? 54 : cols === 3 ? 42 : cols === 4 ? 34 : 28) | |
| : (cols <= 2 ? 92 : cols === 3 ? 72 : cols === 4 ? 58 : 44); | |
| const finalSize = Math.max(minCard, Math.min(maxCard, size)); | |
| this.grid.style.setProperty('--grid-cols', String(cols)); | |
| this.grid.style.setProperty('--grid-rows', String(rows)); | |
| this.grid.style.setProperty('--card-fit-size', `${finalSize}px`); | |
| this.grid.style.setProperty('--card-size', `${finalSize}px`); | |
| this.grid.style.setProperty('--grid-gap', `${gap}px`); | |
| } | |
| renderState(nextState, initial = false) { | |
| this.state = { | |
| ...this.state, | |
| ...nextState, | |
| }; | |
| const isPlaying = Boolean(this.state.game_started); | |
| this.root.classList.toggle('game-playing', isPlaying); | |
| if (this.memoryContainer && this.memoryContainer !== this.root) { | |
| this.memoryContainer.classList.toggle('game-playing', isPlaying); | |
| } | |
| this.bindActiveLevelState(); | |
| this.resetComboFeedback(); | |
| this.flipped = []; | |
| this.matched = []; | |
| this.hintedPairs = new Set(); | |
| this.hinting = false; | |
| this.moves = Number(this.state.moves || 0); | |
| this.locked = false; | |
| this.pendingTransition = false; | |
| const isChallenge = this.state.level_type === 'challenge'; | |
| this.memoryContainer.classList.toggle('challenge-mode', isChallenge); | |
| if (isChallenge) { | |
| this.state.performance_meter = 100; | |
| this.state.challenge_due = false; | |
| } | |
| if (!isChallenge) { | |
| this.hideChallengeIntroModal(); | |
| this.hideChallengePanel(); | |
| this.challengeStarted = false; | |
| this.challengeSelectionLocked = false; | |
| this.challengeResultIndex = null; | |
| this.challengeCorrectIndex = null; | |
| } else { | |
| this.challengeStarted = false; | |
| this.challengeSelectionLocked = false; | |
| this.challengeResultIndex = null; | |
| this.challengeCorrectIndex = null; | |
| } | |
| this.stopDistractorSparkles(); | |
| this.renderCards(); | |
| this.updateHud(); | |
| this.updateCardSize(); | |
| this.applyStateStyle(); | |
| if (!initial) { | |
| const isGameOver = this.state.status === 'game_over' || this.state.game_over; | |
| this.setStatus( | |
| isGameOver | |
| ? this.state.failure_message || 'Game over. Press NEW GAME to start again.' | |
| : isChallenge | |
| ? this.state.challenge_modal_message || this.state.theme || `Challenge level ${this.state.level} loaded.` | |
| : `Level ${this.state.level} loaded.`, | |
| isGameOver ? 'error' : 'show', | |
| this.state.theme || '' | |
| ); | |
| } | |
| } | |
| renderCards() { | |
| if (!this.grid) return; | |
| const html = this.cards.map((card, index) => { | |
| const display = this.getCardDisplayText(card); | |
| const matchKey = this.getCardMatchKey(card); | |
| const cardType = this.getCardType(card); | |
| const isTextCard = cardType === 'text'; | |
| const cardClass = isTextCard ? 'text-card' : 'emoji-card'; | |
| const pairFact = this.getCardPairFact(card); | |
| const titleAttr = pairFact ? ` title="${this.escapeHtml(pairFact)}"` : ''; | |
| return ` | |
| <div class="memory-card ${cardClass} flipped preview-locked" data-index="${index}" data-match-key="${this.escapeHtml(matchKey)}" data-card-type="${this.escapeHtml(cardType)}"${titleAttr}> | |
| <div class="memory-inner"> | |
| <div class="memory-front"> | |
| <img class="memory-front-logo" src="__LOGO_DATA_URI__" alt="" aria-hidden="true"> | |
| </div> | |
| <div class="memory-back">${this.escapeHtml(display)}</div> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| this.grid.innerHTML = html; | |
| this.grid.querySelectorAll('.memory-card').forEach(card => { | |
| card.classList.add('flipped', 'preview-locked'); | |
| }); | |
| } | |
| getPreviewShuffleIntervalMs() { | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| // Starts gentle, then gets a little faster as levels progress. | |
| // Lower bound prevents the board from becoming chaotic on later levels. | |
| return Math.max(360, 760 - Math.min(320, (level - 1) * 22)); | |
| } | |
| getPreviewShuffleAnimationMs() { | |
| const interval = this.getPreviewShuffleIntervalMs(); | |
| // Animation stays slightly shorter than the next shuffle tick. | |
| return Math.max(260, Math.min(560, Math.floor(interval * 0.72))); | |
| } | |
| makeDifferentPreviewOrder(cards) { | |
| const original = [...cards]; | |
| const shuffled = [...cards]; | |
| for (let i = shuffled.length - 1; i > 0; i -= 1) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; | |
| } | |
| // Avoid a weak shuffle where too many cards remain in the same grid cell. | |
| const sameSpotCount = shuffled.reduce((count, card, index) => { | |
| return count + (card === original[index] ? 1 : 0); | |
| }, 0); | |
| if (shuffled.length > 2 && sameSpotCount > Math.floor(shuffled.length * 0.45)) { | |
| shuffled.push(shuffled.shift()); | |
| } else if (shuffled.length === 2 && shuffled[0] === original[0]) { | |
| shuffled.reverse(); | |
| } | |
| return shuffled; | |
| } | |
| shufflePreviewGridOnce() { | |
| if (!this.grid || this.state.status !== 'preview') return; | |
| const cards = Array.from(this.grid.querySelectorAll('.memory-card.preview-locked:not(.matched)')); | |
| if (cards.length < 2) return; | |
| const firstRects = new Map(); | |
| cards.forEach((card) => { | |
| firstRects.set(card, card.getBoundingClientRect()); | |
| }); | |
| const shuffledCards = this.makeDifferentPreviewOrder(cards); | |
| const fragment = document.createDocumentFragment(); | |
| shuffledCards.forEach((card) => { | |
| fragment.appendChild(card); | |
| }); | |
| this.grid.appendChild(fragment); | |
| const duration = this.getPreviewShuffleAnimationMs(); | |
| this.previewShuffleAnimationMs = duration; | |
| shuffledCards.forEach((card) => { | |
| const first = firstRects.get(card); | |
| const last = card.getBoundingClientRect(); | |
| if (!first || !last) return; | |
| const dx = first.left - last.left; | |
| const dy = first.top - last.top; | |
| if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; | |
| card.getAnimations().forEach((animation) => { | |
| animation.cancel(); | |
| }); | |
| card.animate( | |
| [ | |
| { | |
| transform: `translate(${dx}px, ${dy}px) scale(1.015)`, | |
| zIndex: 5, | |
| }, | |
| { | |
| transform: 'translate(0px, 0px) scale(1)', | |
| zIndex: 1, | |
| } | |
| ], | |
| { | |
| duration, | |
| easing: 'cubic-bezier(0.22, 1, 0.36, 1)', | |
| fill: 'both', | |
| } | |
| ); | |
| }); | |
| } | |
| clearPreviewChallengeEffects() { | |
| this.previewFogTimers.forEach((timer) => clearTimeout(timer)); | |
| this.previewFogTimers = []; | |
| this.previewHideTimers.forEach((timer) => clearTimeout(timer)); | |
| this.previewHideTimers = []; | |
| const effectRoots = [this.memoryContainer, this.root].filter(Boolean); | |
| effectRoots.forEach((root) => root.classList.remove('preview-fog-blink')); | |
| if (this.grid) { | |
| this.grid.querySelectorAll('.memory-card.preview-hidden-card').forEach((card) => { | |
| card.classList.remove('preview-hidden-card'); | |
| }); | |
| } | |
| } | |
| triggerPreviewFogBlinkOnce() { | |
| const effectRoot = this.memoryContainer || this.root; | |
| if (!effectRoot || this.state.status !== 'preview') return; | |
| effectRoot.classList.remove('preview-fog-blink'); | |
| void effectRoot.offsetWidth; | |
| effectRoot.classList.add('preview-fog-blink'); | |
| const timer = setTimeout(() => { | |
| effectRoot.classList.remove('preview-fog-blink'); | |
| }, 280); | |
| this.previewFogTimers.push(timer); | |
| } | |
| startPreviewFogChallenge() { | |
| const level = Math.max(1, Number(this.levelNumber || this.state.level || 1)); | |
| if (level < 10) return; | |
| const blinkCount = level >= 18 ? 2 : 1; | |
| const delays = blinkCount >= 2 ? [760, 1760] : [1260]; | |
| delays.slice(0, blinkCount).forEach((delay) => { | |
| const timer = setTimeout(() => { | |
| if (window.__activeMemoryLevelId === this.levelId && this.state.status === 'preview') { | |
| this.triggerPreviewFogBlinkOnce(); | |
| } | |
| }, delay); | |
| this.previewFogTimers.push(timer); | |
| }); | |
| } | |
| getPreviewHideCardCount() { | |
| const completedChallengeLevels = this.getCompletedChallengeLevels(); | |
| if (completedChallengeLevels < 1) return 0; | |
| return completedChallengeLevels >= 3 ? 2 : 1; | |
| } | |
| hideRandomPreviewCardsOnce() { | |
| if (!this.grid || this.state.status !== 'preview') return; | |
| const hideCount = this.getPreviewHideCardCount(); | |
| if (hideCount <= 0) return; | |
| const cards = Array.from(this.grid.querySelectorAll('.memory-card.preview-locked:not(.matched):not(.preview-hidden-card)')); | |
| if (!cards.length) return; | |
| const shuffled = this.makeDifferentPreviewOrder(cards).slice(0, Math.min(hideCount, cards.length)); | |
| shuffled.forEach((card) => { | |
| card.classList.add('preview-hidden-card'); | |
| const timer = setTimeout(() => { | |
| card.classList.remove('preview-hidden-card'); | |
| }, 650); | |
| this.previewHideTimers.push(timer); | |
| }); | |
| } | |
| startPreviewHideChallenge() { | |
| if (this.getPreviewHideCardCount() <= 0) return; | |
| [920, 1880].forEach((delay, index) => { | |
| const timer = setTimeout(() => { | |
| if (window.__activeMemoryLevelId === this.levelId && this.state.status === 'preview') { | |
| // The second pulse only appears once challenge progress is stronger. | |
| if (index === 0 || this.getCompletedChallengeLevels() >= 2) { | |
| this.hideRandomPreviewCardsOnce(); | |
| } | |
| } | |
| }, delay); | |
| this.previewHideTimers.push(timer); | |
| }); | |
| } | |
| startPreviewDifficultyEffects() { | |
| this.clearPreviewChallengeEffects(); | |
| this.startPreviewFogChallenge(); | |
| this.startPreviewHideChallenge(); | |
| } | |
| startPreviewGridShuffle() { | |
| this.stopPreviewGridShuffle(); | |
| if (!this.grid || this.state.status !== 'preview') return; | |
| const interval = this.getPreviewShuffleIntervalMs(); | |
| // Start while countdown shows 4. | |
| this.shufflePreviewGridOnce(); | |
| this.previewShuffleTimer = setInterval(() => { | |
| if (window.__activeMemoryLevelId !== this.levelId || this.state.status !== 'preview') { | |
| this.stopPreviewGridShuffle(); | |
| return; | |
| } | |
| this.shufflePreviewGridOnce(); | |
| }, interval); | |
| } | |
| stopPreviewGridShuffle() { | |
| if (this.previewShuffleTimer) { | |
| clearInterval(this.previewShuffleTimer); | |
| this.previewShuffleTimer = null; | |
| } | |
| this.clearPreviewChallengeEffects(); | |
| if (!this.grid) return; | |
| this.grid.querySelectorAll('.memory-card').forEach((card) => { | |
| card.getAnimations().forEach((animation) => { | |
| try { | |
| animation.finish(); | |
| } catch (err) { | |
| animation.cancel(); | |
| } | |
| }); | |
| card.style.transform = ''; | |
| card.style.zIndex = ''; | |
| }); | |
| } | |
| shakeCardsDuringPreview() { | |
| if (!this.grid) return; | |
| const allCards = this.grid.querySelectorAll('.memory-card'); | |
| allCards.forEach((cardEl, index) => { | |
| cardEl.getAnimations().forEach((animation) => { | |
| animation.cancel(); | |
| }); | |
| cardEl.animate( | |
| [ | |
| { transform: 'translateX(0px) rotate(0deg)' }, | |
| { transform: 'translateX(-14px) rotate(-4deg)' }, | |
| { transform: 'translateX(14px) rotate(4deg)' }, | |
| { transform: 'translateX(-12px) rotate(-3deg)' }, | |
| { transform: 'translateX(12px) rotate(3deg)' }, | |
| { transform: 'translateX(-8px) rotate(-2deg)' }, | |
| { transform: 'translateX(8px) rotate(2deg)' }, | |
| { transform: 'translateX(0px) rotate(0deg)' } | |
| ], | |
| { | |
| duration: 850, | |
| easing: 'ease-in-out', | |
| iterations: 1, | |
| fill: 'both', | |
| delay: index * 70, | |
| } | |
| ); | |
| }); | |
| } | |
| clearPreviewCardAnimations() { | |
| if (!this.grid) return; | |
| const allCards = this.grid.querySelectorAll('.memory-card'); | |
| allCards.forEach((cardEl) => { | |
| cardEl.getAnimations().forEach((animation) => { | |
| animation.cancel(); | |
| }); | |
| cardEl.style.transform = ''; | |
| cardEl.style.zIndex = ''; | |
| cardEl.classList.remove('preview-hidden-card', 'distractor-sparkle'); | |
| }); | |
| } | |
| applyStateStyle() { | |
| const status = this.state.status || 'preview'; | |
| if (status === 'playing') { | |
| if (this.state.level_type === 'challenge') { | |
| this.setBadge('CHALLENGE', 'challenge'); | |
| } else { | |
| this.setBadge('PLAYING', 'playing'); | |
| } | |
| } else if (status === 'complete') { | |
| const ratingLabel = this.state.performance_label || this.getCompletionRatingLabel(); | |
| const stars = this.state.performance_rating || this.getCompletionBadgeText(); | |
| this.setBadge('COMPLETE', 'complete', `${ratingLabel} ${stars}`); | |
| } else if (status === 'game_over') { | |
| this.setBadge('GAME OVER', 'gameover'); | |
| } else { | |
| this.setBadge(this.state.level_type === 'challenge' ? 'CHALLENGE' : 'MEMORIZE', this.state.level_type === 'challenge' ? 'challenge' : ''); | |
| } | |
| if (this.nextBtn) { | |
| this.nextBtn.disabled = status !== 'complete'; | |
| } | |
| if (status === 'playing') { | |
| this.startDistractorSparkles(); | |
| } else { | |
| this.stopDistractorSparkles(); | |
| } | |
| this.updateHintsDisplay(); | |
| this.syncControlsVisibility(); | |
| } | |
| startPreview() { | |
| if (!this.levelId) { | |
| return; | |
| } | |
| if (this.state.level_type === 'challenge') { | |
| this.startChallengeLevel(); | |
| return; | |
| } | |
| this.clearPreviewTimer(); | |
| this.clearPreviewCardAnimations(); | |
| window.__activeMemoryLevelId = this.levelId; | |
| if (window.__memoryGameTimer) { | |
| clearInterval(window.__memoryGameTimer); | |
| window.__memoryGameTimer = null; | |
| } | |
| this.state.status = 'preview'; | |
| this.state.level_complete = false; | |
| this.state.game_over = false; | |
| this.recentlyFlippedCardIndexes = []; | |
| this.peekCooldownMoves = Number(this.state.peek_cooldown_moves || 0); | |
| this.syncProgressiveHintLimit(true); | |
| this.applyStateStyle(); | |
| this.setStatus( | |
| 'Memorize the cards before they flip!', | |
| '', | |
| this.state.theme || '' | |
| ); | |
| const cards = this.grid ? Array.from(this.grid.querySelectorAll('.memory-card')) : []; | |
| cards.forEach(card => { | |
| card.classList.add('flipped', 'preview-locked'); | |
| card.classList.remove('matched', 'wrong'); | |
| }); | |
| let count = PREVIEW_SECONDS; | |
| if (this.countdown) this.countdown.textContent = String(count); | |
| if (this.overlay) { | |
| this.overlay.classList.remove('hidden'); | |
| this.overlay.style.display = 'flex'; | |
| this.overlay.style.opacity = '1'; | |
| } | |
| this.scrollPreviewIntoView(); | |
| setTimeout(() => { | |
| this.shakeCardsDuringPreview(); | |
| }, 120); | |
| setTimeout(() => { | |
| this.startPreviewGridShuffle(); | |
| this.startPreviewDifficultyEffects(); | |
| }, 260); | |
| this.updateHintsDisplay(); | |
| this.previewTimer = setInterval(() => { | |
| if (window.__activeMemoryLevelId !== this.levelId) { | |
| this.clearPreviewTimer(); | |
| return; | |
| } | |
| count -= 1; | |
| if (this.countdown) this.countdown.textContent = String(Math.max(count, 0)); | |
| if (count <= 1) { | |
| this.stopPreviewGridShuffle(); | |
| } | |
| if (count <= 0) { | |
| this.clearPreviewTimer(); | |
| this.hidePreview(); | |
| } | |
| }, 1000); | |
| } | |
| async startChallengePreview() { | |
| this.clearPreviewTimer(); | |
| this.clearPreviewCardAnimations(); | |
| window.__activeMemoryLevelId = this.levelId; | |
| this.challengeStarted = true; | |
| this.challengeSelectionLocked = false; | |
| this.challengeResultIndex = null; | |
| this.state.challenge_started = true; | |
| this.state.status = 'preview'; | |
| this.state.level_complete = false; | |
| this.state.game_over = false; | |
| this.recentlyFlippedCardIndexes = []; | |
| this.peekCooldownMoves = Number(this.state.peek_cooldown_moves || 0); | |
| this.syncProgressiveHintLimit(true); | |
| if (this.overlay) { | |
| this.overlay.style.display = 'flex'; | |
| this.overlay.style.opacity = '1'; | |
| this.overlay.classList.remove('hidden'); | |
| } | |
| const cards = this.grid ? Array.from(this.grid.querySelectorAll('.memory-card')) : []; | |
| cards.forEach((card) => { | |
| card.classList.add('flipped', 'preview-locked'); | |
| card.classList.remove('matched', 'wrong'); | |
| }); | |
| let count = PREVIEW_SECONDS; | |
| if (this.countdown) this.countdown.textContent = String(count); | |
| this.scrollPreviewIntoView(); | |
| this.applyStateStyle(); | |
| this.setStatus( | |
| 'Memorize the cards before they flip!', | |
| '', | |
| this.state.theme || this.state.mode_popup_message || this.state.challenge_modal_message || '' | |
| ); | |
| this.updateHintsDisplay(); | |
| setTimeout(() => { | |
| this.shakeCardsDuringPreview(); | |
| }, 120); | |
| setTimeout(() => { | |
| this.startPreviewGridShuffle(); | |
| this.startPreviewDifficultyEffects(); | |
| }, 260); | |
| this.previewTimer = setInterval(() => { | |
| if (window.__activeMemoryLevelId !== this.levelId) { | |
| this.clearPreviewTimer(); | |
| return; | |
| } | |
| count -= 1; | |
| if (this.countdown) this.countdown.textContent = String(Math.max(count, 0)); | |
| if (count <= 1) { | |
| this.stopPreviewGridShuffle(); | |
| } | |
| if (count <= 0) { | |
| this.clearPreviewTimer(); | |
| this.hidePreview(); | |
| } | |
| }, 1000); | |
| } | |
| startChallengeLevel() { | |
| this.clearPreviewTimer(); | |
| this.clearPreviewCardAnimations(); | |
| const introCopy = this.getChallengeIntroCopy(); | |
| const modalTitle = introCopy.title || 'Challenge Level'; | |
| const modalSubtitle = introCopy.message; | |
| if (this.overlay) { | |
| this.overlay.style.opacity = '0'; | |
| setTimeout(() => { | |
| if (this.overlay) this.overlay.style.display = 'none'; | |
| }, 420); | |
| } | |
| if (this.grid) { | |
| this.grid.querySelectorAll('.memory-card').forEach((card) => { | |
| card.classList.remove('flipped', 'preview-locked'); | |
| }); | |
| } | |
| this.scrollPreviewIntoView(); | |
| this.challengeCorrectIndex = Number.isFinite(Number(this.state.challenge_correct_index)) | |
| ? Number(this.state.challenge_correct_index) | |
| : null; | |
| this.renderChallengePanel(); | |
| if (this.challengeModalTitleEl) { | |
| this.challengeModalTitleEl.textContent = modalTitle; | |
| } | |
| if (this.challengeModalMessageEl) { | |
| this.challengeModalMessageEl.textContent = modalSubtitle; | |
| } | |
| if (this.challengeModalSubtitleEl) { | |
| this.challengeModalSubtitleEl.textContent = modalSubtitle; | |
| } | |
| const challengeAlreadyStarted = Boolean(this.state.challenge_started); | |
| if (challengeAlreadyStarted) { | |
| this.challengeStarted = true; | |
| this.challengeSelectionLocked = false; | |
| this.hideChallengeIntroModal(); | |
| this.hideChallengePanel(); | |
| if (this.challengeModalTitleEl) { | |
| this.challengeModalTitleEl.textContent = introCopy.title; | |
| } | |
| if (this.challengeModalMessageEl) { | |
| this.challengeModalMessageEl.textContent = introCopy.message; | |
| } | |
| if (this.state.status === 'complete') { | |
| this.setStatus( | |
| this.state.victory_message || 'Great answer! You cleared the challenge.', | |
| 'win', | |
| `Correct: ${String(this.challengeOptions[this.challengeCorrectIndex] || '').trim()}` | |
| ); | |
| } else { | |
| this.setStatus( | |
| introCopy.message, | |
| 'show', | |
| this.state.theme || '' | |
| ); | |
| } | |
| this.applyStateStyle(); | |
| return; | |
| } | |
| this.challengeStarted = false; | |
| this.challengeSelectionLocked = false; | |
| this.challengeResultIndex = null; | |
| this.state.status = 'preview'; | |
| this.state.level_complete = false; | |
| this.state.game_over = false; | |
| this.hideChallengePanel(); | |
| if (this.challengeModalTitleEl) { | |
| this.challengeModalTitleEl.textContent = introCopy.title; | |
| } | |
| if (this.challengeModalMessageEl) { | |
| this.challengeModalMessageEl.textContent = introCopy.message; | |
| } | |
| this.showChallengeIntroModal(); | |
| this.scrollPreviewIntoView(); | |
| this.setStatus( | |
| introCopy.message, | |
| 'show', | |
| this.state.theme || '' | |
| ); | |
| this.applyStateStyle(); | |
| } | |
| clearPreviewTimer() { | |
| if (this.previewTimer) { | |
| clearInterval(this.previewTimer); | |
| this.previewTimer = null; | |
| } | |
| this.stopPreviewGridShuffle(); | |
| } | |
| scrollPreviewIntoView() { | |
| const target = this.state.level_type === 'challenge' | |
| ? (this.grid || this.cardsPanel || this.boardFrame) | |
| : (this.grid || this.cardsPanel || this.boardFrame); | |
| if (!target) return; | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| try { | |
| target.scrollIntoView({ | |
| behavior: 'smooth', | |
| block: 'center', | |
| inline: 'nearest', | |
| }); | |
| } catch (err) { | |
| const fallbackTarget = target === this.boardFrame ? this.boardFrame : (this.cardsPanel || this.boardFrame); | |
| if (!fallbackTarget) return; | |
| const top = Math.max( | |
| 0, | |
| window.scrollY + fallbackTarget.getBoundingClientRect().top - Math.max(12, window.innerHeight * 0.14) | |
| ); | |
| window.scrollTo({ top, behavior: 'smooth' }); | |
| } | |
| }); | |
| }); | |
| } | |
| hidePreview() { | |
| if (this.overlay) { | |
| this.overlay.style.opacity = '0'; | |
| setTimeout(() => { | |
| if (this.overlay) this.overlay.style.display = 'none'; | |
| }, 420); | |
| } | |
| this.clearPreviewCardAnimations(); | |
| this.grid.querySelectorAll('.memory-card').forEach((card, idx) => { | |
| if (!this.matched.includes(idx)) { | |
| card.classList.remove('flipped', 'preview-locked'); | |
| } | |
| }); | |
| if (this.state.level_type === 'challenge') { | |
| this.state.status = 'playing'; | |
| this.state.level_complete = false; | |
| this.state.game_over = false; | |
| this.applyStateStyle(); | |
| this.updateHintsDisplay(); | |
| return; | |
| } | |
| this.state.status = 'playing'; | |
| this.levelStartTime = Date.now(); | |
| this.applyStateStyle(); | |
| this.updateHintsDisplay(); | |
| this.setStatus( | |
| 'Find matching pairs.', | |
| 'show', | |
| this.state.theme || 'Click two cards to test a match.' | |
| ); | |
| } | |
| async handleCardClick(card) { | |
| if (this.state.status !== 'playing' || this.locked) return; | |
| const idx = Number(card.dataset.index); | |
| if (this.matched.includes(idx) || this.flipped.includes(idx)) { | |
| return; | |
| } | |
| card.classList.add('flipped'); | |
| this.flipped.push(idx); | |
| this.rememberRecentlyFlipped(idx); | |
| if (this.flipped.length === 1) { | |
| this.updateHintsDisplay(); | |
| this.setStatus('Pick one more card.', '', this.state.theme || ''); | |
| return; | |
| } | |
| if (this.flipped.length < 2) { | |
| return; | |
| } | |
| this.locked = true; | |
| this.moves += 1; | |
| this.state.moves = this.moves; | |
| this.decrementPeekCooldownAfterMove(); | |
| this.updateHud(); | |
| const [a, b] = this.flipped; | |
| const cardAValue = this.getCardMatchKey(this.cards[a]); | |
| const cardBValue = this.getCardMatchKey(this.cards[b]); | |
| if (cardAValue && cardAValue === cardBValue) { | |
| await this.onMatch(a, b); | |
| } else { | |
| this.onWrong(a, b); | |
| } | |
| } | |
| async onMatch(a, b) { | |
| const cardA = this.grid.querySelector(`[data-index="${a}"]`); | |
| const cardB = this.grid.querySelector(`[data-index="${b}"]`); | |
| if (cardA) cardA.classList.add('matched'); | |
| if (cardB) cardB.classList.add('matched'); | |
| const pairFact = this.getCardPairFact(this.cards[a]) || this.getCardPairFact(this.cards[b]); | |
| const isChallengeBoard = this.isChallengeMode(); | |
| this.matched.push(a, b); | |
| this.flipped = []; | |
| this.state.matched_count = Math.floor(this.matched.length / 2); | |
| this.state.score = Number(this.state.score || 0) + 20; | |
| if (isChallengeBoard) { | |
| this.state.performance_meter = 100; | |
| this.state.challenge_due = false; | |
| } else { | |
| this.state.performance_meter = Math.min( | |
| 100, | |
| Number(this.state.performance_meter || 0) + 20 | |
| ); | |
| this.state.challenge_due = Number(this.state.performance_meter || 0) >= 100; | |
| } | |
| this.comboStreak += 1; | |
| this.state.combo_streak = this.comboStreak; | |
| this.updateHud(); | |
| this.playCorrectSound(); | |
| if (this.matched.length === this.cards.length) { | |
| const featuredFact = this.formatFeaturedFact(this.getFeaturedFact()); | |
| if (isChallengeBoard) { | |
| this.state.lives = Number(this.state.lives || this.lives || MAX_LIVES) + 1; | |
| this.lives = Number(this.state.lives); | |
| this.state.performance_meter = 0; | |
| this.state.challenge_due = false; | |
| } | |
| const effectiveMaxHints = this.getEffectiveMaxHints(); | |
| const shouldAwardPeek = this.hints < effectiveMaxHints; | |
| this.showFeedbackOverlay( | |
| 'LEVEL COMPLETE', | |
| isChallengeBoard ? 'Level Complete!\n+1 Life!' : (shouldAwardPeek ? 'Level Complete!\n+1 Peek earned!' : 'Level Complete!'), | |
| 'level', | |
| false | |
| ); | |
| this.state.performance_rating = this.getCompletionBadgeText(); | |
| this.state.performance_label = this.getCompletionRatingLabel(); | |
| this.state.status = 'complete'; | |
| this.state.level_complete = true; | |
| this.state.previous_level_completed = true; | |
| this.state.previous_level_wrong_attempts = Number(this.state.wrong_attempts || 0); | |
| this.state.previous_level_time_seconds = this.levelStartTime | |
| ? Math.max(1, Math.round((Date.now() - this.levelStartTime) / 1000)) | |
| : Number(this.state.previous_level_time_seconds || 0); | |
| this.state.win_streak = Number(this.state.win_streak || 0) + 1; | |
| this.locked = true; | |
| this.setBadge('COMPLETE', 'complete', `${this.state.performance_label} ${this.state.performance_rating}`); | |
| this.setStatus( | |
| this.state.victory_message || 'Level cleared. Click NEXT LEVEL to continue.', | |
| 'win', | |
| pairFact || featuredFact || this.state.theme || `Bonus ready for level ${this.state.level}.` | |
| ); | |
| if (!isChallengeBoard) { | |
| this.hints = Math.min(effectiveMaxHints, this.hints + 1); | |
| this.state.hints = this.hints; | |
| } | |
| this.updateHud(); | |
| this.applyStateStyle(); | |
| if (this.nextBtn) this.nextBtn.disabled = false; | |
| const response = await this.syncBackendEvent('complete'); | |
| if (response && response.state) { | |
| this.state = { | |
| ...this.state, | |
| ...response.state, | |
| }; | |
| this.bindActiveLevelState(); | |
| this.updateHud(); | |
| } | |
| const hintMessage = shouldAwardPeek | |
| ? '+1 peek earned!' | |
| : `Max peeks earned for this level: ${this.hints} / ${effectiveMaxHints}`; | |
| const completionDetails = [featuredFact, hintMessage].filter(Boolean).join('\n'); | |
| this.setStatus( | |
| this.state.victory_message || 'Level cleared. Click NEXT LEVEL to continue.', | |
| 'win', | |
| completionDetails || hintMessage | |
| ); | |
| return; | |
| } | |
| if (this.comboStreak >= 2) { | |
| this.showComboFeedback(); | |
| } else { | |
| this.triggerPopup('Matched!', 'pop-good-runtime'); | |
| } | |
| this.setStatus('Match found.', 'show', pairFact || this.state.theme || ''); | |
| this.locked = false; | |
| } | |
| onWrong(a, b) { | |
| const cardA = this.grid.querySelector(`[data-index="${a}"]`); | |
| const cardB = this.grid.querySelector(`[data-index="${b}"]`); | |
| if (cardA) cardA.classList.add('wrong'); | |
| if (cardB) cardB.classList.add('wrong'); | |
| this.resetComboFeedback(); | |
| this.clearWrongMatchTimer(); | |
| this.locked = true; | |
| const wrongFlipBackDelay = this.getWrongFlipBackDelayMs(); | |
| this.wrongMatchTimer = window.setTimeout(() => { | |
| try { | |
| [a, b].forEach((index) => { | |
| const card = this.grid ? this.grid.querySelector(`[data-index="${index}"]`) : null; | |
| if (card) { | |
| card.classList.remove('flipped', 'wrong'); | |
| } | |
| }); | |
| } finally { | |
| this.flipped = []; | |
| this.locked = false; | |
| this.wrongMatchTimer = null; | |
| if (this.state.lives <= 0) { | |
| this.gameOver(); | |
| } | |
| } | |
| }, wrongFlipBackDelay); | |
| try { | |
| this.showFeedbackOverlay('WRONG MATCH', 'Lost 1 live', 'negative', false); | |
| this.playWrongSound(); | |
| this.state.wrong_attempts = Number(this.state.wrong_attempts || 0) + 1; | |
| this.state.score = Math.max(0, Number(this.state.score || 0) - 10); | |
| if (this.isChallengeMode()) { | |
| this.state.performance_meter = 100; | |
| this.state.challenge_due = false; | |
| } else { | |
| this.state.performance_meter = Math.max( | |
| 0, | |
| Number(this.state.performance_meter || 0) - 20 | |
| ); | |
| this.state.challenge_due = Number(this.state.performance_meter || 0) >= 100; | |
| } | |
| this.state.previous_level_completed = false; | |
| this.updateHud(); | |
| this.setStatus( | |
| this.state.failure_message || 'No match. Try again.', | |
| 'error', | |
| this.state.theme || `Wrong attempts: ${this.state.wrong_attempts}` | |
| ); | |
| } catch (err) { | |
| console.error('onWrong UI update failed', err); | |
| } | |
| void this.syncBackendEvent('wrong'); | |
| } | |
| async useHint() { | |
| if (this.state.status !== 'playing') return; | |
| this.syncProgressiveHintLimit(false); | |
| if (this.hinting) return; | |
| if (this.peekCooldownMoves > 0) { | |
| this.setStatus('Peek is cooling down.', 'error', `${this.peekCooldownMoves} move${this.peekCooldownMoves === 1 ? '' : 's'} left before next peek.`); | |
| this.updateHintsDisplay(); | |
| return; | |
| } | |
| if (this.hints <= 0) { | |
| this.setStatus('No peeks left!', 'error', ''); | |
| this.updateHintsDisplay(); | |
| return; | |
| } | |
| if (this.flipped.length > 0) return; | |
| this.resetComboFeedback(); | |
| const pair = this.findUnmatchedPairForHint(); | |
| if (!pair) { | |
| this.setStatus('No peek available!', 'error', ''); | |
| return; | |
| } | |
| this.hints = Math.max(0, this.hints - 1); | |
| this.state.hints = this.hints; | |
| this.hinting = true; | |
| this.locked = true; | |
| this.updateHintsDisplay(); | |
| const [a, b] = pair; | |
| const cardA = this.grid.querySelector(`[data-index="${a}"]`); | |
| const cardB = this.grid.querySelector(`[data-index="${b}"]`); | |
| if (cardA) { | |
| cardA.classList.add('flipped', 'hint-peek'); | |
| } | |
| if (cardB) { | |
| cardB.classList.add('flipped', 'hint-peek'); | |
| } | |
| this.setStatus('🔍 Peek revealed a pair!', 'show', ''); | |
| setTimeout(async () => { | |
| if (!this.matched.includes(a) && cardA) { | |
| cardA.classList.remove('flipped', 'hint-peek'); | |
| } | |
| if (!this.matched.includes(b) && cardB) { | |
| cardB.classList.remove('flipped', 'hint-peek'); | |
| } | |
| this.hinting = false; | |
| this.locked = false; | |
| this.peekCooldownMoves = this.getHintCooldownLengthMoves(); | |
| this.state.peek_cooldown_moves = this.peekCooldownMoves; | |
| this.updateHintsDisplay(); | |
| await this.syncBackendEvent('hint'); | |
| }, 1200); | |
| } | |
| findUnmatchedPairForHint() { | |
| const groups = new Map(); | |
| this.cards.forEach((emoji, idx) => { | |
| if (this.matched.includes(idx)) return; | |
| if (!groups.has(emoji)) { | |
| groups.set(emoji, []); | |
| } | |
| groups.get(emoji).push(idx); | |
| }); | |
| const candidates = [...groups.entries()].filter(([, indices]) => indices.length >= 2); | |
| if (!candidates.length) return null; | |
| const fresh = candidates.find(([emoji, indices]) => { | |
| if (this.hintedPairs.has(emoji)) return false; | |
| return indices.every((index) => !this.flipped.includes(index)); | |
| }); | |
| const selected = fresh || candidates.find(([, indices]) => indices.every((index) => !this.flipped.includes(index))) || candidates[0]; | |
| if (!selected) return null; | |
| this.hintedPairs.add(selected[0]); | |
| return selected[1].slice(0, 2); | |
| } | |
| gameOver() { | |
| this.state.status = 'game_over'; | |
| this.state.game_over = true; | |
| this.state.previous_level_completed = false; | |
| this.state.previous_level_wrong_attempts = Number(this.state.wrong_attempts || 0); | |
| this.state.previous_level_time_seconds = this.levelStartTime | |
| ? Math.max(1, Math.round((Date.now() - this.levelStartTime) / 1000)) | |
| : Number(this.state.previous_level_time_seconds || 0); | |
| this.state.win_streak = 0; | |
| this.resetComboFeedback(); | |
| this.stopDistractorSparkles(); | |
| this.locked = true; | |
| this.applyStateStyle(); | |
| this.setStatus( | |
| this.state.failure_message || 'Game over. Press NEW GAME to try again.', | |
| 'error', | |
| this.state.theme || 'You used all 5 lives.' | |
| ); | |
| if (this.nextBtn) this.nextBtn.disabled = true; | |
| this.clearPreviewTimer(); | |
| this.showGameOverModal(this.state.score || 0); | |
| this.updateHintsDisplay(); | |
| this.scheduleLeaderboardRefresh(0); | |
| } | |
| async syncBackendEvent(kind) { | |
| try { | |
| let response = null; | |
| if (kind === 'wrong') { | |
| response = this.normalizeServerResponse(await server.on_wrong_match(JSON.stringify(this.snapshot()))); | |
| } else if (kind === 'complete') { | |
| response = this.normalizeServerResponse(await server.on_level_complete(JSON.stringify(this.snapshot()))); | |
| } else if (kind === 'hint') { | |
| response = this.normalizeServerResponse(await server.on_hint_used(JSON.stringify(this.snapshot()))); | |
| } else if (kind === 'challenge') { | |
| response = this.normalizeServerResponse(await server.on_challenge_answer(JSON.stringify(this.snapshot()))); | |
| } | |
| const nextState = response && (response.state || response); | |
| if (nextState && (nextState.game_id || nextState.active_level || nextState.lives !== undefined)) { | |
| this.state = { | |
| ...this.state, | |
| ...nextState, | |
| }; | |
| this.bindActiveLevelState(); | |
| this.updateHud(); | |
| if (this.state.level_type === 'challenge') { | |
| this.renderChallengePanel(); | |
| if (this.state.challenge_selected_index !== undefined && this.state.challenge_selected_index !== null) { | |
| const isCorrect = this.state.challenge_result === 'correct'; | |
| this.setChallengeOptionState( | |
| Number(this.state.challenge_selected_index), | |
| Number(this.challengeCorrectIndex), | |
| isCorrect | |
| ); | |
| } | |
| } | |
| if (this.state.game_over || this.state.status === 'game_over') { | |
| this.showGameOverModal(this.state.score || 0); | |
| this.scheduleLeaderboardRefresh(0); | |
| } | |
| this.applyStateStyle(); | |
| } | |
| return response; | |
| } catch (err) { | |
| return null; | |
| } | |
| return null; | |
| } | |
| async mountActiveLevelAndPreview(nextState, showPreparingOverlay = false) { | |
| if (showPreparingOverlay) { | |
| this.showTransitionOverlay('🤖 Preparing Next Level', 'Building the new board and loading the next challenge...'); | |
| } | |
| try { | |
| this.renderState(nextState); | |
| this.hideTransitionOverlay(true); | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| this.hintedPairs = new Set(); | |
| this.hinting = false; | |
| this.startPreview(); | |
| }); | |
| }); | |
| } catch (err) { | |
| console.error('mountActiveLevelAndPreview failed', err); | |
| this.hideTransitionOverlay(true); | |
| this.pendingTransition = false; | |
| this.locked = false; | |
| this.setStatus( | |
| 'Could not load the next level.', | |
| 'error', | |
| 'Please try again.' | |
| ); | |
| } | |
| } | |
| async advanceLevel() { | |
| if (this.pendingTransition || this.state.status !== 'complete') return; | |
| this.pendingTransition = true; | |
| this.locked = true; | |
| this.showTransitionOverlay('🤖 Preparing Next Level', 'Loading your next challenge...'); | |
| if (this.nextBtn) this.nextBtn.disabled = true; | |
| try { | |
| const response = this.normalizeServerResponse( | |
| await server.on_next_level_click(JSON.stringify(this.snapshot())) | |
| ); | |
| if (!response) { | |
| this.hideTransitionOverlay(true); | |
| return; | |
| } | |
| if (response.ui_action === 'load_level_and_start_preview' && response.state) { | |
| await this.mountActiveLevelAndPreview(response.state, true); | |
| } else { | |
| this.hideTransitionOverlay(true); | |
| this.setStatus( | |
| 'Could not load the next level.', | |
| 'error', | |
| 'The server returned an unexpected response.' | |
| ); | |
| } | |
| } catch (err) { | |
| console.error('on_next_level_click failed', err); | |
| this.setStatus('Could not load the next level.', 'error', 'The local generator fell back or failed.'); | |
| this.hideTransitionOverlay(); | |
| } finally { | |
| this.pendingTransition = false; | |
| } | |
| } | |
| async startGame() { | |
| if (this.pendingTransition) return; | |
| this.pendingTransition = true; | |
| this.sessionHash = this.sessionHash || ((window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`); | |
| if (this.startGameBtn) { | |
| this.startGameBtn.disabled = true; | |
| this.startGameBtn.textContent = 'Starting...'; | |
| } | |
| if (this.startNote) { | |
| this.startNote.textContent = 'Generating your first level...'; | |
| } | |
| try { | |
| const startResponse = this.normalizeServerResponse( | |
| await server.start_game(JSON.stringify({ | |
| session_hash: this.sessionHash, | |
| game_id: this.sessionHash, | |
| })) | |
| ); | |
| const startState = startResponse?.state || startResponse; | |
| if (!startState) { | |
| throw new Error(startResponse?.error || 'Could not create session'); | |
| } | |
| this.sessionHash = String(startState.game_id || this.sessionHash); | |
| const nextUrl = new URL(window.location.href); | |
| nextUrl.pathname = '/level'; | |
| nextUrl.searchParams.set('session_hash', this.sessionHash); | |
| window.location.assign(nextUrl.toString()); | |
| } catch (err) { | |
| console.error('start_game failed', err); | |
| if (this.startNote) { | |
| this.startNote.textContent = 'Could not start the game. Please try again.'; | |
| } | |
| } finally { | |
| this.pendingTransition = false; | |
| if (this.startGameBtn) { | |
| this.startGameBtn.disabled = false; | |
| this.startGameBtn.textContent = 'Start'; | |
| } | |
| } | |
| } | |
| async resetGame() { | |
| if (this.pendingTransition) return; | |
| this.pendingTransition = true; | |
| this.locked = true; | |
| await this.refreshLeaderboardData(); | |
| this.hideResetConfirm(); | |
| this.hideGameOverModal(); | |
| this.stopDistractorSparkles(); | |
| this.showTransitionOverlay('🤖 Starting New Game', 'Resetting the board and preparing a fresh run...'); | |
| if (this.nextBtn) this.nextBtn.disabled = true; | |
| try { | |
| const freshState = this.normalizeServerResponse(await server.reset_game()); | |
| if (freshState) { | |
| this.renderState(freshState, true); | |
| this.hints = Number(freshState.hints || 1); | |
| this.maxHints = Number(freshState.max_hints || 5); | |
| this.hintedPairs = new Set(); | |
| this.hinting = false; | |
| this.updateHintsDisplay(); | |
| requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| this.hideTransitionOverlay(true); | |
| requestAnimationFrame(() => { | |
| this.startPreview(); | |
| }); | |
| }); | |
| }); | |
| } | |
| } catch (err) { | |
| console.error('reset_game failed', err); | |
| this.hideTransitionOverlay(); | |
| } finally { | |
| this.pendingTransition = false; | |
| } | |
| } | |
| } | |
| new MatchWiseApp(); | |
| """ | |
| JS = JS.replace("__LOGO_DATA_URI__", LOGO_DATA_URI) | |
| # ========================================================= | |
| # BUILD DEMO | |
| # ========================================================= | |
| GLOBAL_SHELL_CSS = """ | |
| html, body { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| width: 100% !important; | |
| background: #040714 !important; | |
| color-scheme: dark; | |
| } | |
| .gradio-container { | |
| width: 100vw !important; | |
| min-height: 100vh; | |
| background: #040714 !important; | |
| color-scheme: dark; | |
| } | |
| .gradio-container nav, | |
| .gradio-container header { | |
| display: none !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| footer { | |
| display: none !important; | |
| } | |
| #hf-login-button { | |
| position: fixed !important; | |
| top: 16px; | |
| right: 16px; | |
| z-index: 1200; | |
| width: auto !important; | |
| min-width: 0 !important; | |
| display: inline-flex !important; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .top-utility-bar #hf-login-button { | |
| position: static !important; | |
| top: auto !important; | |
| right: auto !important; | |
| z-index: auto; | |
| display: inline-flex !important; | |
| } | |
| #hf-login-button :is(button, a) { | |
| min-height: 38px !important; | |
| padding: 8px 12px !important; | |
| border-radius: 999px !important; | |
| font-size: 13px !important; | |
| line-height: 1 !important; | |
| white-space: nowrap !important; | |
| } | |
| #game-shell, | |
| .gradio-container .main { | |
| background: #040714 !important; | |
| border-top: none !important; | |
| box-shadow: none !important; | |
| } | |
| @media (max-width: 640px) { | |
| #hf-login-button { | |
| top: 10px; | |
| right: 10px; | |
| transform: scale(0.9); | |
| transform-origin: top right; | |
| } | |
| #hf-login-button :is(button, a) { | |
| min-height: 34px !important; | |
| padding: 7px 10px !important; | |
| font-size: 12px !important; | |
| } | |
| } | |
| """ | |
| GLOBAL_SHELL_HEAD = """ | |
| <meta name=\"color-scheme\" content=\"dark\"> | |
| """ | |
| DARK_THEME = gr.themes.Base().set( | |
| body_background_fill="#040714", | |
| body_background_fill_dark="#040714", | |
| body_text_color="#f8fafc", | |
| body_text_color_dark="#f8fafc", | |
| body_text_color_subdued="#cbd5e1", | |
| body_text_color_subdued_dark="#cbd5e1", | |
| ) | |
| def build_demo() -> gr.Blocks: | |
| initial_state = build_shell_state() | |
| with gr.Blocks(title="MatchWise", fill_width=True) as demo: | |
| gr.Navbar(visible=False) | |
| gr.LoginButton( | |
| value="Sign in with HF", | |
| logout_value="Logout ({})", | |
| elem_id="hf-login-button", | |
| ) | |
| gr.HTML( | |
| value="", | |
| html_template=START_HTML, | |
| css_template=CSS, | |
| js_on_load=JS, | |
| elem_id="game-shell", | |
| server_functions=[ | |
| start_game, | |
| get_leaderboard_data, | |
| ], | |
| state_json=json.dumps(public_game_state(initial_state), ensure_ascii=False), | |
| game_id=initial_state["game_id"], | |
| active_level_json=json.dumps(initial_state["active_level"], ensure_ascii=False), | |
| generation_status_json=json.dumps(initial_state["generation_status"], ensure_ascii=False), | |
| ) | |
| with demo.route("Level", "/level", show_in_navbar=False): | |
| gr.Navbar(visible=False) | |
| gr.LoginButton( | |
| value="Sign in with HF", | |
| logout_value="Logout ({})", | |
| elem_id="hf-login-button", | |
| ) | |
| gr.HTML( | |
| value="", | |
| html_template=LEVEL_HTML, | |
| css_template=CSS, | |
| js_on_load=JS, | |
| elem_id="game-shell", | |
| server_functions=[ | |
| load_level_state, | |
| sync_state, | |
| on_wrong_match, | |
| on_level_complete, | |
| on_hint_used, | |
| on_challenge_answer, | |
| on_next_level_click, | |
| reset_game, | |
| get_leaderboard_data, | |
| ], | |
| state_json=json.dumps(public_game_state(initial_state), ensure_ascii=False), | |
| game_id=initial_state["game_id"], | |
| level=initial_state["active_level"]["level_number"], | |
| score=initial_state["score"], | |
| performance_meter=initial_state["performance_meter"], | |
| lives=initial_state["lives"], | |
| max_lives=initial_state["max_lives"], | |
| hints=initial_state["hints"], | |
| max_hints=initial_state["max_hints"], | |
| total_pairs=initial_state["active_level"]["pair_count"], | |
| grid_cols=initial_state["active_level"]["cols"], | |
| grid_rows=initial_state["active_level"]["rows"], | |
| card_size=initial_state["active_level"]["rows"] <= 2 and 160 or initial_state["active_level"]["rows"] <= 3 and 150 or 132, | |
| level_title=initial_state["active_level"]["blueprint"]["level_title"], | |
| theme=initial_state["active_level"]["blueprint"]["theme"], | |
| educational_focus=initial_state["active_level"]["blueprint"]["educational_focus"], | |
| victory_message=initial_state["active_level"]["blueprint"]["victory_message"], | |
| failure_message=initial_state["active_level"]["blueprint"]["failure_message"], | |
| active_level_json=json.dumps(initial_state["active_level"], ensure_ascii=False), | |
| generation_status_json=json.dumps(initial_state["generation_status"], ensure_ascii=False), | |
| ) | |
| return demo | |
| demo: gr.Blocks | None = None | |
| def main() -> None: | |
| global demo | |
| bootstrap_llama_runtime() | |
| demo = build_demo() | |
| demo.queue(default_concurrency_limit=1, max_size=20) | |
| demo.launch(server_name="0.0.0.0", ssr_mode=False, css=GLOBAL_SHELL_CSS, theme=DARK_THEME, head=GLOBAL_SHELL_HEAD) | |
| if __name__ == "__main__": | |
| main() |