""" ๐Ÿฆ NumZoo โ€” Math practice with cute AI-generated animal rewards! """ import base64 import io import os 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 # Animals โ€” ordered by lookalike groups (pets ยท canines/bears ยท big cats ยท farm ยท # water/amphibian ยท bugs ยท mythical) so similar creatures sit together in the picker. ANIMAL_EMOJIS = [ "๐Ÿถ", "๐Ÿฑ", "๐Ÿฐ", "๐Ÿญ", "๐ŸฆŠ", "๐Ÿบ", "๐Ÿป", "๐Ÿผ", "๐Ÿจ", "๐Ÿฆ", "๐Ÿฏ", "๐Ÿท", "๐Ÿฎ", "๐Ÿด", "๐Ÿ‘", "๐Ÿธ", "๐Ÿฆฆ", "๐Ÿง", "๐Ÿ™", "๐Ÿฆ‹", "๐Ÿ", "๐Ÿž", "๐Ÿฆ„", "๐Ÿ‰", ] PLACE_EMOJIS = [ # 18 places โ†’ renders as 3 rows of 6 "๐ŸŒŠ", "๐Ÿ”๏ธ", "๐ŸŒธ", "๐ŸŒˆ", "๐ŸŒ™", "โญ", "๐ŸŒด", "๐Ÿก", "๐ŸŒบ", "๐Ÿ„", "๐Ÿœ๏ธ", "๐Ÿฐ", "๐ŸŽข", "โ›บ", "๐Ÿชฉ", "๐ŸŽช", "โ›ต", "๐Ÿš€", ] MATH_LEVEL_NAMES = {1: "Additions", 2: "Subtractions", 3: "Multiplications", 4: "Mix", 5: "Mix +"} # --------------------------------------------------------------------------- # Game helpers # --------------------------------------------------------------------------- def _math_level(state: dict) -> int: return min(state.get("level", 1), 5) 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) level = state.get("level", 1) return (f"๐Ÿ‘ค {state['name']} | ๐ŸŽฎ Level: {level} | " 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 _picker_level_label(level: int) -> str: diff = MATH_LEVEL_NAMES.get(min(level, 5), "Mix +") return f"## ๐ŸŒŸ Level {level} โ€” {diff}" # --------------------------------------------------------------------------- # Collection helpers โ€” server-side render, no JS polling # --------------------------------------------------------------------------- def _add_locked(items: list, level_id: int) -> list: """Append a locked placeholder for level_id if not already in the list.""" id_str = str(level_id) if any(x["id"] == id_str for x in items): return items return items + [{"id": id_str, "status": "locked", "src": None}] def _unlock_item(items: list, level_id: int, src: str) -> list: """Replace the locked entry for level_id with an unlocked image.""" id_str = str(level_id) updated, found = [], False for item in items: if item["id"] == id_str: updated.append({"id": id_str, "status": "unlocked", "src": src}) found = True else: updated.append(item) if not found: updated.append({"id": id_str, "status": "unlocked", "src": src}) return updated def _render_collection(items: list) -> str: """Render the collection as HTML, newest first. Empty string when no items.""" if not items: return "" cards = [] for item in reversed(items): if item["status"] == "locked": cards.append( '
' '
' '
๐Ÿ”’
' '
' ) elif item.get("src"): src = item["src"] cards.append( f'
' f'' f'
' ) if not cards: return "" return ( '
' '
๐Ÿ–ผ๏ธ My Collection
' '
' + "".join(cards) + '
' ) # --------------------------------------------------------------------------- # 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 random.sample(ANIMAL_EMOJIS, 1), random.sample(PLACE_EMOJIS, 1), ) 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, coll_items: list): 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!", "", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) level = state.get("level", 1) state.update({ "selected_animals": animal_sel[:5], "selected_places": place_sel[:3], "correct_this_level": 0, "generate_now": False, }) math_lv = _math_level(state) question, answer = generate_question(math_lv) state["question"] = question state["answer"] = answer new_coll = _add_locked(coll_items, level) return (state, gr.update(visible=False), # emoji_panel gr.update(visible=True), # game_panel "", # picker_error _status_text(state), # status_md gr.update(value=_safe_question(state), visible=True), # question_md gr.update(value="", visible=True), # answer_input gr.update(visible=True), # check_btn gr.update(visible=False), # reward_panel (reset) new_coll, # coll_items _render_collection(new_coll)) # collection_html 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.", "", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) # --------------------------------------------------------------------------- # Background pre-generation โ€” fires after "Let's go!" so the image is ready # when the user finishes the level. # --------------------------------------------------------------------------- def pregenerate_image(state: dict): level = state.get("level", 1) try: animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)]) places = state.get("selected_places", [random.choice(PLACE_EMOJIS)]) print(f"[pregenerate] level={level} | animals={animals} | places={places}") result, prompt = generate_reward_image(animals, places, reward_id=level) 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 # --------------------------------------------------------------------------- # check_outputs order: # state, status_md, question_md, answer_input, feedback_md, # reward_panel, loader_html, reward_image, reward_error, # hidden_image, hidden_data_url, coll_items, collection_html, check_btn def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str, coll_items: list): def _no_change(): return (state, _status_text(state), gr.update(), gr.update(), "", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) try: if not state or not state.get("question"): return _no_change() try: user_answer = int(user_input.strip()) except (ValueError, AttributeError): return (state, _status_text(state), gr.update(), gr.update(), "โš ๏ธ Numbers only!", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), gr.update(), 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 โ€” hide quiz UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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) new_coll = _unlock_item(coll_items, level, pre_data_url) return (state, _status_text(state), gr.update(visible=False), # question_md gr.update(visible=False), # answer_input 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_* new_coll, _render_collection(new_coll), gr.update(visible=False)) # check_btn else: state["generate_now"] = True return (state, _status_text(state), gr.update(visible=False), # question_md gr.update(visible=False), # answer_input f"๐ŸŽ‰ Level {level} complete! {streak_fire}", gr.update(visible=True), # reward_panel LOADER_HTML, gr.update(visible=False), "", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(visible=False)) # check_btn else: # โ”€โ”€ Keep going โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ math_lv = _math_level(state) question, answer = generate_question(math_lv) state["question"] = question state["answer"] = answer return (state, _status_text(state), gr.update(value=_safe_question(state)), gr.update(value=""), f"โœ… {remaining} to go! {streak_fire}", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) else: correct_answer = state["answer"] # capture before overwrite current_q = state["question"] # the expression, e.g. "15 + 9" 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), gr.update(value=_safe_question(state)), gr.update(value=""), f"โŒ {current_q} = **{correct_answer}**", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), gr.update(), 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), gr.update(value=_safe_question(state)), gr.update(value=""), "โš ๏ธ Something went wrong!", gr.update(visible=False), "", gr.update(visible=False), "", gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) # --------------------------------------------------------------------------- # On-demand generation โ€” fallback when pre-image wasn't ready at level end # --------------------------------------------------------------------------- def generate_on_demand(state: dict, coll_items: list): """Generate reward image on demand. No-op if generate_now is False.""" if not state.get("generate_now"): return state, "", gr.update(), "", gr.update(), 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)]) print(f"[on_demand] level={level}") result, prompt = generate_reward_image(animals, places, reward_id=level) print(f"[on_demand] level={level} done") if result is not None: data_url = _img_to_data_url(result) new_coll = _unlock_item(coll_items, level, data_url) return (state, "", gr.update(visible=True, value=result), "", new_coll, _render_collection(new_coll)) return state, "", gr.update(visible=False), "โš ๏ธ Could not generate image โ€” try again later!", gr.update(), gr.update() except Exception as e: import traceback print(f"[on_demand] โŒ {e}\n{traceback.format_exc()}") return state, "", gr.update(visible=False), f"โš ๏ธ Error: {e}", gr.update(), gr.update() # --------------------------------------------------------------------------- # 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, 3), random.sample(PLACE_EMOJIS, 3), 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, "", [], "") # --------------------------------------------------------------------------- # Banner image โ€” loaded once at startup, inlined as base64 # --------------------------------------------------------------------------- def _load_banner() -> str: path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "samples", "panda-frog-fox-wave.jpeg") try: with open(path, "rb") as f: b64 = base64.b64encode(f.read()).decode() return f"data:image/jpeg;base64,{b64}" except Exception: return "" _BANNER_URL = _load_banner() BANNER_HTML = f"""
NumZoo
Do maths. Win cute animals!
""" # --------------------------------------------------------------------------- # Gradio UI # --------------------------------------------------------------------------- CSS = """ .gradio-container { max-width: 560px !important; margin: 0 auto !important; } /* All top-level panels fill the container identically */ .gradio-group { width: 100% !important; box-sizing: border-box !important; } .gradio-group > .form { padding: 20px 24px !important; box-sizing: border-box !important; width: 100% !important; } .prose h3 { margin-top: 14px !important; margin-bottom: 8px !important; padding-left: 4px !important; } #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(6, 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 */ @keyframes numzoo-pulse { 0%,100% { opacity:.4; } 50% { opacity:.9; } } #nz-coll-wrap { margin-top: 20px; } .nz-coll-title { font-size: 1.1em; font-weight: bold; text-align: center; padding: 8px 20px; margin: 0 0 12px; background: rgba(124, 58, 237, 0.12) !important; border: 1.5px solid rgba(124, 58, 237, 0.35) !important; border-radius: 14px; color: #a78bfa !important; letter-spacing: 0.02em; } #nz-coll-grid { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } .nz-card { width: 80px; height: 80px; border-radius: 12px; overflow: hidden; flex-shrink: 0; position: relative; } .nz-card-locked { background: #1e1e2e; border: 2px dashed #555; } .nz-card-pulse { position: absolute; inset: 0; background: linear-gradient(135deg,#2d2d44,#111128); animation: numzoo-pulse 2s ease-in-out infinite; } .nz-card-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 2em; z-index: 1; position: relative; } .nz-card img { width: 100%; height: 100%; object-fit: cover; cursor: pointer; display: block; } """ LOADER_HTML = """
๐ŸŽจ
Generating your rewardโ€ฆ
""" ZOOM_MODAL_HTML = """ """ with gr.Blocks(title="๐Ÿฆ NumZoo") as demo: state = gr.State({}) coll_items = gr.State([]) hidden_image = gr.Image(visible=False, label="", type="pil") hidden_data_url = gr.Textbox(visible=False, value="") gr.HTML(BANNER_HTML) gr.HTML(""" """) # โ”€โ”€ Welcome โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with gr.Group(visible=True) as welcome_panel: gr.Markdown("### Who are you?") player_name = gr.Textbox(placeholder="Your nameโ€ฆ", label=" ", show_label=False, max_lines=1) next_btn = gr.Button("Next โžก๏ธ", variant="primary", size="lg") # โ”€โ”€ Emoji picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with gr.Group(visible=False) as emoji_panel: picker_level_md = gr.Markdown("", visible=False, elem_id="picker-level") gr.Markdown("### Pick your animals ๐Ÿพ") animal_picker = gr.CheckboxGroup(choices=ANIMAL_EMOJIS, label="", show_label=False, elem_classes=["emoji-group"], elem_id="animal-picker") gr.Markdown("### Pick your places ๐ŸŒ") place_picker = gr.CheckboxGroup(choices=PLACE_EMOJIS, label="", show_label=False, elem_classes=["emoji-group"], elem_id="place-picker") picker_error = gr.Markdown("") go_btn = gr.Button("Let's go! ๐Ÿš€", variant="primary", size="lg") # โ”€โ”€ Game โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with gr.Group(visible=False) as game_panel: status_md = gr.Markdown("", elem_id="status-box", container=True) question_md = gr.Markdown("", elem_id="question-box") answer_input = gr.Textbox(placeholder="?", label="Your answer", max_lines=1, elem_classes=["answer-input"]) check_btn = gr.Button("โœ”๏ธ Check!", variant="primary") feedback_md = gr.Markdown("", elem_id="feedback-box") with gr.Group(visible=False) as reward_panel: gr.Markdown("### ๐ŸŽ Your reward!") loader_html = gr.HTML("") reward_image = gr.Image(label="", show_label=False, elem_id="reward-img", height=400, visible=False) reward_error = gr.Markdown("") next_level_btn = gr.Button("Next Level โžก๏ธ", variant="primary", size="lg") # โ”€โ”€ Collection (server-side rendered) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ collection_html = gr.HTML("", elem_id="nz-collection-html") gr.HTML(ZOOM_MODAL_HTML) restart_btn = gr.Button("๐Ÿ”„ Restart", variant="secondary", size="sm") # โ”€โ”€ Wiring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ enter_name_outputs = [state, welcome_panel, emoji_panel, game_panel, picker_level_md, animal_picker, place_picker] next_btn.click(enter_name, [player_name, state], enter_name_outputs) player_name.submit(enter_name, [player_name, state], enter_name_outputs) # start_level resets quiz visibility + hides reward_panel from prior level start_level_outputs = [state, emoji_panel, game_panel, picker_error, status_md, question_md, answer_input, check_btn, reward_panel, coll_items, collection_html] go_btn.click( start_level, [animal_picker, place_picker, state, coll_items], start_level_outputs, show_progress="hidden" ).then( pregenerate_image, [state], [state, hidden_image, hidden_data_url], show_progress="hidden" ) check_outputs = [ state, status_md, question_md, answer_input, feedback_md, reward_panel, loader_html, reward_image, reward_error, hidden_image, hidden_data_url, coll_items, collection_html, check_btn, ] ondemand_outputs = [state, loader_html, reward_image, reward_error, coll_items, collection_html] for trigger in [check_btn.click, answer_input.submit]: trigger( check_answer, [answer_input, state, hidden_image, hidden_data_url, coll_items], check_outputs, show_progress="hidden", ).then( generate_on_demand, [state, coll_items], ondemand_outputs, show_progress="hidden", ) next_level_outputs = [state, game_panel, emoji_panel, picker_level_md, animal_picker, place_picker, hidden_image, hidden_data_url] next_level_btn.click(next_level, [state], next_level_outputs) restart_btn.click( restart, [state], [state, welcome_panel, emoji_panel, game_panel, player_name, picker_error, hidden_image, hidden_data_url, coll_items, collection_html] ) if __name__ == "__main__": demo.launch( css=CSS, theme=gr.themes.Soft(primary_hue="purple", secondary_hue="pink"), )