goumsss Claude Sonnet 4.6 commited on
Commit
a15351b
Β·
1 Parent(s): 75bda08

Redesign game loop: level-based flow with reward at end of each level

Browse files

New flow per cycle:
- Pick animals + places (picker shows "Level N" for level 2+)
- Answer 5 questions correctly
- Reward screen shows AI image + "Next Level β†’" button
- Back to picker with fresh random emoji pre-selections for variety

Key changes:
- REWARD_EVERY=3 mid-level rewards removed; replaced with ANSWERS_PER_LEVEL=5 per-level reward
- Collection gets add-locked at level START, unlock at level END
- pregenerate_image fires once at level start (not after each reward)
- New next_level() function: increments level, resets to picker
- New next_level_btn in reward panel
- New picker_level_md shows "Level N β€” Difficulty" on return to picker
- Status bar shows correct count (X/5) during level
- Math difficulty caps at 4 (Mix), level display number uncapped
- State simplified: removed reward_count, correct_since_reward, level_correct, pregenerate_next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +153 -140
app.py CHANGED
@@ -9,45 +9,57 @@ import random
9
 
10
  import gradio as gr
11
 
12
- from math_engine import generate_question, LEVEL_NAMES, LEVEL_THRESHOLDS, level_up_message
13
  from image_generator import generate_reward_image
14
 
15
  # ---------------------------------------------------------------------------
16
  # Constants
17
  # ---------------------------------------------------------------------------
18
 
19
- REWARD_EVERY = 3
20
 
21
  ANIMAL_EMOJIS = ["🐢", "🐱", "🐰", "🦊", "🐼", "🐨", "🦁", "🐯", "🐸", "🐧", "πŸ¦‹", "πŸ¦„"]
22
  PLACE_EMOJIS = ["🌊", "πŸ”οΈ", "🌸", "🌈", "πŸŒ™", "⭐", "🌴", "🏑", "🌺", "πŸ„"]
23
 
 
 
 
24
  # ---------------------------------------------------------------------------
25
  # Helpers
26
  # ---------------------------------------------------------------------------
27
 
 
 
 
 
28
  def _status_text(state: dict) -> str:
29
  if not state:
30
  return ""
31
- return f"πŸ‘€ {state['name']} | ⭐ {state['score']} | πŸ“š {LEVEL_NAMES.get(state['level'], '')}"
 
 
 
32
 
33
  def _safe_question(state: dict) -> str:
34
  return f"## {state.get('question', '')} = ?"
35
 
36
  def _img_to_data_url(pil_image) -> str:
37
- """Convert PIL image to JPEG data URL for localStorage."""
38
  buf = io.BytesIO()
39
  pil_image.save(buf, format="JPEG", quality=80)
40
  return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
41
 
42
  def _coll(*actions) -> str:
43
- """Encode collection actions into the collection_trigger HTML component.
44
- Each action is a (action_name, reward_id, src) tuple."""
45
  payload = json.dumps({
46
  "ts": random.random(),
47
  "actions": [{"action": a, "id": str(i), "src": s} for a, i, s in actions],
48
  })
49
  return f"<div id='numzoo-coll-data' data-payload='{payload}'></div>"
50
 
 
 
 
 
51
  # ---------------------------------------------------------------------------
52
  # Step 1 β€” Name entry
53
  # ---------------------------------------------------------------------------
@@ -55,82 +67,75 @@ def _coll(*actions) -> str:
55
  def enter_name(player_name: str, state: dict):
56
  try:
57
  name = player_name.strip() or "Player"
58
- state = {"name": name}
59
  return (
60
  state,
61
- gr.update(visible=False), # welcome
62
- gr.update(visible=True), # emoji picker
63
- gr.update(visible=False), # game
64
- random.sample(ANIMAL_EMOJIS, 1),
65
- random.sample(PLACE_EMOJIS, 1),
 
66
  )
67
  except Exception as e:
68
  print(f"enter_name error: {e}")
69
- return state, gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), [], []
 
70
 
71
  # ---------------------------------------------------------------------------
72
- # Step 2 β€” Emoji selection β†’ start quiz
73
  # ---------------------------------------------------------------------------
74
 
75
- def start_game(animal_sel: list, place_sel: list, state: dict):
76
  try:
77
  if not animal_sel or not place_sel:
