horiyouta's picture
2508181826
c3dd274
// --- DOM Elements ---
const battleBtn = document.getElementById('battle-btn');
const enemyMessage = document.getElementById('enemy-message');
const enemyImage = document.getElementById('enemy-image');
const battleLog = document.getElementById('battle-log');
const playerModelLayers = document.getElementById('player-model-layers');
const layerInventory = document.getElementById('layer-inventory');
const messageLogArea = document.getElementById('message-log-area');
const animationModal = document.getElementById('animation-modal');
const vizArea = document.getElementById('visualization-area');
const predictionResult = document.getElementById('prediction-result');
const closeModalBtn = document.getElementById('close-modal-btn');
const playerHpBar = document.getElementById('player-hp-bar');
const enemyHpBar = document.getElementById('enemy-hp-bar');
const itemSelectionModal = document.getElementById('item-selection-modal');
const itemChoices = document.getElementById('item-choices');
const restartBtn = document.getElementById('restart-btn');
const layerDescription = document.getElementById('layer-description');
const itemInfoArea = document.getElementById('item-info-area');
const previewAnimationArea = document.getElementById('preview-animation-area');
const choiceInfoArea = document.getElementById('choice-info-area');
const choiceDescription = document.getElementById('choice-description');
const choicePreviewArea = document.getElementById('choice-preview-area');
const titleScreen = document.getElementById('title-screen');
const startGameBtn = document.getElementById('start-game-btn');
const gameContainer = document.getElementById('game-container');
// --- Game State & Config ---
let playerLayers = []; // 現在モデルに配置されているレイヤー
let playerHP = 100;
let enemyHP = 100;
let currentStage = 1;
let isBattleInProgress = false;
let draggedItem = null;
let dragOverIndex = null; // 並び替え先のインデックス
let wasDroppedSuccessfully = false; // ★★★ このフラグを追加
let currentEnemy = { image_b64: null, label: null }; // ★★★ クライアント側で敵の状態を保持
let ENEMY_MAX_HP = 100;
let selectedLayerForMove = null; // タップで移動対象として選択されたレイヤーのインデックス
const PLAYER_MAX_HP = 100;
const allAvailableLayers = [
// --- Normal Items ---
{
id: 0, name: '畳み込み層 (3x3, 4フィルタ)', type: 'Conv2d', icon: 'fa-th-large', params: { out_channels: 4, kernel_size: 3 },
rarity: 'normal',
desc: '画像から特徴(エッジ等)を抽出するCNNの心臓部。<br><br><b>使い方:</b> 画像の「パーツ」を見つける専門家です。モデルの<span class="text-yellow-300">最初の方</span>に置いて、画像から形の特徴を捉えさせましょう。'
},
{
id: 2, name: 'ReLU活性化関数', type: 'ReLU', icon: 'fa-chart-line', params: {},
rarity: 'normal',
desc: '負の値を0に変換し、モデルに非線形性を与え表現力を高めます。<br><br><b>使い方:</b> モデルが複雑な判断をするための「スイッチ」です。<span class="text-yellow-300">畳み込み層や全結合層の直後</span>に挟むのが定石です。'
},
{
id: 3, name: '最大プーリング (2x2)', type: 'MaxPool2d', icon: 'fa-compress-arrows-alt', params: { kernel_size: 2 },
rarity: 'normal',
desc: '情報を圧縮し、位置ズレに強いモデルを作ります。<br><br><b>使い方:</b> 画像の「要約」を行い、重要な部分だけを残します。<span class="text-yellow-300">畳み込み層(とReLU)の後</span>に入れると、より頑健なモデルになります。'
},
{
id: 4, name: '平坦化層', type: 'Flatten', icon: 'fa-stream', params: {},
rarity: 'normal',
desc: '2次元の画像データを1次元に変換し、全結合層に渡せるようにします。<br><br><b>使い方:</b> 画像処理パートから最終判断パートへの「橋渡し」役です。モデルの<span class="text-yellow-300">中盤に必ず1つ</span>だけ配置してください。'
},
{
id: 5, name: '全結合層 (16ノード)', type: 'Linear', icon: 'fa-braille', params: { out_features: 16 },
rarity: 'normal',
desc: '全ての特徴を結合し、最終的な分類を行います。<br><br><b>使い方:</b> これまでの情報を元に「最終判断」を下す賢者です。モデルの<span class="text-yellow-300">最後の方</span>、平坦化層の後に置きます。'
},
{
id: 8, name: '平均プーリング (2x2)', type: 'AvgPool2d', icon: 'fa-wave-square', params: { kernel_size: 2 },
rarity: 'normal',
desc: '範囲内の特徴を「平均化」して情報を圧縮します。<br><br><b>使い方:</b> 最大プーリングと似ていますが、より滑らかに情報を要約します。<span class="text-yellow-300">畳み込み層(とReLU)の後</span>に使い、最大プーリングと使い比べてみましょう。'
},
// --- Gold Rare Items ---
{
id: 1, name: '畳み込み層 (5x5, 8フィルタ)', type: 'Conv2d', icon: 'fa-border-all', params: { out_channels: 8, kernel_size: 5 },
rarity: 'gold',
desc: 'より広い範囲の特徴を抽出する強力な畳み込み層。<br><br><b>使い方:</b> 3x3より広い範囲を見るため、より大局的な特徴を捉えます。これもモデルの<span class="text-yellow-300">最初の方</span>に置きます。'
},
{
id: 6, name: '全結合層 (64ノード)', type: 'Linear', icon: 'fa-sitemap', params: { out_features: 64 },
rarity: 'gold',
desc: 'より多くのパラメータを持つ、より強力な全結合層。<br><br><b>使い方:</b> 16ノードより賢い賢者ですが、学習に少し時間がかかります。これも<span class="text-yellow-300">最後の方</span>に置きます。'
},
{
id: 7, name: 'ドロップアウト (p=0.5)', type: 'Dropout', icon: 'fa-random', params: { p: 0.5 },
rarity: 'gold',
desc: '学習中にノードをランダムに無効化し、過学習を防ぎます。<br><br><b>使い方:</b> モデルが特定の情報に頼りすぎるのを防ぐ「保険」です。未知の敵に強くなります。<span class="text-yellow-300">全結合層の間</span>に挟むのが効果的です。'
},
{
id: 9, name: '残差ブロック', type: 'ResidualBlock', icon: 'fa-project-diagram', params: {},
rarity: 'gold',
desc: '入力情報を「近道」させ、深いモデルの学習を安定させます。<br><br><b>使い方:</b> 情報を失わずに変換を加える特殊なブロックです。モデルが深くなりすぎた時に<span class="text-yellow-300">全結合層の代わり</span>に入れると、性能が改善することがあります。'
}
];
// ★★★ インベントリを所持数管理に変更
let playerInventory = {}; // { layerId: count, ... }
const ANIMATION_SPEED = 0.7;
// --- Helper & UI Functions ---
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms * ANIMATION_SPEED));
async function animateBattleLog(text, clear = true) {
if (clear) battleLog.textContent = '';
for (let i = 0; i < text.length; i++) {
battleLog.textContent += text[i];
await sleep(30);
}
}
function logMessage(message, type = 'info') {
const colors = { info: 'text-gray-400', success: 'text-green-400', error: 'text-red-400', action: 'text-yellow-400' };
const p = document.createElement('p');
// メッセージが追加されるときに少し遅延させることで、アニメーションが目に見えるようにする
p.style.animationDelay = `${messageLogArea.childElementCount * 50}ms`;
p.innerHTML = `> ${message}`;
p.className = colors[type];
messageLogArea.appendChild(p);
messageLogArea.scrollTop = messageLogArea.scrollHeight;
}
function initializeUI() {
logMessage('Machine Learning RPGへようこそ!');
logMessage('インベントリのアイテムをドラッグしてモデルを構築しましょう。');
startGame();
}
function drawLayerConnections() {
const canvas = document.getElementById('layer-connections'); // Get canvas element
if (!canvas) return;
const ctx = canvas.getContext('2d');
const parent = playerModelLayers;
// Canvasのサイズを親要素に合わせる
canvas.width = parent.clientWidth;
canvas.height = parent.scrollHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const layers = parent.querySelectorAll('.player-layer');
if (layers.length < 2) return;
ctx.strokeStyle = 'rgba(0, 242, 255, 0.4)';
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(0, 242, 255, 0.8)';
ctx.shadowBlur = 10;
for (let i = 0; i < layers.length - 1; i++) {
const startEl = layers[i];
const endEl = layers[i + 1];
const startRect = startEl.getBoundingClientRect();
const endRect = endEl.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const startX = startRect.left + startRect.width / 2 - parentRect.left;
const startY = startRect.bottom - parentRect.top + parent.scrollTop;
const endX = endRect.left + endRect.width / 2 - parentRect.left;
const endY = endRect.top - parentRect.top + parent.scrollTop;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.bezierCurveTo(startX, startY + 30, endX, endY - 30, endX, endY);
ctx.stroke();
}
}
function removeLayer(index) {
const removedLayer = playerLayers.splice(index, 1)[0];
returnInventoryItem(removedLayer);
logMessage(`Layer Removed: <span class="text-indigo-300">${removedLayer.name}</span>`, 'action');
updatePlayerModelUI();
updatePlayerInventoryUI();
}
function updatePlayerModelUI() {
const modelArea = playerModelLayers;
// Canvas以外の要素をクリア
Array.from(modelArea.children).forEach(child => {
if (child.tagName !== 'CANVAS') {
child.remove();
}
});
const createDropZone = (index) => {
const zone = document.createElement('div');
zone.className = 'player-layer-drop-zone h-4'; // スマホで見えるように少し高さを付ける
zone.dataset.index = index;
zone.addEventListener('click', () => handleLayerMove(index));
modelArea.appendChild(zone);
};
if (playerLayers.length === 0) {
const p = document.createElement('p');
p.className = 'text-gray-400 text-center p-4';
p.textContent = 'インベントリからレイヤー(層)をここにドラッグ&ドロップ or タップしてください。';
modelArea.appendChild(p);
} else {
createDropZone(0); // 最初のレイヤーの上にドロップゾーンを作成
playerLayers.forEach((layer, index) => {
const div = document.createElement('div');
div.className = `player-layer ${layer.rarity === 'gold' ? 'gold-rare' : ''}`;
div.draggable = true;
div.dataset.index = index;
// ★★★ 選択状態のスタイルを適用 ★★★
if (selectedLayerForMove === index) {
div.classList.add('border-cyan-400', 'scale-105');
}
div.innerHTML = `<span><i class="fas ${layer.icon} mr-2"></i>${layer.name}</span><button class="text-red-400 hover:text-red-200 text-lg">&times;</button>`;
div.querySelector('button').onclick = (e) => {
e.stopPropagation(); // 親要素のクリックイベントを発火させない
removeLayer(index);
};
// ドラッグ&ドロップイベント (PC用)
div.addEventListener('dragstart', (e) => handleDragStart(e, index, layer));
div.addEventListener('dragend', handleDragEnd);
div.addEventListener('dragenter', (e) => handleDragEnterModelItem(e, index));
div.addEventListener('dragleave', (e) => e.currentTarget.classList.remove('is-dragged-over'));
// ★★★ タップイベント (スマホ用) ★★★
div.addEventListener('click', () => handleLayerSelectForMove(index));
modelArea.appendChild(div);
createDropZone(index + 1); // 各レイヤーの下にドロップゾーンを作成
});
}
battleBtn.disabled = playerLayers.length === 0;
requestAnimationFrame(drawLayerConnections);
}
function updatePlayerInventoryUI() {
layerInventory.innerHTML = '';
Object.keys(playerInventory).forEach(layerId => {
const count = playerInventory[layerId];
if (count > 0) {
const layer = allAvailableLayers.find(l => l.id == layerId);
const item = document.createElement('div');
item.className = `layer-item min-h-16 relative ${layer.rarity === 'gold' ? 'gold-rare' : ''}`;
item.draggable = true;
item.dataset.id = layer.id;
// ★★★ HTML構造を新しいレイアウト用に変更
item.innerHTML = `
<i class="fas ${layer.icon} layer-icon"></i>
<p>${layer.name}</p>
<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>
`;
// イベントリスナー
item.addEventListener('click', () => {
// PCでの説明表示機能は維持
showDescription(layer, document.getElementById('layer-description'));
runPreviewAnimation(layer, previewAnimationArea);
// スマホ用の追加機能
addLayerFromInventory(layer);
});
item.addEventListener('dragstart', (e) => handleInventoryDragStart(e, layer));
item.addEventListener('dragend', handleDragEnd);
// ゴールドレアの場合、キラキラエフェクトを追加
if (layer.rarity === 'gold') {
const sparkleContainer = document.createElement('div');
sparkleContainer.className = 'sparkle-container';
for (let i = 0; i < 3; i++) {
const sparkle = document.createElement('div');
sparkle.className = 'sparkle';
sparkle.style.top = `${Math.random() * 100}%`;
sparkle.style.left = `${Math.random() * 100}%`;
sparkle.style.animationDelay = `${Math.random() * 1.5}s`;
sparkleContainer.appendChild(sparkle);
}
item.appendChild(sparkleContainer);
}
layerInventory.appendChild(item);
}
});
}
function showDescription(layer, element) {
element.innerHTML = `<p class="font-bold text-cyan-300">${layer.name}</p><p>${layer.desc}</p>`;
}
function updateHpBars() {
const playerHpPercent = Math.max(0, (playerHP / PLAYER_MAX_HP) * 100);
playerHpBar.style.width = `${playerHpPercent}%`;
// ★★★ 表示を「現在HP / 最大HP」に変更
playerHpBar.textContent = `${Math.max(0, playerHP)} / ${PLAYER_MAX_HP}`;
const enemyHpPercent = Math.max(0, (enemyHP / ENEMY_MAX_HP) * 100);
enemyHpBar.style.width = `${enemyHpPercent}%`;
// ★★★ 表示を「現在HP / 最大HP」に変更
enemyHpBar.textContent = `${Math.max(0, enemyHP)} / ${ENEMY_MAX_HP}`;
// Flash effect on change
if (playerHpBar.dataset.lastHp && playerHpBar.dataset.lastHp != playerHP) {
playerHpBar.parentElement.classList.add('flash-damage');
setTimeout(() => playerHpBar.parentElement.classList.remove('flash-damage'), 300);
}
if (enemyHpBar.dataset.lastHp && enemyHpBar.dataset.lastHp != enemyHP) {
enemyHpBar.parentElement.classList.add('flash-damage');
setTimeout(() => enemyHpBar.parentElement.classList.remove('flash-damage'), 300);
}
playerHpBar.dataset.lastHp = playerHP;
enemyHpBar.dataset.lastHp = enemyHP;
}
async function fetchNewEnemy() {
const response = await fetch('/api/get_enemy');
const enemyData = await response.json();
// ★★★ グローバル変数に保存
currentEnemy = {
image_b64: enemyData.image_b64,
label: enemyData.label
};
enemyMessage.textContent = '野生のMNISTモンスターが現れた!';
enemyImage.src = currentEnemy.image_b64;
enemyImage.classList.remove('hidden');
await animateBattleLog('', true);
}
// --- D&D Functions ---
// ドラッグ開始時の処理
function handleDragStart(e, index, layer) {
draggedItem = { type: 'model', index: index, layer: layer, element: e.target };
setTimeout(() => e.target.classList.add('dragging'), 0);
}
function handleInventoryDragStart(e, layer) {
// ★★★ 新しい一意なインスタンスを作成
const layerInstance = {
...layer,
instanceId: `inst_${Date.now()}_${Math.random()}`
};
draggedItem = { type: 'inventory', layer: layerInstance, element: e.target };
wasDroppedSuccessfully = false; // ★★★ ドラッグ開始時にフラグをリセット
setTimeout(() => e.target.classList.add('dragging'), 0);
}
// ドラッグ終了時の処理
function handleDragEnd(e) {
// ★★★ バグ修正:キャンセル時のクリーンアップ処理 ★★★
if (draggedItem && draggedItem.type === 'inventory' && !wasDroppedSuccessfully) {
// インベントリからのドラッグが、モデルエリアにドロップされずに終了した場合
const tempIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
if (tempIndex > -1) {
// モデルデータから仮追加されたアイテムを削除
playerLayers.splice(tempIndex, 1);
logMessage('モデルへの追加をキャンセルしました。', 'info');
// UIを再描画して見た目を元に戻す
updatePlayerModelUI();
}
}
// --- 既存のクリーンアップ処理 ---
// is-dragged-over クラスをすべて削除
document.querySelectorAll('.is-dragged-over').forEach(el => el.classList.remove('is-dragged-over'));
// dragging クラスを削除
if (draggedItem && draggedItem.element) {
draggedItem.element.classList.remove('dragging');
}
draggedItem = null;
}
// ドロップを許可するエリアの処理
function allowDrop(ev) {
ev.preventDefault();
const modelArea = document.getElementById('player-model-layers');
if (modelArea.contains(ev.target)) {
modelArea.classList.add('drag-over');
}
}
// モデルエリアへのドロップ処理 (メインロジック)
function dropOnModelArea(ev) {
ev.preventDefault();
const modelArea = document.getElementById('player-model-layers');
modelArea.classList.remove('drag-over');
if (!draggedItem) return;
// ★★★ ドロップが成功した(試みられた)ことを記録
if (draggedItem.type === 'inventory') {
wasDroppedSuccessfully = true;
}
if (draggedItem.type === 'inventory') {
const originalLayer = allAvailableLayers.find(l => l.id === draggedItem.layer.id);
if (!useInventoryItem(originalLayer)) {
logMessage(`インベントリに ${draggedItem.layer.name} がありません`, 'error');
const tempIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
if (tempIndex > -1) playerLayers.splice(tempIndex, 1);
} else {
logMessage(`レイヤー追加: ${draggedItem.layer.name}`, 'action');
}
} else {
logMessage('モデルの順序を変更しました。', 'info');
}
updatePlayerModelUI();
updatePlayerInventoryUI();
handleDragEnd({ target: ev.target });
}
// モデルエリア内のアイテムにドラッグが入ったときの処理
function handleDragEnterModelItem(e, targetIndex) {
e.preventDefault();
const targetItem = e.currentTarget;
if (!draggedItem || (draggedItem.type === 'model' && draggedItem.layer.instanceId === playerLayers[targetIndex].instanceId)) {
return;
}
document.querySelectorAll('.is-dragged-over').forEach(el => el.classList.remove('is-dragged-over'));
targetItem.classList.add('is-dragged-over');
let currentIndex = -1;
// ★★★ instanceId を使ってドラッグ中のアイテムを検索
if (draggedItem.layer.instanceId) {
currentIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
}
let movedLayer;
if (currentIndex > -1) {
[movedLayer] = playerLayers.splice(currentIndex, 1);
} else {
movedLayer = draggedItem.layer;
}
playerLayers.splice(targetIndex, 0, movedLayer);
if (draggedItem.type === 'model') {
draggedItem.index = targetIndex;
}
updatePlayerModelUI();
}
// ★★★ モデルエリアの「何もない部分」にドラッグが入ったときの処理を追加
function handleDragEnterModelArea(e) {
if (e.target.id !== 'player-model-layers') {
return;
}
document.querySelectorAll('.is-dragged-over').forEach(el => el.classList.remove('is-dragged-over'));
if (!draggedItem) return;
// ★★★ instanceId を使ってドラッグ中のアイテムを検索
let currentIndex = -1;
if (draggedItem.layer.instanceId) {
currentIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
}
if (currentIndex > -1) {
if (currentIndex === playerLayers.length - 1) return; // 既に末尾なら何もしない
const [movedLayer] = playerLayers.splice(currentIndex, 1);
playerLayers.push(movedLayer);
if (draggedItem.type === 'model') {
draggedItem.index = playerLayers.length - 1;
}
} else {
playerLayers.push(draggedItem.layer);
}
updatePlayerModelUI();
}
// ★★★ モデルエリアからドラッグが出たときのクリーンアップ処理を追加
function handleDragLeaveModelArea(e) {
const modelArea = document.getElementById('player-model-layers');
// ★★★ エリア外に出た判定のみ残し、データ操作ロジックは削除
if (!modelArea.contains(e.relatedTarget)) {
modelArea.classList.remove('drag-over');
}
}
// --- Game Flow (バグ修正) ---
function startGame() {
playerHP = PLAYER_MAX_HP;
currentStage = 1;
playerInventory = {};
playerLayers = []; // ★★★ ここでモデルの状態もリセットする
isBattleInProgress = false; // バトル状態をリセット
// 初期アイテム
addInventoryItem(allAvailableLayers.find(l => l.id === 0));
addInventoryItem(allAvailableLayers.find(l => l.id === 5));
logMessage(`ゲーム開始!初期インベントリを獲得しました。`, 'success');
// ... (UIリセット処理は変更なし) ...
battleBtn.classList.remove('hidden');
restartBtn.classList.add('hidden');
battleBtn.disabled = true; // モデルが空なので最初は無効
battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
battleLog.textContent = '';
startStage();
}
// --- Game Flow (タイトル画面対応) ---
// ★★★ ゲーム初期化とゲーム開始を分離
function initializeGame() {
logMessage('Machine Learning RPGへようこそ!');
logMessage('インベントリのアイテムをドラッグしてモデルを構築しましょう。');
playerHP = PLAYER_MAX_HP;
currentStage = 1;
playerInventory = {};
playerLayers = [];
isBattleInProgress = false;
addInventoryItem(allAvailableLayers.find(l => l.id === 0));
addInventoryItem(allAvailableLayers.find(l => l.id === 5));
logMessage(`ゲーム開始!初期インベントリを獲得しました。`, 'success');
battleBtn.classList.remove('hidden');
restartBtn.classList.add('hidden');
restartBtn.textContent = 'タイトルへ戻る'; // テキストを統一
battleBtn.disabled = true;
battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
battleLog.textContent = '';
messageLogArea.innerHTML = ''; // メッセージログもクリア
startStage();
}
function handleGameOver() {
logMessage('ゲームオーバー...', 'error');
animateBattleLog('敗北...'); // アニメーション付きに変更
battleBtn.classList.add('hidden');
restartBtn.classList.remove('hidden');
// ★★★ ボタンのテキストを明示的に変更
restartBtn.innerHTML = '<i class="fas fa-undo"></i> タイトルへ戻る';
isBattleInProgress = false;
}
function startStage() {
logMessage(`--- Stage ${currentStage} Start ---`, 'action');
playerHP = PLAYER_MAX_HP;
// ★★★ ステージに応じて敵のHPを計算・設定
ENEMY_MAX_HP = 100 * currentStage;
enemyHP = ENEMY_MAX_HP;
// ★★★ 構築済みモデルをインベントリに戻す
if (playerLayers.length > 0) {
// playerLayersの各アイテムをインベントリに戻す
playerLayers.forEach(layer => returnInventoryItem(layer));
playerLayers = []; // モデルを空にする
logMessage('Your previous model has been returned to inventory.', 'info');
}
updateHpBars();
updatePlayerModelUI();
fetchNewEnemy();
updatePlayerInventoryUI();
// ★★★ 最終ステージクリア後はアイテム選択画面を出さない
if (currentStage <= 5) {
showItemSelection();
}
}
function showItemSelection() {
itemSelectionModal.classList.remove('hidden');
itemSelectionModal.classList.add('flex');
itemChoices.innerHTML = '';
const confirmBtn = document.getElementById('confirm-choice-btn');
const choiceFooter = document.getElementById('choice-selection-footer');
const selectedChoiceInput = document.getElementById('selected-choice-id');
confirmBtn.disabled = true;
choiceFooter.classList.add('opacity-0');
selectedChoiceInput.value = '';
// ★★★ ステージに応じたゴールドレア出現確率を計算
// ステージ1: 10%, ステージ2: 20%, ステージ3: 30%, ステージ4: 40%, ステージ5: 50%
const goldChance = Math.min(0.1 * currentStage, 0.5);
// プレイヤーがまだ持っていないレイヤーをフィルタリング
const unownedLayers = allAvailableLayers.filter(layer => !playerInventory[layer.id] || playerInventory[layer.id] === 0);
const normalChoices = unownedLayers.filter(l => l.rarity === 'normal');
const goldChoices = unownedLayers.filter(l => l.rarity === 'gold');
const finalChoices = [];
const numChoices = 3;
for (let i = 0; i < numChoices; i++) {
let chosenLayer = null;
// 確率判定でゴールドレアを引くか、通常枠しか残っていない場合
if (Math.random() < goldChance && goldChoices.length > 0) {
const index = Math.floor(Math.random() * goldChoices.length);
chosenLayer = goldChoices.splice(index, 1)[0];
}
// 通常枠を引くか、ゴールド枠が空の場合
else if (normalChoices.length > 0) {
const index = Math.floor(Math.random() * normalChoices.length);
chosenLayer = normalChoices.splice(index, 1)[0];
}
// それでも選択肢がなければ、残っている方から引く
else if (goldChoices.length > 0) {
const index = Math.floor(Math.random() * goldChoices.length);
chosenLayer = goldChoices.splice(index, 1)[0];
}
if (chosenLayer) {
finalChoices.push(chosenLayer);
}
}
if (finalChoices.length === 0) {
logMessage('全てのレイヤーを収集しました!', 'success');
itemSelectionModal.classList.add('hidden');
itemSelectionModal.classList.remove('flex');
return;
}
// 選択肢のUIを生成
finalChoices.forEach(layer => {
const item = document.createElement('div');
item.className = `layer-item w-48 h-48 flex flex-col justify-center ${layer.rarity === 'gold' ? 'gold-rare' : ''}`;
item.innerHTML = `<i class="fas ${layer.icon} layer-icon text-5xl"></i><p class="text-lg mt-2">${layer.name}</p>`;
item.dataset.id = layer.id; // ★★★ IDをデータ属性として保持
// ★★★ ゴールドレアの場合、キラキラエフェクトを追加
if (layer.rarity === 'gold') {
const sparkleContainer = document.createElement('div');
sparkleContainer.className = 'sparkle-container';
for (let i = 0; i < 5; i++) { // モーダルでは少し多めに
const sparkle = document.createElement('div');
sparkle.className = 'sparkle';
sparkle.style.top = `${Math.random() * 100}%`;
sparkle.style.left = `${Math.random() * 100}%`;
sparkle.style.animationDelay = `${Math.random() * 1.5}s`;
sparkleContainer.appendChild(sparkle);
}
item.appendChild(sparkleContainer);
}
// ★★★ クリック/タップ時の動作を変更 ★★★
item.addEventListener('click', () => {
// 他のアイテムの選択状態を解除
document.querySelectorAll('#item-choices .layer-item').forEach(el => {
el.classList.remove('border-cyan-400', 'scale-105');
});
// このアイテムを選択状態にする
item.classList.add('border-cyan-400', 'scale-105');
// 説明とプレビューを表示
showDescription(layer, choiceDescription);
runPreviewAnimation(layer, choicePreviewArea);
// 決定ボタンを有効化し、選択したIDを保持
selectedChoiceInput.value = layer.id;
confirmBtn.disabled = false;
choiceFooter.classList.remove('opacity-0');
});
itemChoices.appendChild(item);
});
}
function selectItem(selectedLayer) {
addInventoryItem(selectedLayer);
logMessage(`You got a new layer: <span class="text-indigo-300">${selectedLayer.name}</span>!`, 'success');
updatePlayerInventoryUI();
itemSelectionModal.classList.add('hidden');
itemSelectionModal.classList.remove('flex');
// ★★★ アイテム選択後、モデルをインベントリに戻す
if (playerLayers.length > 0) {
playerLayers.forEach(layer => returnInventoryItem(layer));
playerLayers = [];
logMessage('Model reset. Please rebuild your model.', 'info');
updatePlayerModelUI();
updatePlayerInventoryUI();
}
}
// --- PREVIEW ANIMATION ENGINE ---
function generateDummyData(shape = [4, 14, 14]) {
// [channels, height, width]
return Array.from({ length: shape[0] }, () =>
Array.from({ length: shape[1] }, () =>
Array.from({ length: shape[2] }, () => Math.random() * 2 - 1)
)
);
}
// --- PREVIEW ANIMATION ENGINE ---
async function runPreviewAnimation(layer, previewArea) {
const currentAnimationId = Date.now();
previewArea.dataset.animationId = currentAnimationId;
previewArea.innerHTML = '';
const vizArea = previewArea;
const checkInterrupted = () => previewArea.dataset.animationId != currentAnimationId;
// ★★★ プレビュー要素に適用する基本スタイル
const previewElementStyle = (el) => {
el.style.position = 'absolute';
el.style.left = '50%';
el.style.top = '50%';
el.style.transform = 'translate(-50%, -50%)';
};
let fromEl, toEl, inputData;
switch (layer.type) {
case 'Conv2d':
case 'MaxPool2d':
fromEl = document.createElement('div');
previewElementStyle(fromEl);
previewArea.appendChild(fromEl);
inputData = (layer.type === 'Conv2d') ? generateDummyData([1, 28, 28]) : generateDummyData([4, 28, 28]);
await animateGrid(fromEl, inputData, { isInput: true, duration: 500 }, vizArea);
if (checkInterrupted()) return;
toEl = document.createElement('div');
previewElementStyle(toEl);
previewArea.appendChild(toEl);
// fromElをすぐに消さず、toElが生成されるのを待つ
fromEl.style.transition = 'opacity 0.5s ease-out 0.5s'; // 少し遅れてフェードアウト
fromEl.style.opacity = '0';
if (layer.type === 'Conv2d') {
await animateConv(fromEl, toEl, generateDummyData([layer.params.out_channels, 28, 28]), { duration: 1200, isPreview: true }, vizArea);
} else {
await animatePool(fromEl, toEl, generateDummyData([4, 14, 14]), { duration: 1200, isPreview: true }, vizArea);
}
break;
case 'AvgPool2d':
fromEl = document.createElement('div');
previewElementStyle(fromEl);
previewArea.appendChild(fromEl);
inputData = (layer.type === 'Conv2d') ? generateDummyData([1, 28, 28]) : generateDummyData([4, 28, 28]);
await animateGrid(fromEl, inputData, { isInput: true, duration: 500 }, vizArea);
if (checkInterrupted()) return;
toEl = document.createElement('div');
previewElementStyle(toEl);
previewArea.appendChild(toEl);
fromEl.style.transition = 'opacity 0.5s ease-out 0.5s';
fromEl.style.opacity = '0';
if (layer.type === 'Conv2d') {
await animateConv(fromEl, toEl, generateDummyData([layer.params.out_channels, 28, 28]), { duration: 1200, isPreview: true }, vizArea);
} else {
const isAverage = layer.type === 'AvgPool2d';
await animatePool(fromEl, toEl, generateDummyData([4, 14, 14]), { duration: 1200, isPreview: true, isAverage }, vizArea);
}
break;
case 'ReLU':
case 'Dropout':
fromEl = document.createElement('div');
fromEl.style.position = 'absolute';
fromEl.style.left = '50%';
fromEl.style.top = '50%';
previewArea.appendChild(fromEl);
const dummyDataWithNegatives = generateDummyData([4, 10, 10]);
await animateGrid(fromEl, dummyDataWithNegatives, { duration: 500 }, vizArea); // ★ 時間を延長
if (checkInterrupted()) return;
if (layer.type === 'ReLU') {
await animateReLU(fromEl, { duration: 800 }); // ★ 時間を延長
} else {
await animateDropout(fromEl, { duration: 800 }); // ★ 時間を延長
}
break;
case 'Flatten':
fromEl = document.createElement('div');
previewArea.appendChild(fromEl);
await animateGrid(fromEl, generateDummyData([4, 14, 14]), { duration: 600 }, vizArea);
if (checkInterrupted()) return;
toEl = document.createElement('div');
previewArea.appendChild(toEl);
await animateFlatten(fromEl, toEl, 4 * 14 * 14, { duration: 1000 }, vizArea); // ★ 時間を延長
if (checkInterrupted()) return;
fromEl.style.opacity = 0;
break;
case 'Linear':
fromEl = document.createElement('div'); // Flattened bar
previewElementStyle(fromEl); // 中央に配置
fromEl.style.left = '25%'; // 左側に配置
previewArea.appendChild(fromEl);
await animateFlatten(null, fromEl, 100, { duration: 500 }, vizArea);
if (checkInterrupted()) return;
toEl = document.createElement('div'); // Nodes
previewElementStyle(toEl); // 中央に配置
toEl.style.left = '75%'; // 右側に配置
previewArea.appendChild(toEl);
await animateLinear(fromEl, toEl, Array(layer.params.out_features).fill(0), null, null, { duration: 1000 }, vizArea);
if (checkInterrupted()) return;
fromEl.style.opacity = 0;
break;
case 'ResidualBlock':
fromEl = document.createElement('div');
previewElementStyle(fromEl);
fromEl.style.left = '25%'; // 左側に配置
previewArea.appendChild(fromEl);
await animateLinear(null, fromEl, Array(32).fill(0), null, null, { duration: 500, nodeSize: 8 }, vizArea);
if (checkInterrupted()) return;
toEl = document.createElement('div');
previewElementStyle(toEl);
toEl.style.left = '75%'; // 右側に配置
previewArea.appendChild(toEl);
// プレビューなので skipFromEl と fromEl は同じものを渡す
await animateResidual(fromEl, fromEl, toEl, Array(32).fill(0), { duration: 1200, nodeSize: 8 }, vizArea);
if (checkInterrupted()) return;
fromEl.style.opacity = 0;
break;
}
}
// ★★★ インベントリからのタップでレイヤーを追加する関数 ★★★
function addLayerFromInventory(layer) {
// 新しい一意なインスタンスを作成して追加
const layerInstance = {
...layer,
instanceId: `inst_${Date.now()}_${Math.random()}`
};
if (useInventoryItem(layerInstance)) {
playerLayers.push(layerInstance);
logMessage(`レイヤー追加: ${layerInstance.name}`, 'action');
updatePlayerModelUI();
updatePlayerInventoryUI();
} else {
logMessage(`インベントリに ${layer.name} がありません`, 'error');
}
}
// ★★★ モデル内のレイヤーを移動のために「選択」する関数 ★★★
function handleLayerSelectForMove(index) {
if (selectedLayerForMove === index) {
// 同じレイヤーを再度タップしたら選択解除
selectedLayerForMove = null;
logMessage('レイヤーの移動をキャンセルしました。', 'info');
} else {
selectedLayerForMove = index;
logMessage(`レイヤー '${playerLayers[index].name}' を選択しました。移動先の青いエリアをタップしてください。`, 'action');
}
updatePlayerModelUI(); // UIを再描画して選択状態を反映
}
// ★★★ 選択したレイヤーをドロップゾーンに「移動」する関数 ★★★
function handleLayerMove(targetIndex) {
if (selectedLayerForMove === null) return; // 何も選択されていなければ何もしない
// 移動するレイヤーを取得
const [movedLayer] = playerLayers.splice(selectedLayerForMove, 1);
// 削除によってインデックスがずれるのを補正
const adjustedTargetIndex = selectedLayerForMove < targetIndex ? targetIndex - 1 : targetIndex;
// 新しい場所にレイヤーを挿入
playerLayers.splice(adjustedTargetIndex, 0, movedLayer);
logMessage('レイヤーを移動しました。', 'success');
selectedLayerForMove = null; // 移動が終わったら選択状態を解除
updatePlayerModelUI();
}
// --- Main Game Logic ---
function addInventoryItem(layer) {
playerInventory[layer.id] = (playerInventory[layer.id] || 0) + 1;
}
function useInventoryItem(layer) {
if (playerInventory[layer.id] && playerInventory[layer.id] > 0) {
playerInventory[layer.id]--;
return true;
}
return false;
}
function returnInventoryItem(layer) {
playerInventory[layer.id]++;
}
// ★★★ モデル構築ロジックを所持数システムに対応
function addLayer(layer) {
if (useInventoryItem(layer)) {
playerLayers.push(layer);
logMessage(`Layer Added: <span class="text-indigo-300">${layer.name}</span>`, 'action');
updatePlayerModelUI();
updatePlayerInventoryUI();
} else {
logMessage(`You don't have any more <span class="text-indigo-300">${layer.name}</span>`, 'error');
}
}
function validateArchitecture(layers) {
if (layers.length === 0) {
return { isValid: false, message: 'モデルが空です。' };
}
let isFlattened = false; // テンソルが平坦化されたかどうかを追跡
for (let i = 0; i < layers.length; i++) {
const currentLayerType = layers[i].type;
if (isFlattened) {
// 平坦化された後に入れることができない層
if (['Conv2d', 'MaxPool2d', 'AvgPool2d', 'Flatten'].includes(currentLayerType)) {
return {
isValid: false,
message: `無効な順序: ${layers[i - 1].name} の後には ${currentLayerType} を配置できません。一度平坦化すると元に戻せません。`
};
}
} else {
// 平坦化される前にしか入れられない層
if (['Conv2d', 'MaxPool2d', 'AvgPool2d'].includes(currentLayerType)) {
// OK
} else if (currentLayerType === 'Flatten') {
isFlattened = true;
} else if (['Linear', 'Dropout', 'ResidualBlock'].includes(currentLayerType)) { // ★★★ ResidualBlockを追加
isFlattened = true;
}
}
}
return { isValid: true, message: '有効なアーキテクチャです。' };
}
// --- Main Battle Logic ---
async function handleBattle() {
const validationResult = validateArchitecture(playerLayers);
if (!validationResult.isValid) {
logMessage(`エラー: ${validationResult.message}`, 'error');
await animateBattleLog(`モデル構成エラー!`);
await sleep(2000);
await animateBattleLog('', true);
return;
}
if (isBattleInProgress) return;
isBattleInProgress = true;
battleBtn.disabled = true;
// // --- フェーズ1: 訓練 ---
// battleBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> モデルを訓練中...';
// await animateBattleLog('戦闘準備... モデルを訓練中...');
// logMessage('モデルの訓練を開始しました...', 'info');
// // EelからFetch APIに変更
// const trainResponse = await fetch('/api/train_player_model', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(playerLayers),
// });
// const trainResult = await trainResponse.json();
// if (!trainResult.success) {
// await animateBattleLog(`エラー: ${trainResult.message}`);
// logMessage(`訓練エラー: ${trainResult.message}`, 'error');
// isBattleInProgress = false;
// updatePlayerModelUI();
// battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
// return;
// }
// logMessage(trainResult.message, 'success');
// await sleep(500);
// --- フェーズ2: 戦闘ループ ---
while (playerHP > 0 && enemyHP > 0) {
battleBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 攻撃中...';
await animateBattleLog('新たな敵をスキャン... 推論実行...');
const inferenceResponse = await fetch('/api/run_inference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layer_configs: playerLayers,
enemy_image_b64: currentEnemy.image_b64,
enemy_label: currentEnemy.label,
}),
});
const result = await inferenceResponse.json();
if (result.error) {
// ★★★ エラーメッセージを正しく表示するように修正 ★★★
// 「訓練エラー: undefined」の問題はここで発生していた
await animateBattleLog(`エラー: ${result.error}`);
logMessage(`推論エラー: ${result.error}`, 'error');
break; // エラーが発生したらループを抜ける
}
enemyImage.src = result.image_b64;
logMessage('推論完了!攻撃を可視化します...', 'success');
animationModal.classList.remove('hidden');
await runDynamicAnimation(result);
const damage = Math.max(1, Math.round(result.confidence * 100));
if (result.is_correct) {
enemyHP -= damage;
await animateBattleLog(`攻撃成功! ${damage} のダメージ!`);
logMessage(`正解! ${damage} のダメージを与えた。(自信度: ${(result.confidence * 100).toFixed(1)}%) 敵HP: ${Math.max(0, enemyHP)}`, 'success');
} else {
playerHP -= damage;
await animateBattleLog(`攻撃失敗! ${damage} のダメージ!`);
logMessage(`不正解! ${damage} のダメージを受けた。(自信度: ${(result.confidence * 100).toFixed(1)}%) プレイヤーHP: ${Math.max(0, playerHP)}`, 'error');
}
updateHpBars();
if (enemyHP > 0 && playerHP > 0) {
await sleep(1500);
await fetchNewEnemy(); // 次の敵を取得
}
}
// --- フェーズ3: バトル終了後処理 ---
if (enemyHP <= 0) {
// ★★★ ステージ5をクリアしたか判定
if (currentStage >= 5) {
logMessage(`CONGRATULATIONS! 全てのステージをクリアしました!`, 'success');
await animateBattleLog('GAME CLEAR!');
enemyMessage.textContent = '全てのMNISTモンスターを倒した!';
battleBtn.classList.add('hidden');
restartBtn.classList.remove('hidden');
} else {
// 通常のステージクリア処理
logMessage(`勝利! 敵を倒した。`, 'success');
await animateBattleLog('勝利!');
await sleep(2000);
currentStage++;
startStage();
}
} else if (playerHP <= 0) {
handleGameOver();
} else {
await animateBattleLog('バトル中断');
}
isBattleInProgress = false;
updatePlayerModelUI();
battleBtn.innerHTML = '<i class="fas fa-fist-raised"></i> バトル開始!';
}
function returnToTitle() {
gameContainer.style.opacity = 0;
setTimeout(() => {
gameContainer.classList.add('hidden');
titleScreen.classList.remove('hidden');
titleScreen.style.opacity = 1;
}, 500);
}
// --- Event Listeners & Initial Load ---
// ★★★ スタートボタンのイベントリスナー
startGameBtn.addEventListener('click', () => {
titleScreen.style.opacity = 0;
setTimeout(() => {
titleScreen.classList.add('hidden');
gameContainer.classList.remove('hidden');
gameContainer.style.display = 'flex'; // flexを再適用
gameContainer.style.opacity = 1;
initializeGame();
}, 1000); // フェードアウトを待つ
});
// ★★★ リスタートボタンのイベントリスナーをタイトルへ戻るように変更
restartBtn.addEventListener('click', returnToTitle);
// --- DYNAMIC ANIMATION ENGINE (変更なし) ---
// ... (以下、アニメーション関連の長いコードは変更がないため省略します)
const valueToColor = (value, maxAbs) => {
if (maxAbs === 0) return 'rgb(128, 128, 128)';
const intensity = Math.min(Math.abs(value) / maxAbs, 1);
if (value > 0) {
const r = 128 + 127 * intensity; const g = 128 - 128 * intensity; const b = 128 + 127 * intensity;
return `rgb(${r}, ${g}, ${b})`;
} else {
const r = 128 - 128 * intensity; const g = 128 - 128 * intensity; const b = 128 + 127 * intensity;
return `rgb(${r}, ${g}, ${b})`;
}
};
async function runDynamicAnimation(data) {
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>';
document.getElementById('prediction-result').textContent = '';
document.getElementById('prediction-result').classList.remove('opacity-100');
const { architecture, outputs, weights } = data;
const vizWidth = vizArea.clientWidth;
const vizHeight = vizArea.clientHeight;
const stageCount = architecture.length + 1; // +1 for output probabilities
let currentElement = null;
// ★★★ 視覚的な要素をスタックで管理
const vizElementStack = [];
for (let i = 0; i < architecture.length; i++) {
const layerInfo = architecture[i];
const xPercent = (i + 1) / stageCount;
if (layerInfo.type === 'ReLU' || layerInfo.type === 'Dropout') {
await showStageLabel(layerInfo.type, xPercent, null);
// スタックのトップにある要素に対してアニメーションを適用
const targetElement = vizElementStack[vizElementStack.length - 1];
if (layerInfo.type === 'ReLU') {
await animateReLU(targetElement);
} else {
await animateDropout(targetElement);
}
continue;
// ★★★ 修正: ReLU/DropoutではcurrentElementのopacityを変えないように変更
}
const nextElement = document.createElement('div');
nextElement.className = 'layer-viz absolute';
nextElement.style.left = `${xPercent * 100}%`;
nextElement.style.top = '50%';
nextElement.style.transform = 'translate(-50%, -50%)';
vizArea.appendChild(nextElement);
if (currentElement) {
currentElement.style.transition = 'opacity 0.3s';
currentElement.style.opacity = '0.3';
}
await showStageLabel(layerInfo.type, xPercent, layerInfo.shape);
switch (layerInfo.type) {
case 'Input':
currentData = outputs.input[0];
await animateGrid(nextElement, currentData, { isInput: true }, vizArea);
break;
case 'Conv2d':
currentData = outputs[layerInfo.name][0];
await animateConv(currentElement, nextElement, currentData, {}, vizArea);
break;
case 'MaxPool2d':
// ★★★ AvgPool2d を追加
case 'AvgPool2d':
currentData = outputs[layerInfo.name][0];
const isAverage = layerInfo.type === 'AvgPool2d';
await animatePool(currentElement, nextElement, currentData, { isAverage }, vizArea);
break;
case 'Flatten':
await animateFlatten(currentElement, nextElement, layerInfo.shape[0], {}, vizArea);
break;
case 'Linear':
// ★★★ 修正: スタックから最新の視覚要素を取得
const sourceElement = vizElementStack[vizElementStack.length - 1];
currentData = outputs[layerInfo.name][0];
const linearWeights = weights[layerInfo.name + '_w'];
const linearBiases = weights[layerInfo.name + '_b'];
await animateLinear(sourceElement, nextElement, currentData, linearWeights, linearBiases, {}, vizArea);
break;
// ★★★ ResidualBlock を追加
case 'ResidualBlock':
const skipFromElement = vizElementStack.length > 1 ? vizElementStack[vizElementStack.length - 2] : currentElement;
currentData = outputs[layerInfo.name][0];
await animateResidual(skipFromElement, currentElement, nextElement, currentData, {}, vizArea);
break;
default: // Input, Conv2d, MaxPool2d
currentData = outputs[layerInfo.name] ? outputs[layerInfo.name][0] : outputs.input[0];
await animateGrid(nextElement, currentData, { isInput: layerInfo.type === 'Input' }, vizArea);
}
currentElement = nextElement;
// ★★★ 視覚的に意味のある要素だけをスタックに積む
if (layerInfo.type !== 'Flatten') {
vizElementStack.push(currentElement);
}
}
// --- Final Output/Softmax Animation ---
const finalLayerInfo = architecture[architecture.length - 1];
const finalLayerName = finalLayerInfo.name;
// ★★★ デバッグ用ログと安全なアクセス
console.log(`Accessing final output with key: ${finalLayerName}`);
const logits = outputs[finalLayerName] ? outputs[finalLayerName][0] : [];
const finalSourceElement = vizElementStack[vizElementStack.length - 1];
await animateSoftmax(finalSourceElement, data.prediction, data.label, logits, {}, vizArea);
await sleep(1500);
animationModal.classList.add('hidden');
}
// --- Layer-specific Animation Functions ---
const showStageLabel = async (text, xPercent, shape) => {
const labelsContainer = document.getElementById('labels-container');
labelsContainer.querySelectorAll('.stage-label').forEach(l => {
l.style.opacity = '0'; l.style.transform = 'translateY(20px)';
});
await sleep(200);
labelsContainer.innerHTML = '';
const label = document.createElement('div');
label.className = 'stage-label';
let labelText = text;
if (shape) {
labelText += `<br><span class="text-sm text-gray-400">(${shape.join(' &times; ')})</span>`;
}
label.innerHTML = labelText;
label.style.left = `${xPercent * 100}%`;
labelsContainer.appendChild(label);
await sleep(50);
label.style.opacity = '1';
label.style.transform = 'translateX(-50%) translateY(-100%)';
};
// Simplified grid creation for any 2D/3D data
async function animateGrid(container, data3d, options = {}, vizArea = window.vizArea) {
container.style.transform = 'translate(-50%, -50%) perspective(1000px) rotateY(-90deg)';
container.style.opacity = '0';
const duration = options.duration || 500;
container.style.transition = `transform ${duration / 1000}s ease-out, opacity ${duration / 1000}s`;
const vizHeight = vizArea.clientHeight;
const displayChannels = Math.min(data3d.length, 6);
const displaySize = Math.min(data3d[0].length, 14);
const availableHeight = vizHeight * (options.isInput ? 0.4 : 0.6);
const margin = 10;
const singleGridMaxHeight = (availableHeight - (displayChannels - 1) * margin) / displayChannels;
const gridSize = Math.max(20, singleGridMaxHeight); // ★★★ 正しい変数名はこちら
const cellSize = gridSize / displaySize;
const totalHeight = displayChannels * gridSize + (displayChannels - 1) * margin;
// ★★★ エラー修正箇所: `totalGridSize` を `gridSize` に修正
container.style.width = `${gridSize}px`;
container.style.height = `${totalHeight}px`;
container.style.transform = `translate(-50%, -${totalHeight / 2}px) perspective(1000px) rotateY(-90deg)`;
const maxAbs = Math.max(...data3d.flat().flat().map(Math.abs));
for (let i = 0; i < displayChannels; i++) {
const featureMap = document.createElement('div');
featureMap.className = 'absolute';
featureMap.style.width = `${gridSize}px`;
featureMap.style.height = `${gridSize}px`;
featureMap.style.top = `${i * (gridSize + margin)}px`;
for (let r = 0; r < displaySize; r++) {
for (let c = 0; c < displaySize; c++) {
// Sample from original data if larger
const origR = Math.floor(r * data3d[i].length / displaySize);
const origC = Math.floor(c * data3d[i][0].length / displaySize);
const val = data3d[i][origR][origC];
const cell = document.createElement('div');
cell.className = 'grid-cell absolute';
cell.style.width = `${cellSize}px`; cell.style.height = `${cellSize}px`;
cell.style.left = `${c * cellSize}px`; cell.style.top = `${r * cellSize}px`;
cell.style.backgroundColor = valueToColor(val, maxAbs);
cell.dataset.value = val;
featureMap.appendChild(cell);
}
}
container.appendChild(featureMap);
}
await sleep(50);
container.style.transform = `translate(-50%, -${totalHeight / 2}px) perspective(1000px) rotateY(0deg)`;
container.style.opacity = '1';
await sleep(duration);
}
async function animateConv(fromEl, toEl, toData, options = {}, vizArea = window.vizArea) {
const duration = options.duration || 500;
// Kernel animation on 'from' element
if (options.isPreview) {
// fromElは背景として表示され続けるので、カーネルアニメーションは不要
} else {
const firstMap = fromEl.querySelector(':scope > div');
if (firstMap) {
const kernelHighlight = document.createElement('div');
kernelHighlight.style.position = 'absolute';
kernelHighlight.style.border = '2px solid #a5b4fc';
kernelHighlight.style.transition = 'all 0.1s linear';
const mapSize = firstMap.clientWidth;
const kernelDisplaySize = mapSize / 7; // e.g., 14px -> 2px kernel
kernelHighlight.style.width = `${kernelDisplaySize}px`;
kernelHighlight.style.height = `${kernelDisplaySize}px`;
firstMap.appendChild(kernelHighlight);
for (let i = 0; i <= 6; i++) {
kernelHighlight.style.top = `${i * kernelDisplaySize}px`;
kernelHighlight.style.left = `${(i % 3) * mapSize / 3}px`;
await sleep(duration * 0.05); // ★ 全体時間に対する割合でsleep
}
kernelHighlight.remove();
}
}
await sleep(options.duration * 0.4 || 200);
await animateGrid(toEl, toData, { duration: duration * 0.6 }, vizArea);
}
async function animatePool(fromEl, toEl, toData, options = {}, vizArea = window.vizArea) {
const duration = options.duration || 500;
if (!options.isPreview) {
const maps = fromEl.querySelectorAll(':scope > div');
maps.forEach((map) => {
map.style.transition = `all ${duration * 0.4 / 1000}s ease-in-out`;
// isAverageの場合、ぼかしエフェクトを追加
if (options.isAverage) {
map.style.filter = 'blur(2px)';
}
map.style.transform = `scale(0.5)`;
map.style.opacity = '0';
});
await sleep(duration * 0.4);
}
await animateGrid(toEl, toData, { duration: duration * 0.8 }, vizArea);
}
async function animateDropout(element, options = {}) {
if (!element) return;
const cells = element.querySelectorAll('.grid-cell');
const originalColors = new Map();
const cellsToDrop = Array.from(cells).sort(() => 0.5 - Math.random()).slice(0, cells.length / 2);
cellsToDrop.forEach(cell => {
originalColors.set(cell, cell.style.backgroundColor); // 元の色を保存
cell.style.transition = 'all 0.2s ease-in-out';
cell.style.backgroundColor = 'rgb(40, 40, 40)'; // 暗い色(非アクティブ)に
cell.style.transform = 'scale(0.8)';
});
await sleep(options.duration || 400);
cellsToDrop.forEach(cell => {
// 元の色に戻す
cell.style.backgroundColor = originalColors.get(cell);
cell.style.transform = 'scale(1)';
});
await sleep(options.duration ? options.duration * 0.5 : 200);
}
async function animateFlatten(fromEl, toEl, toShape, options = {}, vizArea = window.vizArea) {
// ★★★ エラー修正箇所 ★★★
// fromElがnullの場合(Linearプレビューなど)は、ソース要素のアニメーションをスキップ
if (fromEl) {
fromEl.style.transition = 'transform 0.4s ease-in-out, opacity 0.4s';
fromEl.style.transform += ' scale(0)';
fromEl.style.opacity = '0';
}
// Display a simplified, representative bar
const vizHeight = vizArea.clientHeight;
// ... (以降のロジックは変更なし)
const displayNodes = Math.min(toShape, 128);
const nodeHeight = Math.min(2.5, (vizHeight * 0.8) / displayNodes);
const totalHeight = displayNodes * nodeHeight;
toEl.style.height = `${totalHeight}px`;
toEl.style.width = '20px';
toEl.style.position = 'absolute'; // ★ 念のため追加
toEl.style.left = '50%'; // ★ 念のため追加
toEl.style.top = '50%'; // ★ 念のため追加
toEl.style.transform = `translate(-50%, -${totalHeight / 2}px)`; // ★ 念のため追加
const maxAbs = 1; // Dummy value for color
for (let i = 0; i < displayNodes; i++) {
const cell = document.createElement('div');
cell.className = 'grid-cell absolute';
cell.style.width = '100%';
cell.style.height = `${nodeHeight}px`;
cell.style.top = `${i * nodeHeight}px`;
cell.style.backgroundColor = valueToColor(Math.random() * 2 - 1, maxAbs);
cell.style.transform = 'scale(0)';
cell.style.transition = 'transform 0.3s';
toEl.appendChild(cell);
// プレビュー時のアニメーションが速すぎないように調整
const delay = (options.duration || 300) / displayNodes;
await sleep(delay > 5 ? 5 : delay); // 5msより長くは待たない
cell.style.transform = 'scale(1)';
}
await sleep(options.duration * 0.5 || 150);
}
async function animateLinear(fromEl, toEl, toData, weights, biases, options = {}, vizArea = window.vizArea) {
const vizRect = vizArea.getBoundingClientRect();
const displayNodes = Math.min(toData.length, 64);
const vizHeight = vizArea.clientHeight;
// ★★★ nodeSizeをオプションで受け取れるように
const nodeSize = options.nodeSize || Math.min(15, (vizHeight * 0.7) / displayNodes);
const spacing = (vizHeight * 0.7) / displayNodes;
const totalHeight = displayNodes * spacing;
toEl.style.height = `${totalHeight}px`;
// ★★★ transformのY座標計算を修正
toEl.style.transform = `translate(-50%, -${totalHeight / 2}px)`;
const toCells = [];
const maxAbs = Math.max(...toData.map(Math.abs));
for (let i = 0; i < displayNodes; i++) {
const cell = document.createElement('div');
cell.className = 'grid-cell';
cell.style.width = `${nodeSize}px`; cell.style.height = `${nodeSize}px`;
cell.style.borderRadius = '50%';
cell.style.position = 'absolute';
cell.style.top = `${i * spacing}px`;
cell.style.backgroundColor = 'rgb(128,128,128)';
toEl.appendChild(cell);
toCells.push(cell);
}
// fromElがnullの場合(プレビューの初回など)はパーティクルを飛ばさない
if (!fromEl) {
await sleep(options.duration || 400);
} else {
const maxParticles = 50;
for (let i = 0; i < maxParticles; i++) {
// ★★★ 座標計算の基準をvizAreaの左上隅(0,0)に統一
const fromRect = fromEl.getBoundingClientRect();
const toRect = toEl.getBoundingClientRect();
const particle = document.createElement('div');
particle.className = 'particle';
vizArea.appendChild(particle);
const startX = fromRect.right - vizRect.left;
const startY = (fromRect.top - vizRect.top) + Math.random() * fromRect.height;
const endX = toRect.left - vizRect.left;
const endY = (toRect.top - vizRect.top) + Math.random() * toRect.height;
particle.animate([
{ transform: `translate(${startX}px, ${startY}px) scale(0.5)`, opacity: 1 },
{ transform: `translate(${endX}px, ${endY}px) scale(1)`, opacity: 0 }
], {
duration: 300 + Math.random() * 200,
easing: 'ease-in-out',
delay: Math.random() * 200,
}).onfinish = () => particle.remove();
}
await sleep(options.duration || 400);
}
toCells.forEach((cell, i) => {
const dataIdx = Math.floor(i * toData.length / displayNodes);
cell.style.transition = 'background-color 0.5s';
cell.style.backgroundColor = valueToColor(toData[dataIdx], maxAbs);
});
await sleep(options.duration ? options.duration * 0.5 : 500);
}
async function animateReLU(element, options = {}) {
if (!element) return;
const cells = element.querySelectorAll('.grid-cell');
const animations = [];
for (const cell of cells) {
const value = parseFloat(cell.dataset.value || 0);
if (value < 0) {
const animationPromise = new Promise(async (resolve) => {
await sleep(Math.random() * (options.duration || 400) * 0.5); // ランダムな遅延
cell.style.transition = 'background-color 0.3s';
cell.style.backgroundColor = 'rgb(128, 128, 128)';
// ★★★ データを更新して、ReLUが適用されたことを記録
cell.dataset.value = 0;
resolve();
});
animations.push(animationPromise);
}
}
await Promise.all(animations); // 全てのアニメーションが終わるのを待つ
await sleep((options.duration || 400) * 0.5);
}
async function animateResidual(skipFromEl, fromEl, toEl, toData, options = {}, vizArea = window.vizArea) {
const duration = options.duration || 1000;
// メインパスのアニメーション (Linearと同様)
animateLinear(fromEl, toEl, toData, null, null, options, vizArea);
// スキップコネクションのアニメーション
await sleep(duration * 0.1); // 少し遅れて開始
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
svg.style.zIndex = '10';
vizArea.appendChild(svg);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'url(#skip-gradient)');
path.setAttribute('stroke-width', '3');
const vizRect = vizArea.getBoundingClientRect();
const startRect = skipFromEl.getBoundingClientRect();
const endRect = toEl.getBoundingClientRect();
const startX = startRect.right - vizRect.left;
const startY = startRect.top + startRect.height / 2 - vizRect.top;
const endX = endRect.left - vizRect.left;
const endY = endRect.top + endRect.height / 2 - vizRect.top;
const ctrlYOffset = -80; // 上に膨らむカーブ
const d = `M ${startX},${startY} C ${startX + 50},${startY + ctrlYOffset} ${endX - 50},${endY + ctrlYOffset} ${endX},${endY}`;
path.setAttribute('d', d);
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
gradient.id = 'skip-gradient';
gradient.innerHTML = `
<stop offset="0%" stop-color="#a5b4fc" stop-opacity="0" />
<stop offset="50%" stop-color="#a5b4fc" stop-opacity="1" />
<stop offset="100%" stop-color="#a5b4fc" stop-opacity="0" />
`;
defs.appendChild(gradient);
svg.appendChild(defs);
svg.appendChild(path);
const length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;
path.animate([
{ strokeDashoffset: length },
{ strokeDashoffset: 0 }
], {
duration: duration * 0.8,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
}).onfinish = () => {
setTimeout(() => svg.remove(), 200);
};
await sleep(duration);
}
const softmax = (logits) => {
const maxLogit = Math.max(...logits);
const exps = logits.map(logit => Math.exp(logit - maxLogit));
const sumExps = exps.reduce((a, b) => a + b, 0);
return exps.map(exp => exp / sumExps);
};
async function animateSoftmax(fromEl, prediction, label, logits, options = {}, vizArea = window.vizArea) {
const probabilities = softmax(logits);
const toEl = fromEl; // Reuse the last linear layer element
toEl.innerHTML = '';
const vizHeight = vizArea.clientHeight;
const numOutputNodes = 10;
const nodeHeight = Math.min(40, (vizHeight * 0.8) / numOutputNodes * 0.8);
const spacing = (vizHeight * 0.8) / numOutputNodes;
const totalHeight = numOutputNodes * spacing;
toEl.style.transform = `translate(-50%, -${totalHeight / 2}px)`;
for (let i = 0; i < numOutputNodes; i++) {
const prob = probabilities[i];
const wrapper = document.createElement('div');
wrapper.className = 'flex items-center relative transition-all duration-300';
wrapper.style.height = `${spacing}px`;
wrapper.style.width = `200px`;
const labelDiv = document.createElement('div');
labelDiv.className = 'mr-4 font-bold';
labelDiv.textContent = i;
labelDiv.style.fontSize = `${nodeHeight * 0.6}px`;
const barContainer = document.createElement('div');
barContainer.className = 'flex-grow h-full bg-white/10 rounded overflow-hidden border border-indigo-400/50';
const bar = document.createElement('div');
bar.style.width = '0%';
bar.style.height = '100%';
bar.style.backgroundColor = '#a5b4fc';
bar.style.transition = 'width 0.8s ease-out';
const probText = document.createElement('div');
probText.className = 'absolute right-0 top-1/2 -translate-y-1/2 font-mono';
probText.textContent = `${(prob * 100).toFixed(1)}%`;
probText.style.fontSize = `${nodeHeight * 0.4}px`;
barContainer.appendChild(bar);
wrapper.appendChild(labelDiv);
wrapper.appendChild(barContainer);
wrapper.appendChild(probText);
toEl.appendChild(wrapper);
setTimeout(() => { bar.style.width = `${prob * 100}%`; }, 100);
}
await sleep(1000);
// Highlight prediction
const predWrapper = toEl.childNodes[prediction];
predWrapper.style.transform = 'scale(1.1)';
predWrapper.style.backgroundColor = 'rgba(253, 224, 71, 0.2)';
predWrapper.style.borderRadius = '8px';
const resultText = document.getElementById('prediction-result');
resultText.innerHTML = `Prediction: <span class="text-yellow-300 text-3xl">${prediction}</span> (True: ${label})`;
resultText.classList.add('opacity-100');
}
// --- Event Listeners & Initial Load ---
battleBtn.addEventListener('click', handleBattle);
restartBtn.addEventListener('click', startGame);
closeModalBtn.addEventListener('click', () => {
animationModal.classList.add('hidden');
});
// D&Dイベントリスナーのセットアップ
const modelArea = document.getElementById('player-model-layers');
modelArea.addEventListener('dragover', allowDrop);
modelArea.addEventListener('drop', dropOnModelArea);
// ★★★ 修正: dragleave イベントリスナーをより堅牢なものに変更
modelArea.addEventListener('dragleave', handleDragLeaveModelArea);
modelArea.addEventListener('dragenter', handleDragEnterModelArea);
document.addEventListener('drop', (e) => {
// モデルエリア外へのドロップ処理 (このロジックは重要なので残す)
if (draggedItem) {
const modelArea = document.getElementById('player-model-layers');
if (!modelArea.contains(e.target)) {
if (draggedItem.type === 'model') {
// モデル外へのドロップはキャンセルとみなし、UIを更新して元の位置に戻す
// ★★★ バグ修正: 元の状態に戻すには、playerLayersを再構築する必要がある
// ただし、この操作は複雑なので、単純にログだけ出すか、何もしないのが安全
logMessage('モデルの並び替えをキャンセルしました。', 'info');
// UIを再描画すればOK
updatePlayerModelUI();
} else if (draggedItem.type === 'inventory') {
// ★★★ instanceId を使って検索
const tempIndex = playerLayers.findIndex(l => l.instanceId === draggedItem.layer.instanceId);
if (tempIndex > -1) playerLayers.splice(tempIndex, 1);
updatePlayerModelUI();
}
}
}
});
document.addEventListener('DOMContentLoaded', () => {
gameContainer.style.opacity = 0;
gameContainer.style.transition = 'opacity 0.5s ease-in-out';
});
document.getElementById('confirm-choice-btn').addEventListener('click', () => {
const selectedId = document.getElementById('selected-choice-id').value;
if (selectedId) {
const selectedLayer = allAvailableLayers.find(l => l.id == selectedId);
if (selectedLayer) {
selectItem(selectedLayer);
}
}
});