horiyouta commited on
Commit
0fd1185
·
1 Parent(s): eceed7d

2508181650

Browse files
Files changed (6) hide show
  1. Dockerfile +22 -0
  2. main.py +241 -0
  3. requirements.txt +6 -0
  4. web/index.html +122 -0
  5. web/script.js +1568 -0
  6. web/style.css +566 -0
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ベースイメージとしてPython 3.9-slimを使用
2
+ FROM python:3.9-slim
3
+
4
+ # コンテナ内の作業ディレクトリを設定
5
+ WORKDIR /app
6
+
7
+ # requirements.txtをコンテナにコピー
8
+ COPY requirements.txt .
9
+
10
+ # requirements.txtに記載されたライブラリをインストール
11
+ # --no-cache-dirでイメージサイズを削減
12
+ # PyTorchはCPU版をインストールしてさらにサイズを削減
13
+ RUN pip install --no-cache-dir -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
14
+
15
+ # アプリケーションのコードをコンテナにコピー
16
+ COPY . .
17
+
18
+ # Hugging Face Spacesのデフォルトポートである7860を公開
19
+ EXPOSE 7860
20
+
21
+ # コンテナ起動時にFastAPIサーバーを起動
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Body
2
+ from fastapi.staticfiles import StaticFiles
3
+ from typing import List, Dict, Any
4
+ import torch
5
+ import torch.nn as nn
6
+ import torch.optim as optim
7
+ from torchvision import datasets, transforms
8
+ from torch.utils.data import DataLoader, Subset
9
+ import numpy as np
10
+ import base64
11
+ from io import BytesIO
12
+ from PIL import Image
13
+ import random
14
+ import json
15
+
16
+ # FastAPIアプリケーションインスタンスを作成
17
+ app = FastAPI()
18
+
19
+ # --- 動的なプレイヤーモデル (変更なし) ---
20
+ class PlayerModel(nn.Module):
21
+ def __init__(self, layer_configs):
22
+ super(PlayerModel, self).__init__()
23
+ self.layers = nn.ModuleList()
24
+ self.architecture_info = []
25
+ self.hookable_layers = {} # ★★★ nameをキーとしてフック対象レイヤーを保存
26
+
27
+ in_channels = 1
28
+ feature_map_size = 28
29
+ is_flattened = False
30
+
31
+ for i, config in enumerate(layer_configs):
32
+ layer_type = config['type']
33
+ # ユニークな名前を生成
34
+ name = f"{layer_type.lower()}_{len([info for info in self.architecture_info if info['type'] == layer_type])}"
35
+
36
+ # 畳み込み/プーリング層
37
+ if layer_type in ['Conv2d', 'MaxPool2d', 'AvgPool2d']:
38
+ is_flattened = False
39
+ if layer_type == 'Conv2d':
40
+ out_channels = config['params']['out_channels']
41
+ kernel_size = config['params']['kernel_size']
42
+ layer = nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, padding=kernel_size//2)
43
+ self.layers.append(layer)
44
+ self.hookable_layers[name] = layer
45
+ in_channels = out_channels
46
+ self.architecture_info.append({"type": "Conv2d", "name": name, "shape": [out_channels, feature_map_size, feature_map_size]})
47
+ else: # MaxPool2d or AvgPool2d
48
+ kernel_size = config['params']['kernel_size']
49
+ if layer_type == 'MaxPool2d':
50
+ layer = nn.MaxPool2d(kernel_size=kernel_size, stride=kernel_size)
51
+ else: # AvgPool2d
52
+ layer = nn.AvgPool2d(kernel_size=kernel_size, stride=kernel_size)
53
+ self.layers.append(layer)
54
+ self.hookable_layers[name] = layer
55
+ feature_map_size //= kernel_size
56
+ self.architecture_info.append({"type": layer_type, "name": name, "shape": [in_channels, feature_map_size, feature_map_size]})
57
+
58
+ # 活性化/ドロップアウト層
59
+ elif layer_type in ['ReLU', 'Dropout']:
60
+ if layer_type == 'ReLU':
61
+ self.layers.append(nn.ReLU())
62
+ else: # Dropout
63
+ p = config['params']['p']
64
+ self.layers.append(nn.Dropout(p=p))
65
+ self.architecture_info.append({"type": layer_type, "name": name})
66
+
67
+ # 構造変更層
68
+ elif layer_type == 'Flatten':
69
+ if not is_flattened:
70
+ layer = nn.Flatten()
71
+ self.layers.append(layer)
72
+ self.hookable_layers[name] = layer
73
+ flat_features = in_channels * feature_map_size * feature_map_size
74
+ in_channels = flat_features
75
+ self.architecture_info.append({"type": "Flatten", "name": name, "shape": [flat_features]})
76
+ is_flattened = True
77
+
78
+ # 全結合/残差ブロック層
79
+ elif layer_type in ['Linear', 'ResidualBlock']:
80
+ if not is_flattened:
81
+ auto_flatten_name = f"auto_flatten_{i}"
82
+ self.layers.append(nn.Flatten())
83
+ flat_features = in_channels * feature_map_size * feature_map_size
84
+ in_channels = flat_features
85
+ self.architecture_info.append({"type": "Flatten", "name": auto_flatten_name, "shape": [flat_features]})
86
+ is_flattened = True
87
+
88
+ if layer_type == 'Linear':
89
+ out_features = config['params']['out_features']
90
+ layer = nn.Linear(in_channels, out_features)
91
+ in_channels = out_features
92
+ else: # ResidualBlock
93
+ # ★★★ 残差ブロックは次元を維持する線形層として実装
94
+ features = in_channels
95
+ layer = nn.Linear(features, features)
96
+
97
+ self.layers.append(layer)
98
+ self.hookable_layers[name] = layer
99
+ self.architecture_info.append({"type": layer_type, "name": name, "shape": [in_channels]})
100
+
101
+ # 最終出力層を強制的に追加
102
+ if not self.layers or not isinstance(self.layers[-1], nn.Linear) or self.layers[-1].out_features != 10:
103
+ if not is_flattened:
104
+ self.layers.append(nn.Flatten())
105
+ final_in_features = in_channels * feature_map_size * feature_map_size
106
+ else:
107
+ final_in_features = in_channels
108
+
109
+ output_layer = nn.Linear(final_in_features, 10)
110
+ self.layers.append(output_layer)
111
+ self.hookable_layers["linear_output"] = output_layer
112
+ self.architecture_info.append({"type": "Linear", "name": "linear_output", "shape": [10]})
113
+
114
+ def forward(self, x):
115
+ for layer in self.layers:
116
+ x = layer(x)
117
+ return x
118
+
119
+ # --- グローバル変数 (変更なし) ---
120
+ device = torch.device("cpu")
121
+ transform = transforms.ToTensor()
122
+ full_train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
123
+ train_subset = Subset(full_train_dataset, range(1000))
124
+ train_loader = DataLoader(train_subset, batch_size=32, shuffle=True)
125
+ test_dataset = datasets.MNIST('./data', train=False, download=True, transform=transform)
126
+ test_images = list(DataLoader(test_dataset, batch_size=1, shuffle=True))
127
+ current_enemy = None
128
+ trained_player_model = None
129
+
130
+ # --- バックエンドロジック (Eelデコレータを削除) ---
131
+ def get_enemy():
132
+ global current_enemy
133
+ image, label = random.choice(test_images)
134
+ current_enemy = {"image": image, "label": label}
135
+
136
+ img_pil = transforms.ToPILImage()(image.squeeze(0))
137
+ buffered = BytesIO()
138
+ img_pil.save(buffered, format="PNG")
139
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
140
+
141
+ return {"image_b64": "data:image/png;base64," + img_str}
142
+
143
+ def train_player_model(layer_configs: list):
144
+ global trained_player_model
145
+ if not layer_configs:
146
+ return {"success": False, "message": "モデルが空です。"}
147
+
148
+ try:
149
+ model = PlayerModel(layer_configs).to(device)
150
+ optimizer = optim.Adam(model.parameters(), lr=0.001)
151
+ loss_fn = nn.CrossEntropyLoss()
152
+
153
+ model.train()
154
+ for epoch in range(3): # 3エポック学習
155
+ for batch_idx, (data, target) in enumerate(train_loader):
156
+ data, target = data.to(device), target.to(device)
157
+ optimizer.zero_grad()
158
+ output = model(data)
159
+ loss = loss_fn(output, target)
160
+ loss.backward()
161
+ optimizer.step()
162
+ print(f"Epoch {epoch+1} training finished.")
163
+
164
+ trained_player_model = model
165
+ return {"success": True, "message": "モデルの訓練が完了しました!"}
166
+ except Exception as e:
167
+ print(f"Error during training: {e}")
168
+ return {"success": False, "message": f"訓練中にエラーが発生しました: {e}"}
169
+
170
+ def run_inference():
171
+ global trained_player_model, current_enemy
172
+ if trained_player_model is None:
173
+ return {"error": "モデルが訓練されていません。"}
174
+
175
+ image, label = random.choice(test_images)
176
+ current_enemy = {"image": image, "label": label}
177
+
178
+ model = trained_player_model
179
+ model.eval()
180
+
181
+ intermediate_outputs = {}
182
+ hooks = []
183
+ def get_hook(name):
184
+ def hook(model, input, output):
185
+ intermediate_outputs[name] = output.detach().cpu().clone().numpy().tolist()
186
+ return hook
187
+
188
+ for name, layer in model.hookable_layers.items():
189
+ hooks.append(layer.register_forward_hook(get_hook(name)))
190
+
191
+ with torch.no_grad():
192
+ image_tensor = current_enemy["image"].to(device)
193
+ output = model(image_tensor)
194
+
195
+ for h in hooks: h.remove()
196
+
197
+ probabilities = torch.nn.functional.softmax(output, dim=1)
198
+ prediction = torch.argmax(probabilities, dim=1).item()
199
+ confidence = probabilities[0, prediction].item()
200
+
201
+ intermediate_outputs['input'] = image_tensor.cpu().numpy().tolist()
202
+
203
+ weights = {}
204
+ for name, layer in model.hookable_layers.items():
205
+ if isinstance(layer, (nn.Linear, nn.Conv2d)):
206
+ if hasattr(layer, 'weight') and hasattr(layer, 'bias'):
207
+ weights[name + '_w'] = layer.weight.cpu().detach().numpy().tolist()
208
+ weights[name + '_b'] = layer.bias.cpu().detach().numpy().tolist()
209
+
210
+ is_correct = (prediction == current_enemy["label"].item())
211
+
212
+ img_pil = transforms.ToPILImage()(image.squeeze(0))
213
+ buffered = BytesIO()
214
+ img_pil.save(buffered, format="PNG")
215
+ img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
216
+
217
+ return {
218
+ "prediction": prediction, "label": current_enemy["label"].item(), "is_correct": is_correct,
219
+ "confidence": confidence,
220
+ "image_b64": "data:image/png;base64," + img_str,
221
+ "architecture": [{"type": "Input", "name": "input", "shape": [1, 28, 28]}] + model.architecture_info,
222
+ "outputs": intermediate_outputs,
223
+ "weights": weights
224
+ }
225
+
226
+ # --- FastAPI Endpoints ---
227
+ @app.get("/api/get_enemy")
228
+ async def get_enemy_endpoint():
229
+ return get_enemy()
230
+
231
+ @app.post("/api/train_player_model")
232
+ async def train_player_model_endpoint(layer_configs: List[Dict[str, Any]] = Body(...)):
233
+ return train_player_model(layer_configs)
234
+
235
+ @app.post("/api/run_inference")
236
+ async def run_inference_endpoint():
237
+ return run_inference()
238
+
239
+ # --- 静的ファイルの配信 ---
240
+ # フロントエンドのファイル (index.html, style.css, script.js) を配信
241
+ app.mount("/", StaticFiles(directory="web", html=True), name="static")
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ torch
4
+ torchvision
5
+ numpy
6
+ Pillow
web/index.html ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Machine Learning RPG</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
13
+ <link rel="stylesheet" href="style.css">
14
+ </head>
15
+
16
+ <body class="text-white">
17
+ <div class="background-grid"></div>
18
+ <div class="background-glow"></div>
19
+
20
+ <!-- ★★★ タイトル画面を追加 ★★★ -->
21
+ <div id="title-screen" class="fixed inset-0 z-50 flex flex-col justify-center items-center">
22
+ <div class="title-logo">
23
+ <h1 class="text-7xl font-bold tracking-widest">MACHINE LEARNING</h1><br>
24
+ <h2 class="text-8xl font-bold tracking-wider text-glow-yellow">RPG</h2>
25
+ </div>
26
+ <button id="start-game-btn" class="btn btn-primary mt-12 text-2xl px-12 py-4">ゲームを始める</button>
27
+ <p class="mt-8 text-cyan-300/60 font-mono">ニューラルネットワークを構築・訓練し、MNISTモンスターを倒せ!</p>
28
+ </div>
29
+
30
+ <!-- ★★★ ゲームコンテナを追加 ★★★ -->
31
+ <div id="game-container" class="container mx-auto p-4 h-screen flex-col hidden">
32
+ <header class="text-center mb-4 flex-shrink-0">
33
+ <h1 class="text-5xl font-bold tracking-widest text-glow">Machine Learning RPG</h1>
34
+ <p class="text-cyan-300/80 font-mono tracking-wider">ニューラルネットワークを構築・訓練し、MNISTモンスターを倒せ!</p>
35
+ </header>
36
+
37
+ <div class="flex-grow flex gap-4 min-h-0">
38
+ <!-- 左: モデル構築エリア -->
39
+ <aside class="w-1/4 glass-pane flex flex-col">
40
+ <h2 class="section-title"><i class="fas fa-microchip mr-3"></i>あなたのモデル</h2>
41
+ <div id="player-model-layers" class="flex-grow bg-black/20 rounded-lg p-2 space-y-2 overflow-y-auto min-h-0 relative">
42
+ <canvas id="layer-connections" class="absolute top-0 left-0 w-full h-full pointer-events-none"></canvas>
43
+ <p class="text-gray-400 text-center p-4">インベントリからレイヤー(層)をここにドラッグ&ドロップしてください。</p>
44
+ </div>
45
+ <div class="mt-4 text-center p-4">
46
+ <button id="battle-btn" class="btn btn-primary w-full" disabled>
47
+ <i class="fas fa-fist-raised"></i> バトル開始!
48
+ </button>
49
+ <button id="restart-btn" class="btn btn-secondary w-full mt-2 hidden">
50
+ <i class="fas fa-redo"></i> タイトルへ戻る
51
+ </button>
52
+ </div>
53
+ </aside>
54
+
55
+ <!-- 中央: バトルエリア -->
56
+ <main class="w-1/2 glass-pane p-4 flex flex-col items-center justify-between relative">
57
+ <div class="w-full">
58
+ <h2 class="character-name text-red-400">ENEMY: MNIST_MONSTER</h2>
59
+ <div class="hp-bar-container">
60
+ <div id="enemy-hp-bar" class="hp-bar" style="width: 100%">100%</div>
61
+ </div>
62
+ </div>
63
+ <div id="enemy-area" class="text-center relative">
64
+ <div class="enemy-scanline"></div>
65
+ <div class="enemy-glitch-overlay"></div>
66
+ <p id="enemy-message" class="text-lg text-cyan-200">挑戦者を待っている...</p>
67
+ <img id="enemy-image" src="" alt="MNIST Monster" class="hidden w-48 h-48 rounded-lg mt-4 enemy-image-style">
68
+ </div>
69
+ <div class="w-full">
70
+ <h2 class="character-name text-green-400">PLAYER: AI_AGENT</h2>
71
+ <div class="hp-bar-container">
72
+ <div id="player-hp-bar" class="hp-bar" style="width: 100%">100%</div>
73
+ </div>
74
+ </div>
75
+ <div id="battle-log" class="absolute bottom-24 left-1/2 -translate-x-1/2 text-2xl font-bold text-center w-full text-glow-yellow"></div>
76
+ </main>
77
+
78
+ <!-- 右: アイテム(層)エリア -->
79
+ <aside class="w-1/4 glass-pane flex flex-col">
80
+ <h2 class="section-title"><i class="fas fa-cubes mr-3"></i>インベントリ</h2>
81
+ <div id="layer-inventory" class="p-2 min-h-0 flex-grow overflow-y-auto"></div>
82
+ <div id="item-info-area" class="mt-4 h-64 glass-pane-inner flex-shrink-0 flex flex-col">
83
+ <div id="layer-description" class="text-sm text-gray-300 h-16 overflow-y-auto mb-2 p-2">
84
+ <p>アイテムをクリックまたはホバーすると、ここに説明が表示されます。</p>
85
+ </div>
86
+ <div id="preview-animation-area" class="flex-grow bg-black/30 rounded-lg relative overflow-hidden">
87
+ <div class="idle-particles"></div>
88
+ </div>
89
+ </div>
90
+ </aside>
91
+ </div>
92
+
93
+ <!-- 下: メッセージログエリア -->
94
+ <footer id="message-log-area" class="flex-shrink-0 h-24 glass-pane mt-4 p-3 overflow-y-auto font-mono text-sm"></footer>
95
+ </div>
96
+
97
+ <!-- モーダル (変更なし) -->
98
+ <div id="item-selection-modal" class="fixed inset-0 bg-black bg-opacity-80 backdrop-blur-sm flex-col justify-center items-center z-50 hidden">
99
+ <h2 class="text-4xl font-bold mb-8 text-glow-yellow tracking-widest">報酬を選択せよ</h2>
100
+ <div id="item-choices" class="flex gap-8"></div>
101
+ <div id="choice-info-area" class="mt-8 w-3/4 h-64 glass-pane flex gap-4 p-4">
102
+ <div id="choice-description" class="w-1/2 text-lg text-gray-300">
103
+ <p>アイテムにカーソルを合わせると、ここに説明が表示されます。</p>
104
+ </div>
105
+ <div id="choice-preview-area" class="w-1/2 bg-black/30 rounded-lg relative overflow-hidden">
106
+ <div class="idle-particles"></div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ <div id="animation-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 backdrop-blur-sm flex justify-center items-center z-50 hidden">
111
+ <div id="visualization-area" class="w-[95%] h-[90%] glass-pane relative p-4 overflow-hidden">
112
+ <div id="labels-container" class="absolute inset-x-0 top-0 h-24 pointer-events-none flex items-center"></div>
113
+ <div id="prediction-result" class="absolute text-2xl font-bold right-8 top-8 opacity-0 transition-opacity duration-500"></div>
114
+ <button id="close-modal-btn" class="absolute top-4 left-4 text-3xl text-gray-400 hover:text-white transition-colors">&times;</button>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- eel.jsの読み込みを削除 -->
119
+ <script type="text/javascript" src="script.js"></script>
120
+ </body>
121
+
122
+ </html>
web/script.js ADDED
@@ -0,0 +1,1568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // --- DOM Elements ---
2
+ const battleBtn = document.getElementById('battle-btn');
3
+ const enemyMessage = document.getElementById('enemy-message');
4
+ const enemyImage = document.getElementById('enemy-image');
5
+ const battleLog = document.getElementById('battle-log');
6
+ const playerModelLayers = document.getElementById('player-model-layers');
7
+ const layerInventory = document.getElementById('layer-inventory');
8
+ const messageLogArea = document.getElementById('message-log-area');
9
+ const animationModal = document.getElementById('animation-modal');
10
+ const vizArea = document.getElementById('visualization-area');
11
+ const predictionResult = document.getElementById('prediction-result');
12
+ const closeModalBtn = document.getElementById('close-modal-btn');
13
+ const playerHpBar = document.getElementById('player-hp-bar');
14
+ const enemyHpBar = document.getElementById('enemy-hp-bar');
15
+ const itemSelectionModal = document.getElementById('item-selection-modal');
16
+ const itemChoices = document.getElementById('item-choices');
17
+ const restartBtn = document.getElementById('restart-btn');
18
+ const layerDescription = document.getElementById('layer-description');
19
+ const itemInfoArea = document.getElementById('item-info-area');
20
+ const previewAnimationArea = document.getElementById('preview-animation-area');
21
+ const choiceInfoArea = document.getElementById('choice-info-area');
22
+ const choiceDescription = document.getElementById('choice-description');
23
+ const choicePreviewArea = document.getElementById('choice-preview-area');
24
+ const titleScreen = document.getElementById('title-screen');
25
+ const startGameBtn = document.getElementById('start-game-btn');
26
+ const gameContainer = document.getElementById('game-container');
27
+
28
+ // --- Game State & Config ---
29
+ let playerLayers = []; // 現在モデルに配置されているレイヤー
30
+ let playerHP = 100;
31
+ let enemyHP = 100;
32
+ let currentStage = 1;
33
+ let isBattleInProgress = false; // ★★★ バトルループ中のフラグを追加
34
+ let draggedItem = null; // { type, layer, index }
35
+ let dragOverIndex = null; // 並び替え先のインデックス
36
+ let wasDroppedSuccessfully = false; // ★★★ このフラグを追加
37
+ let ENEMY_MAX_HP = 100;
38
+ const PLAYER_MAX_HP = 100;
39
+
40
+ const allAvailableLayers = [
41
+ // --- Normal Items ---
42
+ {
43
+ id: 0, name: '畳み込み層 (3x3, 4フィルタ)', type: 'Conv2d', icon: 'fa-th-large', params: { out_channels: 4, kernel_size: 3 },
44
+ rarity: 'normal',
45
+ desc: '画像から特徴(エッジ等)を抽出するCNNの心臓部。<br><br><b>使い方:</b> 画像の「パーツ」を見つける専門家です。モデルの<span class="text-yellow-300">最初の方</span>に置いて、画像から形の特徴を捉えさせましょう。'
46
+ },
47
+ {
48
+ id: 2, name: 'ReLU活性化関数', type: 'ReLU', icon: 'fa-chart-line', params: {},
49
+ rarity: 'normal',
50
+ desc: '負の値を0に変換し、モデルに非線形性を与え表現力を高めます。<br><br><b>使い方:</b> モデルが複雑な判断をするための「スイッチ」です。<span class="text-yellow-300">畳み込み層や全結合層の直後</span>に挟むのが定石です。'
51
+ },
52
+ {
53
+ id: 3, name: '最大プーリング (2x2)', type: 'MaxPool2d', icon: 'fa-compress-arrows-alt', params: { kernel_size: 2 },
54
+ rarity: 'normal',
55
+ desc: '情報を圧縮し、位置ズレに強いモデルを作ります。<br><br><b>使い方:</b> 画像の「要約」を行い、重要な部分だけを残します。<span class="text-yellow-300">畳み込み層(とReLU)の後</span>に入れると、より頑健なモデルになります。'
56
+ },
57
+ {
58
+ id: 4, name: '平坦化層', type: 'Flatten', icon: 'fa-stream', params: {},
59
+ rarity: 'normal',
60
+ desc: '2次元の画像データを1次元に変換し、全結合層に渡せるようにします。<br><br><b>使い方:</b> 画像処理パートから最終判断パートへの「橋渡し」役です。モデルの<span class="text-yellow-300">中盤に必ず1つ</span>だけ配置してください。'
61
+ },
62
+ {
63
+ id: 5, name: '全結合層 (16ノード)', type: 'Linear', icon: 'fa-braille', params: { out_features: 16 },
64
+ rarity: 'normal',
65
+ desc: '全ての特徴を結合し、最終的な分類を行います。<br><br><b>使い方:</b> これまでの情報を元に「最終判断」を下す賢者です。モデルの<span class="text-yellow-300">最後の方</span>、平坦化層の後に置きます。'
66
+ },
67
+ {
68
+ id: 8, name: '平均プーリング (2x2)', type: 'AvgPool2d', icon: 'fa-wave-square', params: { kernel_size: 2 },
69
+ rarity: 'normal',
70
+ desc: '範囲内の特徴を「平均化」して情報を圧縮します。<br><br><b>使い方:</b> 最大プーリングと似ていますが、より滑らかに情報を要約します。<span class="text-yellow-300">畳み込み層(とReLU)の後</span>に使い、最大プーリングと使い比べてみましょう。'
71
+ },
72
+
73
+ // --- Gold Rare Items ---
74
+ {
75
+ id: 1, name: '畳み込み層 (5x5, 8フィルタ)', type: 'Conv2d', icon: 'fa-border-all', params: { out_channels: 8, kernel_size: 5 },
76
+ rarity: 'gold',
77
+ desc: 'より広い範囲の特徴を抽出する強力な畳み込み層。<br><br><b>使い方:</b> 3x3より広い範囲を見るため、より大局的な特徴を捉えます。これもモデルの<span class="text-yellow-300">最初の方</span>に置きます。'
78
+ },
79
+ {
80
+ id: 6, name: '全結合層 (64ノード)', type: 'Linear', icon: 'fa-sitemap', params: { out_features: 64 },
81
+ rarity: 'gold',
82
+ desc: 'より多くのパラメータを持つ、より強力な全結合層。<br><br><b>使い方:</b> 16ノードより賢い賢者ですが、学習に少し時間がかかります。これも<span class="text-yellow-300">最後の方</span>に置きます。'
83
+ },
84
+ {
85
+ id: 7, name: 'ドロップアウト (p=0.5)', type: 'Dropout', icon: 'fa-random', params: { p: 0.5 },
86
+ rarity: 'gold',
87
+ desc: '学習中にノードをランダムに無効化し、過学習を防ぎます。<br><br><b>使い方:</b> モデルが特定の情報に頼りすぎるのを防ぐ「保険」です。未知の敵に強くなります。<span class="text-yellow-300">全結合層の間</span>に挟むのが効果的です。'
88
+ },
89
+ {
90
+ id: 9, name: '残差ブロック', type: 'ResidualBlock', icon: 'fa-project-diagram', params: {},
91
+ rarity: 'gold',
92
+ desc: '入力情報を「近道」させ、深いモデルの学習を安定させます。<br><br><b>使い方:</b> 情報を失わずに変換を加える特殊なブロックです。モデルが深くなりすぎた時に<span class="text-yellow-300">全結合層の代わり</span>に入れると、性能が改善することがあります。'
93
+ }
94
+ ];
95
+
96
+ // ★★★ インベントリを所持数管理に変更
97
+ let playerInventory = {}; // { layerId: count, ... }
98
+ const ANIMATION_SPEED = 0.7;
99
+
100
+ // --- Helper & UI Functions ---
101
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms * ANIMATION_SPEED));
102
+
103
+ async function animateBattleLog(text, clear = true) {
104
+ if (clear) battleLog.textContent = '';
105
+ for (let i = 0; i < text.length; i++) {
106
+ battleLog.textContent += text[i];
107
+ await sleep(30);
108
+ }
109
+ }
110
+
111
+ function logMessage(message, type = 'info') {
112
+ const colors = { info: 'text-gray-400', success: 'text-green-400', error: 'text-red-400', action: 'text-yellow-400' };
113
+ const p = document.createElement('p');
114
+ // メッセージが追加されるときに少し遅延させることで、アニメーションが目に見えるようにする
115
+ p.style.animationDelay = `${messageLogArea.childElementCount * 50}ms`;
116
+ p.innerHTML = `> ${message}`;
117
+ p.className = colors[type];
118
+ messageLogArea.appendChild(p);
119
+ messageLogArea.scrollTop = messageLogArea.scrollHeight;
120
+ }
121
+
122
+ function initializeUI() {
123
+ logMessage('Machine Learning RPGへようこそ!');
124
+ logMessage('インベントリのアイテムをドラッグしてモデルを構築しましょう。');
125
+ startGame();
126
+ }
127
+
128
+ function drawLayerConnections() {
129
+ const canvas = document.getElementById('layer-connections'); // Get canvas element
130
+ if (!canvas) return;
131
+ const ctx = canvas.getContext('2d');
132
+ const parent = playerModelLayers;
133
+
134
+ // Canvasのサイズを親要素に合わせる
135
+ canvas.width = parent.clientWidth;
136
+ canvas.height = parent.scrollHeight;
137
+
138
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
139
+
140
+ const layers = parent.querySelectorAll('.player-layer');
141
+ if (layers.length < 2) return;
142
+
143
+ ctx.strokeStyle = 'rgba(0, 242, 255, 0.4)';
144
+ ctx.lineWidth = 2;
145
+ ctx.shadowColor = 'rgba(0, 242, 255, 0.8)';
146
+ ctx.shadowBlur = 10;
147
+
148
+ for (let i = 0; i < layers.length - 1; i++) {
149
+ const startEl = layers[i];
150
+ const endEl = layers[i + 1];
151
+
152
+ const startRect = startEl.getBoundingClientRect();
153
+ const endRect = endEl.getBoundingClientRect();
154
+ const parentRect = parent.getBoundingClientRect();
155
+
156
+ const startX = startRect.left + startRect.width / 2 - parentRect.left;
157
+ const startY = startRect.bottom - parentRect.top + parent.scrollTop;
158
+ const endX = endRect.left + endRect.width / 2 - parentRect.left;
159
+ const endY = endRect.top - parentRect.top + parent.scrollTop;
160
+
161
+ ctx.beginPath();
162
+ ctx.moveTo(startX, startY);
163
+ ctx.bezierCurveTo(startX, startY + 30, endX, endY - 30, endX, endY);
164
+ ctx.stroke();
165
+ }
166
+ }
167
+
168
+
169
+ function removeLayer(index) {
170
+ const removedLayer = playerLayers.splice(index, 1)[0];
171
+ returnInventoryItem(removedLayer);
172
+ logMessage(`Layer Removed: <span class="text-indigo-300">${removedLayer.name}</span>`, 'action');
173
+ updatePlayerModelUI();
174
+ updatePlayerInventoryUI();
175
+ }
176
+
177
+ function updatePlayerModelUI() {
178
+ const modelArea = playerModelLayers;
179
+ // Canvas以外の要素をクリア
180
+ Array.from(modelArea.children).forEach(child => {
181
+ if (child.tagName !== 'CANVAS') {
182
+ child.remove();
183
+ }
184
+ });
185
+
186
+ if (playerLayers.length === 0) {
187
+ const p = document.createElement('p');
188
+ p.className = 'text-gray-400 text-center p-4';
189
+ p.textContent = 'インベントリからレイヤー(層)をここにドラッグ&ドロップしてください。';
190
+ modelArea.appendChild(p);
191
+ } else {
192
+ playerLayers.forEach((layer, index) => {
193
+ const div = document.createElement('div');
194
+ div.className = `player-layer ${layer.rarity === 'gold' ? 'gold-rare' : ''}`;
195
+ div.draggable = true;
196
+ div.dataset.index = index;
197
+ div.innerHTML = `<span><i class="fas ${layer.icon} mr-2"></i>${layer.name}</span><button class="text-red-400 hover:text-red-200 text-lg">&times;</button>`;
198
+ div.querySelector('button').onclick = () => removeLayer(index);
199
+
200
+ div.addEventListener('dragstart', (e) => handleDragStart(e, index, layer));
201
+ div.addEventListener('dragend', handleDragEnd);
202
+ div.addEventListener('dragenter', (e) => handleDragEnterModelItem(e, index));
203
+ div.addEventListener('dragleave', (e) => e.currentTarget.classList.remove('is-dragged-over'));
204
+
205
+ modelArea.appendChild(div);
206
+ });
207
+ }
208
+ battleBtn.disabled = playerLayers.length === 0;
209
+ // UI更新後に接続線を描画
210
+ requestAnimationFrame(drawLayerConnections);
211
+ }
212
+
213
+ function updatePlayerInventoryUI() {
214
+ layerInventory.innerHTML = '';
215
+ Object.keys(playerInventory).forEach(layerId => {
216
+ const count = playerInventory[layerId];
217
+ if (count > 0) {
218
+ const layer = allAvailableLayers.find(l => l.id == layerId);
219
+ const item = document.createElement('div');
220
+ item.className = `layer-item min-h-16 relative ${layer.rarity === 'gold' ? 'gold-rare' : ''}`;
221
+ item.draggable = true;
222
+ item.dataset.id = layer.id;
223
+
224
+ // ★★★ HTML構造を新しいレイアウト用に変更
225
+ item.innerHTML = `
226
+ <i class="fas ${layer.icon} layer-icon"></i>
227
+ <p>${layer.name}</p>
228
+ <div class="ml-auto bg-indigo-500 text-white text-xs font-bold rounded-full h-6 w-6 flex items-center justify-center">${count}</div>
229
+ `;
230
+
231
+ // イベントリスナー
232
+ item.addEventListener('click', () => {
233
+ showDescription(layer, document.getElementById('layer-description'));
234
+ runPreviewAnimation(layer, previewAnimationArea);
235
+ });
236
+ item.addEventListener('dragstart', (e) => handleInventoryDragStart(e, layer));
237
+ item.addEventListener('dragend', handleDragEnd);
238
+
239
+ // ゴールドレアの場合、キラキラエフェクトを追加
240
+ if (layer.rarity === 'gold') {
241
+ const sparkleContainer = document.createElement('div');
242
+ sparkleContainer.className = 'sparkle-container';
243
+ for (let i = 0; i < 3; i++) {
244
+ const sparkle = document.createElement('div');
245
+ sparkle.className = 'sparkle';
246
+ sparkle.style.top = `${Math.random() * 100}%`;
247
+ sparkle.style.left = `${Math.random() * 100}%`;
248
+ sparkle.style.animationDelay = `${Math.random() * 1.5}s`;
249
+ sparkleContainer.appendChild(sparkle);
250
+ }
251
+ item.appendChild(sparkleContainer);
252
+ }
253
+
254
+ layerInventory.appendChild(item);
255
+ }
256
+ });
257
+ }
258
+
259
+ function showDescription(layer, element) {
260
+ element.innerHTML = `<p class="font-bold text-cyan-300">${layer.name}</p><p>${layer.desc}</p>`;
261
+ }
262
+
263
+ function updateHpBars() {
264
+ const playerHpPercent = Math.max(0, (playerHP / PLAYER_MAX_HP) * 100);
265
+ playerHpBar.style.width = `${playerHpPercent}%`;
266
+ // ★★★ 表示を「現在HP / 最大HP」に変更
267
+ playerHpBar.textContent = `${Math.max(0, playerHP)} / ${PLAYER_MAX_HP}`;
268
+
269
+ const enemyHpPercent = Math.max(0, (enemyHP / ENEMY_MAX_HP) * 100);
270
+ enemyHpBar.style.width = `${enemyHpPercent}%`;
271
+ // ★★★ 表示を「現在HP / 最大HP」に変更
272
+ enemyHpBar.textContent = `${Math.max(0, enemyHP)} / ${ENEMY_MAX_HP}`;
273
+
274
+ // Flash effect on change
275
+ if (playerHpBar.dataset.lastHp && playerHpBar.dataset.lastHp != playerHP) {
276
+ playerHpBar.parentElement.classList.add('flash-damage');
277
+ setTimeout(() => playerHpBar.parentElement.classList.remove('flash-damage'), 300);
278
+ }
279
+ if (enemyHpBar.dataset.lastHp && enemyHpBar.dataset.lastHp != enemyHP) {
280
+ enemyHpBar.parentElement.classList.add('flash-damage');
281
+ setTimeout(() => enemyHpBar.parentElement.classList.remove('flash-damage'), 300);
282
+ }
283
+ playerHpBar.dataset.lastHp = playerHP;
284
+ enemyHpBar.dataset.lastHp = enemyHP;
285
+ }
286
+
287
+ async function fetchNewEnemy() {
288
+ // EelからFetch APIに変更
289
+ const response = await fetch('/api/get_enemy');
290
+ const enemy = await response.json();
291
+
292
+ enemyMessage.textContent = '野生のMNISTモンスターが現れた!';
293
+ enemyImage.src = enemy.image_b64;
294
+ enemyImage.classList.remove('hidden');
295
+ await animateBattleLog('', true); // Clear battle log
296
+ }
297
+
298
+ // --- D&D Functions ---
299
+
300
+ // ドラッグ開始時の処理
301
+ function handleDragStart(e, index, layer) {
302
+ draggedItem = { type: 'model', index: index, layer: layer, element: e.target };
303
+ setTimeout(() => e.target.classList.add('dragging'), 0);
304
+ }
305
+
306
+ function handleInventoryDragStart(e, layer) {
307
+ // ★★★ 新しい一意なインスタンスを作成
308
+ const layerInstance = {
309
+ ...layer,
310
+ instanceId: `inst_${Date.now()}_${Math.random()}`
311
+ };
312
+ draggedItem = { type: 'inventory', layer: layerInstance, element: e.target };
313
+ wasDroppedSuccessfully = false; // ★★★ ドラッグ開始時にフラグをリセット
314
+ setTimeout(() => e.target.classList.add('dragging'), 0);
315
+ }
316
+
317
+ // ドラッグ終了時の処理
318
+ function handleDragEnd(e) {
319
+ // ★★★ バグ修正:キャンセル時のクリーンアップ処理 ★★★
320
+ if (draggedItem && draggedItem.type === 'inventory' && !wasDroppedSuccessfully) {
321
+ // インベントリからのドラッグが、モデルエリアにドロップされずに終了した場合
322
+ const tempIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
323
+ if (tempIndex > -1) {
324
+ // モデルデータから仮追加されたアイテムを削除
325
+ playerLayers.splice(tempIndex, 1);
326
+ logMessage('モデルへの追加をキャンセルしました。', 'info');
327
+ // UIを再描画して見た目を元に戻す
328
+ updatePlayerModelUI();
329
+ }
330
+ }
331
+
332
+ // --- 既存のクリーンアップ処理 ---
333
+ // is-dragged-over クラスをすべて削除
334
+ document.querySelectorAll('.is-dragged-over').forEach(el => el.classList.remove('is-dragged-over'));
335
+ // dragging クラスを削除
336
+ if (draggedItem && draggedItem.element) {
337
+ draggedItem.element.classList.remove('dragging');
338
+ }
339
+ draggedItem = null;
340
+ }
341
+
342
+ // ドロップを許可するエリアの処理
343
+ function allowDrop(ev) {
344
+ ev.preventDefault();
345
+ const modelArea = document.getElementById('player-model-layers');
346
+ if (modelArea.contains(ev.target)) {
347
+ modelArea.classList.add('drag-over');
348
+ }
349
+ }
350
+
351
+ // モデルエリアへのドロップ処理 (メインロジック)
352
+ function dropOnModelArea(ev) {
353
+ ev.preventDefault();
354
+ const modelArea = document.getElementById('player-model-layers');
355
+ modelArea.classList.remove('drag-over');
356
+
357
+ if (!draggedItem) return;
358
+
359
+ // ★★★ ドロップが成功した(試みられた)ことを記録
360
+ if (draggedItem.type === 'inventory') {
361
+ wasDroppedSuccessfully = true;
362
+ }
363
+
364
+ if (draggedItem.type === 'inventory') {
365
+ const originalLayer = allAvailableLayers.find(l => l.id === draggedItem.layer.id);
366
+ if (!useInventoryItem(originalLayer)) {
367
+ logMessage(`インベントリに ${draggedItem.layer.name} がありません`, 'error');
368
+ const tempIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
369
+ if (tempIndex > -1) playerLayers.splice(tempIndex, 1);
370
+ } else {
371
+ logMessage(`レイヤー追加: ${draggedItem.layer.name}`, 'action');
372
+ }
373
+ } else {
374
+ logMessage('モデルの順序を変更しました。', 'info');
375
+ }
376
+
377
+ updatePlayerModelUI();
378
+ updatePlayerInventoryUI();
379
+ handleDragEnd({ target: ev.target });
380
+ }
381
+
382
+ // モデルエリア内のアイテムにドラッグが入ったときの処理
383
+ function handleDragEnterModelItem(e, targetIndex) {
384
+ e.preventDefault();
385
+ const targetItem = e.currentTarget;
386
+
387
+ if (!draggedItem || (draggedItem.type === 'model' && draggedItem.layer.instanceId === playerLayers[targetIndex].instanceId)) {
388
+ return;
389
+ }
390
+
391
+ document.querySelectorAll('.is-dragged-over').forEach(el => el.classList.remove('is-dragged-over'));
392
+ targetItem.classList.add('is-dragged-over');
393
+
394
+ let currentIndex = -1;
395
+ // ★★★ instanceId を使ってドラッグ中のアイテムを検索
396
+ if (draggedItem.layer.instanceId) {
397
+ currentIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
398
+ }
399
+
400
+ let movedLayer;
401
+ if (currentIndex > -1) {
402
+ [movedLayer] = playerLayers.splice(currentIndex, 1);
403
+ } else {
404
+ movedLayer = draggedItem.layer;
405
+ }
406
+
407
+ playerLayers.splice(targetIndex, 0, movedLayer);
408
+
409
+ if (draggedItem.type === 'model') {
410
+ draggedItem.index = targetIndex;
411
+ }
412
+
413
+ updatePlayerModelUI();
414
+ }
415
+
416
+ // ★★★ モデルエリアの「何もない部分」にドラッグが入ったときの処理を追加
417
+ function handleDragEnterModelArea(e) {
418
+ if (e.target.id !== 'player-model-layers') {
419
+ return;
420
+ }
421
+ document.querySelectorAll('.is-dragged-over').forEach(el => el.classList.remove('is-dragged-over'));
422
+
423
+ if (!draggedItem) return;
424
+
425
+ // ★★★ instanceId を使ってドラッグ中のアイテムを検索
426
+ let currentIndex = -1;
427
+ if (draggedItem.layer.instanceId) {
428
+ currentIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
429
+ }
430
+
431
+ if (currentIndex > -1) {
432
+ if (currentIndex === playerLayers.length - 1) return; // 既に末尾なら何もしない
433
+ const [movedLayer] = playerLayers.splice(currentIndex, 1);
434
+ playerLayers.push(movedLayer);
435
+ if (draggedItem.type === 'model') {
436
+ draggedItem.index = playerLayers.length - 1;
437
+ }
438
+ } else {
439
+ playerLayers.push(draggedItem.layer);
440
+ }
441
+
442
+ updatePlayerModelUI();
443
+ }
444
+
445
+ // ★★★ モデルエリアからドラッグが出たときのクリーンアップ処理を追加
446
+ function handleDragLeaveModelArea(e) {
447
+ const modelArea = document.getElementById('player-model-layers');
448
+ // ★★★ エリア外に出た判定のみ残し、データ操作ロジックは削除
449
+ if (!modelArea.contains(e.relatedTarget)) {
450
+ modelArea.classList.remove('drag-over');
451
+ }
452
+ }
453
+
454
+ // --- Game Flow (バグ修正) ---
455
+ function startGame() {
456
+ playerHP = PLAYER_MAX_HP;
457
+ currentStage = 1;
458
+ playerInventory = {};
459
+ playerLayers = []; // ★★★ ここでモデルの状態もリセットする
460
+ isBattleInProgress = false; // バトル状態をリセット
461
+
462
+ // 初期アイテム
463
+ addInventoryItem(allAvailableLayers.find(l => l.id === 0));
464
+ addInventoryItem(allAvailableLayers.find(l => l.id === 5));
465
+ logMessage(`ゲーム開始!初期インベントリを獲得しました。`, 'success');
466
+
467
+ // ... (UIリセット処理は変更なし) ...
468
+ battleBtn.classList.remove('hidden');
469
+ restartBtn.classList.add('hidden');
470
+ battleBtn.disabled = true; // モデルが空なので最初は無効
471
+ battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
472
+ battleLog.textContent = '';
473
+
474
+ startStage();
475
+ }
476
+
477
+ // --- Game Flow (タイトル画面対応) ---
478
+
479
+ // ★★★ ゲーム初期化とゲーム開始を分離
480
+ function initializeGame() {
481
+ logMessage('Machine Learning RPGへようこそ!');
482
+ logMessage('インベントリのアイテムをドラッグしてモデルを構築しましょう。');
483
+
484
+ playerHP = PLAYER_MAX_HP;
485
+ currentStage = 1;
486
+ playerInventory = {};
487
+ playerLayers = [];
488
+ isBattleInProgress = false;
489
+
490
+ addInventoryItem(allAvailableLayers.find(l => l.id === 0));
491
+ addInventoryItem(allAvailableLayers.find(l => l.id === 5));
492
+ logMessage(`ゲーム開始!初期インベントリを獲得しました。`, 'success');
493
+
494
+ battleBtn.classList.remove('hidden');
495
+ restartBtn.classList.add('hidden');
496
+ restartBtn.textContent = 'タイトルへ戻る'; // テキストを統一
497
+ battleBtn.disabled = true;
498
+ battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
499
+ battleLog.textContent = '';
500
+ messageLogArea.innerHTML = ''; // メッセージログもクリア
501
+
502
+ startStage();
503
+ }
504
+
505
+ function handleGameOver() {
506
+ logMessage('ゲームオーバー...', 'error');
507
+ animateBattleLog('敗北...'); // アニメーション付きに変更
508
+ battleBtn.classList.add('hidden');
509
+ restartBtn.classList.remove('hidden');
510
+ // ★★★ ボタンのテキストを明示的に変更
511
+ restartBtn.innerHTML = '<i class="fas fa-undo"></i> タイトルへ戻る';
512
+ isBattleInProgress = false;
513
+ }
514
+
515
+ function startStage() {
516
+ logMessage(`--- Stage ${currentStage} Start ---`, 'action');
517
+ playerHP = PLAYER_MAX_HP;
518
+
519
+ // ★★★ ステージに応じて敵のHPを計算・設定
520
+ ENEMY_MAX_HP = 100 * currentStage;
521
+ enemyHP = ENEMY_MAX_HP;
522
+
523
+ // ★★★ 構築済みモデルをインベントリに戻す
524
+ if (playerLayers.length > 0) {
525
+ // playerLayersの各アイテムをインベントリに戻す
526
+ playerLayers.forEach(layer => returnInventoryItem(layer));
527
+ playerLayers = []; // モデルを空にする
528
+ logMessage('Your previous model has been returned to inventory.', 'info');
529
+ }
530
+
531
+ updateHpBars();
532
+ updatePlayerModelUI();
533
+ fetchNewEnemy();
534
+ updatePlayerInventoryUI();
535
+
536
+ // ★★★ 最終ステージクリア後はアイテム選択画面を出さない
537
+ if (currentStage <= 5) {
538
+ showItemSelection();
539
+ }
540
+ }
541
+
542
+ function showItemSelection() {
543
+ itemSelectionModal.classList.remove('hidden');
544
+ itemSelectionModal.classList.add('flex');
545
+ itemChoices.innerHTML = '';
546
+
547
+ // ★★★ ステージに応じたゴールドレア出現確率を計算
548
+ // ステージ1: 10%, ステージ2: 20%, ステージ3: 30%, ステージ4: 40%, ステージ5: 50%
549
+ const goldChance = Math.min(0.1 * currentStage, 0.5);
550
+
551
+ // プレイヤ���がまだ持っていないレイヤーをフィルタリング
552
+ const unownedLayers = allAvailableLayers.filter(layer => !playerInventory[layer.id] || playerInventory[layer.id] === 0);
553
+ const normalChoices = unownedLayers.filter(l => l.rarity === 'normal');
554
+ const goldChoices = unownedLayers.filter(l => l.rarity === 'gold');
555
+
556
+ const finalChoices = [];
557
+ const numChoices = 3;
558
+
559
+ for (let i = 0; i < numChoices; i++) {
560
+ let chosenLayer = null;
561
+ // 確率判定でゴールドレアを引くか、通常枠しか残っていない場合
562
+ if (Math.random() < goldChance && goldChoices.length > 0) {
563
+ const index = Math.floor(Math.random() * goldChoices.length);
564
+ chosenLayer = goldChoices.splice(index, 1)[0];
565
+ }
566
+ // 通常枠を引くか、ゴールド枠が空の場合
567
+ else if (normalChoices.length > 0) {
568
+ const index = Math.floor(Math.random() * normalChoices.length);
569
+ chosenLayer = normalChoices.splice(index, 1)[0];
570
+ }
571
+ // それでも選択肢がなければ、残っている方から引く
572
+ else if (goldChoices.length > 0) {
573
+ const index = Math.floor(Math.random() * goldChoices.length);
574
+ chosenLayer = goldChoices.splice(index, 1)[0];
575
+ }
576
+
577
+ if (chosenLayer) {
578
+ finalChoices.push(chosenLayer);
579
+ }
580
+ }
581
+
582
+ if (finalChoices.length === 0) {
583
+ logMessage('全てのレイヤーを収集しました!', 'success');
584
+ itemSelectionModal.classList.add('hidden');
585
+ itemSelectionModal.classList.remove('flex');
586
+ return;
587
+ }
588
+
589
+ // 選択肢のUIを生成
590
+ finalChoices.forEach(layer => {
591
+ const item = document.createElement('div');
592
+ // ★★★ レアリティクラスを追加
593
+ item.className = `layer-item w-48 h-48 flex flex-col justify-center ${layer.rarity === 'gold' ? 'gold-rare' : ''}`;
594
+ item.innerHTML = `<i class="fas ${layer.icon} layer-icon text-5xl"></i><p class="text-lg mt-2">${layer.name}</p>`;
595
+
596
+ // ★★★ ゴールドレアの場合、キラキラエフェクトを追加
597
+ if (layer.rarity === 'gold') {
598
+ const sparkleContainer = document.createElement('div');
599
+ sparkleContainer.className = 'sparkle-container';
600
+ for (let i = 0; i < 5; i++) { // モーダルでは少し多めに
601
+ const sparkle = document.createElement('div');
602
+ sparkle.className = 'sparkle';
603
+ sparkle.style.top = `${Math.random() * 100}%`;
604
+ sparkle.style.left = `${Math.random() * 100}%`;
605
+ sparkle.style.animationDelay = `${Math.random() * 1.5}s`;
606
+ sparkleContainer.appendChild(sparkle);
607
+ }
608
+ item.appendChild(sparkleContainer);
609
+ }
610
+
611
+ item.onclick = () => selectItem(layer);
612
+ item.addEventListener('mouseenter', () => {
613
+ showDescription(layer, choiceDescription);
614
+ runPreviewAnimation(layer, choicePreviewArea);
615
+ });
616
+ itemChoices.appendChild(item);
617
+ });
618
+ }
619
+
620
+ function selectItem(selectedLayer) {
621
+ addInventoryItem(selectedLayer);
622
+ logMessage(`You got a new layer: <span class="text-indigo-300">${selectedLayer.name}</span>!`, 'success');
623
+ updatePlayerInventoryUI();
624
+ itemSelectionModal.classList.add('hidden');
625
+ itemSelectionModal.classList.remove('flex');
626
+
627
+ // ★★★ アイテム選択後、モデルをインベントリに戻す
628
+ if (playerLayers.length > 0) {
629
+ playerLayers.forEach(layer => returnInventoryItem(layer));
630
+ playerLayers = [];
631
+ logMessage('Model reset. Please rebuild your model.', 'info');
632
+ updatePlayerModelUI();
633
+ updatePlayerInventoryUI();
634
+ }
635
+ }
636
+
637
+ // --- PREVIEW ANIMATION ENGINE ---
638
+ function generateDummyData(shape = [4, 14, 14]) {
639
+ // [channels, height, width]
640
+ return Array.from({ length: shape[0] }, () =>
641
+ Array.from({ length: shape[1] }, () =>
642
+ Array.from({ length: shape[2] }, () => Math.random() * 2 - 1)
643
+ )
644
+ );
645
+ }
646
+
647
+ // --- PREVIEW ANIMATION ENGINE ---
648
+ async function runPreviewAnimation(layer, previewArea) {
649
+ const currentAnimationId = Date.now();
650
+ previewArea.dataset.animationId = currentAnimationId;
651
+ previewArea.innerHTML = '';
652
+ const vizArea = previewArea;
653
+ const checkInterrupted = () => previewArea.dataset.animationId != currentAnimationId;
654
+
655
+ // ★★★ プレビュー要素に適用する基本スタイル
656
+ const previewElementStyle = (el) => {
657
+ el.style.position = 'absolute';
658
+ el.style.left = '50%';
659
+ el.style.top = '50%';
660
+ el.style.transform = 'translate(-50%, -50%)';
661
+ };
662
+
663
+ let fromEl, toEl, inputData;
664
+ switch (layer.type) {
665
+ case 'Conv2d':
666
+ case 'MaxPool2d':
667
+ fromEl = document.createElement('div');
668
+ previewElementStyle(fromEl);
669
+ previewArea.appendChild(fromEl);
670
+
671
+ inputData = (layer.type === 'Conv2d') ? generateDummyData([1, 28, 28]) : generateDummyData([4, 28, 28]);
672
+ await animateGrid(fromEl, inputData, { isInput: true, duration: 500 }, vizArea);
673
+ if (checkInterrupted()) return;
674
+
675
+ toEl = document.createElement('div');
676
+ previewElementStyle(toEl);
677
+ previewArea.appendChild(toEl);
678
+
679
+ // fromElをすぐに消さず、toElが生成されるのを待つ
680
+ fromEl.style.transition = 'opacity 0.5s ease-out 0.5s'; // 少し遅れてフェードアウト
681
+ fromEl.style.opacity = '0';
682
+
683
+ if (layer.type === 'Conv2d') {
684
+ await animateConv(fromEl, toEl, generateDummyData([layer.params.out_channels, 28, 28]), { duration: 1200, isPreview: true }, vizArea);
685
+ } else {
686
+ await animatePool(fromEl, toEl, generateDummyData([4, 14, 14]), { duration: 1200, isPreview: true }, vizArea);
687
+ }
688
+ break;
689
+
690
+ case 'AvgPool2d':
691
+ fromEl = document.createElement('div');
692
+ previewElementStyle(fromEl);
693
+ previewArea.appendChild(fromEl);
694
+
695
+ inputData = (layer.type === 'Conv2d') ? generateDummyData([1, 28, 28]) : generateDummyData([4, 28, 28]);
696
+ await animateGrid(fromEl, inputData, { isInput: true, duration: 500 }, vizArea);
697
+ if (checkInterrupted()) return;
698
+
699
+ toEl = document.createElement('div');
700
+ previewElementStyle(toEl);
701
+ previewArea.appendChild(toEl);
702
+
703
+ fromEl.style.transition = 'opacity 0.5s ease-out 0.5s';
704
+ fromEl.style.opacity = '0';
705
+
706
+ if (layer.type === 'Conv2d') {
707
+ await animateConv(fromEl, toEl, generateDummyData([layer.params.out_channels, 28, 28]), { duration: 1200, isPreview: true }, vizArea);
708
+ } else {
709
+ const isAverage = layer.type === 'AvgPool2d';
710
+ await animatePool(fromEl, toEl, generateDummyData([4, 14, 14]), { duration: 1200, isPreview: true, isAverage }, vizArea);
711
+ }
712
+ break;
713
+
714
+ case 'ReLU':
715
+ case 'Dropout':
716
+ fromEl = document.createElement('div');
717
+ fromEl.style.position = 'absolute';
718
+ fromEl.style.left = '50%';
719
+ fromEl.style.top = '50%';
720
+ previewArea.appendChild(fromEl);
721
+ const dummyDataWithNegatives = generateDummyData([4, 10, 10]);
722
+ await animateGrid(fromEl, dummyDataWithNegatives, { duration: 500 }, vizArea); // ★ 時間を延長
723
+ if (checkInterrupted()) return;
724
+
725
+ if (layer.type === 'ReLU') {
726
+ await animateReLU(fromEl, { duration: 800 }); // ★ 時間を延長
727
+ } else {
728
+ await animateDropout(fromEl, { duration: 800 }); // ★ 時間を延長
729
+ }
730
+ break;
731
+
732
+ case 'Flatten':
733
+ fromEl = document.createElement('div');
734
+ previewArea.appendChild(fromEl);
735
+ await animateGrid(fromEl, generateDummyData([4, 14, 14]), { duration: 600 }, vizArea);
736
+ if (checkInterrupted()) return;
737
+
738
+ toEl = document.createElement('div');
739
+ previewArea.appendChild(toEl);
740
+ await animateFlatten(fromEl, toEl, 4 * 14 * 14, { duration: 1000 }, vizArea); // ★ 時間を延長
741
+ if (checkInterrupted()) return;
742
+
743
+ fromEl.style.opacity = 0;
744
+ break;
745
+
746
+ case 'Linear':
747
+ fromEl = document.createElement('div'); // Flattened bar
748
+ previewElementStyle(fromEl); // 中央に配置
749
+ fromEl.style.left = '25%'; // 左側に配置
750
+ previewArea.appendChild(fromEl);
751
+ await animateFlatten(null, fromEl, 100, { duration: 500 }, vizArea);
752
+ if (checkInterrupted()) return;
753
+
754
+ toEl = document.createElement('div'); // Nodes
755
+ previewElementStyle(toEl); // 中央に配置
756
+ toEl.style.left = '75%'; // 右側に配置
757
+ previewArea.appendChild(toEl);
758
+ await animateLinear(fromEl, toEl, Array(layer.params.out_features).fill(0), null, null, { duration: 1000 }, vizArea);
759
+ if (checkInterrupted()) return;
760
+
761
+ fromEl.style.opacity = 0;
762
+ break;
763
+
764
+ case 'ResidualBlock':
765
+ fromEl = document.createElement('div');
766
+ previewElementStyle(fromEl);
767
+ fromEl.style.left = '25%'; // 左側に配置
768
+ previewArea.appendChild(fromEl);
769
+ await animateLinear(null, fromEl, Array(32).fill(0), null, null, { duration: 500, nodeSize: 8 }, vizArea);
770
+ if (checkInterrupted()) return;
771
+
772
+ toEl = document.createElement('div');
773
+ previewElementStyle(toEl);
774
+ toEl.style.left = '75%'; // 右側に配置
775
+ previewArea.appendChild(toEl);
776
+ // プレビューなので skipFromEl と fromEl は同じものを渡す
777
+ await animateResidual(fromEl, fromEl, toEl, Array(32).fill(0), { duration: 1200, nodeSize: 8 }, vizArea);
778
+ if (checkInterrupted()) return;
779
+
780
+ fromEl.style.opacity = 0;
781
+ break;
782
+ }
783
+ }
784
+
785
+ // --- Main Game Logic ---
786
+ function addInventoryItem(layer) {
787
+ playerInventory[layer.id] = (playerInventory[layer.id] || 0) + 1;
788
+ }
789
+
790
+ function useInventoryItem(layer) {
791
+ if (playerInventory[layer.id] && playerInventory[layer.id] > 0) {
792
+ playerInventory[layer.id]--;
793
+ return true;
794
+ }
795
+ return false;
796
+ }
797
+
798
+ function returnInventoryItem(layer) {
799
+ playerInventory[layer.id]++;
800
+ }
801
+
802
+ // ★★★ モデル構築ロジックを所持数システムに対応
803
+ function addLayer(layer) {
804
+ if (useInventoryItem(layer)) {
805
+ playerLayers.push(layer);
806
+ logMessage(`Layer Added: <span class="text-indigo-300">${layer.name}</span>`, 'action');
807
+ updatePlayerModelUI();
808
+ updatePlayerInventoryUI();
809
+ } else {
810
+ logMessage(`You don't have any more <span class="text-indigo-300">${layer.name}</span>`, 'error');
811
+ }
812
+ }
813
+
814
+
815
+ function validateArchitecture(layers) {
816
+ if (layers.length === 0) {
817
+ return { isValid: false, message: 'モデルが空です。' };
818
+ }
819
+
820
+ let isFlattened = false; // テンソルが平坦化されたかどうかを追跡
821
+
822
+ for (let i = 0; i < layers.length; i++) {
823
+ const currentLayerType = layers[i].type;
824
+
825
+ if (isFlattened) {
826
+ // 平坦化された後に入れることができない層
827
+ if (['Conv2d', 'MaxPool2d', 'AvgPool2d', 'Flatten'].includes(currentLayerType)) {
828
+ return {
829
+ isValid: false,
830
+ message: `無効な順序: ${layers[i - 1].name} の後には ${currentLayerType} を配置できません。一度平坦化すると元に戻せません。`
831
+ };
832
+ }
833
+ } else {
834
+ // 平坦化される前にしか入れられない層
835
+ if (['Conv2d', 'MaxPool2d', 'AvgPool2d'].includes(currentLayerType)) {
836
+ // OK
837
+ } else if (currentLayerType === 'Flatten') {
838
+ isFlattened = true;
839
+ } else if (['Linear', 'Dropout', 'ResidualBlock'].includes(currentLayerType)) { // ★★★ ResidualBlockを追加
840
+ isFlattened = true;
841
+ }
842
+ }
843
+ }
844
+ return { isValid: true, message: '有効なアーキテクチャです。' };
845
+ }
846
+
847
+ // --- Main Battle Logic ---
848
+ async function handleBattle() {
849
+ const validationResult = validateArchitecture(playerLayers);
850
+ if (!validationResult.isValid) {
851
+ logMessage(`エラー: ${validationResult.message}`, 'error');
852
+ await animateBattleLog(`モデル構成エラー!`);
853
+ await sleep(2000);
854
+ await animateBattleLog('', true);
855
+ return;
856
+ }
857
+
858
+ if (isBattleInProgress) return;
859
+ isBattleInProgress = true;
860
+ battleBtn.disabled = true;
861
+
862
+ // --- フェーズ1: 訓練 ---
863
+ battleBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> モデルを訓練中...';
864
+ await animateBattleLog('戦闘準備... モデルを訓練中...');
865
+ logMessage('モデルの訓練を開始しました...', 'info');
866
+
867
+ // EelからFetch APIに変更
868
+ const trainResponse = await fetch('/api/train_player_model', {
869
+ method: 'POST',
870
+ headers: {
871
+ 'Content-Type': 'application/json',
872
+ },
873
+ body: JSON.stringify(playerLayers),
874
+ });
875
+ const trainResult = await trainResponse.json();
876
+
877
+ if (!trainResult.success) {
878
+ await animateBattleLog(`エラー: ${trainResult.message}`);
879
+ logMessage(`訓練エラー: ${trainResult.message}`, 'error');
880
+ isBattleInProgress = false;
881
+ updatePlayerModelUI();
882
+ battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
883
+ return;
884
+ }
885
+ logMessage(trainResult.message, 'success');
886
+ await sleep(500);
887
+
888
+ // --- フェーズ2: 戦闘ループ ---
889
+ while (playerHP > 0 && enemyHP > 0) {
890
+ battleBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 攻撃中...';
891
+ await animateBattleLog('新たな敵をスキャン... 推論実行...');
892
+
893
+ // EelからFetch APIに変更
894
+ const inferenceResponse = await fetch('/api/run_inference', { method: 'POST' });
895
+ const result = await inferenceResponse.json();
896
+
897
+ if (result.error) {
898
+ await animateBattleLog(`エラー: ${result.error}`);
899
+ logMessage(`推論エラー: ${result.error}`, 'error');
900
+ break;
901
+ }
902
+
903
+ enemyImage.src = result.image_b64;
904
+ logMessage('推論完了!攻撃を可視化します...', 'success');
905
+
906
+ animationModal.classList.remove('hidden');
907
+ await runDynamicAnimation(result);
908
+
909
+ const damage = Math.max(1, Math.round(result.confidence * 100));
910
+
911
+ if (result.is_correct) {
912
+ enemyHP -= damage;
913
+ await animateBattleLog(`攻撃成功! ${damage} のダメージ!`);
914
+ logMessage(`正解! ${damage} のダメージを与え��。(自信度: ${(result.confidence * 100).toFixed(1)}%) 敵HP: ${Math.max(0, enemyHP)}`, 'success');
915
+ } else {
916
+ playerHP -= damage;
917
+ await animateBattleLog(`攻撃失敗! ${damage} のダメージ!`);
918
+ logMessage(`不正解! ${damage} のダメージを受けた。(自信度: ${(result.confidence * 100).toFixed(1)}%) プレイヤーHP: ${Math.max(0, playerHP)}`, 'error');
919
+ }
920
+ updateHpBars();
921
+
922
+ if (enemyHP > 0 && playerHP > 0) {
923
+ await sleep(1500);
924
+ }
925
+ }
926
+
927
+ // --- フェーズ3: バトル終了後処理 ---
928
+ if (enemyHP <= 0) {
929
+ // ★★★ ステージ5をクリアしたか判定
930
+ if (currentStage >= 5) {
931
+ logMessage(`CONGRATULATIONS! 全てのステージをクリアしました!`, 'success');
932
+ await animateBattleLog('GAME CLEAR!');
933
+ enemyMessage.textContent = '全てのMNISTモンスターを倒した!';
934
+ battleBtn.classList.add('hidden');
935
+ restartBtn.classList.remove('hidden');
936
+ } else {
937
+ // 通常のステージクリア処理
938
+ logMessage(`勝利! 敵を倒した。`, 'success');
939
+ await animateBattleLog('勝利!');
940
+ await sleep(2000);
941
+ currentStage++;
942
+ startStage();
943
+ }
944
+ } else if (playerHP <= 0) {
945
+ handleGameOver();
946
+ } else {
947
+ await animateBattleLog('バトル中断');
948
+ }
949
+
950
+ isBattleInProgress = false;
951
+ updatePlayerModelUI();
952
+ battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
953
+ }
954
+
955
+ function returnToTitle() {
956
+ gameContainer.style.opacity = 0;
957
+ setTimeout(() => {
958
+ gameContainer.classList.add('hidden');
959
+ titleScreen.classList.remove('hidden');
960
+ titleScreen.style.opacity = 1;
961
+ }, 500);
962
+ }
963
+
964
+ // --- Event Listeners & Initial Load ---
965
+
966
+ // ★★★ スタートボタンのイベントリスナー
967
+ startGameBtn.addEventListener('click', () => {
968
+ titleScreen.style.opacity = 0;
969
+ setTimeout(() => {
970
+ titleScreen.classList.add('hidden');
971
+ gameContainer.classList.remove('hidden');
972
+ gameContainer.style.display = 'flex'; // flexを再適用
973
+ gameContainer.style.opacity = 1;
974
+ initializeGame();
975
+ }, 1000); // フェードアウトを待つ
976
+ });
977
+
978
+ // ★★★ リスタートボタンのイベントリスナーをタイトルへ戻るように変更
979
+ restartBtn.addEventListener('click', returnToTitle);
980
+
981
+ // --- DYNAMIC ANIMATION ENGINE (変更なし) ---
982
+ // ... (以下、アニメーション関連の長いコードは変更がないため省略します)
983
+ const valueToColor = (value, maxAbs) => {
984
+ if (maxAbs === 0) return 'rgb(128, 128, 128)';
985
+ const intensity = Math.min(Math.abs(value) / maxAbs, 1);
986
+ if (value > 0) {
987
+ const r = 128 + 127 * intensity; const g = 128 - 128 * intensity; const b = 128 + 127 * intensity;
988
+ return `rgb(${r}, ${g}, ${b})`;
989
+ } else {
990
+ const r = 128 - 128 * intensity; const g = 128 - 128 * intensity; const b = 128 + 127 * intensity;
991
+ return `rgb(${r}, ${g}, ${b})`;
992
+ }
993
+ };
994
+
995
+ async function runDynamicAnimation(data) {
996
+ vizArea.innerHTML = '<div id="labels-container" class="absolute inset-x-0 top-0 h-24 pointer-events-none flex items-center"></div><div id="prediction-result" class="absolute text-2xl font-bold right-8 top-8 opacity-0 transition-opacity duration-500"></div>';
997
+ document.getElementById('prediction-result').textContent = '';
998
+ document.getElementById('prediction-result').classList.remove('opacity-100');
999
+
1000
+ const { architecture, outputs, weights } = data;
1001
+ const vizWidth = vizArea.clientWidth;
1002
+ const vizHeight = vizArea.clientHeight;
1003
+ const stageCount = architecture.length + 1; // +1 for output probabilities
1004
+
1005
+ let currentElement = null;
1006
+ // ★★★ 視覚的な要素をスタックで管理
1007
+ const vizElementStack = [];
1008
+
1009
+ for (let i = 0; i < architecture.length; i++) {
1010
+ const layerInfo = architecture[i];
1011
+ const xPercent = (i + 1) / stageCount;
1012
+
1013
+ if (layerInfo.type === 'ReLU' || layerInfo.type === 'Dropout') {
1014
+ await showStageLabel(layerInfo.type, xPercent, null);
1015
+ // スタックのトップにある要素に対してアニメーションを適用
1016
+ const targetElement = vizElementStack[vizElementStack.length - 1];
1017
+ if (layerInfo.type === 'ReLU') {
1018
+ await animateReLU(targetElement);
1019
+ } else {
1020
+ await animateDropout(targetElement);
1021
+ }
1022
+ continue;
1023
+ // ★★★ 修正: ReLU/DropoutではcurrentElementのopacityを変えないように変更
1024
+ }
1025
+
1026
+ const nextElement = document.createElement('div');
1027
+ nextElement.className = 'layer-viz absolute';
1028
+ nextElement.style.left = `${xPercent * 100}%`;
1029
+ nextElement.style.top = '50%';
1030
+ nextElement.style.transform = 'translate(-50%, -50%)';
1031
+ vizArea.appendChild(nextElement);
1032
+
1033
+ if (currentElement) {
1034
+ currentElement.style.transition = 'opacity 0.3s';
1035
+ currentElement.style.opacity = '0.3';
1036
+ }
1037
+
1038
+ await showStageLabel(layerInfo.type, xPercent, layerInfo.shape);
1039
+
1040
+ switch (layerInfo.type) {
1041
+ case 'Input':
1042
+ currentData = outputs.input[0];
1043
+ await animateGrid(nextElement, currentData, { isInput: true }, vizArea);
1044
+ break;
1045
+ case 'Conv2d':
1046
+ currentData = outputs[layerInfo.name][0];
1047
+ await animateConv(currentElement, nextElement, currentData, {}, vizArea);
1048
+ break;
1049
+ case 'MaxPool2d':
1050
+ // ★★★ AvgPool2d を追加
1051
+ case 'AvgPool2d':
1052
+ currentData = outputs[layerInfo.name][0];
1053
+ const isAverage = layerInfo.type === 'AvgPool2d';
1054
+ await animatePool(currentElement, nextElement, currentData, { isAverage }, vizArea);
1055
+ break;
1056
+ case 'Flatten':
1057
+ await animateFlatten(currentElement, nextElement, layerInfo.shape[0], {}, vizArea);
1058
+ break;
1059
+ case 'Linear':
1060
+ // ★★★ 修正: スタックから最新の視覚要素を取得
1061
+ const sourceElement = vizElementStack[vizElementStack.length - 1];
1062
+
1063
+ currentData = outputs[layerInfo.name][0];
1064
+ const linearWeights = weights[layerInfo.name + '_w'];
1065
+ const linearBiases = weights[layerInfo.name + '_b'];
1066
+ await animateLinear(sourceElement, nextElement, currentData, linearWeights, linearBiases, {}, vizArea);
1067
+ break;
1068
+ // ★★★ ResidualBlock を追加
1069
+ case 'ResidualBlock':
1070
+ const skipFromElement = vizElementStack.length > 1 ? vizElementStack[vizElementStack.length - 2] : currentElement;
1071
+ currentData = outputs[layerInfo.name][0];
1072
+ await animateResidual(skipFromElement, currentElement, nextElement, currentData, {}, vizArea);
1073
+ break;
1074
+
1075
+ default: // Input, Conv2d, MaxPool2d
1076
+ currentData = outputs[layerInfo.name] ? outputs[layerInfo.name][0] : outputs.input[0];
1077
+ await animateGrid(nextElement, currentData, { isInput: layerInfo.type === 'Input' }, vizArea);
1078
+ }
1079
+
1080
+ currentElement = nextElement;
1081
+ // ★★★ 視覚的に意味のある要素だけをスタックに積む
1082
+ if (layerInfo.type !== 'Flatten') {
1083
+ vizElementStack.push(currentElement);
1084
+ }
1085
+ }
1086
+
1087
+ // --- Final Output/Softmax Animation ---
1088
+ const finalLayerInfo = architecture[architecture.length - 1];
1089
+ const finalLayerName = finalLayerInfo.name;
1090
+
1091
+ // ★★★ デバッグ用ログと安全なアクセス
1092
+ console.log(`Accessing final output with key: ${finalLayerName}`);
1093
+ const logits = outputs[finalLayerName] ? outputs[finalLayerName][0] : [];
1094
+
1095
+ const finalSourceElement = vizElementStack[vizElementStack.length - 1];
1096
+ await animateSoftmax(finalSourceElement, data.prediction, data.label, logits, {}, vizArea);
1097
+
1098
+ await sleep(1500);
1099
+ animationModal.classList.add('hidden');
1100
+ }
1101
+
1102
+ // --- Layer-specific Animation Functions ---
1103
+
1104
+ const showStageLabel = async (text, xPercent, shape) => {
1105
+ const labelsContainer = document.getElementById('labels-container');
1106
+ labelsContainer.querySelectorAll('.stage-label').forEach(l => {
1107
+ l.style.opacity = '0'; l.style.transform = 'translateY(20px)';
1108
+ });
1109
+ await sleep(200);
1110
+ labelsContainer.innerHTML = '';
1111
+
1112
+ const label = document.createElement('div');
1113
+ label.className = 'stage-label';
1114
+ let labelText = text;
1115
+ if (shape) {
1116
+ labelText += `<br><span class="text-sm text-gray-400">(${shape.join(' &times; ')})</span>`;
1117
+ }
1118
+ label.innerHTML = labelText;
1119
+ label.style.left = `${xPercent * 100}%`;
1120
+ labelsContainer.appendChild(label);
1121
+
1122
+ await sleep(50);
1123
+ label.style.opacity = '1';
1124
+ label.style.transform = 'translateX(-50%) translateY(-100%)';
1125
+ };
1126
+
1127
+ // Simplified grid creation for any 2D/3D data
1128
+ async function animateGrid(container, data3d, options = {}, vizArea = window.vizArea) {
1129
+ container.style.transform = 'translate(-50%, -50%) perspective(1000px) rotateY(-90deg)';
1130
+ container.style.opacity = '0';
1131
+ const duration = options.duration || 500;
1132
+ container.style.transition = `transform ${duration / 1000}s ease-out, opacity ${duration / 1000}s`;
1133
+
1134
+ const vizHeight = vizArea.clientHeight;
1135
+ const displayChannels = Math.min(data3d.length, 6);
1136
+ const displaySize = Math.min(data3d[0].length, 14);
1137
+
1138
+ const availableHeight = vizHeight * (options.isInput ? 0.4 : 0.6);
1139
+ const margin = 10;
1140
+ const singleGridMaxHeight = (availableHeight - (displayChannels - 1) * margin) / displayChannels;
1141
+ const gridSize = Math.max(20, singleGridMaxHeight); // ★★★ 正しい変数名はこちら
1142
+ const cellSize = gridSize / displaySize;
1143
+
1144
+ const totalHeight = displayChannels * gridSize + (displayChannels - 1) * margin;
1145
+
1146
+ // ★★★ エラー修正箇所: `totalGridSize` を `gridSize` に修正
1147
+ container.style.width = `${gridSize}px`;
1148
+ container.style.height = `${totalHeight}px`;
1149
+ container.style.transform = `translate(-50%, -${totalHeight / 2}px) perspective(1000px) rotateY(-90deg)`;
1150
+
1151
+ const maxAbs = Math.max(...data3d.flat().flat().map(Math.abs));
1152
+
1153
+ for (let i = 0; i < displayChannels; i++) {
1154
+ const featureMap = document.createElement('div');
1155
+ featureMap.className = 'absolute';
1156
+ featureMap.style.width = `${gridSize}px`;
1157
+ featureMap.style.height = `${gridSize}px`;
1158
+ featureMap.style.top = `${i * (gridSize + margin)}px`;
1159
+
1160
+ for (let r = 0; r < displaySize; r++) {
1161
+ for (let c = 0; c < displaySize; c++) {
1162
+ // Sample from original data if larger
1163
+ const origR = Math.floor(r * data3d[i].length / displaySize);
1164
+ const origC = Math.floor(c * data3d[i][0].length / displaySize);
1165
+ const val = data3d[i][origR][origC];
1166
+
1167
+ const cell = document.createElement('div');
1168
+ cell.className = 'grid-cell absolute';
1169
+ cell.style.width = `${cellSize}px`; cell.style.height = `${cellSize}px`;
1170
+ cell.style.left = `${c * cellSize}px`; cell.style.top = `${r * cellSize}px`;
1171
+ cell.style.backgroundColor = valueToColor(val, maxAbs);
1172
+ cell.dataset.value = val;
1173
+ featureMap.appendChild(cell);
1174
+ }
1175
+ }
1176
+ container.appendChild(featureMap);
1177
+ }
1178
+
1179
+ await sleep(50);
1180
+ container.style.transform = `translate(-50%, -${totalHeight / 2}px) perspective(1000px) rotateY(0deg)`;
1181
+ container.style.opacity = '1';
1182
+ await sleep(duration);
1183
+ }
1184
+
1185
+ async function animateConv(fromEl, toEl, toData, options = {}, vizArea = window.vizArea) {
1186
+ const duration = options.duration || 500;
1187
+
1188
+ // Kernel animation on 'from' element
1189
+ if (options.isPreview) {
1190
+ // fromElは背景として表示され続けるので、カーネルアニメーションは不要
1191
+ } else {
1192
+ const firstMap = fromEl.querySelector(':scope > div');
1193
+ if (firstMap) {
1194
+ const kernelHighlight = document.createElement('div');
1195
+ kernelHighlight.style.position = 'absolute';
1196
+ kernelHighlight.style.border = '2px solid #a5b4fc';
1197
+ kernelHighlight.style.transition = 'all 0.1s linear';
1198
+ const mapSize = firstMap.clientWidth;
1199
+ const kernelDisplaySize = mapSize / 7; // e.g., 14px -> 2px kernel
1200
+ kernelHighlight.style.width = `${kernelDisplaySize}px`;
1201
+ kernelHighlight.style.height = `${kernelDisplaySize}px`;
1202
+ firstMap.appendChild(kernelHighlight);
1203
+
1204
+ for (let i = 0; i <= 6; i++) {
1205
+ kernelHighlight.style.top = `${i * kernelDisplaySize}px`;
1206
+ kernelHighlight.style.left = `${(i % 3) * mapSize / 3}px`;
1207
+ await sleep(duration * 0.05); // ★ 全体時間に対する割合でsleep
1208
+ }
1209
+ kernelHighlight.remove();
1210
+ }
1211
+ }
1212
+
1213
+ await sleep(options.duration * 0.4 || 200);
1214
+ await animateGrid(toEl, toData, { duration: duration * 0.6 }, vizArea);
1215
+ }
1216
+
1217
+ async function animatePool(fromEl, toEl, toData, options = {}, vizArea = window.vizArea) {
1218
+ const duration = options.duration || 500;
1219
+
1220
+ if (!options.isPreview) {
1221
+ const maps = fromEl.querySelectorAll(':scope > div');
1222
+ maps.forEach((map) => {
1223
+ map.style.transition = `all ${duration * 0.4 / 1000}s ease-in-out`;
1224
+ // isAverageの場合、ぼかしエフェクトを追加
1225
+ if (options.isAverage) {
1226
+ map.style.filter = 'blur(2px)';
1227
+ }
1228
+ map.style.transform = `scale(0.5)`;
1229
+ map.style.opacity = '0';
1230
+ });
1231
+ await sleep(duration * 0.4);
1232
+ }
1233
+
1234
+ await animateGrid(toEl, toData, { duration: duration * 0.8 }, vizArea);
1235
+ }
1236
+
1237
+ async function animateDropout(element, options = {}) {
1238
+ if (!element) return;
1239
+ const cells = element.querySelectorAll('.grid-cell');
1240
+ const originalColors = new Map();
1241
+ const cellsToDrop = Array.from(cells).sort(() => 0.5 - Math.random()).slice(0, cells.length / 2);
1242
+
1243
+ cellsToDrop.forEach(cell => {
1244
+ originalColors.set(cell, cell.style.backgroundColor); // 元の色を保存
1245
+ cell.style.transition = 'all 0.2s ease-in-out';
1246
+ cell.style.backgroundColor = 'rgb(40, 40, 40)'; // 暗い色(非アクティブ)に
1247
+ cell.style.transform = 'scale(0.8)';
1248
+ });
1249
+
1250
+ await sleep(options.duration || 400);
1251
+
1252
+ cellsToDrop.forEach(cell => {
1253
+ // 元の色に戻す
1254
+ cell.style.backgroundColor = originalColors.get(cell);
1255
+ cell.style.transform = 'scale(1)';
1256
+ });
1257
+ await sleep(options.duration ? options.duration * 0.5 : 200);
1258
+ }
1259
+
1260
+ async function animateFlatten(fromEl, toEl, toShape, options = {}, vizArea = window.vizArea) {
1261
+ // ★★★ エラー修正箇所 ★★★
1262
+ // fromElがnullの場合(Linearプレビューなど)は、ソース要素のアニメーションをスキップ
1263
+ if (fromEl) {
1264
+ fromEl.style.transition = 'transform 0.4s ease-in-out, opacity 0.4s';
1265
+ fromEl.style.transform += ' scale(0)';
1266
+ fromEl.style.opacity = '0';
1267
+ }
1268
+
1269
+ // Display a simplified, representative bar
1270
+ const vizHeight = vizArea.clientHeight;
1271
+ // ... (以降のロジックは変更なし)
1272
+ const displayNodes = Math.min(toShape, 128);
1273
+ const nodeHeight = Math.min(2.5, (vizHeight * 0.8) / displayNodes);
1274
+ const totalHeight = displayNodes * nodeHeight;
1275
+ toEl.style.height = `${totalHeight}px`;
1276
+ toEl.style.width = '20px';
1277
+ toEl.style.position = 'absolute'; // ★ 念のため追加
1278
+ toEl.style.left = '50%'; // ★ 念のため追加
1279
+ toEl.style.top = '50%'; // ★ 念のため追加
1280
+ toEl.style.transform = `translate(-50%, -${totalHeight / 2}px)`; // ★ 念のため追加
1281
+
1282
+ const maxAbs = 1; // Dummy value for color
1283
+ for (let i = 0; i < displayNodes; i++) {
1284
+ const cell = document.createElement('div');
1285
+ cell.className = 'grid-cell absolute';
1286
+ cell.style.width = '100%';
1287
+ cell.style.height = `${nodeHeight}px`;
1288
+ cell.style.top = `${i * nodeHeight}px`;
1289
+ cell.style.backgroundColor = valueToColor(Math.random() * 2 - 1, maxAbs);
1290
+ cell.style.transform = 'scale(0)';
1291
+ cell.style.transition = 'transform 0.3s';
1292
+ toEl.appendChild(cell);
1293
+
1294
+ // プレビュー時のアニメーションが速すぎないように調整
1295
+ const delay = (options.duration || 300) / displayNodes;
1296
+ await sleep(delay > 5 ? 5 : delay); // 5msより長くは待たない
1297
+
1298
+ cell.style.transform = 'scale(1)';
1299
+ }
1300
+ await sleep(options.duration * 0.5 || 150);
1301
+ }
1302
+
1303
+ async function animateLinear(fromEl, toEl, toData, weights, biases, options = {}, vizArea = window.vizArea) {
1304
+ const vizRect = vizArea.getBoundingClientRect();
1305
+
1306
+ const displayNodes = Math.min(toData.length, 64);
1307
+ const vizHeight = vizArea.clientHeight;
1308
+ // ★★★ nodeSizeをオプションで受け取れるように
1309
+ const nodeSize = options.nodeSize || Math.min(15, (vizHeight * 0.7) / displayNodes);
1310
+ const spacing = (vizHeight * 0.7) / displayNodes;
1311
+ const totalHeight = displayNodes * spacing;
1312
+
1313
+ toEl.style.height = `${totalHeight}px`;
1314
+ // ★★★ transformのY座標計算を修正
1315
+ toEl.style.transform = `translate(-50%, -${totalHeight / 2}px)`;
1316
+
1317
+ const toCells = [];
1318
+ const maxAbs = Math.max(...toData.map(Math.abs));
1319
+
1320
+ for (let i = 0; i < displayNodes; i++) {
1321
+ const cell = document.createElement('div');
1322
+ cell.className = 'grid-cell';
1323
+ cell.style.width = `${nodeSize}px`; cell.style.height = `${nodeSize}px`;
1324
+ cell.style.borderRadius = '50%';
1325
+ cell.style.position = 'absolute';
1326
+ cell.style.top = `${i * spacing}px`;
1327
+ cell.style.backgroundColor = 'rgb(128,128,128)';
1328
+ toEl.appendChild(cell);
1329
+ toCells.push(cell);
1330
+ }
1331
+
1332
+ // fromElがnullの場合(プレビューの初回など)はパーティクルを飛ばさない
1333
+ if (!fromEl) {
1334
+ await sleep(options.duration || 400);
1335
+ } else {
1336
+ const maxParticles = 50;
1337
+ for (let i = 0; i < maxParticles; i++) {
1338
+ // ★★★ 座標計算の基準をvizAreaの左上隅(0,0)に統一
1339
+ const fromRect = fromEl.getBoundingClientRect();
1340
+ const toRect = toEl.getBoundingClientRect();
1341
+
1342
+ const particle = document.createElement('div');
1343
+ particle.className = 'particle';
1344
+ vizArea.appendChild(particle);
1345
+
1346
+ const startX = fromRect.right - vizRect.left;
1347
+ const startY = (fromRect.top - vizRect.top) + Math.random() * fromRect.height;
1348
+ const endX = toRect.left - vizRect.left;
1349
+ const endY = (toRect.top - vizRect.top) + Math.random() * toRect.height;
1350
+
1351
+ particle.animate([
1352
+ { transform: `translate(${startX}px, ${startY}px) scale(0.5)`, opacity: 1 },
1353
+ { transform: `translate(${endX}px, ${endY}px) scale(1)`, opacity: 0 }
1354
+ ], {
1355
+ duration: 300 + Math.random() * 200,
1356
+ easing: 'ease-in-out',
1357
+ delay: Math.random() * 200,
1358
+ }).onfinish = () => particle.remove();
1359
+ }
1360
+ await sleep(options.duration || 400);
1361
+ }
1362
+
1363
+ toCells.forEach((cell, i) => {
1364
+ const dataIdx = Math.floor(i * toData.length / displayNodes);
1365
+ cell.style.transition = 'background-color 0.5s';
1366
+ cell.style.backgroundColor = valueToColor(toData[dataIdx], maxAbs);
1367
+ });
1368
+ await sleep(options.duration ? options.duration * 0.5 : 500);
1369
+ }
1370
+
1371
+ async function animateReLU(element, options = {}) {
1372
+ if (!element) return;
1373
+ const cells = element.querySelectorAll('.grid-cell');
1374
+
1375
+ const animations = [];
1376
+ for (const cell of cells) {
1377
+ const value = parseFloat(cell.dataset.value || 0);
1378
+ if (value < 0) {
1379
+ const animationPromise = new Promise(async (resolve) => {
1380
+ await sleep(Math.random() * (options.duration || 400) * 0.5); // ランダムな遅延
1381
+ cell.style.transition = 'background-color 0.3s';
1382
+ cell.style.backgroundColor = 'rgb(128, 128, 128)';
1383
+ // ★★★ データを更新して、ReLUが適用されたことを記録
1384
+ cell.dataset.value = 0;
1385
+ resolve();
1386
+ });
1387
+ animations.push(animationPromise);
1388
+ }
1389
+ }
1390
+ await Promise.all(animations); // 全てのアニメーションが終わるのを待つ
1391
+ await sleep((options.duration || 400) * 0.5);
1392
+ }
1393
+
1394
+ async function animateResidual(skipFromEl, fromEl, toEl, toData, options = {}, vizArea = window.vizArea) {
1395
+ const duration = options.duration || 1000;
1396
+
1397
+ // メインパスのアニメーション (Linearと同様)
1398
+ animateLinear(fromEl, toEl, toData, null, null, options, vizArea);
1399
+
1400
+ // スキップコネクションのアニメーション
1401
+ await sleep(duration * 0.1); // 少し遅れて開始
1402
+
1403
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1404
+ svg.style.position = 'absolute';
1405
+ svg.style.top = '0';
1406
+ svg.style.left = '0';
1407
+ svg.style.width = '100%';
1408
+ svg.style.height = '100%';
1409
+ svg.style.pointerEvents = 'none';
1410
+ svg.style.zIndex = '10';
1411
+ vizArea.appendChild(svg);
1412
+
1413
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1414
+ path.setAttribute('fill', 'none');
1415
+ path.setAttribute('stroke', 'url(#skip-gradient)');
1416
+ path.setAttribute('stroke-width', '3');
1417
+
1418
+ const vizRect = vizArea.getBoundingClientRect();
1419
+ const startRect = skipFromEl.getBoundingClientRect();
1420
+ const endRect = toEl.getBoundingClientRect();
1421
+
1422
+ const startX = startRect.right - vizRect.left;
1423
+ const startY = startRect.top + startRect.height / 2 - vizRect.top;
1424
+ const endX = endRect.left - vizRect.left;
1425
+ const endY = endRect.top + endRect.height / 2 - vizRect.top;
1426
+
1427
+ const ctrlYOffset = -80; // 上に膨らむカーブ
1428
+ const d = `M ${startX},${startY} C ${startX + 50},${startY + ctrlYOffset} ${endX - 50},${endY + ctrlYOffset} ${endX},${endY}`;
1429
+ path.setAttribute('d', d);
1430
+
1431
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1432
+ const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
1433
+ gradient.id = 'skip-gradient';
1434
+ gradient.innerHTML = `
1435
+ <stop offset="0%" stop-color="#a5b4fc" stop-opacity="0" />
1436
+ <stop offset="50%" stop-color="#a5b4fc" stop-opacity="1" />
1437
+ <stop offset="100%" stop-color="#a5b4fc" stop-opacity="0" />
1438
+ `;
1439
+ defs.appendChild(gradient);
1440
+ svg.appendChild(defs);
1441
+ svg.appendChild(path);
1442
+
1443
+ const length = path.getTotalLength();
1444
+ path.style.strokeDasharray = length;
1445
+ path.style.strokeDashoffset = length;
1446
+
1447
+ path.animate([
1448
+ { strokeDashoffset: length },
1449
+ { strokeDashoffset: 0 }
1450
+ ], {
1451
+ duration: duration * 0.8,
1452
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
1453
+ }).onfinish = () => {
1454
+ setTimeout(() => svg.remove(), 200);
1455
+ };
1456
+
1457
+ await sleep(duration);
1458
+ }
1459
+
1460
+ const softmax = (logits) => {
1461
+ const maxLogit = Math.max(...logits);
1462
+ const exps = logits.map(logit => Math.exp(logit - maxLogit));
1463
+ const sumExps = exps.reduce((a, b) => a + b, 0);
1464
+ return exps.map(exp => exp / sumExps);
1465
+ };
1466
+
1467
+ async function animateSoftmax(fromEl, prediction, label, logits, options = {}, vizArea = window.vizArea) {
1468
+ const probabilities = softmax(logits);
1469
+ const toEl = fromEl; // Reuse the last linear layer element
1470
+ toEl.innerHTML = '';
1471
+
1472
+ const vizHeight = vizArea.clientHeight;
1473
+ const numOutputNodes = 10;
1474
+ const nodeHeight = Math.min(40, (vizHeight * 0.8) / numOutputNodes * 0.8);
1475
+ const spacing = (vizHeight * 0.8) / numOutputNodes;
1476
+ const totalHeight = numOutputNodes * spacing;
1477
+ toEl.style.transform = `translate(-50%, -${totalHeight / 2}px)`;
1478
+
1479
+ for (let i = 0; i < numOutputNodes; i++) {
1480
+ const prob = probabilities[i];
1481
+ const wrapper = document.createElement('div');
1482
+ wrapper.className = 'flex items-center relative transition-all duration-300';
1483
+ wrapper.style.height = `${spacing}px`;
1484
+ wrapper.style.width = `200px`;
1485
+
1486
+ const labelDiv = document.createElement('div');
1487
+ labelDiv.className = 'mr-4 font-bold';
1488
+ labelDiv.textContent = i;
1489
+ labelDiv.style.fontSize = `${nodeHeight * 0.6}px`;
1490
+
1491
+ const barContainer = document.createElement('div');
1492
+ barContainer.className = 'flex-grow h-full bg-white/10 rounded overflow-hidden border border-indigo-400/50';
1493
+
1494
+ const bar = document.createElement('div');
1495
+ bar.style.width = '0%';
1496
+ bar.style.height = '100%';
1497
+ bar.style.backgroundColor = '#a5b4fc';
1498
+ bar.style.transition = 'width 0.8s ease-out';
1499
+
1500
+ const probText = document.createElement('div');
1501
+ probText.className = 'absolute right-0 top-1/2 -translate-y-1/2 font-mono';
1502
+ probText.textContent = `${(prob * 100).toFixed(1)}%`;
1503
+ probText.style.fontSize = `${nodeHeight * 0.4}px`;
1504
+
1505
+ barContainer.appendChild(bar);
1506
+ wrapper.appendChild(labelDiv);
1507
+ wrapper.appendChild(barContainer);
1508
+ wrapper.appendChild(probText);
1509
+ toEl.appendChild(wrapper);
1510
+
1511
+ setTimeout(() => { bar.style.width = `${prob * 100}%`; }, 100);
1512
+ }
1513
+
1514
+ await sleep(1000);
1515
+
1516
+ // Highlight prediction
1517
+ const predWrapper = toEl.childNodes[prediction];
1518
+ predWrapper.style.transform = 'scale(1.1)';
1519
+ predWrapper.style.backgroundColor = 'rgba(253, 224, 71, 0.2)';
1520
+ predWrapper.style.borderRadius = '8px';
1521
+
1522
+ const resultText = document.getElementById('prediction-result');
1523
+ resultText.innerHTML = `Prediction: <span class="text-yellow-300 text-3xl">${prediction}</span> (True: ${label})`;
1524
+ resultText.classList.add('opacity-100');
1525
+ }
1526
+
1527
+
1528
+ // --- Event Listeners & Initial Load ---
1529
+ battleBtn.addEventListener('click', handleBattle);
1530
+ restartBtn.addEventListener('click', startGame);
1531
+ closeModalBtn.addEventListener('click', () => {
1532
+ animationModal.classList.add('hidden');
1533
+ });
1534
+
1535
+ // D&Dイベントリスナーのセットアップ
1536
+ const modelArea = document.getElementById('player-model-layers');
1537
+ modelArea.addEventListener('dragover', allowDrop);
1538
+ modelArea.addEventListener('drop', dropOnModelArea);
1539
+ // ★★★ 修正: dragleave イベントリスナーをより堅牢なものに変更
1540
+ modelArea.addEventListener('dragleave', handleDragLeaveModelArea);
1541
+ modelArea.addEventListener('dragenter', handleDragEnterModelArea);
1542
+
1543
+ document.addEventListener('drop', (e) => {
1544
+ // モデルエリア外へのドロップ処理 (このロジックは重要なので残す)
1545
+ if (draggedItem) {
1546
+ const modelArea = document.getElementById('player-model-layers');
1547
+ if (!modelArea.contains(e.target)) {
1548
+ if (draggedItem.type === 'model') {
1549
+ // モデル外へのドロップはキャンセルとみなし、UIを更新して元の位置に戻す
1550
+ // ★★★ バグ修正: 元の状態に戻すには、playerLayersを再構築する必要がある
1551
+ // ただし、この操作は複雑なので、単純にログだけ出すか、何もしないのが安全
1552
+ logMessage('モデルの並び替えをキャンセルしました。', 'info');
1553
+ // UIを再描画すればOK
1554
+ updatePlayerModelUI();
1555
+ } else if (draggedItem.type === 'inventory') {
1556
+ // ★★★ instanceId を使って検索
1557
+ const tempIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
1558
+ if (tempIndex > -1) playerLayers.splice(tempIndex, 1);
1559
+ updatePlayerModelUI();
1560
+ }
1561
+ }
1562
+ }
1563
+ });
1564
+
1565
+ document.addEventListener('DOMContentLoaded', () => {
1566
+ gameContainer.style.opacity = 0;
1567
+ gameContainer.style.transition = 'opacity 0.5s ease-in-out';
1568
+ });
web/style.css ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* --- SF UI THEME FOR MACHINE LEARNING RPG --- */
2
+
3
+ @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Share+Tech+Mono&display=swap');
4
+
5
+ :root {
6
+ --bg-color: #02041b;
7
+ --glass-bg: rgba(20, 25, 70, 0.4);
8
+ --glass-border: rgba(138, 143, 255, 0.2);
9
+ --text-primary: #e0e0ff;
10
+ --text-secondary: #a0a0c0;
11
+ --accent-indigo: #8a8fff;
12
+ --accent-cyan: #00f2ff;
13
+ --accent-red: #ff4a6d;
14
+ --accent-green: #4aff97;
15
+ --font-main: 'Orbitron', sans-serif;
16
+ --font-mono: 'Share Tech Mono', monospace;
17
+ }
18
+
19
+ * {
20
+ user-select: none;
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ body {
25
+ background-color: var(--bg-color);
26
+ color: var(--text-primary);
27
+ font-family: var(--font-main);
28
+ overflow: hidden;
29
+ }
30
+
31
+ /* --- BACKGROUND EFFECTS --- */
32
+ .background-grid {
33
+ position: fixed;
34
+ top: 0;
35
+ left: 0;
36
+ width: 100%;
37
+ height: 100%;
38
+ background-image:
39
+ linear-gradient(rgba(138, 143, 255, 0.1) 1px, transparent 1px),
40
+ linear-gradient(90deg, rgba(138, 143, 255, 0.1) 1px, transparent 1px);
41
+ background-size: 50px 50px;
42
+ animation: pan-grid 60s linear infinite;
43
+ z-index: -2;
44
+ }
45
+
46
+ @keyframes pan-grid {
47
+ 0% {
48
+ background-position: 0 0;
49
+ }
50
+
51
+ 100% {
52
+ background-position: 500px 500px;
53
+ }
54
+ }
55
+
56
+ .background-glow {
57
+ position: fixed;
58
+ top: 50%;
59
+ left: 50%;
60
+ width: 800px;
61
+ height: 800px;
62
+ background: radial-gradient(circle, rgba(79, 70, 229, 0.3) 0%, rgba(79, 70, 229, 0) 70%);
63
+ transform: translate(-50%, -50%);
64
+ animation: pulse-glow 10s ease-in-out infinite;
65
+ z-index: -1;
66
+ }
67
+
68
+ @keyframes pulse-glow {
69
+
70
+ 0%,
71
+ 100% {
72
+ transform: translate(-50%, -50%) scale(1);
73
+ opacity: 0.8;
74
+ }
75
+
76
+ 50% {
77
+ transform: translate(-50%, -50%) scale(1.2);
78
+ opacity: 1;
79
+ }
80
+ }
81
+
82
+ /* --- TYPOGRAPHY & HEADERS --- */
83
+ .text-glow {
84
+ text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px var(--accent-indigo), 0 0 20px var(--accent-indigo);
85
+ }
86
+
87
+ .text-glow-yellow {
88
+ text-shadow: 0 0 5px #fff, 0 0 10px #facc15, 0 0 15px #facc15;
89
+ }
90
+
91
+ .section-title {
92
+ font-size: 1.5rem;
93
+ font-weight: bold;
94
+ padding: 1rem;
95
+ color: var(--accent-cyan);
96
+ border-bottom: 1px solid var(--glass-border);
97
+ text-shadow: 0 0 8px var(--accent-cyan);
98
+ }
99
+
100
+ .character-name {
101
+ font-family: var(--font-mono);
102
+ font-size: 1.25rem;
103
+ text-align: center;
104
+ letter-spacing: 0.2em;
105
+ padding-bottom: 0.5rem;
106
+ }
107
+
108
+ /* --- GLASSMORPHISM PANELS --- */
109
+ .glass-pane {
110
+ background: var(--glass-bg);
111
+ backdrop-filter: blur(12px);
112
+ -webkit-backdrop-filter: blur(12px);
113
+ border-radius: 16px;
114
+ border: 1px solid var(--glass-border);
115
+ box-shadow: 0 0 40px rgba(0, 0, 0, 0.5), inset 0 0 10px rgba(138, 143, 255, 0.1);
116
+ transition: all 0.3s ease;
117
+ }
118
+
119
+ .glass-pane-inner {
120
+ background: rgba(0, 0, 0, 0.2);
121
+ border-radius: 12px;
122
+ padding: 0.5rem;
123
+ border: 1px solid var(--glass-border);
124
+ }
125
+
126
+ /* --- LAYER & INVENTORY ITEMS --- */
127
+ .layer-item,
128
+ .player-layer {
129
+ background: rgba(0, 0, 0, 0.3);
130
+ border: 1px solid var(--glass-border);
131
+ border-radius: 12px;
132
+ padding: 0.75rem;
133
+ text-align: center;
134
+ transition: all 0.2s ease-in-out;
135
+ position: relative;
136
+ cursor: pointer;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .layer-item::before,
141
+ .player-layer::before {
142
+ content: '';
143
+ position: absolute;
144
+ top: 0;
145
+ left: -100%;
146
+ width: 100%;
147
+ height: 100%;
148
+ background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
149
+ opacity: 0.3;
150
+ transition: left 0.4s ease;
151
+ }
152
+
153
+ .layer-item:hover::before,
154
+ .player-layer:hover::before {
155
+ left: 100%;
156
+ }
157
+
158
+ .layer-item:hover,
159
+ .player-layer:hover {
160
+ transform: translateY(-4px);
161
+ border-color: var(--accent-cyan);
162
+ box-shadow: 0 0 15px rgba(0, 242, 255, 0.4);
163
+ }
164
+
165
+ .player-layer {
166
+ display: flex;
167
+ justify-content: space-between;
168
+ align-items: center;
169
+ cursor: move;
170
+ }
171
+
172
+ .layer-icon {
173
+ font-size: 1.75rem;
174
+ margin-bottom: 0.25rem;
175
+ color: var(--accent-cyan);
176
+ text-shadow: 0 0 10px var(--accent-cyan);
177
+ }
178
+
179
+ /* Drag & Drop Styles */
180
+ .dragging {
181
+ opacity: 0.5;
182
+ transform: scale(0.95);
183
+ box-shadow: 0 0 20px var(--accent-indigo);
184
+ }
185
+
186
+ #player-model-layers.drag-over {
187
+ background-color: rgba(79, 70, 229, 0.3);
188
+ box-shadow: inset 0 0 20px rgba(138, 143, 255, 0.5);
189
+ }
190
+
191
+ .drop-placeholder {
192
+ height: 4px;
193
+ background-color: var(--accent-cyan);
194
+ margin: 4px 0;
195
+ border-radius: 2px;
196
+ box-shadow: 0 0 8px var(--accent-cyan);
197
+ }
198
+
199
+ /* --- BUTTONS --- */
200
+ .btn {
201
+ font-family: var(--font-main);
202
+ font-weight: bold;
203
+ padding: 0.75rem 1.5rem;
204
+ border-radius: 8px;
205
+ border: 1px solid transparent;
206
+ transition: all 0.3s ease;
207
+ cursor: pointer;
208
+ }
209
+
210
+ .btn-primary {
211
+ background-color: var(--accent-red);
212
+ color: white;
213
+ border-color: #ff7a8d;
214
+ text-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
215
+ box-shadow: 0 0 10px rgba(255, 74, 109, 0.5), inset 0 0 5px rgba(255, 255, 255, 0.2);
216
+ }
217
+
218
+ .btn-primary:hover:not(:disabled) {
219
+ background-color: #ff6a7d;
220
+ box-shadow: 0 0 20px rgba(255, 74, 109, 0.8);
221
+ transform: translateY(-2px);
222
+ }
223
+
224
+ .btn-primary:disabled {
225
+ background-color: #552020;
226
+ color: #a0a0a0;
227
+ cursor: not-allowed;
228
+ box-shadow: none;
229
+ border-color: #773030;
230
+ }
231
+
232
+ .btn-secondary {
233
+ background-color: #4a4a6a;
234
+ color: var(--text-primary);
235
+ border-color: #6a6a8a;
236
+ }
237
+
238
+ .btn-secondary:hover {
239
+ background-color: #5a5a7a;
240
+ transform: translateY(-2px);
241
+ box-shadow: 0 0 15px rgba(138, 143, 255, 0.3);
242
+ }
243
+
244
+ /* --- BATTLE AREA --- */
245
+ .hp-bar-container {
246
+ background: rgba(0, 0, 0, 0.4);
247
+ border-radius: 9999px;
248
+ padding: 4px;
249
+ border: 1px solid var(--glass-border);
250
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
251
+ }
252
+
253
+ .hp-bar {
254
+ border-radius: 9999px;
255
+ transition: width 0.5s cubic-bezier(0.25, 1, 0.5, 1);
256
+ text-shadow: 1px 1px 2px black;
257
+ font-weight: bold;
258
+ color: white;
259
+ text-align: center;
260
+ height: 1.5rem;
261
+ line-height: 1.5rem;
262
+ }
263
+
264
+ #player-hp-bar {
265
+ background: linear-gradient(90deg, var(--accent-green), #8affc7);
266
+ }
267
+
268
+ #enemy-hp-bar {
269
+ background: linear-gradient(90deg, var(--accent-red), #ff8a9d);
270
+ }
271
+
272
+ .enemy-image-style {
273
+ border: 2px solid var(--accent-red);
274
+ box-shadow: 0 0 20px var(--accent-red);
275
+ filter: brightness(1.2) contrast(1.1);
276
+ }
277
+
278
+ #enemy-area {
279
+ width: 200px;
280
+ height: 200px;
281
+ display: flex;
282
+ flex-direction: column;
283
+ align-items: center;
284
+ justify-content: center;
285
+ }
286
+
287
+ .enemy-scanline {
288
+ position: absolute;
289
+ top: 0;
290
+ left: 0;
291
+ width: 100%;
292
+ height: 100%;
293
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.05) 50%, rgba(255, 255, 255, 0) 100%);
294
+ background-size: 100% 4px;
295
+ animation: scan 5s linear infinite;
296
+ pointer-events: none;
297
+ }
298
+
299
+ @keyframes scan {
300
+ 0% {
301
+ background-position: 0 0;
302
+ }
303
+
304
+ 100% {
305
+ background-position: 0 200px;
306
+ }
307
+ }
308
+
309
+ /* --- MESSAGE LOG --- */
310
+ #message-log-area {
311
+ font-family: var(--font-mono);
312
+ }
313
+
314
+ #message-log-area p {
315
+ opacity: 0;
316
+ transform: translateY(10px);
317
+ animation: fade-in-up 0.5s forwards;
318
+ }
319
+
320
+ @keyframes fade-in-up {
321
+ to {
322
+ opacity: 1;
323
+ transform: translateY(0);
324
+ }
325
+ }
326
+
327
+ /* --- MODALS --- */
328
+ #item-selection-modal,
329
+ #animation-modal {
330
+ transition: opacity 0.3s ease;
331
+ }
332
+
333
+ #item-selection-modal .layer-item {
334
+ width: 12rem;
335
+ /* 192px */
336
+ height: 12rem;
337
+ /* 192px */
338
+ display: flex;
339
+ flex-direction: column;
340
+ justify-content: center;
341
+ }
342
+
343
+ #item-selection-modal .layer-icon {
344
+ font-size: 4rem;
345
+ }
346
+
347
+ /* --- PREVIEW AREA --- */
348
+ .idle-particles {
349
+ position: absolute;
350
+ width: 100%;
351
+ height: 100%;
352
+ }
353
+
354
+ .idle-particles::after {
355
+ content: "";
356
+ position: absolute;
357
+ width: 2px;
358
+ height: 2px;
359
+ background: var(--accent-cyan);
360
+ border-radius: 50%;
361
+ animation: particle-float 10s ease-in-out infinite;
362
+ box-shadow: 0 0 5px var(--accent-cyan),
363
+ -100px -50px 0 0 var(--accent-cyan),
364
+ 120px -80px 0 -0.5px var(--accent-cyan),
365
+ 80px 90px 0 0.5px var(--accent-cyan),
366
+ -70px 100px 0 0 var(--accent-cyan),
367
+ 90px -120px 0 -0.2px var(--accent-cyan);
368
+ }
369
+
370
+ @keyframes particle-float {
371
+ 0% {
372
+ transform: translate(0, 0);
373
+ opacity: 0.5;
374
+ }
375
+
376
+ 25% {
377
+ transform: translate(10px, 20px);
378
+ opacity: 1;
379
+ }
380
+
381
+ 50% {
382
+ transform: translate(-15px, -10px);
383
+ opacity: 0.7;
384
+ }
385
+
386
+ 75% {
387
+ transform: translate(5px, -25px);
388
+ opacity: 1;
389
+ }
390
+
391
+ 100% {
392
+ transform: translate(0, 0);
393
+ opacity: 0.5;
394
+ }
395
+ }
396
+
397
+
398
+ /* --- ANIMATION VISUALIZATION --- */
399
+ .stage-label {
400
+ position: absolute;
401
+ top: 50%;
402
+ opacity: 0;
403
+ transform: translateX(-50%) translateY(20px);
404
+ transition: all 0.3s ease-in-out;
405
+ font-size: 1.2rem;
406
+ font-weight: bold;
407
+ color: var(--accent-cyan);
408
+ text-shadow: 0 0 10px var(--accent-cyan);
409
+ white-space: nowrap;
410
+ }
411
+
412
+ .particle {
413
+ position: absolute;
414
+ width: 5px;
415
+ height: 5px;
416
+ background-color: #fde047;
417
+ /* yellow-300 */
418
+ border-radius: 50%;
419
+ box-shadow: 0 0 8px #fde047, 0 0 15px #facc15;
420
+ pointer-events: none;
421
+ opacity: 0;
422
+ }
423
+
424
+ .grid-cell {
425
+ transition: all 0.2s ease-in-out;
426
+ box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.5);
427
+ }
428
+
429
+ /* ★★★ TITLE SCREEN STYLES ★★★ */
430
+ #title-screen {
431
+ background: var(--bg-color);
432
+ transition: opacity 1.5s ease-out;
433
+ }
434
+ .title-logo {
435
+ text-align: center;
436
+ line-height: 1;
437
+ position: relative;
438
+ animation: title-float 6s ease-in-out infinite;
439
+ }
440
+ .title-logo::before {
441
+ content: '';
442
+ position: absolute;
443
+ top: 50%;
444
+ left: 50%;
445
+ width: 600px;
446
+ height: 600px;
447
+ background: radial-gradient(circle, rgba(79, 70, 229, 0.4) 0%, rgba(79, 70, 229, 0) 65%);
448
+ transform: translate(-50%, -50%);
449
+ z-index: -1;
450
+ animation: pulse-glow 8s ease-in-out infinite;
451
+ }
452
+ .title-logo h1 {
453
+ color: var(--text-primary);
454
+ text-shadow: 0 0 8px #fff, 0 0 15px var(--accent-indigo);
455
+ }
456
+ .title-logo h2 {
457
+ margin-top: -1rem;
458
+ }
459
+
460
+ @keyframes title-float {
461
+ 0%, 100% { transform: translateY(0); }
462
+ 50% { transform: translateY(-20px); }
463
+ }
464
+
465
+ #start-game-btn {
466
+ animation: start-btn-pulse 2s infinite;
467
+ }
468
+ @keyframes start-btn-pulse {
469
+ 0% { transform: scale(1); box-shadow: 0 0 20px rgba(255, 74, 109, 0.5); }
470
+ 50% { transform: scale(1.05); box-shadow: 0 0 30px rgba(255, 74, 109, 0.9); }
471
+ 100% { transform: scale(1); box-shadow: 0 0 20px rgba(255, 74, 109, 0.5); }
472
+ }
473
+
474
+ /* ★★★ GOLD RARE ITEM STYLES ★★★ */
475
+ .layer-item.gold-rare, .player-layer.gold-rare {
476
+ background: radial-gradient(ellipse at center, rgba(30,25,0,0.5) 0%, rgba(0,0,0,0.5) 70%),
477
+ linear-gradient(160deg, rgba(255, 215, 0, 0.3), rgba(255, 165, 0, 0.1));
478
+ border-color: #ffd700; /* gold */
479
+ box-shadow: 0 0 15px rgba(255, 215, 0, 0.6), inset 0 0 10px rgba(255, 215, 0, 0.2);
480
+ }
481
+
482
+ .layer-item.gold-rare::before, .player-layer.gold-rare::before {
483
+ background: linear-gradient(90deg, transparent, #fff, transparent);
484
+ opacity: 0.3;
485
+ }
486
+
487
+ .layer-item.gold-rare:hover, .player-layer.gold-rare:hover {
488
+ border-color: #fff;
489
+ box-shadow: 0 0 25px rgba(255, 215, 0, 0.9);
490
+ }
491
+
492
+ .layer-item.gold-rare .layer-icon, .player-layer.gold-rare .layer-icon {
493
+ color: #ffd700;
494
+ text-shadow: 0 0 10px #ffd700;
495
+ }
496
+
497
+ /* ゴールドレアアイテム用のキラキラエフェクト */
498
+ .sparkle-container {
499
+ position: absolute;
500
+ top: 0; left: 0;
501
+ width: 100%; height: 100%;
502
+ pointer-events: none;
503
+ overflow: hidden;
504
+ border-radius: 12px;
505
+ }
506
+
507
+ .sparkle {
508
+ position: absolute;
509
+ width: 3px;
510
+ height: 3px;
511
+ background: #fff;
512
+ border-radius: 50%;
513
+ box-shadow: 0 0 5px #fff, 0 0 10px #ffd700;
514
+ animation: sparkle-anim 1.5s ease-in-out infinite;
515
+ opacity: 0;
516
+ }
517
+
518
+ @keyframes sparkle-anim {
519
+ 0% { transform: scale(0); opacity: 0; }
520
+ 50% { transform: scale(1.5); opacity: 1; }
521
+ 100% { transform: scale(0); opacity: 0; }
522
+ }
523
+
524
+ /* ★★★ 新しいインベントリのスタイルを追加 ★★★ */
525
+ #layer-inventory {
526
+ display: flex;
527
+ flex-direction: column;
528
+ gap: 0.75rem;
529
+ overflow-y: auto; /* ★★★ スクロールを有効化 ★★★ */
530
+ }
531
+
532
+ /* ★★★ インベントリアイテムのスタイルを微調整 ★★★ */
533
+ #layer-inventory .layer-item {
534
+ display: flex; /* アイコンとテキストを横並びに */
535
+ align-items: center;
536
+ text-align: left; /* テキストを左揃えに */
537
+ padding: 0.5rem 1rem; /* パディングを調整 */
538
+ }
539
+
540
+ #layer-inventory .layer-item .layer-icon {
541
+ font-size: 1.5rem; /* 24px */
542
+ margin-bottom: 0; /* 下マージンを削除 */
543
+ margin-right: 1rem; /* 右にマージンを追加 */
544
+ }
545
+
546
+ #layer-inventory .layer-item p {
547
+ flex-grow: 1; /* テキストエリアが残りのスペースを埋める */
548
+ }
549
+
550
+ /* ★★★ SCROLLBAR STYLES ★★★ */
551
+ /* 既存の ::-webkit-scrollbar { display: none; } は削除またはコメントアウトしてください */
552
+ ::-webkit-scrollbar {
553
+ width: 8px;
554
+ }
555
+ ::-webkit-scrollbar-track {
556
+ background: rgba(0, 0, 0, 0.2);
557
+ border-radius: 4px;
558
+ }
559
+ ::-webkit-scrollbar-thumb {
560
+ background: var(--glass-border);
561
+ border-radius: 4px;
562
+ border: 1px solid rgba(0,0,0,0.3);
563
+ }
564
+ ::-webkit-scrollbar-thumb:hover {
565
+ background: var(--accent-cyan);
566
+ }