78
  return (state, gr.update(visible=True), gr.update(visible=False),
79
  "⚠️ Pick at least one animal and one place!", "", "", "")
80
 
 
81
  state.update({
82
- "selected_animals": animal_sel[:3],
83
- "selected_places": place_sel[:3],
84
- "level": 1,
85
- "score": 0,
86
- "streak": 0,
87
- "correct_since_reward": 0,
88
- "level_correct": 0,
89
- "reward_count": 0,
90
- "pregenerate_next": True, # trigger background generation immediately
91
- "generate_now": False,
92
  })
 
93
 
94
- question, answer = generate_question(state["level"])
 
95
  state["question"] = question
96
  state["answer"] = answer
97
 
98
- # Add locked placeholder for reward #1 immediately
99
  return (state, gr.update(visible=False), gr.update(visible=True),
100
  "", _status_text(state), _safe_question(state),
101
- _coll(("add-locked", 1, "")))
102
 
103
  except Exception as e:
104
- print(f"start_game error: {e}")
105
  return (state, gr.update(visible=True), gr.update(visible=False),
106
  "⚠️ Something went wrong, try again.", "", "", "")
107
 
108
  # ---------------------------------------------------------------------------
109
- # Background pre-generation β€” starts right after "Let's go!" and again after
110
- # each reward, so the next image is ready before the user earns it.
111
  # ---------------------------------------------------------------------------
112
 
113
  def pregenerate_image(state: dict):
114
- """Generate next reward image in background. No-op if pregenerate_next is False."""
115
- if not state.get("pregenerate_next"):
116
- return state, gr.update(), gr.update(), ""
117
-
118
- state["pregenerate_next"] = False
119
- next_id = state.get("reward_count", 0) + 1
120
-
121
  try:
122
  animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
123
  places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
124
  streak = state.get("streak", 0)
125
 
126
- print(f"[pregenerate] generating reward #{next_id}")
127
  result, prompt = generate_reward_image(streak, animals, places)
128
- print(f"[pregenerate] reward #{next_id} done | prompt={prompt!r}")
129
 
130
  if result is not None:
131
  data_url = _img_to_data_url(result)
132
- state["pending_reward_id"] = next_id
133
- # Store in hidden components β€” collection stays LOCKED until user earns reward
134
  return state, result, data_url, ""
135
 
136
  state.pop("pending_reward_id", None)
@@ -147,11 +152,12 @@ def pregenerate_image(state: dict):
147
  # ---------------------------------------------------------------------------
148
 
149
  def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str):
 
 
 
150
  try:
151
  if not state or not state.get("question"):
152
- return (state, _status_text(state), _safe_question(state), "",
153
- "", gr.update(visible=False), "", gr.update(visible=False), "",
154
- gr.update(), gr.update(), "")
155
 
156
  try:
157
  user_answer = int(user_input.strip())
@@ -165,80 +171,70 @@ def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str):
165
  if correct:
166
  state["score"] += 1
167
  state["streak"] += 1
168
- state["correct_since_reward"] += 1
169
- state["level_correct"] += 1
170
-
171
- threshold = LEVEL_THRESHOLDS.get(state["level"], 999)
172
- level_msg = ""
173
- if state["level_correct"] >= threshold and state["level"] < 4:
174
- state["level"] += 1
175
- state["level_correct"] = 0
176
- level_msg = level_up_message(state["level"])
177
 
178
- streak_fire = "πŸ”₯" * min(state["streak"], 5)
179
- feedback = f"βœ… {level_msg}" if level_msg else f"βœ… Great! {streak_fire}"
 
 
180
 
181
- if state["correct_since_reward"] >= REWARD_EVERY:
182
- state["correct_since_reward"] = 0
183
- state["reward_count"] = state.get("reward_count", 0) + 1
184
- reward_id = state["reward_count"]
185
-
186
- question, answer = generate_question(state["level"])
187
- state["question"] = question
188
- state["answer"] = answer
189
-
190
- # Check if a pre-generated image is ready for this exact reward
191
  pending_id = state.get("pending_reward_id")
192
- has_pre = pre_image is not None and bool(pre_data_url) and pending_id == reward_id
193
 
194
  if has_pre:
195
- # βœ… Pre-generated image ready β€” show immediately, unlock in collection
196
  state.pop("pending_reward_id", None)
