stpete2 commited on
Commit
1391263
·
verified ·
1 Parent(s): 81837e2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +209 -166
app.py CHANGED
@@ -1,5 +1,5 @@
1
- # Hugging Face Spacesで動作するGradio版
2
- # app.py として保存してHF Spacesにアップロード
3
 
4
  import gradio as gr
5
  import matplotlib.pyplot as plt
@@ -17,11 +17,10 @@ class PennyPackerGame:
17
  self.coin_radius = 0.5
18
  self.num_coins = 16
19
 
20
- # 初期配置
21
  self.coins = []
22
  self.initialize_coins()
23
 
24
- self.selected_coin = None
25
 
26
  def initialize_coins(self):
27
  """4x4グリッドで初期配置"""
@@ -39,44 +38,34 @@ class PennyPackerGame:
39
  def is_inside_sector(self, x, y):
40
  """コインが完全に扇形内にあるかチェック"""
41
  max_distance = np.sqrt(x**2 + y**2) + self.coin_radius
42
-
43
  if max_distance > self.sector_radius:
44
  return False
45
-
46
  if x - self.coin_radius < 0 or y - self.coin_radius < 0:
47
  return False
48
-
49
  return True
50
 
51
  def check_overlap(self, coin_index):
52
  """指定したコインが他のコインと重なっているかチェック"""
53
  coin1 = self.coins[coin_index]
54
-
55
  for i, coin2 in enumerate(self.coins):
56
  if i == coin_index:
57
  continue
58
-
59
  dx = coin1['x'] - coin2['x']
60
  dy = coin1['y'] - coin2['y']
61
  distance = np.sqrt(dx**2 + dy**2)
62
-
63
  if distance < 2 * self.coin_radius - 0.001:
64
  return True
65
-
66
  return False
67
 
68
  def evaluate_layout(self):
69
  """現在のレイアウトを評価"""
70
  valid_coins = 0
71
-
72
  for i in range(len(self.coins)):
73
  coin = self.coins[i]
74
  inside = self.is_inside_sector(coin['x'], coin['y'])
75
  overlap = self.check_overlap(i)
76
-
77
  if inside and not overlap:
78
  valid_coins += 1
79
-
80
  return valid_coins
81
 
82
  def draw_figure(self):
@@ -84,38 +73,28 @@ class PennyPackerGame:
84
  fig, ax = plt.subplots(figsize=(10, 10))
85
  ax.set_aspect('equal')
86
 
87
- # 表示範囲
88
  margin = 0.5
89
  ax.set_xlim(-margin, self.sector_radius + margin)
90
  ax.set_ylim(-margin, self.sector_radius + margin)
91
 
92
  # 扇形の描画
93
  sector = patches.Wedge(
94
- center=self.sector_center,
95
- r=self.sector_radius,
96
- theta1=0,
97
- theta2=90,
98
- facecolor='lightblue',
99
- alpha=0.15,
100
- edgecolor='blue',
101
- linewidth=2
102
  )
103
  ax.add_patch(sector)
104
 
105
- # 直線エッジ
106
  ax.plot([0, self.sector_radius], [0, 0], 'b-', linewidth=2)
107
  ax.plot([0, 0], [0, self.sector_radius], 'b-', linewidth=2)
108
 
109
- # 円弧
110
  theta = np.linspace(0, 90, 100)
111
  x_arc = self.sector_radius * np.cos(np.radians(theta))
112
  y_arc = self.sector_radius * np.sin(np.radians(theta))
113
  ax.plot(x_arc, y_arc, 'b-', linewidth=2)
114
 
115
- # 原点
116
  ax.plot(0, 0, 'ro', markersize=8)
117
-
118
- # グリッド
119
  ax.grid(True, alpha=0.3, linestyle='--')
120
 
121
  # コインの描画
@@ -136,45 +115,43 @@ class PennyPackerGame:
136
  # 選択されているコインを強調
137
  if self.selected_coin == i:
