From 1c60f5029ae2aed5ce1b10456ca64f12a9573ad4 Mon Sep 17 00:00:00 2001 From: Akira Saito Date: Wed, 16 Jul 2025 06:14:10 +0900 Subject: [PATCH] update --- css/main.css | 522 +++++++++++++++++++++++++++ css/word-reorder.css | 558 +++++++++++++++++++++++++++++ index.html | 68 ++++ js/game-engine.js | 157 +++++++++ js/sql-tokenizer.js | 274 +++++++++++++++ js/ui-controller.js | 388 +++++++++++++++++--- js/word-reorder-ui.js | 779 +++++++++++++++++++++++++++++++++++++++++ slides/challenges.json | 83 ++--- 8 files changed, 2722 insertions(+), 107 deletions(-) create mode 100644 css/word-reorder.css create mode 100644 js/sql-tokenizer.js create mode 100644 js/word-reorder-ui.js diff --git a/css/main.css b/css/main.css index 1105dfb..164ad9c 100644 --- a/css/main.css +++ b/css/main.css @@ -552,4 +552,526 @@ th { width: 100%; height: 100%; border: none; +} +/* === +== ゲームアニメーション ===== */ + +/* ゲームオーバーレイ */ +.game-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(10px); +} + +.game-overlay.hidden { + display: none; +} + +.overlay-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +/* ロボットアニメーション */ +.robot-animation { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +} + +.robot-container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + animation: robotFloat 2s ease-in-out infinite; +} + +@keyframes robotFloat { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } +} + +.robot { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.robot-head { + width: 80px; + height: 80px; + background: linear-gradient(145deg, #e6e6e6, #ffffff); + border-radius: 20px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); + animation: robotThink 3s ease-in-out infinite; +} + +@keyframes robotThink { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } +} + +.robot-eye { + width: 12px; + height: 12px; + background: #667eea; + border-radius: 50%; + position: absolute; + top: 25px; + animation: robotBlink 2s ease-in-out infinite; +} + +.left-eye { + left: 20px; +} + +.right-eye { + right: 20px; +} + +@keyframes robotBlink { + 0%, 90%, 100% { transform: scaleY(1); } + 95% { transform: scaleY(0.1); } +} + +.robot-mouth { + width: 20px; + height: 10px; + background: #4a5568; + border-radius: 0 0 20px 20px; + position: absolute; + bottom: 20px; + animation: robotSpeak 1.5s ease-in-out infinite; +} + +@keyframes robotSpeak { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(1.5); } +} + +.robot-body { + width: 60px; + height: 80px; + background: linear-gradient(145deg, #f0f0f0, #ffffff); + border-radius: 15px; + position: relative; + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15); +} + +.robot-chest { + width: 30px; + height: 30px; + background: #667eea; + border-radius: 50%; + position: absolute; + top: 15px; + left: 50%; + transform: translateX(-50%); + animation: robotHeartbeat 1s ease-in-out infinite; +} + +@keyframes robotHeartbeat { + 0%, 100% { transform: translateX(-50%) scale(1); } + 50% { transform: translateX(-50%) scale(1.1); } +} + +.robot-arm { + width: 15px; + height: 50px; + background: linear-gradient(145deg, #e6e6e6, #ffffff); + border-radius: 10px; + position: absolute; + top: 10px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); +} + +.left-arm { + left: -20px; + animation: robotArmLeft 2s ease-in-out infinite; +} + +.right-arm { + right: -20px; + animation: robotArmRight 2s ease-in-out infinite; +} + +@keyframes robotArmLeft { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(-15deg); } +} + +@keyframes robotArmRight { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(15deg); } +} + +.robot-legs { + display: flex; + gap: 10px; +} + +.robot-leg { + width: 20px; + height: 40px; + background: linear-gradient(145deg, #e6e6e6, #ffffff); + border-radius: 10px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + animation: robotWalk 1s ease-in-out infinite alternate; +} + +.left-leg { + animation-delay: 0s; +} + +.right-leg { + animation-delay: 0.5s; +} + +@keyframes robotWalk { + 0% { transform: translateY(0px); } + 100% { transform: translateY(5px); } +} + +/* 思考バブル */ +.thinking-bubbles { + position: absolute; + top: -60px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 15px; +} + +.bubble { + background: white; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: #4a5568; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + animation: bubbleFloat 2s ease-in-out infinite; +} + +.bubble-1 { + animation-delay: 0s; +} + +.bubble-2 { + animation-delay: 0.5s; +} + +.bubble-3 { + animation-delay: 1s; +} + +@keyframes bubbleFloat { + 0%, 100% { + transform: translateY(0px) scale(1); + opacity: 0.7; + } + 50% { + transform: translateY(-15px) scale(1.1); + opacity: 1; + } +} + +.robot-text { + color: white; + font-size: 1.2rem; + font-weight: 600; + text-align: center; + animation: textPulse 2s ease-in-out infinite; +} + +@keyframes textPulse { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} + +/* 結果アニメーション */ +.result-animation { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + animation: resultAppear 0.8s ease-out; +} + +@keyframes resultAppear { + 0% { + opacity: 0; + transform: scale(0.5) translateY(50px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0px); + } +} + +.result-icon { + font-size: 5rem; + animation: iconBounce 1s ease-out; +} + +@keyframes iconBounce { + 0% { transform: scale(0); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +.result-text { + font-size: 2.5rem; + font-weight: bold; + color: white; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + animation: textSlideUp 0.8s ease-out 0.3s both; +} + +@keyframes textSlideUp { + 0% { + opacity: 0; + transform: translateY(30px); + } + 100% { + opacity: 1; + transform: translateY(0px); + } +} + +/* 成功エフェクト */ +.success-effects { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.cracker { + position: absolute; + bottom: 10%; + width: 100px; + height: 100px; + background: linear-gradient(45deg, #ff6b6b, #feca57); + border-radius: 50%; + animation: crackerExplode 0.8s ease-out; +} + +.left-cracker { + left: 10%; + animation-delay: 0.2s; +} + +.right-cracker { + right: 10%; + animation-delay: 0.4s; +} + +@keyframes crackerExplode { + 0% { + transform: scale(0) rotate(0deg); + opacity: 1; + } + 50% { + transform: scale(1.5) rotate(180deg); + opacity: 0.8; + } + 100% { + transform: scale(0.5) rotate(360deg); + opacity: 0; + } +} + +.rocket-container { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 200px; + height: 100%; +} + +.rocket { + position: absolute; + font-size: 3rem; + animation: rocketLaunch 2s ease-out; +} + +.rocket-1 { + left: 20%; + animation-delay: 0.3s; +} + +.rocket-2 { + left: 50%; + animation-delay: 0.6s; +} + +.rocket-3 { + left: 80%; + animation-delay: 0.9s; +} + +@keyframes rocketLaunch { + 0% { + bottom: 0%; + opacity: 1; + transform: rotate(0deg) scale(1); + } + 50% { + opacity: 1; + transform: rotate(180deg) scale(1.2); + } + 100% { + bottom: 100%; + opacity: 0; + transform: rotate(360deg) scale(0.5); + } +} + +/* 失敗エフェクト */ +.failure-effects { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.shake-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + animation: shakeEffect 0.6s ease-in-out; +} + +@keyframes shakeEffect { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); } + 20%, 40%, 60%, 80% { transform: translateX(10px); } +} + +.error-icon { + font-size: 5rem; + animation: errorPulse 1s ease-in-out infinite; +} + +@keyframes errorPulse { + 0%, 100% { + transform: scale(1); + filter: brightness(1); + } + 50% { + transform: scale(1.1); + filter: brightness(1.2); + } +} + +.error-waves { + position: relative; + width: 200px; + height: 200px; +} + +.wave { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 3px solid #ff6b6b; + border-radius: 50%; + animation: waveExpand 1.5s ease-out infinite; +} + +.wave-1 { + animation-delay: 0s; +} + +.wave-2 { + animation-delay: 0.3s; +} + +.wave-3 { + animation-delay: 0.6s; +} + +@keyframes waveExpand { + 0% { + width: 0; + height: 0; + opacity: 1; + } + 100% { + width: 200px; + height: 200px; + opacity: 0; + } +} + +/* 紙吹雪エフェクト(既存の改良版) */ +.confetti { + position: absolute; + width: 10px; + height: 10px; + background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #feca57); + animation: confettiFall 3s linear infinite; +} + +@keyframes confettiFall { + 0% { + top: -10px; + transform: rotateZ(0deg); + } + 100% { + top: 100vh; + transform: rotateZ(720deg); + } +} + +/* レスポンシブ対応 */ +@media (max-width: 768px) { + .robot-container { + transform: scale(0.8); + } + + .result-icon { + font-size: 3rem; + } + + .result-text { + font-size: 1.8rem; + } + + .robot-text { + font-size: 1rem; + } + + .thinking-bubbles { + transform: translateX(-50%) scale(0.8); + } } \ No newline at end of file diff --git a/css/word-reorder.css b/css/word-reorder.css new file mode 100644 index 0000000..48c1831 --- /dev/null +++ b/css/word-reorder.css @@ -0,0 +1,558 @@ +/* Word Reorder UI Styles */ + +.word-reorder-container { + max-width: 100%; + margin: 0 auto; + padding: 1rem; + font-family: 'Segoe UI', 'Hiragino Sans', 'Yu Gothic UI', sans-serif; +} + +.instruction { + text-align: center; + margin-bottom: 1.5rem; + padding: 1rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.instruction p { + margin: 0; + font-size: 1.1rem; + font-weight: 500; +} + +/* 回答エリア */ +.answer-area { + margin-bottom: 2rem; +} + +.answer-area h4 { + margin: 0 0 0.8rem 0; + color: #2d3748; + font-size: 1.2rem; + display: flex; + align-items: center; +} + +.answer-area h4::before { + content: '✓'; + margin-right: 0.5rem; + color: #48bb78; + font-weight: bold; +} + +.selected-words { + min-height: 80px; + padding: 1rem; + border: 2px dashed #cbd5e0; + border-radius: 12px; + background-color: #f7fafc; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: flex-start; + transition: all 0.3s ease; +} + +.selected-words:not(:empty) { + border-color: #48bb78; + background-color: #f0fff4; +} + +/* 選択可能エリア */ +.available-area { + margin-bottom: 2rem; +} + +.available-area h4 { + margin: 0 0 0.8rem 0; + color: #2d3748; + font-size: 1.2rem; + display: flex; + align-items: center; +} + +.available-area h4::before { + content: '📝'; + margin-right: 0.5rem; +} + +.available-words { + min-height: 120px; + padding: 1rem; + border: 2px solid #e2e8f0; + border-radius: 12px; + background-color: #ffffff; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: flex-start; +} + +/* 単語トークン */ +.word-token { + display: inline-block; + padding: 0.6rem 1rem; + border-radius: 8px; + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + border: 2px solid transparent; + position: relative; + + /* タップターゲットサイズを確保(最小44px) */ + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +/* トークンタイプ別のスタイル */ +.word-token.keyword { + background-color: #e3f2fd; + color: #1976d2; + border-color: #bbdefb; +} + +.word-token.identifier { + background-color: #f3e5f5; + color: #7b1fa2; + border-color: #e1bee7; +} + +.word-token.operator { + background-color: #fff3e0; + color: #f57c00; + border-color: #ffcc02; +} + +.word-token.number { + background-color: #e8f5e8; + color: #388e3c; + border-color: #c8e6c9; +} + +.word-token.string { + background-color: #fce4ec; + color: #c2185b; + border-color: #f8bbd9; +} + +.word-token.punctuation { + background-color: #f5f5f5; + color: #616161; + border-color: #e0e0e0; +} + +/* ホバー効果 */ +.word-token:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-color: currentColor; +} + +/* タップ効果 */ +.word-token.tapped { + transform: scale(0.95); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +/* 選択された単語のスタイル */ +.word-token.selected { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.word-token.selected:hover { + transform: translateY(-1px); + opacity: 0.8; +} + +/* 新しく追加された単語のアニメーション */ +.word-token.word-added { + animation: wordAdded 0.3s ease-out; +} + +@keyframes wordAdded { + 0% { + transform: scale(0.8); + opacity: 0; + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* 空のメッセージ */ +.empty-message { + color: #a0aec0; + font-style: italic; + text-align: center; + width: 100%; + padding: 1rem; + font-size: 1rem; +} + +/* コントロールボタン */ +.controls { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 2rem; +} + +.control-button { + padding: 0.8rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + min-height: 44px; + min-width: 120px; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.reset-button { + background-color: #f56565; + color: white; + box-shadow: 0 2px 8px rgba(245, 101, 101, 0.3); +} + +.reset-button:hover { + background-color: #e53e3e; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4); +} + +.check-button { + background-color: #48bb78; + color: white; + box-shadow: 0 2px 8px rgba(72, 187, 120, 0.3); +} + +.check-button:hover { + background-color: #38a169; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(72, 187, 120, 0.4); +} + +.control-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* リセットアニメーション */ +.word-reorder-container.reset-animation { + animation: resetPulse 0.3s ease-out; +} + +@keyframes resetPulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.02); + } + 100% { + transform: scale(1); + } +} + +/* レスポンシブデザイン */ + +/* タブレット */ +@media (max-width: 768px) { + .word-reorder-container { + padding: 0.8rem; + } + + .instruction { + padding: 0.8rem; + margin-bottom: 1.2rem; + } + + .instruction p { + font-size: 1rem; + } + + .word-token { + padding: 0.5rem 0.8rem; + font-size: 0.9rem; + min-height: 40px; + min-width: 40px; + } + + .controls { + flex-direction: column; + align-items: center; + } + + .control-button { + width: 100%; + max-width: 300px; + } +} + +/* スマートフォン */ +@media (max-width: 480px) { + .word-reorder-container { + padding: 0.5rem; + } + + .instruction { + padding: 0.6rem; + margin-bottom: 1rem; + } + + .instruction p { + font-size: 0.95rem; + } + + .answer-area h4, + .available-area h4 { + font-size: 1.1rem; + } + + .selected-words, + .available-words { + padding: 0.8rem; + gap: 0.4rem; + } + + .word-token { + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + min-height: 44px; /* タップターゲットサイズを維持 */ + min-width: 44px; + } + + .control-button { + padding: 0.7rem 1.2rem; + font-size: 0.95rem; + min-height: 44px; + } +} + +/* 非常に小さい画面 */ +@media (max-width: 320px) { + .word-token { + padding: 0.3rem 0.5rem; + font-size: 0.8rem; + min-height: 44px; + min-width: 44px; + } + + .selected-words, + .available-words { + padding: 0.6rem; + gap: 0.3rem; + } +} + +/* ダークモード対応 */ +@media (prefers-color-scheme: dark) { + .word-reorder-container { + color: #e2e8f0; + } + + .answer-area h4, + .available-area h4 { + color: #e2e8f0; + } + + .selected-words { + background-color: #2d3748; + border-color: #4a5568; + } + + .selected-words:not(:empty) { + border-color: #48bb78; + background-color: #1a202c; + } + + .available-words { + background-color: #2d3748; + border-color: #4a5568; + } + + .empty-message { + color: #718096; + } +} + +/* アクセシビリティ */ +@media (prefers-reduced-motion: reduce) { + .word-token, + .control-button, + .selected-words, + .word-reorder-container { + transition: none; + } + + .word-token.word-added { + animation: none; + } + + .word-reorder-container.reset-animation { + animation: none; + } +} + +/* フォーカス表示(キーボードナビゲーション対応) */ +.word-token:focus, +.control-button:focus { + outline: 2px solid #4299e1; + outline-offset: 2px; +} + +/* 高コントラストモード対応 */ +@media (prefers-contrast: high) { + .word-token { + border-width: 3px; + } + + .selected-words, + .available-words { + border-width: 3px; + } +} +/* +ゲームオーバーレイ(アニメーション用) */ +.game-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(5px); +} + +.game-overlay.hidden { + display: none; +} + +.overlay-content { + text-align: center; + color: white; +} + +.suspense-text { + font-size: 2rem; + font-weight: bold; + margin-bottom: 2rem; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 0.7; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.05); + } +} + +.result-animation { + text-align: center; +} + +.result-icon { + font-size: 4rem; + margin-bottom: 1rem; + animation: bounceIn 0.6s ease-out; +} + +.result-text { + font-size: 2.5rem; + font-weight: bold; + margin-bottom: 1rem; +} + +.result-text.correct { + color: #48bb78; + text-shadow: 0 0 20px rgba(72, 187, 120, 0.5); +} + +.result-text.incorrect { + color: #f56565; + text-shadow: 0 0 20px rgba(245, 101, 101, 0.5); +} + +@keyframes bounceIn { + 0% { + opacity: 0; + transform: scale(0.3); + } + 50% { + opacity: 1; + transform: scale(1.1); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* 紙吹雪アニメーション */ +.confetti { + position: absolute; + width: 10px; + height: 10px; + background: #f39c12; + animation: confetti-fall linear infinite; +} + +.confetti:nth-child(odd) { + background: #e74c3c; + width: 8px; + height: 8px; + animation-duration: 3s; +} + +.confetti:nth-child(even) { + background: #3498db; + width: 6px; + height: 6px; + animation-duration: 2.5s; +} + +.confetti:nth-child(3n) { + background: #2ecc71; + width: 12px; + height: 12px; + animation-duration: 3.5s; +} + +@keyframes confetti-fall { + 0% { + transform: translateY(-100vh) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(100vh) rotate(720deg); + opacity: 0; + } +} \ No newline at end of file diff --git a/index.html b/index.html index 75464aa..201f2bd 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ SQL学習ゲーム +
@@ -66,6 +67,11 @@

解説スライド

+ + +
@@ -105,6 +111,68 @@

💡 ヒント

+ + + diff --git a/js/game-engine.js b/js/game-engine.js index 0942248..ecda5e0 100644 --- a/js/game-engine.js +++ b/js/game-engine.js @@ -105,6 +105,163 @@ export class GameEngine { }; } + /** + * word-reorderチャレンジの回答をチェック + * @param {string} userSQL - ユーザーが構築したSQL + * @returns {Promise} 結果オブジェクト + */ + async checkWordReorderAnswer(userSQL) { + const challenge = this.getCurrentChallenge(); + this.attempts++; + + if (!userSQL || userSQL.trim() === '') { + return { + correct: false, + message: "SQLを構築してください" + }; + } + + try { + // データベースマネージャーが利用可能かチェック + if (!window.dbManager) { + throw new Error('データベースが初期化されていません'); + } + + // ユーザーのSQLと正解SQLの両方を実行 + const [userResult, correctResult] = await Promise.all([ + window.dbManager.executeQuery(userSQL), + window.dbManager.executeQuery(challenge.solution) + ]); + + // ユーザーのSQLでエラーが発生した場合 + if (!userResult.success) { + return { + correct: false, + message: `SQLエラー: ${userResult.error}` + }; + } + + // 正解SQLでエラーが発生した場合(チャレンジデータの問題) + if (!correctResult.success) { + console.error('正解SQLでエラーが発生:', correctResult.error); + return { + correct: false, + message: "チャレンジデータに問題があります。管理者に報告してください。" + }; + } + + // 結果を比較 + const isCorrect = this.compareQueryResults(userResult, correctResult); + + if (isCorrect) { + this.calculateScore(); + return { + correct: true, + message: "正解です!素晴らしい!", + score: this.score, + userResult: userResult, + correctResult: correctResult + }; + } else { + return { + correct: false, + message: "結果が正解と一致しません。もう一度確認してください。", + userResult: userResult, + correctResult: correctResult + }; + } + + } catch (error) { + console.error('SQL実行エラー:', error); + return { + correct: false, + message: `実行エラー: ${error.message}` + }; + } + } + + /** + * 2つのクエリ結果を比較 + * @param {Object} result1 - 1つ目の結果 + * @param {Object} result2 - 2つ目の結果 + * @returns {boolean} 結果が一致するかどうか + */ + compareQueryResults(result1, result2) { + // 両方とも成功している必要がある + if (!result1.success || !result2.success) { + return false; + } + + // カラム数の比較 + if (result1.columns.length !== result2.columns.length) { + return false; + } + + // カラム名の比較(順序も考慮) + for (let i = 0; i < result1.columns.length; i++) { + if (result1.columns[i] !== result2.columns[i]) { + return false; + } + } + + // 行数の比較 + if (result1.data.length !== result2.data.length) { + return false; + } + + // データの比較(行ごと) + for (let i = 0; i < result1.data.length; i++) { + const row1 = result1.data[i]; + const row2 = result2.data[i]; + + // 各カラムの値を比較 + for (const column of result1.columns) { + const value1 = row1[column]; + const value2 = row2[column]; + + // 値の比較(型も考慮) + if (!this.compareValues(value1, value2)) { + return false; + } + } + } + + return true; + } + + /** + * 2つの値を比較(型変換も考慮) + * @param {any} value1 - 1つ目の値 + * @param {any} value2 - 2つ目の値 + * @returns {boolean} 値が一致するかどうか + */ + compareValues(value1, value2) { + // 完全一致の場合 + if (value1 === value2) { + return true; + } + + // null/undefined の処理 + if (value1 == null && value2 == null) { + return true; + } + + if (value1 == null || value2 == null) { + return false; + } + + // 数値の比較(文字列として格納されている場合も考慮) + const num1 = Number(value1); + const num2 = Number(value2); + + if (!isNaN(num1) && !isNaN(num2)) { + return Math.abs(num1 - num2) < 1e-10; // 浮動小数点の誤差を考慮 + } + + // 文字列として比較 + return String(value1) === String(value2); + } + calculateScore() { const baseScore = 100; const attemptPenalty = Math.max(0, (this.attempts - 1) * 10); diff --git a/js/sql-tokenizer.js b/js/sql-tokenizer.js new file mode 100644 index 0000000..a3b63a4 --- /dev/null +++ b/js/sql-tokenizer.js @@ -0,0 +1,274 @@ +export class SQLTokenizer { + constructor() { + // SQLキーワードの定義 + this.keywords = new Set([ + 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', + 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP', 'ALTER', + 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'OUTER', 'ON', 'AS', + 'AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', + 'COUNT', 'SUM', 'AVG', 'MAX', 'MIN', 'DISTINCT', + 'ASC', 'DESC', 'LIMIT', 'OFFSET', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', + 'UNION', 'ALL', 'NULL', 'IS', 'TRUE', 'FALSE' + ]); + + // 演算子の定義 + this.operators = new Set([ + '=', '!=', '<>', '<', '>', '<=', '>=', '+', '-', '*', '/', '%' + ]); + } + + /** + * SQLを単語単位で分解する + * @param {string} sql - 分解するSQL文 + * @returns {Array} トークンの配列 + */ + tokenize(sql) { + if (!sql || typeof sql !== 'string') { + return []; + } + + const tokens = []; + let i = 0; + + while (i < sql.length) { + const char = sql[i]; + + // 空白文字をスキップ + if (/\s/.test(char)) { + i++; + continue; + } + + // 文字列リテラル(シングルクォート) + if (char === "'") { + const result = this.extractString(sql, i); + tokens.push({ + text: result.text, + type: 'string', + position: tokens.length + }); + i = result.nextIndex; + continue; + } + + // 数値 + if (/\d/.test(char)) { + const result = this.extractNumber(sql, i); + tokens.push({ + text: result.text, + type: 'number', + position: tokens.length + }); + i = result.nextIndex; + continue; + } + + // 演算子(2文字の演算子を先にチェック) + if (i < sql.length - 1) { + const twoChar = sql.substring(i, i + 2); + if (this.operators.has(twoChar)) { + tokens.push({ + text: twoChar, + type: 'operator', + position: tokens.length + }); + i += 2; + continue; + } + } + + // 1文字の演算子 + if (this.operators.has(char)) { + tokens.push({ + text: char, + type: 'operator', + position: tokens.length + }); + i++; + continue; + } + + // 区切り文字 + if (/[(),;]/.test(char)) { + tokens.push({ + text: char, + type: 'punctuation', + position: tokens.length + }); + i++; + continue; + } + + // 識別子またはキーワード + if (/[a-zA-Z_]/.test(char)) { + const result = this.extractIdentifier(sql, i); + const upperText = result.text.toUpperCase(); + + tokens.push({ + text: result.text, + type: this.keywords.has(upperText) ? 'keyword' : 'identifier', + position: tokens.length + }); + i = result.nextIndex; + continue; + } + + // その他の文字(エラー処理) + tokens.push({ + text: char, + type: 'unknown', + position: tokens.length + }); + i++; + } + + return tokens; + } + + /** + * 文字列リテラルを抽出 + */ + extractString(sql, startIndex) { + let i = startIndex + 1; // 開始のクォートをスキップ + let text = "'"; + + while (i < sql.length) { + const char = sql[i]; + text += char; + + if (char === "'") { + // エスケープされたクォートかチェック + if (i + 1 < sql.length && sql[i + 1] === "'") { + text += "'"; + i += 2; + } else { + // 文字列の終了 + i++; + break; + } + } else { + i++; + } + } + + return { text, nextIndex: i }; + } + + /** + * 数値を抽出 + */ + extractNumber(sql, startIndex) { + let i = startIndex; + let text = ''; + let hasDecimal = false; + + while (i < sql.length) { + const char = sql[i]; + + if (/\d/.test(char)) { + text += char; + i++; + } else if (char === '.' && !hasDecimal) { + text += char; + hasDecimal = true; + i++; + } else { + break; + } + } + + return { text, nextIndex: i }; + } + + /** + * 識別子を抽出 + */ + extractIdentifier(sql, startIndex) { + let i = startIndex; + let text = ''; + + while (i < sql.length) { + const char = sql[i]; + + if (/[a-zA-Z0-9_]/.test(char)) { + text += char; + i++; + } else { + break; + } + } + + return { text, nextIndex: i }; + } + + /** + * トークンをランダムに並び替える + * @param {Array} tokens - 並び替えるトークンの配列 + * @returns {Array} シャッフルされたトークンの配列 + */ + shuffle(tokens) { + if (!Array.isArray(tokens) || tokens.length === 0) { + return []; + } + + // 元の配列をコピー + const shuffled = [...tokens]; + + // Fisher-Yates シャッフルアルゴリズム + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + return shuffled; + } + + /** + * トークンからSQLを再構築 + * @param {Array} tokens - 再構築するトークンの配列 + * @returns {string} 再構築されたSQL文 + */ + buildSQL(tokens) { + if (!Array.isArray(tokens) || tokens.length === 0) { + return ''; + } + + let sql = ''; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const prevToken = i > 0 ? tokens[i - 1] : null; + + // スペースを追加するかどうかの判定 + if (i > 0 && this.needsSpace(prevToken, token)) { + sql += ' '; + } + + sql += token.text; + } + + return sql.trim(); + } + + /** + * 2つのトークン間にスペースが必要かどうかを判定 + */ + needsSpace(prevToken, currentToken) { + // 区切り文字の前後はスペース不要の場合が多い + if (prevToken.type === 'punctuation' && prevToken.text === '(') { + return false; + } + + if (currentToken.type === 'punctuation' && /[(),;]/.test(currentToken.text)) { + return false; + } + + // 演算子の前後は基本的にスペースが必要 + if (prevToken.type === 'operator' || currentToken.type === 'operator') { + return true; + } + + // その他の場合は基本的にスペースが必要 + return true; + } +} \ No newline at end of file diff --git a/js/ui-controller.js b/js/ui-controller.js index 07a75c2..3b0103c 100644 --- a/js/ui-controller.js +++ b/js/ui-controller.js @@ -3,6 +3,8 @@ export class UIController { this.gameEngine = gameEngine; this.autoComplete = autoComplete; this.currentHintLevel = 0; + this.wordReorderUI = null; + this.sqlTokenizer = null; this.initializeElements(); this.bindEvents(); // ゲームオーバーレイを初期状態で非表示 @@ -44,7 +46,8 @@ export class UIController { resultIcon: document.getElementById('result-icon'), sidebar: document.querySelector('.sidebar'), toggleSidebar: document.getElementById('toggle-sidebar'), - schemaInfo: document.getElementById('schema-info') + schemaInfo: document.getElementById('schema-info'), + wordReorderSection: document.getElementById('word-reorder-section') }; } @@ -106,6 +109,9 @@ export class UIController { if (challenge.type === 'slide') { console.log('Showing slide challenge'); this.showSlideChallenge(challenge); + } else if (challenge.type === 'word-reorder') { + console.log('Showing word-reorder challenge'); + this.showWordReorderChallenge(challenge); } else { console.log('Showing SQL challenge'); this.showSQLChallenge(); @@ -344,87 +350,187 @@ export class UIController { } async startGameAnimation() { - this.elements.gameOverlay.classList.remove('hidden'); - this.elements.suspenseText.textContent = '判定中...'; - this.elements.resultAnimation.classList.add('hidden'); + // オーバーレイを表示 + if (this.elements.gameOverlay) { + this.elements.gameOverlay.classList.remove('hidden'); + } + + // 全てのエフェクトを非表示にしてリセット + this.hideAllEffects(); + + // ロボットアニメーションを表示 + const robotAnimation = document.getElementById('robot-animation'); + if (robotAnimation) { + robotAnimation.style.display = 'flex'; + } } async showSuspense() { - // 静かに待機(1.5秒) - this.elements.suspenseText.textContent = '判定中...'; - await new Promise(resolve => setTimeout(resolve, 1500)); + // ロボットが考えている間の待機時間(2秒) + await new Promise(resolve => setTimeout(resolve, 2000)); } async showResult(isCorrect, message) { - this.elements.suspenseText.style.display = 'none'; - this.elements.resultAnimation.classList.remove('hidden'); + // ロボットアニメーションを非表示 + const robotAnimation = document.getElementById('robot-animation'); + if (robotAnimation) { + robotAnimation.style.display = 'none'; + } + + // 結果アニメーションを表示 + if (this.elements.resultAnimation) { + this.elements.resultAnimation.classList.remove('hidden'); + } if (isCorrect) { - this.elements.resultText.textContent = '正解!'; - this.elements.resultText.className = 'result-text correct'; - this.elements.resultIcon.textContent = '🎉'; - this.createConfetti(); + // 正解の場合 + if (this.elements.resultText) { + this.elements.resultText.textContent = '正解!素晴らしい!'; + this.elements.resultText.className = 'result-text correct'; + } + if (this.elements.resultIcon) { + this.elements.resultIcon.textContent = '🎉'; + } + + // 成功エフェクトを表示 + this.showSuccessEffects(); this.playSuccessSound(); + } else { - this.elements.resultText.textContent = '不正解...'; - this.elements.resultText.className = 'result-text incorrect'; - this.elements.resultIcon.textContent = '😢'; + // 不正解の場合 + if (this.elements.resultText) { + this.elements.resultText.textContent = '不正解...もう一度挑戦!'; + this.elements.resultText.className = 'result-text incorrect'; + } + if (this.elements.resultIcon) { + this.elements.resultIcon.textContent = '💭'; + } + + // 失敗エフェクトを表示 + this.showFailureEffects(); this.playErrorSound(); } - // 結果表示時間 - await new Promise(resolve => setTimeout(resolve, 1500)); + // 結果表示時間(3秒) + await new Promise(resolve => setTimeout(resolve, 3000)); } - endGameAnimation() { - this.elements.gameOverlay.classList.add('hidden'); - this.elements.suspenseText.style.display = 'block'; - // 紙吹雪をクリア - const confetti = document.querySelectorAll('.confetti'); - confetti.forEach(c => c.remove()); + showSuccessEffects() { + const successEffects = document.getElementById('success-effects'); + if (successEffects) { + successEffects.classList.remove('hidden'); + } + + // 追加の紙吹雪エフェクト + this.createEnhancedConfetti(); + + // 3秒後にエフェクトを非表示 + setTimeout(() => { + if (successEffects) { + successEffects.classList.add('hidden'); + } + }, 3000); } - playSuccessSound() { - // Web Audio APIで成功音を生成 - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); + showFailureEffects() { + const failureEffects = document.getElementById('failure-effects'); + if (failureEffects) { + failureEffects.classList.remove('hidden'); + } - oscillator.frequency.setValueAtTime(523.25, audioContext.currentTime); // C5 - oscillator.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1); // E5 - oscillator.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2); // G5 + // 2秒後にエフェクトを非表示 + setTimeout(() => { + if (failureEffects) { + failureEffects.classList.add('hidden'); + } + }, 2000); + } + + hideAllEffects() { + // 全てのエフェクトを非表示 + const effects = [ + 'robot-animation', + 'result-animation', + 'success-effects', + 'failure-effects' + ]; + + effects.forEach(id => { + const element = document.getElementById(id); + if (element) { + if (id === 'robot-animation') { + element.style.display = 'none'; + } else { + element.classList.add('hidden'); + } + } + }); - gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); + // 既存の紙吹雪をクリア + const confetti = document.querySelectorAll('.confetti'); + confetti.forEach(c => c.remove()); + } + + endGameAnimation() { + if (this.elements.gameOverlay) { + this.elements.gameOverlay.classList.add('hidden'); + } - oscillator.start(audioContext.currentTime); - oscillator.stop(audioContext.currentTime + 0.5); + // 全てのエフェクトをクリア + this.hideAllEffects(); + } + + playSuccessSound() { + try { + // Web Audio APIで成功音を生成 + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(523.25, audioContext.currentTime); // C5 + oscillator.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1); // E5 + oscillator.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2); // G5 + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.5); + } catch (error) { + console.warn('音声再生エラー:', error); + } } playErrorSound() { - // Web Audio APIでエラー音を生成 - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - oscillator.frequency.setValueAtTime(200, audioContext.currentTime); - oscillator.frequency.setValueAtTime(150, audioContext.currentTime + 0.2); - - gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4); - - oscillator.start(audioContext.currentTime); - oscillator.stop(audioContext.currentTime + 0.4); + try { + // Web Audio APIでエラー音を生成 + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(200, audioContext.currentTime); + oscillator.frequency.setValueAtTime(150, audioContext.currentTime + 0.2); + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.4); + } catch (error) { + console.warn('音声再生エラー:', error); + } } createConfetti() { // クラッカー風紙吹雪を作成 + if (!this.elements.gameOverlay) return; + for (let i = 0; i < 50; i++) { const confetti = document.createElement('div'); confetti.className = 'confetti'; @@ -435,6 +541,40 @@ export class UIController { } } + createEnhancedConfetti() { + // より豪華な紙吹雪エフェクト + if (!this.elements.gameOverlay) return; + + const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3', '#54a0ff']; + const shapes = ['●', '■', '▲', '★', '♦', '♠', '♥']; + + for (let i = 0; i < 100; i++) { + const confetti = document.createElement('div'); + confetti.className = 'confetti enhanced-confetti'; + confetti.textContent = shapes[Math.floor(Math.random() * shapes.length)]; + confetti.style.color = colors[Math.floor(Math.random() * colors.length)]; + confetti.style.left = Math.random() * 100 + 'vw'; + confetti.style.fontSize = (Math.random() * 20 + 10) + 'px'; + confetti.style.animationDelay = Math.random() * 2 + 's'; + confetti.style.animationDuration = (Math.random() * 4 + 3) + 's'; + confetti.style.position = 'absolute'; + confetti.style.zIndex = '10000'; + + // ランダムな回転を追加 + const rotation = Math.random() * 360; + confetti.style.transform = `rotate(${rotation}deg)`; + + this.elements.gameOverlay.appendChild(confetti); + + // 一定時間後に削除 + setTimeout(() => { + if (confetti.parentNode) { + confetti.parentNode.removeChild(confetti); + } + }, 7000); + } + } + toggleSidebar() { this.elements.sidebar.classList.toggle('collapsed'); const isCollapsed = this.elements.sidebar.classList.contains('collapsed'); @@ -558,9 +698,12 @@ export class UIController { } showSlideChallenge(challenge) { - // SQLエディターを非表示 + // SQLエディターと単語並び替えを非表示 this.elements.sqlEditorSection.classList.add('hidden'); this.elements.resultsSection.classList.add('hidden'); + if (this.elements.wordReorderSection) { + this.elements.wordReorderSection.classList.add('hidden'); + } // スライドを表示 this.elements.slideSection.classList.remove('hidden'); @@ -732,11 +875,142 @@ export class UIController { } showSQLChallenge() { - // スライドを非表示 + // スライドと単語並び替えを非表示 this.elements.slideSection.classList.add('hidden'); + if (this.elements.wordReorderSection) { + this.elements.wordReorderSection.classList.add('hidden'); + } // SQLエディターを表示 this.elements.sqlEditorSection.classList.remove('hidden'); this.elements.resultsSection.classList.remove('hidden'); } + + /** + * 単語並び替えチャレンジを表示 + * @param {Object} challenge - チャレンジオブジェクト + */ + async showWordReorderChallenge(challenge) { + // 他のセクションを非表示 + this.elements.sqlEditorSection.classList.add('hidden'); + this.elements.resultsSection.classList.add('hidden'); + this.elements.slideSection.classList.add('hidden'); + + // 単語並び替えセクションを表示 + if (this.elements.wordReorderSection) { + this.elements.wordReorderSection.classList.remove('hidden'); + + // SQLTokenizerとWordReorderUIを初期化 + if (!this.sqlTokenizer) { + const { SQLTokenizer } = await import('./sql-tokenizer.js'); + this.sqlTokenizer = new SQLTokenizer(); + } + + if (!this.wordReorderUI) { + const { WordReorderUI } = await import('./word-reorder-ui.js'); + this.wordReorderUI = new WordReorderUI(this.elements.wordReorderSection); + + // 回答確認のコールバックを設定 + this.wordReorderUI.setCheckAnswerCallback((sql) => { + this.executeWordReorderQuery(sql); + }); + } + + // 正解SQLをトークン化してシャッフル + const tokens = this.sqlTokenizer.tokenize(challenge.solution); + const shuffledTokens = this.sqlTokenizer.shuffle(tokens); + + // 単語を表示 + this.wordReorderUI.displayWords(shuffledTokens); + + console.log('Word reorder challenge initialized:', { + solution: challenge.solution, + tokens: tokens.length, + shuffled: shuffledTokens.length + }); + } else { + console.error('Word reorder section not found in HTML'); + this.showError('単語並び替え機能の初期化に失敗しました'); + } + } + + /** + * 単語並び替えで構築されたSQLを実行・検証 + * @param {string} userSQL - ユーザーが構築したSQL + */ + async executeWordReorderQuery(userSQL) { + if (!userSQL || userSQL.trim() === '') { + this.showError('単語を並び替えてSQLを構築してください'); + return; + } + + // UIを無効化 + if (this.wordReorderUI) { + this.wordReorderUI.setDisabled(true); + } + + this.elements.executionStatus.textContent = '実行中...'; + this.elements.executionStatus.className = 'status-indicator'; + + try { + // ゲームアニメーション開始(ロボット表示) + await this.startGameAnimation(); + + // GameEngineの word-reorder 専用メソッドを使用(バックグラウンドで実行) + const result = await this.gameEngine.checkWordReorderAnswer(userSQL); + + // ロボットの思考時間を表示 + await this.showSuspense(); + + // 結果アニメーション表示 + await this.showResult(result.correct, result.message); + + // アニメーション終了後に実際の処理結果を反映 + if (result.correct) { + this.updateScore(); + + // 結果を表示 + if (result.userResult && result.userResult.success) { + this.displayResults(result.userResult); + } + + // ステータス更新 + this.elements.executionStatus.textContent = result.message; + this.elements.executionStatus.className = 'status-indicator status-success'; + + // 次の問題へのボタンを有効化 + if (this.gameEngine.currentChallengeIndex < this.gameEngine.challenges.length - 1) { + this.elements.nextButton.disabled = false; + } + } else { + // エラーでない場合は結果を表示 + if (result.userResult && result.userResult.success) { + this.displayResults(result.userResult); + } + + // ステータス更新 + this.elements.executionStatus.textContent = result.message; + this.elements.executionStatus.className = 'status-indicator status-error'; + } + + } catch (error) { + console.error('Word reorder query execution error:', error); + + // エラーの場合もロボットの思考時間を表示 + await this.showSuspense(); + await this.showResult(false, `実行エラー: ${error.message}`); + + // ステータス更新 + this.elements.executionStatus.textContent = `実行エラー: ${error.message}`; + this.elements.executionStatus.className = 'status-indicator status-error'; + } finally { + // UIを再有効化 + if (this.wordReorderUI) { + this.wordReorderUI.setDisabled(false); + } + + // ゲームアニメーション終了 + this.endGameAnimation(); + } + } } \ No newline at end of file diff --git a/js/word-reorder-ui.js b/js/word-reorder-ui.js new file mode 100644 index 0000000..708b182 --- /dev/null +++ b/js/word-reorder-ui.js @@ -0,0 +1,779 @@ +export class WordReorderUI { + constructor(container) { + this.container = container; + this.availableWords = []; // 選択可能な単語 + this.selectedWords = []; // 選択された単語(回答エリア) + this.originalTokens = []; // 元のトークン(リセット用) + + // ドラッグ&ドロップ状態管理 + this.dragState = { + isDragging: false, + draggedElement: null, + draggedToken: null, + draggedFromType: null, // 'available' or 'selected' + draggedFromIndex: null, + dropTarget: null, + insertPosition: null, + + // タッチ操作用 + touchStartTime: null, + touchStartPos: null, + longPressTimer: null, + touchDragThreshold: 10, // px + longPressDelay: 500, // ms + + // ドラッグプレビュー要素 + dragPreview: null + }; + + this.initializeUI(); + this.bindEvents(); + this.initializeDragAndDrop(); + } + + /** + * UIの初期化 + */ + initializeUI() { + this.container.innerHTML = ` +
+
+

単語をタップして正しい順序に並び替えてください

+
+ +
+

回答エリア

+
+
ここに単語を並べてください
+
+
+ +
+

選択可能な単語

+
+
+
+ +
+ + +
+
+ `; + + // 要素の参照を取得 + this.selectedWordsContainer = this.container.querySelector('#selected-words'); + this.availableWordsContainer = this.container.querySelector('#available-words'); + this.resetButton = this.container.querySelector('#reset-words'); + this.checkButton = this.container.querySelector('#check-answer'); + } + + /** + * イベントの設定 + */ + bindEvents() { + this.resetButton.addEventListener('click', () => this.reset()); + this.checkButton.addEventListener('click', () => this.onCheckAnswer()); + + // コールバック関数のプレースホルダー + this.onCheckAnswerCallback = null; + } + + /** + * ドラッグ&ドロップ機能を初期化 + */ + initializeDragAndDrop() { + // ドロップゾーンの設定 + this.setupDropZones(); + + // ドラッグプレビュー要素を作成 + this.createDragPreview(); + + // グローバルドラッグイベントの設定 + document.addEventListener('dragover', (e) => e.preventDefault()); + document.addEventListener('drop', (e) => e.preventDefault()); + } + + /** + * ドロップゾーンを設定 + */ + setupDropZones() { + // 選択可能エリアをドロップゾーンに設定 + this.setupDropZone(this.availableWordsContainer, 'available'); + + // 回答エリアをドロップゾーンに設定 + this.setupDropZone(this.selectedWordsContainer, 'selected'); + } + + /** + * 個別のドロップゾーンを設定 + * @param {HTMLElement} element - ドロップゾーン要素 + * @param {string} type - ドロップゾーンのタイプ + */ + setupDropZone(element, type) { + element.addEventListener('dragover', (e) => { + e.preventDefault(); + this.onDragOver(e, type); + }); + + element.addEventListener('drop', (e) => { + e.preventDefault(); + this.onDrop(e, type); + }); + + element.addEventListener('dragenter', (e) => { + e.preventDefault(); + element.classList.add('drag-over'); + }); + + element.addEventListener('dragleave', (e) => { + // 子要素への移動でdragleaveが発火するのを防ぐ + if (!element.contains(e.relatedTarget)) { + element.classList.remove('drag-over'); + } + }); + } + + /** + * ドラッグプレビュー要素を作成 + */ + createDragPreview() { + this.dragState.dragPreview = document.createElement('div'); + this.dragState.dragPreview.className = 'drag-preview'; + this.dragState.dragPreview.style.cssText = ` + position: fixed; + pointer-events: none; + z-index: 1000; + opacity: 0.8; + transform: rotate(5deg); + display: none; + `; + document.body.appendChild(this.dragState.dragPreview); + } + + /** + * 単語を表示 + * @param {Array} tokens - 表示するトークンの配列 + */ + displayWords(tokens) { + this.originalTokens = [...tokens]; + this.availableWords = [...tokens]; + this.selectedWords = []; + + this.renderAvailableWords(); + this.renderSelectedWords(); + } + + /** + * 選択可能な単語を描画 + */ + renderAvailableWords() { + this.availableWordsContainer.innerHTML = ''; + + if (this.availableWords.length === 0) { + this.availableWordsContainer.innerHTML = '
すべての単語が選択されました
'; + return; + } + + this.availableWords.forEach((token, index) => { + const wordElement = this.createWordElement(token, 'available', index); + this.availableWordsContainer.appendChild(wordElement); + }); + } + + /** + * 選択された単語を描画 + */ + renderSelectedWords() { + this.selectedWordsContainer.innerHTML = ''; + + if (this.selectedWords.length === 0) { + this.selectedWordsContainer.innerHTML = '
ここに単語を並べてください
'; + return; + } + + this.selectedWords.forEach((token, index) => { + const wordElement = this.createWordElement(token, 'selected', index); + this.selectedWordsContainer.appendChild(wordElement); + }); + } + + /** + * 単語要素を作成 + * @param {Object} token - トークンオブジェクト + * @param {string} type - 'available' または 'selected' + * @param {number} index - 配列内のインデックス + * @returns {HTMLElement} 単語要素 + */ + createWordElement(token, type, index) { + const wordElement = document.createElement('div'); + wordElement.className = `word-token ${token.type} ${type}`; + wordElement.textContent = token.text; + wordElement.dataset.type = type; + wordElement.dataset.index = index; + wordElement.dataset.tokenType = token.type; + + // ドラッグ&ドロップ機能を追加 + this.makeDraggable(wordElement, token, type, index); + + // タップイベントの設定 + wordElement.addEventListener('click', (e) => { + e.preventDefault(); + this.onWordTap(type, index); + }); + + // タッチイベントの設定(モバイル対応) + wordElement.addEventListener('touchend', (e) => { + e.preventDefault(); + this.onWordTap(type, index); + }); + + return wordElement; + } + + /** + * 単語要素にドラッグ機能を追加 + * @param {HTMLElement} element - 単語要素 + * @param {Object} token - トークンオブジェクト + * @param {string} type - 'available' または 'selected' + * @param {number} index - 配列内のインデックス + */ + makeDraggable(element, token, type, index) { + // HTML5 Drag and Drop + element.draggable = true; + element.addEventListener('dragstart', (e) => this.onDragStart(e, token, type, index)); + element.addEventListener('dragend', (e) => this.onDragEnd(e)); + + // Touch Events for mobile + element.addEventListener('touchstart', (e) => this.onTouchStart(e, token, type, index)); + element.addEventListener('touchmove', (e) => this.onTouchMove(e)); + element.addEventListener('touchend', (e) => this.onTouchEnd(e)); + } + + /** + * 単語タップ時の処理 + * @param {string} type - 'available' または 'selected' + * @param {number} index - タップされた単語のインデックス + */ + onWordTap(type, index) { + if (type === 'available') { + // 選択可能な単語がタップされた場合、回答エリアに移動 + this.moveWordToSelected(index); + } else if (type === 'selected') { + // 選択された単語がタップされた場合、選択可能エリアに戻す + this.moveWordToAvailable(index); + } + + // 視覚的フィードバック(eventオブジェクトの代わりに要素を直接取得) + const wordElements = this.container.querySelectorAll('.word-token'); + const targetElement = Array.from(wordElements).find(el => + el.dataset.type === type && parseInt(el.dataset.index) === index + ); + if (targetElement) { + this.addTapFeedback(targetElement); + } + } + + /** + * 単語を回答エリアに移動 + * @param {number} index - 移動する単語のインデックス + */ + moveWordToSelected(index) { + if (index < 0 || index >= this.availableWords.length) return; + + const token = this.availableWords.splice(index, 1)[0]; + this.selectedWords.push(token); + + this.renderAvailableWords(); + this.renderSelectedWords(); + + // 新しく追加された単語にアニメーション効果 + setTimeout(() => { + const newWordElement = this.selectedWordsContainer.lastElementChild; + if (newWordElement && !newWordElement.classList.contains('empty-message')) { + newWordElement.classList.add('word-added'); + setTimeout(() => { + newWordElement.classList.remove('word-added'); + }, 300); + } + }, 10); + } + + /** + * 単語を選択可能エリアに戻す + * @param {number} index - 戻す単語のインデックス + */ + moveWordToAvailable(index) { + if (index < 0 || index >= this.selectedWords.length) return; + + const token = this.selectedWords.splice(index, 1)[0]; + this.availableWords.push(token); + + this.renderAvailableWords(); + this.renderSelectedWords(); + } + + /** + * タップ時の視覚的フィードバック + * @param {HTMLElement} element - タップされた要素 + */ + addTapFeedback(element) { + element.classList.add('tapped'); + setTimeout(() => { + element.classList.remove('tapped'); + }, 150); + } + + /** + * ドラッグ開始時の処理 + * @param {DragEvent} event - ドラッグイベント + * @param {Object} token - ドラッグされるトークン + * @param {string} type - 'available' または 'selected' + * @param {number} index - インデックス + */ + onDragStart(event, token, type, index) { + this.dragState.isDragging = true; + this.dragState.draggedToken = token; + this.dragState.draggedFromType = type; + this.dragState.draggedFromIndex = index; + this.dragState.draggedElement = event.target; + + // ドラッグデータを設定 + event.dataTransfer.setData('text/plain', JSON.stringify({ + token, type, index + })); + + // 視覚的フィードバック + event.target.classList.add('dragging'); + + // ドロップゾーンをハイライト + this.highlightDropZones(); + } + + /** + * ドラッグ終了時の処理 + * @param {DragEvent} event - ドラッグイベント + */ + onDragEnd(event) { + this.dragState.isDragging = false; + + // 視覚的フィードバックを削除 + event.target.classList.remove('dragging'); + this.removeDropZoneHighlights(); + + // 状態をリセット + this.resetDragState(); + } + + /** + * ドラッグオーバー時の処理 + * @param {DragEvent} event - ドラッグイベント + * @param {string} type - ドロップゾーンのタイプ + */ + onDragOver(event, type) { + event.preventDefault(); + + // 挿入位置を計算して表示 + const insertPosition = this.calculateInsertPosition(event, type); + this.showInsertionIndicator(insertPosition, type); + } + + /** + * ドロップ時の処理 + * @param {DragEvent} event - ドラッグイベント + * @param {string} targetType - ドロップ先のタイプ + */ + onDrop(event, targetType) { + event.preventDefault(); + + try { + const dragData = JSON.parse(event.dataTransfer.getData('text/plain')); + const insertPosition = this.calculateInsertPosition(event, targetType); + + this.performDrop(dragData, targetType, insertPosition); + } catch (error) { + console.error('ドロップ処理でエラーが発生しました:', error); + } + + // クリーンアップ + this.removeDropZoneHighlights(); + this.hideInsertionIndicator(); + } + + /** + * タッチ開始時の処理 + * @param {TouchEvent} event - タッチイベント + * @param {Object} token - トークン + * @param {string} type - タイプ + * @param {number} index - インデックス + */ + onTouchStart(event, token, type, index) { + const touch = event.touches[0]; + + this.dragState.touchStartTime = Date.now(); + this.dragState.touchStartPos = { x: touch.clientX, y: touch.clientY }; + this.dragState.draggedToken = token; + this.dragState.draggedFromType = type; + this.dragState.draggedFromIndex = index; + + // 長押し検出タイマー + this.dragState.longPressTimer = setTimeout(() => { + this.startTouchDrag(event.target, token, type, index); + }, this.dragState.longPressDelay); + } + + /** + * タッチ移動時の処理 + * @param {TouchEvent} event - タッチイベント + */ + onTouchMove(event) { + if (!this.dragState.isDragging) { + // 長押し前の移動チェック + const touch = event.touches[0]; + const deltaX = touch.clientX - this.dragState.touchStartPos.x; + const deltaY = touch.clientY - this.dragState.touchStartPos.y; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance > this.dragState.touchDragThreshold) { + clearTimeout(this.dragState.longPressTimer); + } + return; + } + + event.preventDefault(); + + // ドラッグ中の要素を指の位置に追従 + const touch = event.touches[0]; + this.updateDragPreviewPosition(touch.clientX, touch.clientY); + + // ドロップターゲットを更新 + const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); + this.updateDropTarget(elementBelow); + } + + /** + * タッチ終了時の処理 + * @param {TouchEvent} event - タッチイベント + */ + onTouchEnd(event) { + clearTimeout(this.dragState.longPressTimer); + + if (!this.dragState.isDragging) { + // 通常のタップ操作として処理(既存の処理を維持) + return; + } + + event.preventDefault(); + + // ドロップ処理 + const touch = event.changedTouches[0]; + const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY); + this.performTouchDrop(dropTarget); + + // クリーンアップ + this.endTouchDrag(); + } + + /** + * 選択された単語からSQLを構築 + * @returns {string} 構築されたSQL文 + */ + buildSQL() { + if (this.selectedWords.length === 0) { + return ''; + } + + // SQLTokenizerのbuildSQLメソッドを使用 + // ここでは簡単な実装 + let sql = ''; + + for (let i = 0; i < this.selectedWords.length; i++) { + const token = this.selectedWords[i]; + const prevToken = i > 0 ? this.selectedWords[i - 1] : null; + + // スペースを追加するかどうかの判定 + if (i > 0 && this.needsSpace(prevToken, token)) { + sql += ' '; + } + + sql += token.text; + } + + return sql.trim(); + } + + /** + * 2つのトークン間にスペースが必要かどうかを判定 + */ + needsSpace(prevToken, currentToken) { + // 区切り文字の前後はスペース不要の場合が多い + if (prevToken.type === 'punctuation' && prevToken.text === '(') { + return false; + } + + if (currentToken.type === 'punctuation' && /[(),;]/.test(currentToken.text)) { + return false; + } + + // 演算子の前後は基本的にスペースが必要 + if (prevToken.type === 'operator' || currentToken.type === 'operator') { + return true; + } + + // その他の場合は基本的にスペースが必要 + return true; + } + + /** + * リセット(すべての単語を選択可能エリアに戻す) + */ + reset() { + this.availableWords = [...this.originalTokens]; + this.selectedWords = []; + + this.renderAvailableWords(); + this.renderSelectedWords(); + + // リセットアニメーション + this.container.classList.add('reset-animation'); + setTimeout(() => { + this.container.classList.remove('reset-animation'); + }, 300); + } + + /** + * 回答確認ボタンがクリックされた時の処理 + */ + onCheckAnswer() { + if (this.selectedWords.length === 0) { + alert('単語を並び替えてからチェックしてください'); + return; + } + + const sql = this.buildSQL(); + + if (this.onCheckAnswerCallback) { + this.onCheckAnswerCallback(sql); + } + } + + /** + * 回答確認のコールバック関数を設定 + * @param {Function} callback - コールバック関数 + */ + setCheckAnswerCallback(callback) { + this.onCheckAnswerCallback = callback; + } + + /** + * 進捗状況を取得 + * @returns {Object} 進捗情報 + */ + getProgress() { + return { + totalWords: this.originalTokens.length, + selectedWords: this.selectedWords.length, + remainingWords: this.availableWords.length, + isComplete: this.availableWords.length === 0 + }; + } + + /** + * UIを無効化/有効化 + * @param {boolean} disabled - 無効化するかどうか + */ + setDisabled(disabled) { + const wordElements = this.container.querySelectorAll('.word-token'); + wordElements.forEach(element => { + element.style.pointerEvents = disabled ? 'none' : 'auto'; + element.style.opacity = disabled ? '0.6' : '1'; + }); + + this.resetButton.disabled = disabled; + this.checkButton.disabled = disabled; + } + + // ===== ドラッグ&ドロップ補助メソッド ===== + + /** + * ドロップゾーンをハイライト表示 + */ + highlightDropZones() { + this.availableWordsContainer.classList.add('drop-zone-highlight'); + this.selectedWordsContainer.classList.add('drop-zone-highlight'); + } + + /** + * ドロップゾーンのハイライトを削除 + */ + removeDropZoneHighlights() { + this.availableWordsContainer.classList.remove('drag-over', 'drop-zone-highlight'); + this.selectedWordsContainer.classList.remove('drag-over', 'drop-zone-highlight'); + } + + /** + * ドラッグ状態をリセット + */ + resetDragState() { + this.dragState.isDragging = false; + this.dragState.draggedElement = null; + this.dragState.draggedToken = null; + this.dragState.draggedFromType = null; + this.dragState.draggedFromIndex = null; + this.dragState.dropTarget = null; + this.dragState.insertPosition = null; + } + + /** + * 挿入位置を計算 + * @param {Event} event - ドラッグイベント + * @param {string} targetType - ターゲットタイプ + * @returns {number} 挿入位置 + */ + calculateInsertPosition(event, targetType) { + // 簡単な実装:末尾に挿入 + if (targetType === 'selected') { + return this.selectedWords.length; + } else { + return this.availableWords.length; + } + } + + /** + * 挿入位置インジケーターを表示 + * @param {number} position - 挿入位置 + * @param {string} type - エリアタイプ + */ + showInsertionIndicator(position, type) { + // 後で実装予定 + } + + /** + * 挿入位置インジケーターを非表示 + */ + hideInsertionIndicator() { + // 後で実装予定 + } + + /** + * ドロップ処理を実行 + * @param {Object} dragData - ドラッグデータ + * @param {string} targetType - ドロップ先タイプ + * @param {number} insertPosition - 挿入位置 + */ + performDrop(dragData, targetType, insertPosition) { + const { token, type: sourceType, index: sourceIndex } = dragData; + + // 同じ場所へのドロップは無視 + if (sourceType === targetType) { + return; + } + + // ソースから削除 + if (sourceType === 'available') { + this.availableWords.splice(sourceIndex, 1); + } else { + this.selectedWords.splice(sourceIndex, 1); + } + + // ターゲットに追加 + if (targetType === 'available') { + this.availableWords.push(token); + } else { + this.selectedWords.push(token); + } + + // UIを更新 + this.renderAvailableWords(); + this.renderSelectedWords(); + } + + /** + * タッチドラッグを開始 + * @param {HTMLElement} element - ドラッグ要素 + * @param {Object} token - トークン + * @param {string} type - タイプ + * @param {number} index - インデックス + */ + startTouchDrag(element, token, type, index) { + this.dragState.isDragging = true; + this.dragState.draggedElement = element; + + // 視覚的フィードバック + element.classList.add('dragging'); + this.highlightDropZones(); + + // ドラッグプレビューを表示 + this.showDragPreview(token.text); + } + + /** + * ドラッグプレビューを表示 + * @param {string} text - 表示テキスト + */ + showDragPreview(text) { + this.dragState.dragPreview.textContent = text; + this.dragState.dragPreview.className = 'drag-preview word-token'; + this.dragState.dragPreview.style.display = 'block'; + } + + /** + * ドラッグプレビューの位置を更新 + * @param {number} x - X座標 + * @param {number} y - Y座標 + */ + updateDragPreviewPosition(x, y) { + if (this.dragState.dragPreview) { + this.dragState.dragPreview.style.left = (x + 10) + 'px'; + this.dragState.dragPreview.style.top = (y - 10) + 'px'; + } + } + + /** + * ドロップターゲットを更新 + * @param {HTMLElement} element - 要素 + */ + updateDropTarget(element) { + // ドロップ可能な要素かチェック + const dropZone = element?.closest('.available-words, .selected-words'); + this.dragState.dropTarget = dropZone; + } + + /** + * タッチドロップを実行 + * @param {HTMLElement} dropTarget - ドロップターゲット + */ + performTouchDrop(dropTarget) { + if (!dropTarget) return; + + const targetType = dropTarget.closest('.available-words') ? 'available' : + dropTarget.closest('.selected-words') ? 'selected' : null; + + if (targetType && targetType !== this.dragState.draggedFromType) { + const dragData = { + token: this.dragState.draggedToken, + type: this.dragState.draggedFromType, + index: this.dragState.draggedFromIndex + }; + + this.performDrop(dragData, targetType, 0); + } + } + + /** + * タッチドラッグを終了 + */ + endTouchDrag() { + if (this.dragState.draggedElement) { + this.dragState.draggedElement.classList.remove('dragging'); + } + + this.removeDropZoneHighlights(); + this.dragState.dragPreview.style.display = 'none'; + this.resetDragState(); + } +} \ No newline at end of file diff --git a/slides/challenges.json b/slides/challenges.json index c554708..6582385 100644 --- a/slides/challenges.json +++ b/slides/challenges.json @@ -5,41 +5,28 @@ "content": "# SELECT文の基本\n\nSELECT文はデータベースからデータを取得するための**基本的なSQL文**です。\n\n## 基本構文\n\n```sql\nSELECT カラム名 FROM テーブル名;\n```\n\n## 主な使い方\n\n- **全てのカラムを取得**: `SELECT *`\n- **特定のカラムを取得**: `SELECT カラム1, カラム2`\n- **別名を付ける**: `SELECT カラム名 AS 別名`\n\n### 実例\n\n```sql\nSELECT product_name, price \nFROM products;\n```\n\n*商品名と価格を取得する例*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-001", "title": "商品一覧を表示しよう", - "description": "productsテーブルから全ての商品情報を取得してください。", + "description": "単語を正しい順序に並び替えて、productsテーブルから全ての商品情報を取得するSQLを完成させてください。", "difficulty": 1, - "expectedColumns": [ - "product_id", - "product_name", - "category_id", - "price", - "stock_quantity", - "supplier", - "description", - "created_date" - ], "hints": [ - "SELECT文を使用します", + "SELECT文から始めましょう", "全てのカラムを取得するには * を使用します", "FROM句でテーブル名を指定します" ], "solution": "SELECT * FROM products" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-002", "title": "商品名と価格を表示しよう", - "description": "productsテーブルから商品名(product_name)と価格(price)のみを取得してください。", + "description": "単語を正しい順序に並び替えて、productsテーブルから商品名と価格のみを取得するSQLを完成させてください。", "difficulty": 1, - "expectedColumns": [ - "product_name", - "price" - ], "hints": [ + "SELECT文から始めましょう", "特定のカラムのみを取得する場合は、カラム名をカンマで区切って指定します", - "SELECT product_name, price の形式で書きます" + "FROM句でテーブル名を指定します" ], "solution": "SELECT product_name, price FROM products" }, @@ -49,24 +36,20 @@ "content": "# WHERE句による条件指定\n\nWHERE句を使用してデータを**絞り込む**ことができます。\n\n## 基本構文\n\n```sql\nSELECT カラム名 FROM テーブル名 WHERE 条件;\n```\n\n## 主な比較演算子\n\n- `=` 等しい\n- `<>` または `!=` 等しくない \n- `>` より大きい\n- `<` より小さい\n- `>=` 以上\n- `<=` 以下\n\n### 実例\n\n```sql\nSELECT product_name, price \nFROM products \nWHERE price >= 10000;\n```\n\n*価格が10000円以上の商品を検索*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-003", "title": "高額商品を探そう", - "description": "価格が10000円以上の商品の商品名と価格を取得してください。", + "description": "単語を正しい順序に並び替えて、価格が10000円以上の商品の商品名と価格を取得するSQLを完成させてください。", "difficulty": 2, - "expectedColumns": [ - "product_name", - "price" - ], "hints": [ + "SELECT文から始めましょう", "WHERE句を使用して条件を指定します", - "価格の条件は price >= 10000 です", - "数値の比較では引用符は不要です" + "価格の条件は price >= 10000 です" ], "solution": "SELECT product_name, price FROM products WHERE price >= 10000" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-004", "title": "特定カテゴリの商品を探そう", "description": "category_idが1(ファッション)の商品の商品名を取得してください。", @@ -86,7 +69,7 @@ "content": "# ORDER BY句によるソート\n\nORDER BY句を使用してデータを**並び替える**ことができます。\n\n## 基本構文\n\n```sql\nSELECT カラム名 FROM テーブル名 ORDER BY カラム名 [ASC|DESC];\n```\n\n## ソート順序\n\n- **ASC**: 昇順(小さい順、デフォルト)\n- **DESC**: 降順(大きい順)\n- 複数カラムでのソートも可能\n\n### 実例\n\n```sql\nSELECT product_name, price \nFROM products \nORDER BY price DESC;\n```\n\n*価格の高い順に商品を並び替え*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-005", "title": "商品を価格順に並べよう", "description": "全ての商品を価格の安い順に並べて、商品名と価格を表示してください。", @@ -103,7 +86,7 @@ "solution": "SELECT product_name, price FROM products ORDER BY price" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-006", "title": "在庫の多い商品を探そう", "description": "商品を在庫数の多い順に並べて、商品名と在庫数を表示してください。", @@ -124,7 +107,7 @@ "content": "# LIMIT句による件数制限\n\nLIMIT句を使用して取得する**行数を制限**できます。\n\n## 基本構文\n\n```sql\nSELECT カラム名 FROM テーブル名 LIMIT 件数;\n```\n\n## ページング処理\n\nOFFSETと組み合わせてページング処理も可能:\n\n```sql\nSELECT カラム名 FROM テーブル名 \nLIMIT 件数 OFFSET 開始位置;\n```\n\n### 実例\n\n```sql\nSELECT product_name, price \nFROM products \nORDER BY price DESC \nLIMIT 5;\n```\n\n*上位5件の高額商品を取得*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-007", "title": "上位3つの高額商品を表示しよう", "description": "価格の高い順に上位3つの商品の商品名と価格を表示してください。", @@ -146,7 +129,7 @@ "content": "# 集約関数の基本\n\n集約関数を使用してデータを**集計**できます。\n\n## 主な集約関数\n\n- **COUNT()**: 行数をカウント\n- **SUM()**: 合計値を計算\n- **AVG()**: 平均値を計算\n- **MAX()**: 最大値を取得\n- **MIN()**: 最小値を取得\n\n### 基本的な使い方\n\n```sql\nSELECT COUNT(*) FROM products;\nSELECT AVG(price) FROM products;\n```\n\n*商品数と平均価格を取得する例*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-008", "title": "商品数を数えよう", "description": "productsテーブルに登録されている商品の総数を取得してください。", @@ -161,7 +144,7 @@ "solution": "SELECT COUNT(*) as count FROM products" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-009", "title": "平均価格を計算しよう", "description": "全商品の平均価格を計算してください。", @@ -182,7 +165,7 @@ "content": "# GROUP BY句によるグループ化\n\nGROUP BY句を使用してデータを**グループ化**し、グループごとに集計できます。\n\n## 基本構文\n\n```sql\nSELECT カラム名, 集約関数 \nFROM テーブル名 \nGROUP BY カラム名;\n```\n\n## 重要な注意点\n\n- SELECT句には**GROUP BYで指定したカラム**か**集約関数**のみ記述可能\n- **HAVING句**でグループ化後の条件指定が可能\n\n### 実例\n\n```sql\nSELECT category_id, COUNT(*) as product_count\nFROM products \nGROUP BY category_id;\n```\n\n*カテゴリ別の商品数を集計*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-010", "title": "カテゴリ別商品数を集計しよう", "description": "カテゴリ別に商品数を集計してください。category_idと商品数を表示してください。", @@ -199,7 +182,7 @@ "solution": "SELECT category_id, COUNT(*) as product_count FROM products GROUP BY category_id" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-011", "title": "カテゴリ別平均価格を計算しよう", "description": "カテゴリ別に商品の平均価格を計算してください。category_idと平均価格を表示してください。", @@ -220,7 +203,7 @@ "content": "# HAVING句による集計結果の絞り込み\n\nHAVING句を使用してGROUP BYで集計した結果を**絞り込む**ことができます。\n\n## 基本構文\n\n```sql\nSELECT カラム名, 集約関数 \nFROM テーブル名 \nGROUP BY カラム名 \nHAVING 集約関数の条件;\n```\n\n## WHERE句との違い\n\n- **WHERE**: グループ化**前**の行を絞り込み\n- **HAVING**: グループ化**後**の結果を絞り込み\n\n### 実例\n\n```sql\nSELECT category_id, COUNT(*) as product_count\nFROM products \nGROUP BY category_id \nHAVING COUNT(*) >= 2;\n```\n\n*商品数が2個以上のカテゴリのみ表示*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-012", "title": "商品数の多いカテゴリを探そう", "description": "商品数が2個以上のカテゴリのcategory_idと商品数を表示してください。", @@ -242,7 +225,7 @@ "content": "# JOIN(結合)の基本\n\nJOINを使用して複数のテーブルを**結合**できます。\n\n## 主なJOINの種類\n\n- **INNER JOIN**: 両方のテーブルに存在するデータのみ\n- **LEFT JOIN**: 左のテーブルの全データ + 右のテーブルの一致するデータ\n- **RIGHT JOIN**: 右のテーブルの全データ + 左のテーブルの一致するデータ\n\n## 基本構文\n\n```sql\nSELECT テーブル1.カラム, テーブル2.カラム\nFROM テーブル1\nINNER JOIN テーブル2 ON テーブル1.キー = テーブル2.キー;\n```\n\n### 実例\n\n```sql\nSELECT products.product_name, categories.category_name\nFROM products\nINNER JOIN categories ON products.category_id = categories.category_id;\n```\n\n*商品名とカテゴリ名を結合して取得*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-013", "title": "商品とカテゴリを結合しよう", "description": "商品名とカテゴリ名を表示してください。productsテーブルとcategoriesテーブルを結合してください。", @@ -259,7 +242,7 @@ "solution": "SELECT products.product_name, categories.category_name FROM products INNER JOIN categories ON products.category_id = categories.category_id" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-014", "title": "ファッション商品の詳細を表示しよう", "description": "カテゴリが「ファッション」の商品の商品名、価格、カテゴリ名を表示してください。", @@ -282,7 +265,7 @@ "content": "# 複数テーブルの結合\n\n**3つ以上のテーブル**を結合することも可能です。\n\n## 基本構文\n\n```sql\nSELECT テーブル1.カラム, テーブル2.カラム, テーブル3.カラム\nFROM テーブル1\nINNER JOIN テーブル2 ON テーブル1.キー = テーブル2.キー\nINNER JOIN テーブル3 ON テーブル2.キー = テーブル3.キー;\n```\n\n## 重要なポイント\n\n- **結合の順序**を考慮して、効率的なクエリを作成\n- **ON句**で適切な結合条件を指定\n- **テーブル名.カラム名**で明確に指定\n\n### 実例\n\n```sql\nSELECT orders.order_id, customers.customer_name, products.product_name\nFROM orders\nINNER JOIN customers ON orders.customer_id = customers.customer_id\nINNER JOIN order_details ON orders.order_id = order_details.order_id\nINNER JOIN products ON order_details.product_id = products.product_id;\n```\n\n*注文、顧客、商品情報を結合*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-015", "title": "注文詳細を表示しよう", "description": "注文ID、顧客名、商品名、数量を表示してください。orders、customers、order_details、productsテーブルを結合してください。", @@ -307,7 +290,7 @@ "content": "# サブクエリ(副問い合わせ)\n\nサブクエリを使用してクエリの中に**別のクエリを埋め込む**ことができます。\n\n## サブクエリの種類\n\n- **スカラーサブクエリ**: 1つの値を返す\n- **行サブクエリ**: 1行を返す\n- **テーブルサブクエリ**: 複数行を返す\n\n## 基本構文\n\n```sql\nSELECT * FROM テーブル名 \nWHERE カラム名 > (SELECT AVG(カラム名) FROM テーブル名);\n```\n\n### よく使われるパターン\n\n- **IN演算子**と組み合わせることが多い\n- **比較演算子**で条件指定\n- **EXISTS**で存在チェック\n\n### 実例\n\n```sql\nSELECT product_name, price \nFROM products \nWHERE price > (SELECT AVG(price) FROM products);\n```\n\n*平均価格より高い商品を検索*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-016", "title": "平均価格より高い商品を探そう", "description": "全商品の平均価格より高い商品の商品名と価格を表示してください。", @@ -324,7 +307,7 @@ "solution": "SELECT product_name, price FROM products WHERE price > (SELECT AVG(price) FROM products)" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-017", "title": "注文のある商品を探そう", "description": "注文されたことがある商品の商品名を表示してください(重複なし)。", @@ -345,7 +328,7 @@ "content": "# 日付・時間関数\n\n日付や時間を扱う関数を使用してデータを**分析**できます。\n\n## 主な日付関数\n\n- **DATE()**: 日付部分のみ抽出\n- **YEAR()**: 年を抽出\n- **MONTH()**: 月を抽出\n- **DAY()**: 日を抽出\n- **DATE_DIFF()**: 日付の差を計算\n\n### 基本的な使い方\n\n```sql\nSELECT * FROM orders \nWHERE YEAR(order_date) = 2023;\n```\n\n### 月別集計の例\n\n```sql\nSELECT MONTH(order_date) as month, COUNT(*) as order_count\nFROM orders \nWHERE YEAR(order_date) = 2023\nGROUP BY MONTH(order_date);\n```\n\n*2023年の月別注文数を集計*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-018", "title": "3月の注文を探そう", "description": "2023年3月に作成された注文の注文ID、顧客名、注文日を表示してください。", @@ -368,7 +351,7 @@ "content": "# CASE文による条件分岐\n\nCASE文を使用して条件に応じて**異なる値を返す**ことができます。\n\n## 基本構文\n\n```sql\nSELECT \n CASE \n WHEN 条件1 THEN 値1\n WHEN 条件2 THEN 値2\n ELSE 値3\n END AS 別名\nFROM テーブル名;\n```\n\n## 活用場面\n\n- **データの分類**や変換に便利\n- **条件に応じた表示**の切り替え\n- **複雑な条件分岐**の実装\n\n### 実例\n\n```sql\nSELECT product_name,\n CASE \n WHEN price < 5000 THEN '安価'\n WHEN price < 10000 THEN '普通'\n ELSE '高価'\n END AS price_category\nFROM products;\n```\n\n*価格帯による商品分類*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-019", "title": "価格帯で商品を分類しよう", "description": "商品を価格帯で分類してください。5000円未満は「安価」、5000円以上10000円未満は「普通」、10000円以上は「高価」として、商品名と価格帯を表示してください。", @@ -391,7 +374,7 @@ "content": "# ウィンドウ関数の基本\n\nウィンドウ関数を使用して**行ごとに集計値**を計算できます。\n\n## 基本構文\n\n```sql\nSELECT カラム名,\n 集約関数() OVER (PARTITION BY カラム名 ORDER BY カラム名)\nFROM テーブル名;\n```\n\n## 主なウィンドウ関数\n\n- **ROW_NUMBER()**: 行番号\n- **RANK()**: 順位(同順位あり、次の順位は飛ぶ)\n- **DENSE_RANK()**: 順位(同順位あり、次の順位は飛ばない)\n\n### 実例\n\n```sql\nSELECT product_name, price,\n RANK() OVER (ORDER BY price DESC) AS price_rank\nFROM products;\n```\n\n*商品を価格順にランキング*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-020", "title": "価格順位を付けよう", "description": "商品に価格の高い順で順位を付けてください。商品名、価格、順位を表示してください。", @@ -409,7 +392,7 @@ "solution": "SELECT product_name, price, RANK() OVER (ORDER BY price DESC) AS price_rank FROM products" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-021", "title": "カテゴリ内での価格順位を付けよう", "description": "各カテゴリ内で価格の高い順に順位を付けてください。商品名、カテゴリ名、価格、カテゴリ内順位を表示してください。", @@ -433,7 +416,7 @@ "content": "# 複雑な集計とビジネス分析\n\n実際のビジネスでは複数のテーブルを結合し、**複雑な条件で集計**することが多いです。\n\n## 分析の例\n\n- **売上分析**(期間別、商品別、顧客別)\n- **在庫分析**\n- **顧客行動分析**\n- **トレンド分析**\n\n## 重要なポイント\n\n複数の技術を組み合わせて、**意味のある情報を抽出**しましょう。\n\n### 実例:顧客別売上分析\n\n```sql\nSELECT customers.customer_name, \n SUM(order_details.quantity * order_details.unit_price) AS total_sales\nFROM customers\nINNER JOIN orders ON customers.customer_id = orders.customer_id\nINNER JOIN order_details ON orders.order_id = order_details.order_id\nGROUP BY customers.customer_id, customers.customer_name\nORDER BY total_sales DESC;\n```\n\n*顧客別の総購入金額を分析*" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-022", "title": "顧客別購入金額を集計しよう", "description": "顧客別の総購入金額を計算してください。顧客名と総購入金額を購入金額の多い順に表示してください。", @@ -451,7 +434,7 @@ "solution": "SELECT customers.customer_name, SUM(order_details.quantity * order_details.unit_price) AS total_amount FROM customers INNER JOIN orders ON customers.customer_id = orders.customer_id INNER JOIN order_details ON orders.order_id = order_details.order_id GROUP BY customers.customer_id, customers.customer_name ORDER BY total_amount DESC" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-023", "title": "月別売上を集計しよう", "description": "2023年の月別売上を集計してください。月と売上金額を表示してください。", @@ -469,7 +452,7 @@ "solution": "SELECT MONTH(orders.order_date) AS month, SUM(order_details.quantity * order_details.unit_price) AS monthly_sales FROM orders INNER JOIN order_details ON orders.order_id = order_details.order_id WHERE YEAR(orders.order_date) = 2023 GROUP BY MONTH(orders.order_date) ORDER BY month" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-024", "title": "人気商品ランキングを作ろう", "description": "販売数量の多い商品トップ5を表示してください。商品名、カテゴリ名、総販売数量を表示してください。", @@ -488,7 +471,7 @@ "solution": "SELECT products.product_name, categories.category_name, SUM(order_details.quantity) AS total_quantity FROM products INNER JOIN categories ON products.category_id = categories.category_id INNER JOIN order_details ON products.product_id = order_details.product_id GROUP BY products.product_id, products.product_name, categories.category_name ORDER BY total_quantity DESC LIMIT 5" }, { - "type": "challenge", + "type": "word-reorder", "id": "challenge-025", "title": "リピート顧客を特定しよう", "description": "2回以上注文している顧客の顧客名と注文回数を表示してください。注文回数の多い順に並べてください。",