197
- state["pregenerate_next"] = True # kick off next background gen
198
- next_id = reward_id + 1
199
  return (state, _status_text(state), _safe_question(state), "",
200
- feedback,
201
- gr.update(visible=True), # reward_panel
202
- "", # loader cleared
203
- gr.update(visible=True, value=pre_image), # image shown
204
  "",
205
- None, # clear hidden_image
206
- "", # clear hidden_data_url
207
- _coll(("unlock", reward_id, pre_data_url),
208
- ("add-locked", next_id, "")))
209
  else:
210
- # ⏳ On-demand generation (image not ready yet)
211
- state["generate_now"] = True
212
- state["pregenerate_next"] = False
213
  return (state, _status_text(state), _safe_question(state), "",
214
- feedback,
215
  gr.update(visible=True), # reward_panel
216
  LOADER_HTML, # show loader
217
  gr.update(visible=False), # image hidden
218
  "",
219
- gr.update(), # keep hidden_image
220
- gr.update(), # keep hidden_data_url
221
- "") # collection stays locked
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  else:
224
  state["streak"] = 0
225
- feedback = f"❌ Answer: **{state['answer']}**"
226
-
227
- state["generate_now"] = False
228
- question, answer = generate_question(state["level"])
229
- state["question"] = question
230
- state["answer"] = answer
231
-
232
- return (state, _status_text(state), _safe_question(state), "",
233
- feedback,
234
- gr.update(visible=False), "", gr.update(visible=False), "",
235
- gr.update(), gr.update(), "")
236
 
237
  except Exception as e:
238
  import traceback
239
  print(f"check_answer error: {e}\n{traceback.format_exc()}")
240
  state["generate_now"] = False
241
- question, answer = generate_question(state.get("level", 1))
 
242
  state["question"] = question
243
  state["answer"] = answer
244
  return (state, _status_text(state), _safe_question(state), "",
@@ -247,35 +243,32 @@ def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str):
247
  gr.update(), gr.update(), "")
248
 
249
  # ---------------------------------------------------------------------------
250
- # On-demand generation β€” fallback when pre-image wasn't ready
251
  # ---------------------------------------------------------------------------
252
 
253
  def generate_on_demand(state: dict):
254
- """Generate image on demand. No-op if generate_now is False."""
255
  if not state.get("generate_now"):
256
  return state, "", gr.update(), "", ""
257
 
258
- state["generate_now"] = False
259
- state["pregenerate_next"] = True # kick off next background gen after this
260
- reward_id = state.get("reward_count", 1)
261
 
262
  try:
263
  animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
264
  places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
265
  streak = state.get("streak", 0)
266
 
267
- print(f"[on_demand] generating reward #{reward_id}")
268
  result, prompt = generate_reward_image(streak, animals, places)
269
- print(f"[on_demand] done | reward #{reward_id}")
270
 
271
  if result is not None:
272
  data_url = _img_to_data_url(result)
273
- next_id = reward_id + 1
274
  return (state, "",
275
  gr.update(visible=True, value=result),
276
  "",
277
- _coll(("unlock", reward_id, data_url),
278
- ("add-locked", next_id, "")))
279
 
280
  return state, "", gr.update(visible=False), "⚠️ Could not generate image β€” try again later!", ""
281
 
@@ -284,6 +277,31 @@ def generate_on_demand(state: dict):
284
  print(f"[on_demand] ❌ {e}\n{traceback.format_exc()}")
285
  return state, "", gr.update(visible=False), f"⚠️ Error: {e}", ""
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  # ---------------------------------------------------------------------------
288
  # Restart
289
  # ---------------------------------------------------------------------------
@@ -306,13 +324,12 @@ CSS = """
306
  #feedback-box { text-align: center; font-size: 1.3em; min-height: 2em; }
307
  #status-box { text-align: center; padding: 0.4em; border-radius: 8px; }
308
  #reward-img { border-radius: 16px; }
 
309
  .answer-input input { font-size: 2em !important; text-align: center !important; }
310
- /* Emoji picker grid β€” animals: 6 cols Γ— 2 rows, places: 5 cols Γ— 2 rows */
311
  .emoji-group .wrap {
312
- display: grid !important;
313
- gap: 8px !important;
314
- justify-content: center !important;
315
- justify-items: center !important;
316
  }
317
  #animal-picker .wrap { grid-template-columns: repeat(6, 52px) !important; }
318
  #place-picker .wrap { grid-template-columns: repeat(5, 52px) !important; }
@@ -327,12 +344,6 @@ CSS = """
327
  background: #e9d5ff !important; border-color: #7c3aed !important;
