// --- 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}
${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 += `