138
  edgecolor = 'red'
139
- linewidth = 3
140
  else:
141
  edgecolor = 'darkgoldenrod'
142
  linewidth = 2
143
 
144
  circle = patches.Circle(
145
- (coin['x'], coin['y']),
146
- self.coin_radius,
147
- facecolor=color,
148
- edgecolor=edgecolor,
149
- linewidth=linewidth,
150
- alpha=alpha
151
  )
152
  ax.add_patch(circle)
153
 
154
  # コイン番号
155
  ax.text(coin['x'], coin['y'], str(i+1),
156
  ha='center', va='center',
157
- fontsize=12, fontweight='bold')
 
158
 
159
  # 評価
160
  valid_count = self.evaluate_layout()
161
 
162
  if valid_count >= 16:
163
- title = ' SOLUTION FOUND!'
164
  title_color = 'green'
165
  else:
166
  title = f'Penny Packer 16 - {valid_count}/16 valid'
167
- title_color = 'black'
168
 
169
- ax.set_title(title, fontsize=16, color=title_color, weight='bold', pad=20)
170
- ax.set_xlabel('X', fontsize=12)
171
- ax.set_ylabel('Y', fontsize=12)
172
 
173
  plt.tight_layout()
174
 
175
  # 画像に変換
176
  buf = BytesIO()
177
- plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
178
  buf.seek(0)
179
  img = Image.open(buf)
180
  plt.close(fig)
@@ -185,35 +162,24 @@ class PennyPackerGame:
185
  """ステータステキストを生成"""
186
  valid_count = self.evaluate_layout()
187
 
188
- text = f"**Status:** {valid_count}/16 valid coins\n\n"
189
- text += f"**Coin Radius:** {self.coin_radius:.2f}\n"
190
- text += f"**Sector Radius:** {self.sector_radius:.2f}\n\n"
191
 
192
  if valid_count >= 16:
193
- text += "🎉 **SOLUTION FOUND!**\n"
194
  else:
195
- text += f"Need {16 - valid_count} more valid coins\n"
196
 
197
- text += "\n**Legend:**\n"
198
- text += "🟢 Green: Valid (inside + no overlap)\n"
199
- text += "🟠 Orange: Overlapping\n"
200
- text += "🔴 Red: Outside sector\n"
201
 
202
- if self.selected_coin is not None:
203
- text += f"\n**Selected Coin:** {self.selected_coin + 1}"
 
 
 
204
 
205
  return text
206
-
207
- def export_config(self):
208
- """設定をJSON形式で出力"""
209
- config = {
210
- 'coin_radius': self.coin_radius,
211
- 'sector_radius': self.sector_radius,
212
- 'coins': self.coins,
213
- 'valid_count': self.evaluate_layout(),
214
- 'timestamp': datetime.now().isoformat()
215
- }
216
- return json.dumps(config, indent=2)
217
 
218
  # グローバルゲームインスタンス
219
  game = PennyPackerGame()
@@ -223,57 +189,115 @@ def update_display(coin_radius, sector_radius):
223
  """表示を更新"""
224
  game.coin_radius = coin_radius
225
  game.sector_radius = sector_radius
226
-
227
  img = game.draw_figure()
228
  status = game.get_status_text()
229
-
230
  return img, status
231
 
232
- def select_coin(coin_number):
233
- """コインを選択"""
234
- if 1 <= coin_number <= 16:
235
- game.selected_coin = coin_number - 1
236
- img = game.draw_figure()
237
- status = game.get_status_text()
238
- return img, status, f"Coin {coin_number} selected. Now click 'Move to X,Y' or click on the image."
239
- else:
240
- return game.draw_figure(), game.get_status_text(), "Invalid coin number"
241
 