328
  }
329
  .emoji-group input[type="checkbox"] { display: none !important; }
330
- /* Unlock reveal animation */
331
- @keyframes numzoo-unblur {
332
- from { filter: blur(16px) brightness(0.4); transform: scale(0.95); }
333
- to { filter: blur(0) brightness(1); transform: scale(1); }
334
- }
335
- .numzoo-unlock { animation: numzoo-unblur 0.7s ease-out forwards; }
336
  /* Collection locked pulse */
337
  @keyframes numzoo-pulse { 0%,100% { opacity:.4; } 50% { opacity:.9; } }
338
  """
@@ -395,7 +406,7 @@ COLLECTION_HTML = """
395
 
396
  function renderCollection() {
397
  var col = getCol(), wrap = document.getElementById('numzoo-collection'),
398
- hdr = document.getElementById('numzoo-collection-header');
399
  if (!wrap) return;
400
  wrap.innerHTML = '';
401
  if (!col.length) { hdr.style.display='none'; return; }
@@ -484,16 +495,17 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
484
 
485
  # ── Emoji picker ───────────────────────────────────────────────────────
486
  with gr.Group(visible=False) as emoji_panel:
 
487
  gr.Markdown("### Pick your animals 🐾")
488
- animal_picker = gr.CheckboxGroup(choices=ANIMAL_EMOJIS, label="",
489
- show_label=False, elem_classes=["emoji-group"],
490
- elem_id="animal-picker")
491
  gr.Markdown("### Pick your places 🌍")
492
- place_picker = gr.CheckboxGroup(choices=PLACE_EMOJIS, label="",
493
- show_label=False, elem_classes=["emoji-group"],
494
- elem_id="place-picker")
495
- picker_error = gr.Markdown("")
496
- go_btn = gr.Button("Let's go! πŸš€", variant="primary", size="lg")
497
 
498
  # ── Game ───────────────────────────────────────────────────────────────
499
  with gr.Group(visible=False) as game_panel:
@@ -506,29 +518,28 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
506
 
507
  with gr.Group(visible=False) as reward_panel:
508
  gr.Markdown("### 🎁 Your reward!")
509
- loader_html = gr.HTML("") # empty = hidden; content = shown
510
  reward_image = gr.Image(label="", show_label=False,
511
  elem_id="reward-img", height=400, visible=False)
512
  reward_error = gr.Markdown("")
 
513
 
514
  # ── Collection ─────────────────────────────────────────────────────────
515
- collection_trigger = gr.HTML("") # receives JSON-encoded collection action payloads
516
  gr.HTML(COLLECTION_HTML)
517
 
518
  restart_btn = gr.Button("πŸ”„ Restart", variant="secondary", size="sm")
519
 
520
  # ── Wiring ─────────────────────────────────────────────────────────────
521
 
522
- next_btn.click(enter_name, [player_name, state],
523
- [state, welcome_panel, emoji_panel, game_panel,
524
- animal_picker, place_picker])
525
- player_name.submit(enter_name, [player_name, state],
526
- [state, welcome_panel, emoji_panel, game_panel,
527
- animal_picker, place_picker])
528
 
529
- # "Let's go!" β†’ start game β†’ immediately pre-generate first reward image
530
  go_btn.click(
531
- start_game, [animal_picker, place_picker, state],
532
  [state, emoji_panel, game_panel, picker_error, status_md, question_md, collection_trigger]
533
  ).then(
534
  pregenerate_image, [state],
@@ -541,7 +552,6 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
541
  hidden_image, hidden_data_url, collection_trigger,
542
  ]
543
  ondemand_outputs = [state, loader_html, reward_image, reward_error, collection_trigger]
544
- pre_outputs = [state, hidden_image, hidden_data_url, collection_trigger]
545
 
546
  for trigger in [check_btn.click, answer_input.submit]:
547
  trigger(
@@ -550,10 +560,13 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
550
  check_outputs,
551
  ).then(
552
  generate_on_demand, [state], ondemand_outputs
553
- ).then(
554
- pregenerate_image, [state], pre_outputs
555
  )
556
 
 
 
 
 
 
557
  restart_btn.click(restart, [state],
558
  [state, welcome_panel, emoji_panel, game_panel,
559
  player_name, picker_error, hidden_image, hidden_data_url])
 
9
 
10
  import gradio as gr
11
 
12
+ from math_engine import generate_question, LEVEL_NAMES, level_up_message
13
  from image_generator import generate_reward_image
14
 
15
  # ---------------------------------------------------------------------------
16
  # Constants
17
  # ---------------------------------------------------------------------------
18
 
19
+ ANSWERS_PER_LEVEL = 5 # correct answers needed to complete a level and earn a reward
20
 
21
  ANIMAL_EMOJIS = ["🐢", "🐱", "🐰", "🦊", "🐼", "🐨", "🦁", "🐯", "🐸", "🐧", "πŸ¦‹", "πŸ¦„"]
22
  PLACE_EMOJIS = ["🌊", "πŸ”οΈ", "🌸", "🌈", "πŸŒ™", "⭐", "🌴", "🏑", "🌺", "πŸ„"]
23
 
24
+ # Math difficulty caps at 4 (Mix), but level number shown to user keeps increasing
25
+ MATH_LEVEL_NAMES = {1: "Additions", 2: "Subtractions", 3: "Multiplications", 4: "Mix"}
26
+
27
  # ---------------------------------------------------------------------------
28
  # Helpers
29
  # ---------------------------------------------------------------------------
30
 
31
+ def _math_level(state: dict) -> int:
32
+ """Return math difficulty (1–4), capped, from game level."""
33
+ return min(state.get("level", 1), 4)
34
+
35
  def _status_text(state: dict) -> str:
36
  if not state:
37
  return ""
38
+ diff = MATH_LEVEL_NAMES.get(_math_level(state), "")
39
+ correct = state.get("correct_this_level", 0)
40
+ return (f"πŸ‘€ {state['name']} | ⭐ {state['score']} | "
41
+ f"πŸ“š {diff} | βœ… {correct}/{ANSWERS_PER_LEVEL}")
42
 
43
  def _safe_question(state: dict) -> str:
44
  return f"## {state.get('question', '')} = ?"
45
 
46
  def _img_to_data_url(pil_image) -> str:
 
47
  buf = io.BytesIO()
48
  pil_image.save(buf, format="JPEG", quality=80)
49
  return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
50
 
51
  def _coll(*actions) -> str:
52
+ """Encode collection actions into the collection_trigger HTML component."""
 
53
  payload = json.dumps({
54
  "ts": random.random(),
55
  "actions": [{"action": a, "id": str(i), "src": s} for a, i, s in actions],
56
  })
57
  return f"<div id='numzoo-coll-data' data-payload='{payload}'></div>"
58
 
59
+ def _picker_level_label(level: int) -> str:
60
+ diff = MATH_LEVEL_NAMES.get(min(level, 4), "Mix")
61
+ return f"## 🌟 Level {level} β€” {diff}"
62
+
63
  # ---------------------------------------------------------------------------
64
  # Step 1 β€” Name entry
65
  # ---------------------------------------------------------------------------
 
67
  def enter_name(player_name: str, state: dict):
68
  try:
69
  name = player_name.strip() or "Player"
70
+ state = {"name": name, "level": 1, "score": 0, "streak": 0}
71
  return (
72
  state,
73
+ gr.update(visible=False), # welcome
74
+ gr.update(visible=True), # emoji picker
75
+ gr.update(visible=False), # game
76
+ gr.update(visible=False), # picker_level_md (hidden for level 1)
77
+ random.sample(ANIMAL_EMOJIS, 2),
78
+ random.sample(PLACE_EMOJIS, 2),
79
  )
80
  except Exception as e:
81
  print(f"enter_name error: {e}")
82
+ return (state, gr.update(visible=True), gr.update(visible=False),
83
+ gr.update(visible=False), gr.update(visible=False), [], [])
84
 
85
  # ---------------------------------------------------------------------------
86
+ # Step 2 β€” Emoji selection β†’ start level
87
  # ---------------------------------------------------------------------------
88
 
89
+ def start_level(animal_sel: list, place_sel: list, state: dict):
90
  try:
91
  if not animal_sel or not place_sel:
92
  return (state, gr.update(visible=True), gr.update(visible=False),
93
  "⚠️ Pick at least one animal and one place!", "", "", "")
94
 
95
+ level = state.get("level", 1)
96
  state.update({
97
+ "selected_animals": animal_sel[:3],
98
+ "selected_places": place_sel[:3],
99
+ "correct_this_level": 0,
100
+ "generate_now": False,
 
 
 
 
 
 
101
  })
102
+ # Preserve: name, level, score, streak
103
 
104
+ math_lv = _math_level(state)
105
+ question, answer = generate_question(math_lv)
106
  state["question"] = question
107
  state["answer"] = answer
108
 
109
+ # Add locked placeholder for this level's reward immediately
110
  return (state, gr.update(visible=False), gr.update(visible=True),
111
  "", _status_text(state), _safe_question(state),
112
+ _coll(("add-locked", level, "")))
113
 
114
  except Exception as e:
115
+ print(f"start_level error: {e}")
116
  return (state, gr.update(visible=True), gr.update(visible=False),
117
  "⚠️ Something went wrong, try again.", "", "", "")
118
 
119
  # ---------------------------------------------------------------------------
120
+ # Background pre-generation β€” fires immediately after "Let's go!" so the
121
+ # image is ready when the user finishes the level.
122
  # ---------------------------------------------------------------------------
123
 
124
  def pregenerate_image(state: dict):
125
+ """Pre-generate the reward image for the current level."""
126
+ level = state.get("level", 1)
 
 
 
 
 
127
  try:
128
  animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
129
  places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
130
  streak = state.get("streak", 0)
131
 
132
+ print(f"[pregenerate] level={level} | animals={animals} | places={places}")
133
  result, prompt = generate_reward_image(streak, animals, places)
134
+ print(f"[pregenerate] level={level} done | prompt={prompt!r}")
135
 
136
  if result is not None:
137
  data_url = _img_to_data_url(result)
138
+ state["pending_reward_id"] = level
 
139
  return state, result, data_url, ""
140
 
141
  state.pop("pending_reward_id", None)
 
152
  # ---------------------------------------------------------------------------
153
 
154
  def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str):
