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".*?", "", 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] + "..." 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 = """
MatchWise
Remember. Match. Progress.
Match pairs, discover facts, and see how far your memory can go.
โญ My High Score Guest 0
Built with โค๏ธ by tejasashinde with Gradio, MiniCPM5-1B via Llama.cpp
Press Start to generate your first level.
MatchWise
Match pairs, discover facts, and see how far your memory can go.
โญ My High Score Guest 0
PERFORMANCE METER
๐Ÿƒ
EASY
๐Ÿ”ฅ
Challenge Me
๐Ÿ LEVEL ${level}
โ‡„ MOVES 0
๐Ÿ‘ฅ MATCHES 0 / 0
๐Ÿ† SCORE ${score}
โ™ฅ LIVES ${lives}
MEMORIZE
${level_title}
${educational_focus}
${level_title}
${theme}
${educational_focus}
3
Memorize the cards
@children
""" GRID_HTML = GRID_HTML.replace("__LOGO_DATA_URI__", LOGO_DATA_URI) _LEVEL_SPLIT_INDEX = GRID_HTML.index('
\n" LEVEL_HTML = '
\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 = '
No scores yet. Be the first to play.
'; return; } const currentUsername = String(this.state.leaderboard_username || '').trim().toLowerCase(); this.leaderboardListEl.innerHTML = `
${rows.map((entry) => { const username = String(entry.username || 'Guest').trim(); const score = Number(entry.score || 0); const isCurrentUser = currentUsername && username.toLowerCase() === currentUsername; return ` `; }).join('')}
Rank HF Username High Score
#${Number(entry.rank || 0)} ${this.escapeHtml(username)}${isCurrentUser ? ' (You)' : ''} ${score}
`; } 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 = '
Loading leaderboard...
'; } 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 = `${text}${subtext}`; } 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, '''); } 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 `
${this.escapeHtml(display)}
`; }).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 = """ """ 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()