// --- 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の心臓部。

使い方: 画像の「パーツ」を見つける専門家です。モデルの最初の方に置いて、画像から形の特徴を捉えさせましょう。' }, { id: 2, name: 'ReLU活性化関数', type: 'ReLU', icon: 'fa-chart-line', params: {}, rarity: 'normal', desc: '負の値を0に変換し、モデルに非線形性を与え表現力を高めます。

使い方: モデルが複雑な判断をするための「スイッチ」です。畳み込み層や全結合層の直後に挟むのが定石です。' }, { id: 3, name: '最大プーリング (2x2)', type: 'MaxPool2d', icon: 'fa-compress-arrows-alt', params: { kernel_size: 2 }, rarity: 'normal', desc: '情報を圧縮し、位置ズレに強いモデルを作ります。

使い方: 画像の「要約」を行い、重要な部分だけを残します。畳み込み層(とReLU)の後に入れると、より頑健なモデルになります。' }, { id: 4, name: '平坦化層', type: 'Flatten', icon: 'fa-stream', params: {}, rarity: 'normal', desc: '2次元の画像データを1次元に変換し、全結合層に渡せるようにします。

使い方: 画像処理パートから最終判断パートへの「橋渡し」役です。モデルの中盤に必ず1つだけ配置してください。' }, { id: 5, name: '全結合層 (16ノード)', type: 'Linear', icon: 'fa-braille', params: { out_features: 16 }, rarity: 'normal', desc: '全ての特徴を結合し、最終的な分類を行います。

使い方: これまでの情報を元に「最終判断」を下す賢者です。モデルの最後の方、平坦化層の後に置きます。' }, { id: 8, name: '平均プーリング (2x2)', type: 'AvgPool2d', icon: 'fa-wave-square', params: { kernel_size: 2 }, rarity: 'normal', desc: '範囲内の特徴を「平均化」して情報を圧縮します。

使い方: 最大プーリングと似ていますが、より滑らかに情報を要約します。畳み込み層(とReLU)の後に使い、最大プーリングと使い比べてみましょう。' }, // --- Gold Rare Items --- { id: 1, name: '畳み込み層 (5x5, 8フィルタ)', type: 'Conv2d', icon: 'fa-border-all', params: { out_channels: 8, kernel_size: 5 }, rarity: 'gold', desc: 'より広い範囲の特徴を抽出する強力な畳み込み層。

使い方: 3x3より広い範囲を見るため、より大局的な特徴を捉えます。これもモデルの最初の方に置きます。' }, { id: 6, name: '全結合層 (64ノード)', type: 'Linear', icon: 'fa-sitemap', params: { out_features: 64 }, rarity: 'gold', desc: 'より多くのパラメータを持つ、より強力な全結合層。

使い方: 16ノードより賢い賢者ですが、学習に少し時間がかかります。これも最後の方に置きます。' }, { id: 7, name: 'ドロップアウト (p=0.5)', type: 'Dropout', icon: 'fa-random', params: { p: 0.5 }, rarity: 'gold', desc: '学習中にノードをランダムに無効化し、過学習を防ぎます。

使い方: モデルが特定の情報に頼りすぎるのを防ぐ「保険」です。未知の敵に強くなります。全結合層の間に挟むのが効果的です。' }, { id: 9, name: '残差ブロック', type: 'ResidualBlock', icon: 'fa-project-diagram', params: {}, rarity: 'gold', desc: '入力情報を「近道」させ、深いモデルの学習を安定させます。

使い方: 情報を失わずに変換を加える特殊なブロックです。モデルが深くなりすぎた時に全結合層の代わりに入れると、性能が改善することがあります。' } ]; // ★★★ インベントリを所持数管理に変更 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: ${removedLayer.name}`, '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 = `${layer.name}`; 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 = `

${layer.name}

${count}
`; // イベントリスナー 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 = `

${layer.name}

${layer.desc}

`; } 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 = ' バトル開始!'; 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 = ' バトル開始!'; battleLog.textContent = ''; messageLogArea.innerHTML = ''; // メッセージログもクリア startStage(); } function handleGameOver() { logMessage('ゲームオーバー...', 'error'); animateBattleLog('敗北...'); // アニメーション付きに変更 battleBtn.classList.add('hidden'); restartBtn.classList.remove('hidden'); // ★★★ ボタンのテキストを明示的に変更 restartBtn.innerHTML = ' タイトルへ戻る'; 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 = `

${layer.name}

`; 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: ${selectedLayer.name}!`, '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: ${layer.name}`, 'action'); updatePlayerModelUI(); updatePlayerInventoryUI(); } else { logMessage(`You don't have any more ${layer.name}`, '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 = ' モデルを訓練中...'; // 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 = ' バトル開始!'; // return; // } // logMessage(trainResult.message, 'success'); // await sleep(500); // --- フェーズ2: 戦闘ループ --- while (playerHP > 0 && enemyHP > 0) { battleBtn.innerHTML = ' 攻撃中...'; 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 = ' バトル開始!'; } 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 = '
'; 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 += `
(${shape.join(' × ')})`; } 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 = ` `; 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: ${prediction} (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); } } });