155
+ _empty = (state, _status_text(state), _safe_question(state), "",
156
+ "", gr.update(visible=False), "", gr.update(visible=False), "",
157
+ gr.update(), gr.update(), "")
158
  try:
159
  if not state or not state.get("question"):
160
+ return _empty
 
 
161
 
162
  try:
163
  user_answer = int(user_input.strip())
 
171
  if correct:
172
  state["score"] += 1
173
  state["streak"] += 1
174
+ state["correct_this_level"] = state.get("correct_this_level", 0) + 1
 
 
 
 
 
 
 
 
175
 
176
+ level = state.get("level", 1)
177
+ correct_count = state["correct_this_level"]
178
+ remaining = ANSWERS_PER_LEVEL - correct_count
179
+ streak_fire = "πŸ”₯" * min(state["streak"], 5)
180
 
181
+ if correct_count >= ANSWERS_PER_LEVEL:
182
+ # ── Level complete! ─────────────────────────────────────────
183
+ state["generate_now"] = False
 
 
 
 
 
 
 
184
  pending_id = state.get("pending_reward_id")
185
+ has_pre = pre_image is not None and bool(pre_data_url) and pending_id == level
186
 
187
  if has_pre:
 
188
  state.pop("pending_reward_id", None)
 
 
189
  return (state, _status_text(state), _safe_question(state), "",
190
+ f"πŸŽ‰ Level {level} complete! {streak_fire}",
191
+ gr.update(visible=True), # reward_panel
192
+ "", # loader cleared
193
+ gr.update(visible=True, value=pre_image), # image shown
194
  "",
195
+ None, # clear hidden_image
196
+ "", # clear hidden_data_url
197
+ _coll(("unlock", level, pre_data_url)))
 
