Spaces:
Sleeping
Sleeping
2508181650
Browse files- Dockerfile +22 -0
- main.py +241 -0
- requirements.txt +6 -0
- web/index.html +122 -0
- web/script.js +1568 -0
- 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">×</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">×</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(' × ')})</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 |
+
}
|