242
- def move_coin(x, y):
243
- """選択されたコインを移動"""
244
- if game.selected_coin is None:
245
- return game.draw_figure(), game.get_status_text(), "Please select a coin first"
246
-
247
- game.coins[game.selected_coin]['x'] = x
248
- game.coins[game.selected_coin]['y'] = y
249
-
250
- img = game.draw_figure()
251
- status = game.get_status_text()
252
-
253
- return img, status, f"Coin {game.selected_coin + 1} moved to ({x:.2f}, {y:.2f})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  def reset_positions():
256
  """位置をリセット"""
257
  game.initialize_coins()
258
- game.selected_coin = None
259
-
260
- img = game.draw_figure()
261
- status = game.get_status_text()
262
-
263
- return img, status, "Positions reset to initial grid"
264
 
265
  def randomize_positions():
266
  """ランダム配置"""
267
  for coin in game.coins:
268
  coin['x'] = np.random.uniform(0.5, game.sector_radius - 0.5)
269
  coin['y'] = np.random.uniform(0.5, game.sector_radius - 0.5)
270
-
271
- game.selected_coin = None
272
-
273
- img = game.draw_figure()
274
- status = game.get_status_text()
275
-
276
- return img, status, "Coins randomized"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  def check_solution():
279
  """解答チェック"""
@@ -293,114 +317,133 @@ def check_solution():
293
  result += f"✗ Need {16 - valid_count} more valid coins\n"
294
 
295
  result += "="*50
296
-
297
  return result
298
 
299
  def export_json():
300
  """JSON出力"""
301
- return game.export_config()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
  # Gradio UIの構築
304
  with gr.Blocks(title="Penny Packer 16", theme=gr.themes.Soft()) as demo:
305
  gr.Markdown("# 🪙 Penny Packer 16 - Interactive Puzzle")
306
- gr.Markdown("Fit 16 coins into the quarter circle without overlaps!")
307
 
308
  with gr.Row():
309
  with gr.Column(scale=2):
310
- image_output = gr.Image(label="Game Board", type="pil")
311
 
 
312
  with gr.Row():
313
- coin_radius_slider = gr.Slider(
314
- minimum=0.2, maximum=1.0, value=0.5, step=0.01,
315
- label="Coin Radius"
316
- )
317
- sector_radius_slider = gr.Slider(
318
- minimum=2.0, maximum=8.0, value=4.0, step=0.1,
319
- label="Sector Radius"
320
- )
321
 
322
  with gr.Column(scale=1):
323
- status_output = gr.Markdown(label="Status")
324
-
325
- gr.Markdown("### Move Coins")
326
- with gr.Row():
327
- coin_number_input = gr.Number(
328
- label="Select Coin (1-16)", value=1, minimum=1, maximum=16
329
- )
330
- select_btn = gr.Button("Select", variant="primary")
331
 
 
332
  with gr.Row():
333
- move_x_input = gr.Number(label="X", value=1.0)
334
- move_y_input = gr.Number(label="Y", value=1.0)
335
- move_btn = gr.Button("Move to X,Y", variant="primary")
336
 