198
  else:
199
+ state["generate_now"] = True
 
 
200
  return (state, _status_text(state), _safe_question(state), "",
201
+ f"πŸŽ‰ Level {level} complete! {streak_fire}",
202
  gr.update(visible=True), # reward_panel
203
  LOADER_HTML, # show loader
204
  gr.update(visible=False), # image hidden
205
  "",
206
+ gr.update(),
207
+ gr.update(),
208
+ "")
209
+ else:
210
+ # ── Keep going ───────────────────────────────────────────────
211
+ feedback = f"βœ… {remaining} to go! {streak_fire}"
212
+ math_lv = _math_level(state)
213
+ question, answer = generate_question(math_lv)
214
+ state["question"] = question
215
+ state["answer"] = answer
216
+ return (state, _status_text(state), _safe_question(state), "",
217
+ feedback,
218
+ gr.update(visible=False), "", gr.update(visible=False), "",
219
+ gr.update(), gr.update(), "")
220
 
221
  else:
222
  state["streak"] = 0
223
+ math_lv = _math_level(state)
224
+ question, answer = generate_question(math_lv)
225
+ state["question"] = question
226
+ state["answer"] = answer
227
+ return (state, _status_text(state), _safe_question(state), "",
228
+ f"❌ Answer: **{state['answer']}**",
229
+ gr.update(visible=False), "", gr.update(visible=False), "",
230
+ gr.update(), gr.update(), "")
 
 
 
