""" π¦ NumZoo β Math practice with cute AI-generated animal rewards! """ import base64 import io import json import random import gradio as gr from math_engine import generate_question, LEVEL_NAMES, level_up_message from image_generator import generate_reward_image # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- ANSWERS_PER_LEVEL = 5 # correct answers needed to complete a level and earn a reward ANIMAL_EMOJIS = ["πΆ", "π±", "π°", "π¦", "πΌ", "π¨", "π¦", "π―", "πΈ", "π§", "π¦", "π¦"] PLACE_EMOJIS = ["π", "ποΈ", "πΈ", "π", "π", "β", "π΄", "π‘", "πΊ", "π"] # Math difficulty caps at 4 (Mix), but level number shown to user keeps increasing MATH_LEVEL_NAMES = {1: "Additions", 2: "Subtractions", 3: "Multiplications", 4: "Mix"} # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _math_level(state: dict) -> int: """Return math difficulty (1β4), capped, from game level.""" return min(state.get("level", 1), 4) def _status_text(state: dict) -> str: if not state: return "" diff = MATH_LEVEL_NAMES.get(_math_level(state), "") correct = state.get("correct_this_level", 0) return (f"π€ {state['name']} | β {state['score']} | " f"π {diff} | β {correct}/{ANSWERS_PER_LEVEL}") def _safe_question(state: dict) -> str: return f"## {state.get('question', '')} = ?" def _img_to_data_url(pil_image) -> str: buf = io.BytesIO() pil_image.save(buf, format="JPEG", quality=80) return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() def _coll(*actions) -> str: """Encode collection actions into the collection_trigger HTML component.""" payload = json.dumps({ "ts": random.random(), "actions": [{"action": a, "id": str(i), "src": s} for a, i, s in actions], }) return f"
" def _picker_level_label(level: int) -> str: diff = MATH_LEVEL_NAMES.get(min(level, 4), "Mix") return f"## π Level {level} β {diff}" # --------------------------------------------------------------------------- # Step 1 β Name entry # --------------------------------------------------------------------------- def enter_name(player_name: str, state: dict): try: name = player_name.strip() or "Player" state = {"name": name, "level": 1, "score": 0, "streak": 0} return ( state, gr.update(visible=False), # welcome gr.update(visible=True), # emoji picker gr.update(visible=False), # game gr.update(visible=False), # picker_level_md (hidden for level 1) random.sample(ANIMAL_EMOJIS, 2), random.sample(PLACE_EMOJIS, 2), ) except Exception as e: print(f"enter_name error: {e}") return (state, gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), [], []) # --------------------------------------------------------------------------- # Step 2 β Emoji selection β start level # --------------------------------------------------------------------------- def start_level(animal_sel: list, place_sel: list, state: dict): try: if not animal_sel or not place_sel: return (state, gr.update(visible=True), gr.update(visible=False), "β οΈ Pick at least one animal and one place!", "", "", "") level = state.get("level", 1) state.update({ "selected_animals": animal_sel[:3], "selected_places": place_sel[:3], "correct_this_level": 0, "generate_now": False, }) # Preserve: name, level, score, streak math_lv = _math_level(state) question, answer = generate_question(math_lv) state["question"] = question state["answer"] = answer # Add locked placeholder for this level's reward immediately return (state, gr.update(visible=False), gr.update(visible=True), "", _status_text(state), _safe_question(state), _coll(("add-locked", level, ""))) except Exception as e: print(f"start_level error: {e}") return (state, gr.update(visible=True), gr.update(visible=False), "β οΈ Something went wrong, try again.", "", "", "") # --------------------------------------------------------------------------- # Background pre-generation β fires immediately after "Let's go!" so the # image is ready when the user finishes the level. # --------------------------------------------------------------------------- def pregenerate_image(state: dict): """Pre-generate the reward image for the current level.""" level = state.get("level", 1) try: animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)]) places = state.get("selected_places", [random.choice(PLACE_EMOJIS)]) streak = state.get("streak", 0) print(f"[pregenerate] level={level} | animals={animals} | places={places}") result, prompt = generate_reward_image(streak, animals, places) print(f"[pregenerate] level={level} done | prompt={prompt!r}") if result is not None: data_url = _img_to_data_url(result) state["pending_reward_id"] = level return state, result, data_url, "" state.pop("pending_reward_id", None) return state, None, "", "" except Exception as e: import traceback print(f"[pregenerate] β {e}\n{traceback.format_exc()}") state.pop("pending_reward_id", None) return state, None, "", "" # --------------------------------------------------------------------------- # Step 3 β Answer checking # --------------------------------------------------------------------------- def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str): _empty = (state, _status_text(state), _safe_question(state), "", "", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), "") try: if not state or not state.get("question"): return _empty try: user_answer = int(user_input.strip()) except (ValueError, AttributeError): return (state, _status_text(state), _safe_question(state), "", "β οΈ Numbers only!", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), "") correct = (user_answer == state["answer"]) if correct: state["score"] += 1 state["streak"] += 1 state["correct_this_level"] = state.get("correct_this_level", 0) + 1 level = state.get("level", 1) correct_count = state["correct_this_level"] remaining = ANSWERS_PER_LEVEL - correct_count streak_fire = "π₯" * min(state["streak"], 5) if correct_count >= ANSWERS_PER_LEVEL: # ββ Level complete! βββββββββββββββββββββββββββββββββββββββββ state["generate_now"] = False pending_id = state.get("pending_reward_id") has_pre = pre_image is not None and bool(pre_data_url) and pending_id == level if has_pre: state.pop("pending_reward_id", None) return (state, _status_text(state), _safe_question(state), "", f"π Level {level} complete! {streak_fire}", gr.update(visible=True), # reward_panel "", # loader cleared gr.update(visible=True, value=pre_image), # image shown "", None, # clear hidden_image "", # clear hidden_data_url _coll(("unlock", level, pre_data_url))) else: state["generate_now"] = True return (state, _status_text(state), _safe_question(state), "", f"π Level {level} complete! {streak_fire}", gr.update(visible=True), # reward_panel LOADER_HTML, # show loader gr.update(visible=False), # image hidden "", gr.update(), gr.update(), "") else: # ββ Keep going βββββββββββββββββββββββββββββββββββββββββββββββ feedback = f"β {remaining} to go! {streak_fire}" math_lv = _math_level(state) question, answer = generate_question(math_lv) state["question"] = question state["answer"] = answer return (state, _status_text(state), _safe_question(state), "", feedback, gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), "") else: state["streak"] = 0 math_lv = _math_level(state) question, answer = generate_question(math_lv) state["question"] = question state["answer"] = answer return (state, _status_text(state), _safe_question(state), "", f"β Answer: **{state['answer']}**", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), "") except Exception as e: import traceback print(f"check_answer error: {e}\n{traceback.format_exc()}") state["generate_now"] = False math_lv = _math_level(state) question, answer = generate_question(math_lv) state["question"] = question state["answer"] = answer return (state, _status_text(state), _safe_question(state), "", "β οΈ Something went wrong!", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), "") # --------------------------------------------------------------------------- # On-demand generation β fallback when pre-image wasn't ready at level end # --------------------------------------------------------------------------- def generate_on_demand(state: dict): """Generate reward image on demand. No-op if generate_now is False.""" if not state.get("generate_now"): return state, "", gr.update(), "", "" state["generate_now"] = False level = state.get("level", 1) try: animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)]) places = state.get("selected_places", [random.choice(PLACE_EMOJIS)]) streak = state.get("streak", 0) print(f"[on_demand] level={level}") result, prompt = generate_reward_image(streak, animals, places) print(f"[on_demand] level={level} done") if result is not None: data_url = _img_to_data_url(result) return (state, "", gr.update(visible=True, value=result), "", _coll(("unlock", level, data_url))) return state, "", gr.update(visible=False), "β οΈ Could not generate image β try again later!", "" except Exception as e: import traceback print(f"[on_demand] β {e}\n{traceback.format_exc()}") return state, "", gr.update(visible=False), f"β οΈ Error: {e}", "" # --------------------------------------------------------------------------- # Next level β back to picker with incremented level # --------------------------------------------------------------------------- def next_level(state: dict): try: new_level = state.get("level", 1) + 1 state["level"] = new_level state["correct_this_level"] = 0 state.pop("pending_reward_id", None) label = _picker_level_label(new_level) return (state, gr.update(visible=False), # game_panel gr.update(visible=True), # emoji_panel gr.update(value=label, visible=True), # picker_level_md random.sample(ANIMAL_EMOJIS, 2), # reset animal picker random.sample(PLACE_EMOJIS, 2), # reset place picker None, # clear hidden_image "") # clear hidden_data_url except Exception as e: print(f"next_level error: {e}") return (state, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), None, "") # --------------------------------------------------------------------------- # Restart # --------------------------------------------------------------------------- def restart(state: dict): return ({}, gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(), "", None, "") # --------------------------------------------------------------------------- # Gradio UI # --------------------------------------------------------------------------- CSS = """ .gradio-container { max-width: 560px !important; margin: 0 auto !important; } .gradio-group > .form { padding: 20px 24px !important; } .prose h3 { margin-top: 14px !important; margin-bottom: 8px !important; padding-left: 4px !important; } #title { text-align: center; font-size: 2.2em; margin-bottom: 0.1em; } #subtitle { text-align: center; color: #888; margin-bottom: 1em; font-style: italic; } #question-box { text-align: center; font-size: 2.6em; font-weight: bold; padding: 0.5em; } #feedback-box { text-align: center; font-size: 1.3em; min-height: 2em; } #status-box { text-align: center; padding: 0.4em; border-radius: 8px; } #reward-img { border-radius: 16px; } #picker-level { text-align: center; padding: 0.3em 0 0.6em; } .answer-input input { font-size: 2em !important; text-align: center !important; } /* Emoji picker grid */ .emoji-group .wrap { display: grid !important; gap: 8px !important; justify-content: center !important; justify-items: center !important; } #animal-picker .wrap { grid-template-columns: repeat(6, 52px) !important; } #place-picker .wrap { grid-template-columns: repeat(5, 52px) !important; } .emoji-group label { width: 52px !important; height: 52px !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 1.8em !important; cursor: pointer !important; border-radius: 12px !important; border: 2px solid transparent !important; transition: all 0.15s !important; user-select: none !important; } .emoji-group label:has(input:checked) { background: #e9d5ff !important; border-color: #7c3aed !important; } .emoji-group input[type="checkbox"] { display: none !important; } /* Collection locked pulse */ @keyframes numzoo-pulse { 0%,100% { opacity:.4; } 50% { opacity:.9; } } """ LOADER_HTML = """