337
- message_output = gr.Textbox(label="Message", lines=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
- gr.Markdown("### Actions")
340
  with gr.Row():
341
- reset_btn = gr.Button("Reset Positions", variant="secondary")
342
- random_btn = gr.Button("Randomize", variant="secondary")
 
 
343
 
344
- check_btn = gr.Button("Check Solution", variant="primary")
345
- check_output = gr.Textbox(label="Check Result", lines=8)
 
 
 
 
 
 
346
 
347
- export_btn = gr.Button("Export JSON")
348
- json_output = gr.Textbox(label="Configuration JSON", lines=10)
 
 
 
 
349
 
350
  # イベントハンドラ
351
- def on_radius_change(coin_r, sector_r):
352
- return update_display(coin_r, sector_r)
353
-
354
  coin_radius_slider.change(
355
- on_radius_change,
356
  inputs=[coin_radius_slider, sector_radius_slider],
357
  outputs=[image_output, status_output]
358
  )
359
 
360
  sector_radius_slider.change(
361
- on_radius_change,
362
  inputs=[coin_radius_slider, sector_radius_slider],
363
  outputs=[image_output, status_output]
364
  )
365
 
366
- select_btn.click(
367
- select_coin,
368
- inputs=[coin_number_input],
369
  outputs=[image_output, status_output, message_output]
370
  )
371
 
372
- move_btn.click(
373
- move_coin,
374
- inputs=[move_x_input, move_y_input],
375
- outputs=[image_output, status_output, message_output]
376
- )
377
-
378
- reset_btn.click(
379
- reset_positions,
380
- outputs=[image_output, status_output, message_output]
381
- )
382
 
383
- random_btn.click(
384
- randomize_positions,
385
- outputs=[image_output, status_output, message_output]
386
- )
 
387
 
388
- check_btn.click(
389
- check_solution,
390
- outputs=[check_output]
391
- )
 
392
 
393
- export_btn.click(
394
- export_json,
395
- outputs=[json_output]
396
- )
397
 
398
  # 初期表示
399
- demo.load(
400
- lambda: update_display(0.5, 4.0),
401
- outputs=[image_output, status_output]
402
- )
403
 
404
- # アプリケーション起動
405
  if __name__ == "__main__":
406
  demo.launch()
 
1
+ # Hugging Face Spaces対応 - クリック操作版
2
+ # app.py として保存
3
 
4
  import gradio as gr
5
  import matplotlib.pyplot as plt
 
17
  self.coin_radius = 0.5
18
  self.num_coins = 16
19
 
 
20
  self.coins = []
21
  self.initialize_coins()
22
 
23
+ self.selected_coin = 0 # デフォルトで最初のコインを選択
24
 
25
  def initialize_coins(self):
26
  """4x4グリッドで初期配置"""
 
38
  def is_inside_sector(self, x, y):
39
  """コインが完全に扇形内にあるかチェック"""
40
  max_distance = np.sqrt(x**2 + y**2) + self.coin_radius
 
41
  if max_distance > self.sector_radius:
42
  return False
 
43
  if x - self.coin_radius < 0 or y - self.coin_radius < 0:
44
  return False
 
45
  return True
46
 
47
  def check_overlap(self, coin_index):
48
  """指定したコインが他のコインと重なっているかチェック"""
49
  coin1 = self.coins[coin_index]
 
50
  for i, coin2 in enumerate(self.coins):
51
  if i == coin_index:
52
  continue
 
53
  dx = coin1['x'] - coin2['x']
54
  dy = coin1['y'] - coin2['y']
55
  distance = np.sqrt(dx**2 + dy**2)
 
56
  if distance < 2 * self.coin_radius - 0.001:
57
  return True
 
58
  return False
59
 
60
  def evaluate_layout(self):
61
  """現在のレイアウトを評価"""
62
  valid_coins = 0
 
63
  for i in range(len(self.coins)):
64
  coin = self.coins[i]
65
  inside = self.is_inside_sector(coin['x'], coin['y'])
66
  overlap = self.check_overlap(i)
 
67
  if inside and not overlap:
68
  valid_coins += 1
 
69
  return valid_coins
70
 
71
  def draw_figure(self):
 
73
  fig, ax = plt.subplots(figsize=(10, 10))
74
  ax.set_aspect('equal')
75
 
 
76
  margin = 0.5
77
  ax.set_xlim(-margin, self.sector_radius + margin)
78
  ax.set_ylim(-margin, self.sector_radius + margin)
79
 
80
  # 扇形の描画
81
  sector = patches.Wedge(
82
+ center=self.sector_center, r=self.sector_radius,
83
+ theta1=0, theta2=90,
84
+ facecolor='lightblue', alpha=0.15,
85
+ edgecolor='blue', linewidth=2
 
 
 
 
86
  )
87
  ax.add_patch(sector)
88
 
 
89
  ax.plot([0, self.sector_radius], [0, 0], 'b-', linewidth=2)
90
  ax.plot([0, 0], [0, self.sector_radius], 'b-', linewidth=2)
91
 
 
92
  theta = np.linspace(0, 90, 100)
93
  x_arc = self.sector_radius * np.cos(np.radians(theta))
94
  y_arc = self.sector_radius * np.sin(np.radians(theta))
95
  ax.plot(x_arc, y_arc, 'b-', linewidth=2)
96
 
 
97
  ax.plot(0, 0, 'ro', markersize=8)
 
 
98
  ax.grid(True, alpha=0.3, linestyle='--')
99
 
100
  # コインの描画
 
115
  # 選択されているコインを強調
116
  if self.selected_coin == i:
117
  edgecolor = 'red'
118
+ linewidth = 4
119
  else:
120
  edgecolor = 'darkgoldenrod'
121
  linewidth = 2
122
 
123
  circle = patches.Circle(
124
+ (coin['x'], coin['y']), self.coin_radius,
125
+ facecolor=color, edgecolor=edgecolor,
126
+ linewidth=linewidth, alpha=alpha
 
 
 
127
  )
128
  ax.add_patch(circle)
129
 
130
  # コイン番号
131
  ax.text(coin['x'], coin['y'], str(i+1),
132
  ha='center', va='center',
133
+ fontsize=14, fontweight='bold', color='white',
134
+ bbox=dict(boxstyle='circle', facecolor='black', alpha=0.5, pad=0.3))
135
 
136
  # 評価
137
  valid_count = self.evaluate_layout()
138
 
139
  if valid_count >= 16:
140
+ title = '🎉 SOLUTION FOUND! 🎉'
141
  title_color = 'green'
142
  else:
143
  title = f'Penny Packer 16 - {valid_count}/16 valid'
144
+ title_color = 'darkblue'
145
 
146
+ ax.set_title(title, fontsize=18, color=title_color, weight='bold', pad=20)
147
+ ax.set_xlabel('X', fontsize=14, fontweight='bold')
148
+ ax.set_ylabel('Y', fontsize=14, fontweight='bold')
149
 
150
  plt.tight_layout()
151
 
152
  # 画像に変換
153
  buf = BytesIO()
154
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
155
  buf.seek(0)
156
  img = Image.open(buf)
157
  plt.close(fig)
 
162
  """ステータステキストを生成"""
163
  valid_count = self.evaluate_layout()
164
 
165
+ text = f"## Status: {valid_count}/16 valid coins\n\n"
 
 
166
 
167
  if valid_count >= 16:
168
+ text += "### 🎉 **SOLUTION FOUND!** 🎉\n\n"
169
  else:
170
+ text += f"### Need {16 - valid_count} more valid coins\n\n"
171
 
172
+ text += f"**Selected Coin:** #{self.selected_coin + 1} at ({self.coins[self.selected_coin]['x']:.2f}, {self.coins[self.selected_coin]['y']:.2f})\n\n"
173
+ text += f"**Coin Radius:** {self.coin_radius:.2f}\n"
174
+ text += f"**Sector Radius:** {self.sector_radius:.2f}\n\n"
 
175
 
176
+ text += "**Legend:**\n"
177
+ text += "- 🟢 Green: Valid\n"
178
+ text += "- 🟠 Orange: Overlapping\n"
179
+ text += "- 🔴 Red: Outside\n"
180
+ text += "- **Red border**: Selected\n"
181
 
182
  return text
 
 
 
 
 
 
 
 
 
 
 
183
 
184
  # グローバルゲームインスタンス
185
  game = PennyPackerGame()
 
189
  """表示を更新"""
190
  game.coin_radius = coin_radius
191
  game.sector_radius = sector_radius
 
192
  img = game.draw_figure()
193
  status = game.get_status_text()
 
194
  return img, status
195
 
196
+ def select_next_coin():
197
+ """次のコインを選択"""
198
+ game.selected_coin = (game.selected_coin + 1) % 16
199
+ return game.draw_figure(), game.get_status_text(), f"Coin #{game.selected_coin + 1} selected"
 
 
 
 
 
200
 
201
+ def select_prev_coin():
202
+ """前のコインを選択"""
203
+ game.selected_coin = (game.selected_coin - 1) % 16
204
+ return game.draw_figure(), game.get_status_text(), f"Coin #{game.selected_coin + 1} selected"
205
+
206
+ def select_coin_number(coin_num):
207
+ """コイン番号で選択"""
208
+ if 1 <= coin_num <= 16:
209
+ game.selected_coin = coin_num - 1
210
+ return game.draw_figure(), game.get_status_text(), f"Coin #{game.selected_coin + 1} selected"
211
+ return game.draw_figure(), game.get_status_text(), "Invalid coin number"
212
+
213
+ def move_up():
214
+ """選択したコインを上に移動"""
215
+ game.coins[game.selected_coin]['y'] += 0.1
216
+ return game.draw_figure(), game.get_status_text(), "Moved up"
217
+
218
+ def move_down():
219
+ """選択したコインを下に移動"""
220
+ game.coins[game.selected_coin]['y'] -= 0.1
221
+ return game.draw_figure(), game.get_status_text(), "Moved down"
222
+
223
+ def move_left():
224
+ """選択したコインを左に移動"""
225
+ game.coins[game.selected_coin]['x'] -= 0.1
226
+ return game.draw_figure(), game.get_status_text(), "Moved left"
227
+
228
+ def move_right():
229
+ """選択したコインを右に移動"""
230
+ game.coins[game.selected_coin]['x'] += 0.1
231
+ return game.draw_figure(), game.get_status_text(), "Moved right"
232
+
233
+ def click_to_move(evt: gr.SelectData):
234
+ """画像クリックでコインを移動"""
235
+ # Gradioの画像クリックイベントからピクセル座標を取得
236
+ # 画像サイズとプロット範囲から論理座標に変換
237
+ img_width = 1000 # 推定画像幅(DPI 120 x 10インチ ≈ 1200)
238
+ img_height = 1000
239
+
240
+ click_x = evt.index[0] # ピクセルX
241
+ click_y = evt.index[1] # ピクセルY
242
+
243
+ # おおよその座標変換(余白を考慮)
244
+ margin = 0.5
245
+ plot_range = game.sector_radius + 2 * margin
246
+
247
+ # 画像の有効範囲を推定(余白を除く)
248
+ effective_margin_px = 100 # 推定余白ピクセル
249
+ effective_width = img_width - 2 * effective_margin_px
250
+ effective_height = img_height - 2 * effective_margin_px
251
+
252
+ # ピクセル座標を論理座標に変換
253
+ logical_x = ((click_x - effective_margin_px) / effective_width) * plot_range - margin
254
+ logical_y = plot_range - ((click_y - effective_margin_px) / effective_height) * plot_range - margin
255
+
256
+ # 範囲チェック
257
+ if logical_x < -margin or logical_x > game.sector_radius + margin:
258
+ return game.draw_figure(), game.get_status_text(), "Click within the plot area"
259
+ if logical_y < -margin or logical_y > game.sector_radius + margin:
260
+ return game.draw_figure(), game.get_status_text(), "Click within the plot area"
261
+
262
+ # コインを移動
263
+ game.coins[game.selected_coin]['x'] = max(0, min(game.sector_radius, logical_x))
264
+ game.coins[game.selected_coin]['y'] = max(0, min(game.sector_radius, logical_y))
265
+
266
+ return game.draw_figure(), game.get_status_text(), f"Coin #{game.selected_coin + 1} moved to ({logical_x:.2f}, {logical_y:.2f})"
267
 
268
  def reset_positions():
269
  """位置をリセット"""
270
  game.initialize_coins()
271
+ game.selected_coin = 0
272
+ return game.draw_figure(), game.get_status_text(), "Positions reset"
 
 
 
 
273
 
274
  def randomize_positions():
275
  """ランダム配置"""
276
  for coin in game.coins:
277
  coin['x'] = np.random.uniform(0.5, game.sector_radius - 0.5)
278
  coin['y'] = np.random.uniform(0.5, game.sector_radius - 0.5)
279
+ return game.draw_figure(), game.get_status_text(), "Coins randomized"
280
+
281
+ def auto_arrange():
282
+ """自動配置(六方格子パターン)"""
283
+ spacing = 2 * game.coin_radius * 1.02
284
+ dx = spacing
285
+ dy = spacing * np.sqrt(3) / 2
286
+
287
+ positions = []
288
+ for row in range(5):
289
+ for col in range(5):
290
+ row_offset = (row % 2) * dx * 0.5
291
+ x = 0.5 + col * dx + row_offset
292
+ y = 0.5 + row * dy
293
+ if x + game.coin_radius <= game.sector_radius and y + game.coin_radius <= game.sector_radius:
294
+ positions.append((x, y))
295
+
296
+ for i, (x, y) in enumerate(positions[:16]):
297
+ game.coins[i]['x'] = x
298
+ game.coins[i]['y'] = y
299
+
300
+ return game.draw_figure(), game.get_status_text(), "Auto-arranged in hexagonal pattern"
301
 
302
  def check_solution():
303
  """解答チェック"""
 
317
  result += f"✗ Need {16 - valid_count} more valid coins\n"
318
 
319
  result += "="*50
 
320
  return result
321
 
322
  def export_json():
323
  """JSON出力"""
324
+ config = {
325
+ 'coin_radius': game.coin_radius,
326
+ 'sector_radius': game.sector_radius,
327
+ 'coins': game.coins,
328
+ 'valid_count': game.evaluate_layout(),
329
+ 'timestamp': datetime.now().isoformat()
330
+ }
331
+ return json.dumps(config, indent=2)
332
+
333
+ def import_json(json_str):
334
+ """JSON読み込み"""
335
+ try:
336
+ config = json.loads(json_str)
337
+ game.coin_radius = config['coin_radius']
338
+ game.sector_radius = config['sector_radius']
339
+ game.coins = config['coins']
340
+ return game.draw_figure(), game.get_status_text(), f"Configuration loaded ({config['valid_count']}/16 valid)"
341
+ except Exception as e:
342
+ return game.draw_figure(), game.get_status_text(), f"Error: {str(e)}"
343
 
344
  # Gradio UIの構築
345
  with gr.Blocks(title="Penny Packer 16", theme=gr.themes.Soft()) as demo:
346
  gr.Markdown("# 🪙 Penny Packer 16 - Interactive Puzzle")
347
+ gr.Markdown("**Goal:** Fit all 16 coins into the quarter circle without overlaps!")
348
 
349
  with gr.Row():
350
  with gr.Column(scale=2):
351
+ image_output = gr.Image(label="🎮 Game Board (Click to move selected coin)", type="pil")
352
 
353
+ gr.Markdown("### 🎛️ Adjust Radii")
354
  with gr.Row():
355
+ coin_radius_slider = gr.Slider(0.2, 1.0, value=0.5, step=0.01, label="Coin Radius")
356
+ sector_radius_slider = gr.Slider(2.0, 8.0, value=4.0, step=0.1, label="Sector Radius")
 
 
 
 
 
 
357
 
358
  with gr.Column(scale=1):
359
+ status_output = gr.Markdown(value="Loading...")
 
 
 
 
 
 
 
360
 
361
+ gr.Markdown("### 🎯 Select Coin")
362
  with gr.Row():
363
+ prev_btn = gr.Button("◀ Prev", size="sm")
364
+ coin_num_input = gr.Number(label="Coin #", value=1, minimum=1, maximum=16, precision=0)
365
+ next_btn = gr.Button("Next ", size="sm")
366
 
367
+ gr.Markdown("### ⬆️⬇️⬅️➡️ Move Selected Coin")
368
+ with gr.Column():
369
+ with gr.Row():
370
+ gr.Button("", visible=False, scale=1)
371
+ up_btn = gr.Button("⬆️ Up", scale=2)
372
+ gr.Button("", visible=False, scale=1)
373
+ with gr.Row():
374
+ left_btn = gr.Button("⬅️ Left")
375
+ center_text = gr.Markdown("**Move**", elem_classes="center")
376
+ right_btn = gr.Button("➡️ Right")
377
+ with gr.Row():
378
+ gr.Button("", visible=False, scale=1)
379
+ down_btn = gr.Button("⬇️ Down", scale=2)
380
+ gr.Button("", visible=False, scale=1)
381
 
382
+ gr.Markdown("### 🔧 Actions")
383
  with gr.Row():
384
+ reset_btn = gr.Button("🔄 Reset", variant="secondary")
385
+ random_btn = gr.Button("🎲 Random", variant="secondary")
386
+ auto_btn = gr.Button("🤖 Auto-Arrange", variant="primary")
387
+ check_btn = gr.Button("✅ Check Solution", variant="primary")
388
 
389
+ message_output = gr.Textbox(label="📢 Message", lines=2)
390
+
391
+ with gr.Accordion("📊 Advanced", open=False):
392
+ with gr.Row():
393
+ with gr.Column():
394
+ gr.Markdown("### Export Configuration")
395
+ export_btn = gr.Button("📤 Export JSON")
396
+ json_output = gr.Textbox(label="JSON Output", lines=8)
397
 
398
+ with gr.Column():
399
+ gr.Markdown("### Import Configuration")
400
+ json_input = gr.Textbox(label="Paste JSON", lines=8)
401
+ import_btn = gr.Button("📥 Import JSON")
402
+
403
+ check_output = gr.Textbox(label="Check Result", lines=6)
404
 
405
  # イベントハンドラ
 
 
 
406
  coin_radius_slider.change(
407
+ update_display,
408
  inputs=[coin_radius_slider, sector_radius_slider],
409
  outputs=[image_output, status_output]
410
  )
411
 
412
  sector_radius_slider.change(
413
+ update_display,
414
  inputs=[coin_radius_slider, sector_radius_slider],
415
  outputs=[image_output, status_output]
416
  )
417
 
418
+ # 画像クリック
419
+ image_output.select(
420
+ click_to_move,
421
  outputs=[image_output, status_output, message_output]
422
  )
423
 
424
+ # コイン選択
425
+ next_btn.click(select_next_coin, outputs=[image_output, status_output, message_output])
426
+ prev_btn.click(select_prev_coin, outputs=[image_output, status_output, message_output])
427
+ coin_num_input.change(select_coin_number, inputs=[coin_num_input], outputs=[image_output, status_output, message_output])
 
 
 
 
 
 
428
 
429
+ # 方向キー
430
+ up_btn.click(move_up, outputs=[image_output, status_output, message_output])
431
+ down_btn.click(move_down, outputs=[image_output, status_output, message_output])
432
+ left_btn.click(move_left, outputs=[image_output, status_output, message_output])
433
+ right_btn.click(move_right, outputs=[image_output, status_output, message_output])
434
 
435
+ # アクション
436
+ reset_btn.click(reset_positions, outputs=[image_output, status_output, message_output])
437
+ random_btn.click(randomize_positions, outputs=[image_output, status_output, message_output])
438
+ auto_btn.click(auto_arrange, outputs=[image_output, status_output, message_output])
439
+ check_btn.click(check_solution, outputs=[check_output])
440
 
441
+ # JSON操作
442
+ export_btn.click(export_json, outputs=[json_output])
443
+ import_btn.click(import_json, inputs=[json_input], outputs=[image_output, status_output, message_output])
 
444
 
445
  # 初期表示
446
+ demo.load(lambda: update_display(0.5, 4.0), outputs=[image_output, status_output])
 
 
 
447
 
 
448
  if __name__ == "__main__":
449
  demo.launch()