231
 
232
  except Exception as e:
233
  import traceback
234
  print(f"check_answer error: {e}\n{traceback.format_exc()}")
235
  state["generate_now"] = False
236
+ math_lv = _math_level(state)
237
+ question, answer = generate_question(math_lv)
238
  state["question"] = question
239
  state["answer"] = answer
240
  return (state, _status_text(state), _safe_question(state), "",
 
243
  gr.update(), gr.update(), "")
244
 
245
  # ---------------------------------------------------------------------------
246
+ # On-demand generation β€” fallback when pre-image wasn't ready at level end
247
  # ---------------------------------------------------------------------------
248
 
249
  def generate_on_demand(state: dict):
250
+ """Generate reward image on demand. No-op if generate_now is False."""
251
  if not state.get("generate_now"):
252
  return state, "", gr.update(), "", ""
253
 
254
+ state["generate_now"] = False
255
+ level = state.get("level", 1)
 
256
 
257
  try:
258
  animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
259
  places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
260
  streak = state.get("streak", 0)
261
 
262
+ print(f"[on_demand] level={level}")
263
  result, prompt = generate_reward_image(streak, animals, places)
264
+ print(f"[on_demand] level={level} done")
265
 
266
  if result is not None:
267
  data_url = _img_to_data_url(result)
 
268
  return (state, "",
269
  gr.update(visible=True, value=result),
270
  "",
271
+ _coll(("unlock", level, data_url)))
 
272
 
273
  return state, "", gr.update(visible=False), "⚠️ Could not generate image β€” try again later!", ""
274
 
 
277
  print(f"[on_demand] ❌ {e}\n{traceback.format_exc()}")
278
  return state, "", gr.update(visible=False), f"⚠️ Error: {e}", ""
279
 
280
+ # ---------------------------------------------------------------------------
281
+ # Next level β€” back to picker with incremented level
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def next_level(state: dict):
285
+ try:
286
+ new_level = state.get("level", 1) + 1
287
+ state["level"] = new_level
288
+ state["correct_this_level"] = 0
289
+ state.pop("pending_reward_id", None)
290
+
291
+ label = _picker_level_label(new_level)
292
+ return (state,
293
+ gr.update(visible=False), # game_panel
294
+ gr.update(visible=True), # emoji_panel
295
+ gr.update(value=label, visible=True), # picker_level_md
296
+ random.sample(ANIMAL_EMOJIS, 2), # reset animal picker
297
+ random.sample(PLACE_EMOJIS, 2), # reset place picker
298
+ None, # clear hidden_image
299
+ "") # clear hidden_data_url
300
+ except Exception as e:
301
+ print(f"next_level error: {e}")
302
+ return (state, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
303
+ None, "")
304
+
305
  # ---------------------------------------------------------------------------
306
  # Restart
307
  # ---------------------------------------------------------------------------
 
324
  #feedback-box { text-align: center; font-size: 1.3em; min-height: 2em; }
325
  #status-box { text-align: center; padding: 0.4em; border-radius: 8px; }
326
  #reward-img { border-radius: 16px; }
327
+ #picker-level { text-align: center; padding: 0.3em 0 0.6em; }
328
  .answer-input input { font-size: 2em !important; text-align: center !important; }
329
+ /* Emoji picker grid */
330
  .emoji-group .wrap {
331
+ display: grid !important; gap: 8px !important;
332
+ justify-content: center !important; justify-items: center !important;
 
 
333
  }
