goumsss Claude Sonnet 4.6 commited on
Commit
ecbb6ed
Β·
1 Parent(s): d035858

Async pre-generation, fix loader, fix collection display

Browse files

Key changes:
- Pre-generate reward image immediately on "Let's go!" so it's ready when user earns it
- locked placeholder appears in collection the moment generation starts
- Image unlocks (reveal animation) when user earns the reward
- Fallback on-demand generation if pre-image not ready in time
- Fix loader: use value-swapping ("" vs LOADER_HTML) instead of visibility toggle β€” more reliable on HF Spaces
- Fix collection: drop canvas CORS approach, use Python→JS base64 injection via gr.HTML polling
- Collection stores base64 JPEGs in localStorage, supports tap-to-zoom modal
- Event chain: go_btn β†’ start_game β†’ pregenerate; check_btn β†’ check_answer β†’ generate_on_demand β†’ pregenerate

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

Files changed (2) hide show
  1. CLAUDE.md +22 -2
  2. app.py +301 -178
CLAUDE.md CHANGED
@@ -36,10 +36,30 @@ Full loop shortcut: `bash scripts/dev.sh` (runs steps 1–4, then run test.sh ma
36
 
37
  **Game state dict keys:**
38
  `name`, `level`, `score`, `streak`, `correct_since_reward`, `level_correct`,
39
- `question`, `answer`, `selected_animals`, `selected_places`, `generate_now`
 
40
 
41
  **Reward trigger:** every `REWARD_EVERY = 3` correct answers.
42
- Image generated via `.then()` chaining on the Check button β€” synchronous, within Gradio request context (required for ZeroGPU).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  ## Gradio rules
45
  - Use `gr.update(visible=True/False)` for visibility β€” never return bare booleans
 
36
 
37
  **Game state dict keys:**
38
  `name`, `level`, `score`, `streak`, `correct_since_reward`, `level_correct`,
39
+ `question`, `answer`, `selected_animals`, `selected_places`,
40
+ `reward_count`, `pregenerate_next`, `generate_now`, `pending_reward_id`
41
 
42
  **Reward trigger:** every `REWARD_EVERY = 3` correct answers.
43
+
44
+ **Pre-generation flow (async image UX):**
45
+ 1. "Let's go!" β†’ `start_game()` β†’ adds locked placeholder for reward #1 in collection β†’
46
+ `.then(pregenerate_image)` starts background generation immediately
47
+ 2. `pregenerate_image` stores PIL image in `hidden_image` (gr.Image) and base64 data URL in
48
+ `hidden_data_url` (gr.Textbox), sets `state["pending_reward_id"]`
49
+ 3. When user earns a reward: `check_answer` receives `hidden_image` + `hidden_data_url` as inputs.
50
+ - If pre-image matches reward_id β†’ show immediately + unlock in collection + kick off next pregenerate
51
+ - If not ready β†’ show LOADER_HTML + set `generate_now=True`
52
+ 4. `.then(generate_on_demand)` β†’ generates on-demand if `generate_now`, unlocks collection
53
+ 5. `.then(pregenerate_image)` β†’ generates next reward in background
54
+
55
+ **Collection system:**
56
+ - `collection_trigger = gr.HTML("")` receives JSON-encoded action payloads
57
+ - JS polls `#numzoo-coll-data` element every 200ms for `{ts, actions:[{action,id,src}]}`
58
+ - Actions: `add-locked` (locked placeholder), `unlock` (reveal image), `failed` (remove)
59
+ - Images stored in localStorage as base64 JPEGs (key: `numzoo_collection_v2`)
60
+
61
+ **Loader fix:** Use value-swapping (`""` = hidden, `LOADER_HTML` = shown) instead of `visible` flag.
62
+ This is more reliable in Gradio 6 than toggling visibility on gr.HTML components.
63
 
64
  ## Gradio rules
65
  - Use `gr.update(visible=True/False)` for visibility β€” never return bare booleans
app.py CHANGED
@@ -2,8 +2,13 @@
2
  🦁 NumZoo β€” Math practice with cute AI-generated animal rewards!
3
  """
4
 
 
 
 
5
  import random
 
6
  import gradio as gr
 
7
  from math_engine import generate_question, LEVEL_NAMES, LEVEL_THRESHOLDS, level_up_message
8
  from image_generator import generate_reward_image
9
 
@@ -28,6 +33,21 @@ def _status_text(state: dict) -> str:
28
  def _safe_question(state: dict) -> str:
29
  return f"## {state.get('question', '')} = ?"
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # ---------------------------------------------------------------------------
32
  # Step 1 β€” Name entry
33
  # ---------------------------------------------------------------------------
@@ -56,45 +76,89 @@ def start_game(animal_sel: list, place_sel: list, state: dict):
56
  try:
57
  if not animal_sel or not place_sel:
58
  return (state, gr.update(visible=True), gr.update(visible=False),
59
- "⚠️ Pick at least one animal and one place!", "", "")
60
 
61
  state.update({
62
- "selected_animals": animal_sel[:3],
63
- "selected_places": place_sel[:3],
64
- "level": 1,
65
- "score": 0,
66
- "streak": 0,
67
- "correct_since_reward": 0,
68
- "level_correct": 0,
 
 
 
69
  })
70
 
71
  question, answer = generate_question(state["level"])
72
  state["question"] = question
73
  state["answer"] = answer
74
 
 
75
  return (state, gr.update(visible=False), gr.update(visible=True),
76
- "", _status_text(state), _safe_question(state))
 
77
 
78
  except Exception as e:
79
  print(f"start_game error: {e}")
80
  return (state, gr.update(visible=True), gr.update(visible=False),
81
- "⚠️ Something went wrong, try again.", "", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  # ---------------------------------------------------------------------------
84
- # Step 3 β€” Answer checking (no reward yet, just feedback)
85
  # ---------------------------------------------------------------------------
86
 
87
- def check_answer(user_input: str, state: dict):
88
  try:
89
  if not state or not state.get("question"):
90
  return (state, _status_text(state), _safe_question(state), "",
91
- "", gr.update(visible=False), gr.update(visible=False), None, "")
 
92
 
93
  try:
94
  user_answer = int(user_input.strip())
95
  except (ValueError, AttributeError):
96
  return (state, _status_text(state), _safe_question(state), "",
97
- "⚠️ Numbers only!", gr.update(visible=False), gr.update(visible=False), None, "")
 
98
 
99
  correct = (user_answer == state["answer"])
100
 
@@ -104,7 +168,6 @@ def check_answer(user_input: str, state: dict):
104
  state["correct_since_reward"] += 1
105
  state["level_correct"] += 1
106
 
107
- # Level-up?
108
  threshold = LEVEL_THRESHOLDS.get(state["level"], 999)
109
  level_msg = ""
110
  if state["level_correct"] >= threshold and state["level"] < 4:
@@ -115,19 +178,47 @@ def check_answer(user_input: str, state: dict):
115
  streak_fire = "πŸ”₯" * min(state["streak"], 5)
116
  feedback = f"βœ… {level_msg}" if level_msg else f"βœ… Great! {streak_fire}"
117
 
118
- # Reward due? β†’ show loading panel, trigger generate step
119
  if state["correct_since_reward"] >= REWARD_EVERY:
120
  state["correct_since_reward"] = 0
121
- state["generate_now"] = True # signal .then() chain to generate
 
 
122
  question, answer = generate_question(state["level"])
123
  state["question"] = question
124
  state["answer"] = answer
125
- return (state, _status_text(state), _safe_question(state), "",
126
- feedback,
127
- gr.update(visible=True), # reward_panel visible
128
- gr.update(visible=True), # loader visible
129
- gr.update(visible=False), # image hidden until ready
130
- "") # no error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  else:
133
  state["streak"] = 0
@@ -140,55 +231,58 @@ def check_answer(user_input: str, state: dict):
140
 
141
  return (state, _status_text(state), _safe_question(state), "",
142
  feedback,
143
- gr.update(visible=False), # reward_panel
144
- gr.update(visible=False), # loader
145
- None,
146
- "")
147
 
148
  except Exception as e:
149
- print(f"check_answer error: {e}")
 
150
  state["generate_now"] = False
151
  question, answer = generate_question(state.get("level", 1))
152
  state["question"] = question
153
  state["answer"] = answer
154
  return (state, _status_text(state), _safe_question(state), "",
155
  "⚠️ Something went wrong!",
156
- gr.update(visible=False), gr.update(visible=False), None, "")
 
157
 
158
  # ---------------------------------------------------------------------------
159
- # Step 4 β€” Actually generate the image (called by gr.Timer after reward shown)
160
  # ---------------------------------------------------------------------------
161
 
162
- def generate_image(state: dict):
163
- """Chained generation β€” only runs if check_answer flagged generate_now."""
164
  if not state.get("generate_now"):
165
- return gr.update(), gr.update(), gr.update() # no-op
166
 
167
- state["generate_now"] = False
 
 
168
 
169
  try:
170
  animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
171
  places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
172
  streak = state.get("streak", 0)
173
 
174
- print(f"[generate_image] calling generate_reward_image | streak={streak} | animals={animals} | places={places}")
175
  result, prompt = generate_reward_image(streak, animals, places)
176
- print(f"[generate_image] result={result} | prompt={prompt!r}")
177
 
178
- if result is None:
179
- print("[generate_image] ⚠️ result is None β€” generation failed silently inside image_generator")
180
- return (gr.update(visible=False),
181
- gr.update(visible=False),
182
- "⚠️ Could not generate image β€” try again later!")
 
 
 
183
 
184
- print("[generate_image] βœ… image generated successfully")
185
- return gr.update(visible=False), gr.update(visible=True, value=result), ""
186
 
187
  except Exception as e:
188
  import traceback
189
- print(f"[generate_image] ❌ exception: {e}")
190
- print(traceback.format_exc())
191
- return gr.update(visible=False), gr.update(visible=False), f"⚠️ Error: {e}"
192
 
193
  # ---------------------------------------------------------------------------
194
  # Restart
@@ -196,7 +290,7 @@ def generate_image(state: dict):
196
 
197
  def restart(state: dict):
198
  return ({}, gr.update(visible=True), gr.update(visible=False),
199
- gr.update(visible=False), gr.update(), "")
200
 
201
  # ---------------------------------------------------------------------------
202
  # Gradio UI
@@ -212,7 +306,6 @@ CSS = """
212
  #feedback-box { text-align: center; font-size: 1.3em; min-height: 2em; }
213
  #status-box { text-align: center; padding: 0.4em; border-radius: 8px; }
214
  #reward-img { border-radius: 16px; }
215
- #loader { text-align: center; font-size: 1.4em; padding: 2em; }
216
  .answer-input input { font-size: 2em !important; text-align: center !important; }
217
  /* Emoji picker grid β€” animals: 6 cols Γ— 2 rows, places: 5 cols Γ— 2 rows */
218
  .emoji-group .wrap {
@@ -224,38 +317,141 @@ CSS = """
224
  #animal-picker .wrap { grid-template-columns: repeat(6, 52px) !important; }
225
  #place-picker .wrap { grid-template-columns: repeat(5, 52px) !important; }
226
  .emoji-group label {
227
- width: 52px !important;
228
- height: 52px !important;
229
- display: flex !important;
230
- align-items: center !important;
231
- justify-content: center !important;
232
- font-size: 1.8em !important;
233
- cursor: pointer !important;
234
- border-radius: 12px !important;
235
- border: 2px solid transparent !important;
236
- transition: all 0.15s !important;
237
- user-select: none !important;
238
  }
239
  .emoji-group label:has(input:checked) {
240
- background: #e9d5ff !important;
241
- border-color: #7c3aed !important;
242
  }
243
  .emoji-group input[type="checkbox"] { display: none !important; }
 
 
 
 
 
 
 
 
244
  """
245
 
246
  LOADER_HTML = """
247
- <div id="loader" style="text-align:center; padding:2em;">
248
- <div style="font-size:3em; animation: spin 1s linear infinite; display:inline-block;">🎨</div>
249
  <div style="margin-top:0.5em; color:#888; font-size:1.1em;">Generating your reward…</div>
250
  </div>
251
- <style>
252
- @keyframes spin { 0%{transform:rotate(0deg)} 100%{transform:rotate(360deg)} }
253
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  """
255
 
256
  with gr.Blocks(title="🦁 NumZoo") as demo:
257
 
258
- state = gr.State({})
 
 
259
 
260
  gr.Markdown("# 🦁 NumZoo", elem_id="title")
261
  gr.Markdown("*Do maths. Win cute animals!*", elem_id="subtitle")
@@ -263,22 +459,19 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
263
  gr.HTML("""
264
  <script>
265
  document.addEventListener('DOMContentLoaded', function() {
266
- const saved = localStorage.getItem('numzoo_name');
267
  if (saved) {
268
- setTimeout(() => {
269
- const input = document.querySelector('input[placeholder="Your name…"]');
270
  if (input) {
271
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
272
- nativeInputValueSetter.call(input, saved);
273
- input.dispatchEvent(new Event('input', { bubbles: true }));
274
  }
275
  }, 800);
276
  }
277
  });
278
  document.addEventListener('input', function(e) {
279
- if (e.target.placeholder === 'Your name…') {
280
- localStorage.setItem('numzoo_name', e.target.value);
281
- }
282
  });
283
  </script>
284
  """)
@@ -313,96 +506,14 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
313
 
314
  with gr.Group(visible=False) as reward_panel:
315
  gr.Markdown("### 🎁 Your reward!")
316
- loader_html = gr.HTML(LOADER_HTML, visible=False)
317
  reward_image = gr.Image(label="", show_label=False,
318
- elem_id="reward-img", height=400)
319
- reward_error = gr.Markdown("", visible=True)
320
 
321
  # ── Collection ─────────────────────────────────────────────────────────
322
- gr.HTML("""
323
- <div id="numzoo-collection-wrap" style="margin-top:16px;">
324
- <div id="numzoo-collection-header"
325
- style="display:none; font-weight:bold; font-size:1.1em; margin:0 0 8px 4px;">πŸ–ΌοΈ Collection</div>
326
- <div id="numzoo-collection"
327
- style="display:flex; flex-wrap:wrap; gap:8px; justify-content:center;"></div>
328
- </div>
329
-
330
- <!-- Zoom modal -->
331
- <div id="numzoo-modal"
332
- style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.85);
333
- z-index:9999; align-items:center; justify-content:center; cursor:pointer;">
334
- <img id="numzoo-modal-img"
335
- style="max-width:92vw; max-height:92vh; border-radius:20px; box-shadow:0 8px 40px #0008;">
336
- </div>
337
-
338
- <script>
339
- (function() {
340
- const STORAGE_KEY = 'numzoo_collection';
341
- const MAX_IMAGES = 30;
342
-
343
- function getCollection() {
344
- try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
345
- catch { return []; }
346
- }
347
-
348
- function renderCollection() {
349
- const col = getCollection();
350
- const wrap = document.getElementById('numzoo-collection');
351
- const header = document.getElementById('numzoo-collection-header');
352
- if (!wrap) return;
353
- wrap.innerHTML = '';
354
- if (col.length === 0) { header.style.display = 'none'; return; }
355
- header.style.display = 'block';
356
- [...col].reverse().forEach(src => {
357
- const img = document.createElement('img');
358
- img.src = src;
359
- Object.assign(img.style, {
360
- width: '80px', height: '80px', objectFit: 'cover',
361
- borderRadius: '12px', cursor: 'pointer',
362
- border: '2px solid transparent', transition: 'border-color .15s'
363
- });
364
- img.onmouseenter = () => img.style.borderColor = '#7c3aed';
365
- img.onmouseleave = () => img.style.borderColor = 'transparent';
366
- img.onclick = () => {
367
- document.getElementById('numzoo-modal-img').src = src;
368
- document.getElementById('numzoo-modal').style.display = 'flex';
369
- };
370
- wrap.appendChild(img);
371
- });
372
- }
373
-
374
- document.getElementById('numzoo-modal').onclick = function() {
375
- this.style.display = 'none';
376
- };
377
-
378
- // Poll for new reward image and save to localStorage via canvas
379
- let lastSaved = '';
380
- setInterval(() => {
381
- const img = document.querySelector('#reward-img img');
382
- if (!img || !img.complete || !img.src || img.src === lastSaved) return;
383
- if (img.src.includes('.svg') || img.naturalWidth === 0) return;
384
- lastSaved = img.src;
385
- try {
386
- const canvas = document.createElement('canvas');
387
- canvas.width = img.naturalWidth;
388
- canvas.height = img.naturalHeight;
389
- canvas.getContext('2d').drawImage(img, 0, 0);
390
- const dataUrl = canvas.toDataURL('image/jpeg', 0.75);
391
- const col = getCollection();
392
- if (!col.includes(dataUrl)) {
393
- col.push(dataUrl);
394
- localStorage.setItem(STORAGE_KEY, JSON.stringify(col.slice(-MAX_IMAGES)));
395
- renderCollection();
396
- }
397
- } catch(e) { console.warn('NumZoo collection save error:', e); }
398
- }, 800);
399
-
400
- // Render on page load
401
- document.addEventListener('DOMContentLoaded', renderCollection);
402
- setTimeout(renderCollection, 1200); // fallback after Gradio hydrates
403
- })();
404
- </script>
405
- """)
406
 
407
  restart_btn = gr.Button("πŸ”„ Restart", variant="secondary", size="sm")
408
 
@@ -415,25 +526,37 @@ with gr.Blocks(title="🦁 NumZoo") as demo:
415
  [state, welcome_panel, emoji_panel, game_panel,
416
  animal_picker, place_picker])
417
 
418
- go_btn.click(start_game, [animal_picker, place_picker, state],
419
- [state, emoji_panel, game_panel, picker_error,
420
- status_md, question_md])
421
-
422
- # check_answer β†’ then β†’ generate_image (chained, runs immediately after)
423
- check_outputs = [state, status_md, question_md, answer_input,
424
- feedback_md, reward_panel, loader_html, reward_image, reward_error]
425
- gen_outputs = [loader_html, reward_image, reward_error]
426
-
427
- check_btn.click(check_answer, [answer_input, state], check_outputs).then(
428
- generate_image, inputs=[state], outputs=gen_outputs
429
- )
430
- answer_input.submit(check_answer, [answer_input, state], check_outputs).then(
431
- generate_image, inputs=[state], outputs=gen_outputs
432
  )
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  restart_btn.click(restart, [state],
435
  [state, welcome_panel, emoji_panel, game_panel,
436
- player_name, picker_error])
437
 
438
 
439
  if __name__ == "__main__":
 
2
  🦁 NumZoo β€” Math practice with cute AI-generated animal rewards!
3
  """
4
 
5
+ import base64
6
+ import io
7
+ import json
8
  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
 
 
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
  # ---------------------------------------------------------------------------
 
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)
137
+ return state, None, "", ""
138
+
139
+ except Exception as e:
140
+ import traceback
141
+ print(f"[pregenerate] ❌ {e}\n{traceback.format_exc()}")
142
+ state.pop("pending_reward_id", None)
143
+ return state, None, "", ""
144
 
145
  # ---------------------------------------------------------------------------
146
+ # Step 3 β€” Answer checking
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())
158
  except (ValueError, AttributeError):
159
  return (state, _status_text(state), _safe_question(state), "",
160
+ "⚠️ Numbers only!", gr.update(visible=False), "", gr.update(visible=False), "",
161
+ gr.update(), gr.update(), "")
162
 
163
  correct = (user_answer == state["answer"])
164
 
 
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:
 
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
 
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), "",
245
  "⚠️ Something went wrong!",
246
+ gr.update(visible=False), "", gr.update(visible=False), "",
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
 
282
  except Exception as e:
283
  import traceback
284
+ print(f"[on_demand] ❌ {e}\n{traceback.format_exc()}")
285
+ return state, "", gr.update(visible=False), f"⚠️ Error: {e}", ""
 
286
 
287
  # ---------------------------------------------------------------------------
288
  # Restart
 
290
 
291
  def restart(state: dict):
292
  return ({}, gr.update(visible=True), gr.update(visible=False),
293
+ gr.update(visible=False), gr.update(), "", None, "")
294
 
295
  # ---------------------------------------------------------------------------
296
  # Gradio UI
 
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 {
 
317
  #animal-picker .wrap { grid-template-columns: repeat(6, 52px) !important; }
318
  #place-picker .wrap { grid-template-columns: repeat(5, 52px) !important; }
319
  .emoji-group label {
320
+ width: 52px !important; height: 52px !important;
321
+ display: flex !important; align-items: center !important; justify-content: center !important;
322
+ font-size: 1.8em !important; cursor: pointer !important;
323
+ border-radius: 12px !important; border: 2px solid transparent !important;
324
+ transition: all 0.15s !important; user-select: none !important;
 
 
 
 
 
 
325
  }
326
  .emoji-group label:has(input:checked) {
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
  """
339
 
340
  LOADER_HTML = """
341
+ <div style="text-align:center; padding:2em;">
342
+ <div style="font-size:3em; animation:spin 1s linear infinite; display:inline-block;">🎨</div>
343
  <div style="margin-top:0.5em; color:#888; font-size:1.1em;">Generating your reward…</div>
344
  </div>
345
+ <style>@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}</style>
346
+ """
347
+
348
+ COLLECTION_HTML = """
349
+ <div id="numzoo-collection-wrap" style="margin-top:16px;">
350
+ <div id="numzoo-collection-header"
351
+ style="display:none; font-weight:bold; font-size:1.05em; margin:0 0 8px 4px;">πŸ–ΌοΈ Collection</div>
352
+ <div id="numzoo-collection"
353
+ style="display:flex; flex-wrap:wrap; gap:8px; justify-content:center;"></div>
354
+ </div>
355
+
356
+ <!-- Zoom modal -->
357
+ <div id="numzoo-modal"
358
+ style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88);
359
+ z-index:9999; align-items:center; justify-content:center; cursor:pointer;">
360
+ <img id="numzoo-modal-img"
361
+ style="max-width:92vw; max-height:92vh; border-radius:20px; box-shadow:0 8px 40px #0006;">
362
+ </div>
363
+
364
+ <script>
365
+ (function() {
366
+ var KEY = 'numzoo_collection_v2', MAX = 30;
367
+
368
+ function getCol() { try { return JSON.parse(localStorage.getItem(KEY)||'[]'); } catch(e) { return []; } }
369
+ function saveCol(c) { localStorage.setItem(KEY, JSON.stringify(c.slice(-MAX))); }
370
+
371
+ window.numzooAddLocked = function(id) {
372
+ id = String(id);
373
+ var col = getCol();
374
+ if (!col.find(function(x){return x.id===id;})) {
375
+ col.push({id:id, status:'locked', src:null});
376
+ saveCol(col);
377
+ }
378
+ renderCollection();
379
+ };
380
+
381
+ window.numzooUnlock = function(id, src) {
382
+ id = String(id);
383
+ var col = getCol(), item = col.find(function(x){return x.id===id;});
384
+ if (item) { item.status='unlocked'; item.src=src; }
385
+ else { col.push({id:id, status:'unlocked', src:src}); }
386
+ saveCol(col);
387
+ renderCollection();
388
+ };
389
+
390
+ window.numzooMarkFailed = function(id) {
391
+ id = String(id);
392
+ saveCol(getCol().filter(function(x){return x.id!==id;}));
393
+ renderCollection();
394
+ };
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; }
402
+ hdr.style.display = 'block';
403
+ col.slice().reverse().forEach(function(item) {
404
+ var el = document.createElement('div');
405
+ el.style.cssText = 'position:relative;width:80px;height:80px;border-radius:12px;overflow:hidden;flex-shrink:0;';
406
+ if (item.status === 'locked') {
407
+ el.style.background = '#1e1e2e';
408
+ el.style.border = '2px dashed #555';
409
+ el.innerHTML =
410
+ '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2em;z-index:1;position:relative;">πŸ”’</div>' +
411
+ '<div style="position:absolute;inset:0;background:linear-gradient(135deg,#2d2d44,#111128);animation:numzoo-pulse 2s ease-in-out infinite;"></div>';
412
+ } else if (item.src) {
413
+ var img = document.createElement('img');
414
+ img.src = item.src;
415
+ img.style.cssText = 'width:100%;height:100%;object-fit:cover;cursor:pointer;display:block;';
416
+ img.onclick = function() {
417
+ document.getElementById('numzoo-modal-img').src = item.src;
418
+ document.getElementById('numzoo-modal').style.display = 'flex';
419
+ };
420
+ el.appendChild(img);
421
+ }
422
+ wrap.appendChild(el);
423
+ });
424
+ }
425
+
426
+ document.getElementById('numzoo-modal').onclick = function() { this.style.display='none'; };
427
+
428
+ // Poll the collection_trigger component for action payloads
429
+ var lastTs = -1;
430
+ setInterval(function() {
431
+ var el = document.getElementById('numzoo-coll-data');
432
+ if (!el) return;
433
+ try {
434
+ var data = JSON.parse(el.dataset.payload||'{}');
435
+ if (!data.ts || data.ts === lastTs) return;
436
+ lastTs = data.ts;
437
+ (data.actions||[]).forEach(function(act) {
438
+ if (act.action === 'add-locked') window.numzooAddLocked(act.id);
439
+ else if (act.action === 'unlock') window.numzooUnlock(act.id, act.src);
440
+ else if (act.action === 'failed') window.numzooMarkFailed(act.id);
441
+ });
442
+ } catch(e) {}
443
+ }, 200);
444
+
445
+ setTimeout(renderCollection, 800);
446
+ })();
447
+ </script>
448
  """
449
 
450
  with gr.Blocks(title="🦁 NumZoo") as demo:
451
 
452
+ state = gr.State({})
453
+ hidden_image = gr.Image(visible=False, label="", type="pil")
454
+ hidden_data_url = gr.Textbox(visible=False, value="")
455
 
456
  gr.Markdown("# 🦁 NumZoo", elem_id="title")
457
  gr.Markdown("*Do maths. Win cute animals!*", elem_id="subtitle")
 
459
  gr.HTML("""
460
  <script>
461
  document.addEventListener('DOMContentLoaded', function() {
462
+ var saved = localStorage.getItem('numzoo_name');
463
  if (saved) {
464
+ setTimeout(function() {
465
+ var input = document.querySelector('input[placeholder="Your name…"]');
466
  if (input) {
467
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input, saved);
468
+ input.dispatchEvent(new Event('input', {bubbles:true}));
 
469
  }
470
  }, 800);
471
  }
472
  });
473
  document.addEventListener('input', function(e) {
474
+ if (e.target.placeholder === 'Your name…') localStorage.setItem('numzoo_name', e.target.value);
 
 
475
  });
476
  </script>
477
  """)
 
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
 
 
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],
535
+ [state, hidden_image, hidden_data_url, collection_trigger]
 
 
 
 
 
 
 
536
  )
537
 
538
+ check_outputs = [
539
+ state, status_md, question_md, answer_input,
540
+ feedback_md, reward_panel, loader_html, reward_image, reward_error,
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(
548
+ check_answer,
549
+ [answer_input, state, hidden_image, hidden_data_url],
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])
560
 
561
 
562
  if __name__ == "__main__":