334
  #animal-picker .wrap { grid-template-columns: repeat(6, 52px) !important; }
335
  #place-picker .wrap { grid-template-columns: repeat(5, 52px) !important; }
 
344
  background: #e9d5ff !important; border-color: #7c3aed !important;
345
  }
346
  .emoji-group input[type="checkbox"] { display: none !important; }
 
 
 
 
 
 
347
  /* Collection locked pulse */
348
  @keyframes numzoo-pulse { 0%,100% { opacity:.4; } 50% { opacity:.9; } }
349
  """
 
406
 
407
  function renderCollection() {
408
  var col = getCol(), wrap = document.getElementById('numzoo-collection'),
409
+ hdr = document.getElementById('numzoo-collection-header');
410
  if (!wrap) return;
411
  wrap.innerHTML = '';
412
  if (!col.length) { hdr.style.display='none'; return; }
 
495
 
496
  # ── Emoji picker ───────────────────────────────────────────────────────
497
  with gr.Group(visible=False) as emoji_panel:
498
+ picker_level_md = gr.Markdown("", visible=False, elem_id="picker-level")
499
  gr.Markdown("### Pick your animals 🐾")
500
+ animal_picker = gr.CheckboxGroup(choices=ANIMAL_EMOJIS, label="",
501
+ show_label=False, elem_classes=["emoji-group"],
502
+ elem_id="animal-picker")
503
  gr.Markdown("### Pick your places 🌍")
504
+ place_picker = gr.CheckboxGroup(choices=PLACE_EMOJIS, label="",
505
+ show_label=False, elem_classes=["emoji-group"],
506
+ elem_id="place-picker")
507
+ picker_error = gr.Markdown("")
508
+ go_btn = gr.Button("Let's go! πŸš€", variant="primary", size="lg")
509
 
510
  # ── Game ───────────────────────────────────────────────────────────────
511
  with gr.Group(visible=False) as game_panel:
 
518
 
519
  with gr.Group(visible=False) as reward_panel:
520
  gr.Markdown("### 🎁 Your reward!")
521
+ loader_html = gr.HTML("")
522
  reward_image = gr.Image(label="", show_label=False,
523
  elem_id="reward-img", height=400, visible=False)
524
  reward_error = gr.Markdown("")
525
+ next_level_btn = gr.Button("Next Level ➑️", variant="primary", size="lg")
526
 
527
  # ── Collection ─────────────────────────────────────────────────────────
528
+ collection_trigger = gr.HTML("")
529
  gr.HTML(COLLECTION_HTML)
530
 
531
  restart_btn = gr.Button("πŸ”„ Restart", variant="secondary", size="sm")
532
 
533
  # ── Wiring ─────────────────────────────────────────────────────────────
534
 
535
+ enter_name_outputs = [state, welcome_panel, emoji_panel, game_panel,
536
+ picker_level_md, animal_picker, place_picker]
537
+ next_btn.click(enter_name, [player_name, state], enter_name_outputs)
538
+ player_name.submit(enter_name, [player_name, state], enter_name_outputs)
 
 
539
 
540
+ # "Let's go!" β†’ start level β†’ immediately pre-generate reward image
541
  go_btn.click(
542
+ start_level, [animal_picker, place_picker, state],
543
  [state, emoji_panel, game_panel, picker_error, status_md, question_md, collection_trigger]
544
  ).then(
545
  pregenerate_image, [state],
 
552
  hidden_image, hidden_data_url, collection_trigger,
553
  ]
554
  ondemand_outputs = [state, loader_html, reward_image, reward_error, collection_trigger]
 
555
 
556
  for trigger in [check_btn.click, answer_input.submit]:
557
  trigger(
 
560
  check_outputs,
561
  ).then(
562
  generate_on_demand, [state], ondemand_outputs
 
 
563
  )
564
 
565
+ # "Next Level" β†’ increment level β†’ back to picker with fresh picks
566
+ next_level_outputs = [state, game_panel, emoji_panel, picker_level_md,
567
+ animal_picker, place_picker, hidden_image, hidden_data_url]
568
+ next_level_btn.click(next_level, [state], next_level_outputs)
569
+
570
  restart_btn.click(restart, [state],
571
  [state, welcome_panel, emoji_panel, game_panel,
572
  player_name, picker_error, hidden_image, hidden_data_url])