Spaces:
Running
Running
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" | |
| content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <title>全等重案組 - Congruence Unit</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700;900&family=Orbitron:wght@400;700&display=swap" | |
| rel="stylesheet"> | |
| <style> | |
| body { | |
| font-family: 'Noto Sans TC', sans-serif; | |
| background-color: #050510; | |
| overflow: hidden; | |
| color: white; | |
| touch-action: none; | |
| user-select: none; | |
| } | |
| .font-tech { | |
| font-family: 'Orbitron', sans-serif; | |
| } | |
| /* Glassmorphism Panel */ | |
| .glass-panel { | |
| background: rgba(15, 23, 42, 0.85); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(147, 51, 234, 0.3); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); | |
| } | |
| /* Cyberpunk Dialogue Box */ | |
| .dialogue-box { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 5%; | |
| width: 90%; | |
| height: 220px; | |
| /* Increased height */ | |
| background: rgba(0, 0, 0, 0.9); | |
| border: 2px solid #d946ef; | |
| /* Magenta */ | |
| border-left-width: 8px; | |
| box-shadow: 0 0 20px rgba(217, 70, 239, 0.3); | |
| padding: 24px; | |
| /* Increased padding */ | |
| z-index: 50; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: flex-start; | |
| pointer-events: auto; | |
| clip-path: polygon(0 0, | |
| 100% 0, | |
| 100% 85%, | |
| 95% 100%, | |
| 0% 100%); | |
| } | |
| .dialogue-name { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 1.5rem; | |
| /* Increased size */ | |
| color: #d946ef; | |
| font-weight: bold; | |
| margin-bottom: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| text-shadow: 0 0 10px rgba(217, 70, 239, 0.5); | |
| } | |
| .dialogue-text { | |
| font-size: 1.3rem; | |
| /* Increased size */ | |
| line-height: 1.6; | |
| color: #e2e8f0; | |
| font-weight: 500; | |
| } | |
| /* ... cursor blink styles ... */ | |
| .cursor-blink::after { | |
| content: '▋'; | |
| display: inline-block; | |
| animation: blink 1s infinite; | |
| color: #d946ef; | |
| margin-left: 5px; | |
| } | |
| @keyframes blink { | |
| 0%, | |
| 100% { | |
| opacity: 1; | |
| } | |
| 50% { | |
| opacity: 0; | |
| } | |
| } | |
| /* Hitbox Styles */ | |
| .hitbox { | |
| position: absolute; | |
| width: 60px; | |
| height: 60px; | |
| border-radius: 50%; | |
| background: rgba(250, 204, 21, 0.25); | |
| /* Yellow/Amber tint */ | |
| border: 2px dashed rgba(250, 204, 21, 0.8); | |
| cursor: pointer; | |
| z-index: 100; | |
| transition: all 0.2s; | |
| animation: pulse-ring 2s infinite; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| color: white; | |
| font-weight: 900; | |
| font-size: 1.8rem; | |
| text-shadow: 0 0 5px black; | |
| } | |
| .hitbox.found-angle { | |
| background: rgba(239, 68, 68, 0.4); | |
| /* Red */ | |
| border: 2px solid #ef4444; | |
| animation: none; | |
| pointer-events: none; | |
| } | |
| .hitbox.found-side { | |
| background: rgba(59, 130, 246, 0.4); | |
| /* Blue */ | |
| border: 2px solid #3b82f6; | |
| animation: none; | |
| pointer-events: none; | |
| } | |
| .hitbox:hover { | |
| transform: scale(1.1); | |
| border-color: #fef08a; | |
| /* Lighter yellow on hover */ | |
| background: rgba(250, 204, 21, 0.4); | |
| } | |
| @keyframes pulse-ring { | |
| 0% { | |
| box-shadow: 0 0 0 0 rgba(250, 204, 21, 0.6); | |
| } | |
| 70% { | |
| box-shadow: 0 0 0 15px rgba(250, 204, 21, 0); | |
| } | |
| 100% { | |
| box-shadow: 0 0 0 0 rgba(250, 204, 21, 0); | |
| } | |
| } | |
| .evidence-counter { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: rgba(0, 0, 0, 0.8); | |
| border: 1px solid #d946ef; | |
| padding: 10px 20px; | |
| border-radius: 8px; | |
| z-index: 100; | |
| font-family: 'Orbitron'; | |
| color: #d946ef; | |
| } | |
| .character-sprite { | |
| position: absolute; | |
| bottom: 80px; | |
| /* Moved up significantly - hidden behind dialogue box */ | |
| left: -20px; | |
| /* Adjusted position */ | |
| height: clamp(600px, 100vh, 1200px); | |
| /* Even larger and higher */ | |
| z-index: 40; | |
| /* Behind dialogue box */ | |
| filter: drop-shadow(0 0 20px rgba(217, 70, 239, 0.4)); | |
| transition: all 0.3s ease-out; | |
| pointer-events: none; | |
| } | |
| .character-sprite.hidden { | |
| opacity: 0; | |
| transform: translateX(-50px); | |
| pointer-events: none; | |
| } | |
| /* 平板/手機裝置:警探縮小並置於圖片後方 */ | |
| @media (max-width: 1024px), | |
| (pointer: coarse) { | |
| .character-sprite { | |
| height: clamp(250px, 40vh, 450px); | |
| z-index: 5; | |
| /* 在其他圖片(z-index:10)之後 */ | |
| bottom: 60px; | |
| left: -15px; | |
| opacity: 0.85; | |
| } | |
| } | |
| @media (max-width: 768px) { | |
| .character-sprite { | |
| height: clamp(180px, 30vh, 300px); | |
| z-index: 5; | |
| bottom: 50px; | |
| left: -10px; | |
| opacity: 0.75; | |
| } | |
| } | |
| /* Scanlines */ | |
| .scanlines { | |
| background: linear-gradient(to bottom, | |
| rgba(255, 255, 255, 0), | |
| rgba(255, 255, 255, 0) 50%, | |
| rgba(0, 0, 0, 0.1) 50%, | |
| rgba(0, 0, 0, 0.1)); | |
| background-size: 100% 4px; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 200; | |
| mix-blend-mode: overlay; | |
| } | |
| /* Quiz Layer - Fixed Fullscreen Overlay */ | |
| #quiz-layer { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 150; | |
| background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.8) 100%); | |
| /* Gradient mask to keep top clear */ | |
| backdrop-filter: blur(1px); | |
| display: flex; | |
| align-items: flex-end; | |
| justify-content: center; | |
| padding-bottom: 40px; | |
| /* Lift slightly higher than dialogue */ | |
| pointer-events: auto; | |
| } | |
| #quiz-layer.hidden { | |
| display: none ; | |
| pointer-events: none; | |
| } | |
| #quiz-layer .quiz-box { | |
| position: relative; | |
| width: 90%; | |
| max-width: 1200px; | |
| height: 250px; | |
| background: rgba(5, 5, 10, 0.95); | |
| border: 2px solid #d946ef; | |
| border-left-width: 8px; | |
| box-shadow: 0 0 30px rgba(217, 70, 239, 0.4); | |
| pointer-events: auto; | |
| clip-path: polygon(0 0, 100% 0, 100% 85%, 95% 100%, 0% 100%); | |
| padding: 30px; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: flex-start; | |
| } | |
| /* ====== Virtual Keypad ====== */ | |
| #virtual-keypad { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| left: auto; | |
| transform: translateY(120%); | |
| z-index: 1000; | |
| background: rgba(15, 5, 25, 0.95); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| border: 1px solid rgba(217, 70, 239, 0.4); | |
| border-radius: 20px; | |
| padding: 16px; | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 10px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 20px rgba(217, 70, 239, 0.15); | |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| touch-action: none; | |
| max-width: 200px; | |
| } | |
| #virtual-keypad.active { | |
| transform: translateY(0); | |
| } | |
| #virtual-keypad.dragging { | |
| transition: none; | |
| transform: none; | |
| } | |
| .keypad-handle { | |
| grid-column: span 2; | |
| height: 20px; | |
| margin-bottom: 4px; | |
| background: rgba(255, 255, 255, 0.15); | |
| border-radius: 10px; | |
| cursor: grab; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .keypad-handle::after { | |
| content: ''; | |
| width: 40px; | |
| height: 4px; | |
| background: rgba(255, 255, 255, 0.35); | |
| border-radius: 2px; | |
| } | |
| .keypad-btn { | |
| width: 70px; | |
| height: 60px; | |
| border-radius: 12px; | |
| background: rgba(30, 10, 50, 0.7); | |
| border: 1px solid rgba(217, 70, 239, 0.25); | |
| color: white; | |
| font-size: 22px; | |
| font-weight: bold; | |
| font-family: 'Noto Sans TC', sans-serif; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| user-select: none; | |
| } | |
| .keypad-btn:active { | |
| transform: scale(0.93); | |
| background: rgba(217, 70, 239, 0.25); | |
| border-color: rgba(217, 70, 239, 0.6); | |
| } | |
| .keypad-btn.action { | |
| background: rgba(15, 5, 25, 0.8); | |
| border-color: rgba(217, 70, 239, 0.5); | |
| color: #d946ef; | |
| } | |
| .keypad-btn.submit { | |
| background: rgba(217, 70, 239, 0.7); | |
| color: white; | |
| grid-column: span 2; | |
| width: 100%; | |
| height: 50px; | |
| font-size: 16px; | |
| margin-top: 4px; | |
| letter-spacing: 2px; | |
| } | |
| .keypad-btn.submit:active { | |
| background: rgba(217, 70, 239, 0.9); | |
| } | |
| /* quiz-input 被選中時的高亮 */ | |
| #quiz-input.keypad-active { | |
| border-color: #d946ef; | |
| box-shadow: 0 0 12px rgba(217, 70, 239, 0.4); | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-slate-900 text-white selection:bg-fuchsia-500 selection:text-white"> | |
| <div class="scanlines"></div> | |
| <!-- Background - Static for now, can be dynamic --> | |
| <div class="fixed inset-0 z-0 bg-cover bg-center opacity-40" | |
| style="background-image: url('Assets/index/indexbg.png'); filter: blur(4px) hue-rotate(45deg);"></div> | |
| <!-- Game Container --> | |
| <div id="game-stage" class="relative w-full h-full min-h-screen overflow-hidden flex flex-col"> | |
| <!-- Header --> | |
| <div class="absolute top-0 left-0 w-full p-4 z-50 flex justify-between items-start pointer-events-none"> | |
| <div class="glass-panel px-6 py-2 rounded-br-2xl border-l-4 border-l-fuchsia-500 pointer-events-auto"> | |
| <h1 class="text-xl font-bold text-fuchsia-400 tracking-wider">全等重案組<br><span | |
| class="font-tech text-sm">CONGRUENCE UNIT</span></h1> | |
| <div class="text-xs text-slate-400 font-mono mt-1">CASE FILE #001: THE STYLIST</div> | |
| </div> | |
| <a href="index.html" | |
| class="glass-panel rounded-xl px-3 py-2 flex items-center justify-center text-amber-400 hover:bg-white/10 transition-all pointer-events-auto shadow-lg border border-amber-500/30 bg-slate-900/80"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" | |
| stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> | |
| </svg> | |
| </a> | |
| </div> | |
| <!-- Evidence/Status Display (Hidden by default) --> | |
| <div id="evidence-panel" class="hidden"> | |
| <div class="evidence-counter"> | |
| <div class="text-xs text-secondary mb-1">EVIDENCE COLLECTED</div> | |
| <div class="text-2xl font-bold" id="evidence-count">0 / 6</div> | |
| </div> | |
| </div> | |
| <!-- Main Interaction Area --> | |
| <div id="scene-layer" class="relative flex-1 z-10 flex items-center justify-center"> | |
| <!-- Target Container for Investigation --> | |
| <div id="target-container" class="flex-1 flex justify-center items-center relative p-4 hidden"> | |
| <div id="suspect-wrapper" class="relative inline-block"> | |
| <img id="suspect-image" src="Assets/triangle/嫌疑犯/16造型師.png" alt="Suspect" | |
| class="max-h-[60vh] object-contain shadow-lg shadow-fuchsia-500/20 rounded-xl border border-slate-700"> | |
| </div> | |
| </div> <!-- Hitboxes will be appended here --> | |
| </div> | |
| <!-- Dialogue Layer --> | |
| <div id="dialogue-layer" class="hidden" onclick="window.game.next()"> | |
| <img id="speaker-sprite" src="" class="character-sprite hidden" alt="Character"> | |
| <div class="dialogue-box cursor-pointer hover:bg-black/95 transition-colors"> | |
| <div id="speaker-name" class="dialogue-name">SENIOR DETECTIVE</div> | |
| <div id="dialogue-text" class="dialogue-text cursor-blink"></div> | |
| <div class="absolute bottom-4 right-4 text-xs text-fuchsia-500 animate-bounce">▼ CLICK TO CONTINUE | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Quiz Layer (Modified) --> | |
| <div id="quiz-layer" class="hidden"> | |
| <div class="quiz-box"> | |
| <h2 class="text-lg font-bold text-fuchsia-400 font-tech mb-1">SYSTEM AUTHENTICATION</h2> | |
| <div id="quiz-content" class="w-full"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Virtual Keypad HTML --> | |
| <div id="virtual-keypad" onclick="event.stopPropagation()"> | |
| <div class="keypad-handle"></div> | |
| <div class="keypad-btn" onclick="keypad.input('S')">S</div> | |
| <div class="keypad-btn" onclick="keypad.input('A')">A</div> | |
| <div class="keypad-btn" onclick="keypad.input('邊')">邊</div> | |
| <div class="keypad-btn" onclick="keypad.input('角')">角</div> | |
| <div class="keypad-btn action" onclick="keypad.backspace()">⌫</div> | |
| <div class="keypad-btn action" onclick="keypad.clear()">清除</div> | |
| <div class="keypad-btn submit" onclick="keypad.submit()">確認 ✓</div> | |
| </div> | |
| <!-- 水印 --> | |
| <div id="watermark" | |
| style="position:fixed;bottom:4px;right:8px;text-align:right;font-size:10px;color:rgba(148,163,184,.35);z-index:9998;pointer-events:none;font-family:'Noto Sans TC',sans-serif;line-height:1.5"> | |
| <div>程式設計者:新竹縣精華國中 藍星宇</div> | |
| <div>FB教育社團:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" | |
| style="color:rgba(148,163,184,.5);pointer-events:auto;text-decoration:none;transition:color .2s" | |
| onmouseover="this.style.color='#d946ef'" onmouseout="this.style.color='rgba(148,163,184,.5)'">萬物皆數</a> | |
| </div> | |
| </div> | |
| <script> | |
| // Game Script | |
| let playerName = localStorage.getItem('player_nickname') || '菜鳥探員'; | |
| // Initial Script Placeholder - populated dynamically | |
| let SCRIPT = []; | |
| // ====== Virtual Keypad System ====== | |
| const keypad = { | |
| element: null, | |
| targetInput: null, | |
| submitCallback: null, | |
| init: function () { | |
| this.element = document.getElementById('virtual-keypad'); | |
| if (!this.element) return; | |
| // Prevent click bubbling | |
| this.element.addEventListener('click', (e) => e.stopPropagation()); | |
| // Auto-close when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (this.element.classList.contains('active') && | |
| !this.element.contains(e.target) && | |
| e.target !== this.targetInput && | |
| !e.target.classList.contains('keypad-btn')) { | |
| this.close(); | |
| } | |
| }); | |
| // --- Drag Logic --- | |
| const handle = this.element.querySelector('.keypad-handle'); | |
| this.isDragging = false; | |
| this.offsetX = 0; | |
| this.offsetY = 0; | |
| const startDrag = (e) => { | |
| if (e.type === 'touchstart' && e.cancelable) e.preventDefault(); | |
| this.isDragging = true; | |
| this.element.classList.add('dragging'); | |
| const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX; | |
| const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY; | |
| const rect = this.element.getBoundingClientRect(); | |
| this.offsetX = clientX - rect.left; | |
| this.offsetY = clientY - rect.top; | |
| document.addEventListener(e.type.includes('mouse') ? 'mousemove' : 'touchmove', doDrag, { passive: false }); | |
| document.addEventListener(e.type.includes('mouse') ? 'mouseup' : 'touchend', endDrag); | |
| }; | |
| const doDrag = (e) => { | |
| if (!this.isDragging) return; | |
| e.preventDefault(); | |
| const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX; | |
| const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY; | |
| this.element.style.transform = 'none'; | |
| this.element.style.bottom = 'auto'; | |
| this.element.style.right = 'auto'; | |
| this.element.style.left = (clientX - this.offsetX) + 'px'; | |
| this.element.style.top = (clientY - this.offsetY) + 'px'; | |
| }; | |
| const endDrag = (e) => { | |
| this.isDragging = false; | |
| this.element.classList.remove('dragging'); | |
| document.removeEventListener('mousemove', doDrag); | |
| document.removeEventListener('touchmove', doDrag); | |
| document.removeEventListener('mouseup', endDrag); | |
| document.removeEventListener('touchend', endDrag); | |
| }; | |
| if (handle) { | |
| handle.addEventListener('mousedown', startDrag); | |
| handle.addEventListener('touchstart', startDrag, { passive: false }); | |
| handle.style.cursor = 'grab'; | |
| } | |
| }, | |
| open: function (inputElement, onSubmit) { | |
| this.targetInput = inputElement; | |
| this.submitCallback = onSubmit || null; | |
| this.element.classList.add('active'); | |
| if (inputElement) { | |
| inputElement.classList.add('keypad-active'); | |
| // 阻止系統鍵盤彈出 | |
| inputElement.setAttribute('readonly', 'readonly'); | |
| inputElement.setAttribute('inputmode', 'none'); | |
| } | |
| }, | |
| close: function () { | |
| this.element.classList.remove('active'); | |
| if (this.targetInput) { | |
| this.targetInput.classList.remove('keypad-active'); | |
| this.targetInput.removeAttribute('readonly'); | |
| this.targetInput.removeAttribute('inputmode'); | |
| this.targetInput = null; | |
| } | |
| this.submitCallback = null; | |
| }, | |
| input: function (val) { | |
| if (!this.targetInput) return; | |
| this.targetInput.value += val; | |
| // 視覺回饋 | |
| this.targetInput.focus(); | |
| }, | |
| backspace: function () { | |
| if (!this.targetInput) return; | |
| this.targetInput.value = this.targetInput.value.slice(0, -1); | |
| }, | |
| clear: function () { | |
| if (!this.targetInput) return; | |
| this.targetInput.value = ''; | |
| }, | |
| submit: function () { | |
| if (this.submitCallback) { | |
| this.submitCallback(); | |
| } | |
| } | |
| }; | |
| // 偵測是否為觸控裝置(平板/手機) | |
| const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); | |
| // Initialize keypad | |
| document.addEventListener('DOMContentLoaded', () => { | |
| keypad.init(); | |
| }); | |
| class GameEngine { | |
| constructor() { | |
| this.step = 0; | |
| this.isTyping = false; | |
| this.elDialogue = document.getElementById('dialogue-layer'); | |
| this.elText = document.getElementById('dialogue-text'); | |
| this.elName = document.getElementById('speaker-name'); | |
| this.elScene = document.getElementById('scene-layer'); | |
| this.elTarget = document.getElementById('target-container'); | |
| this.foundFeatures = new Set(); | |
| this.angleCount = 0; | |
| this.sideCount = 0; | |
| this.requiredFeatures = 6; | |
| this.init(); | |
| } | |
| init() { | |
| this.addStyles(); | |
| this.setupInitialScript(); | |
| this.showDialogue(true); | |
| this.processStep(); | |
| // 全螢幕:需要使用者互動事件才能觸發,所以掛在第一次點擊上 | |
| this._fullscreenOnce = false; | |
| const tryFullscreen = () => { | |
| if (this._fullscreenOnce) return; | |
| this._fullscreenOnce = true; | |
| try { | |
| if (document.documentElement.requestFullscreen) { | |
| document.documentElement.requestFullscreen().catch(e => console.log('Fullscreen denied:', e)); | |
| } else if (document.documentElement.webkitRequestFullscreen) { | |
| document.documentElement.webkitRequestFullscreen(); | |
| } else if (document.documentElement.msRequestFullscreen) { | |
| document.documentElement.msRequestFullscreen(); | |
| } | |
| } catch (err) { console.log('Fullscreen not supported'); } | |
| document.removeEventListener('click', tryFullscreen); | |
| document.removeEventListener('touchstart', tryFullscreen); | |
| }; | |
| document.addEventListener('click', tryFullscreen, { once: true }); | |
| document.addEventListener('touchstart', tryFullscreen, { once: true }); | |
| } | |
| setupInitialScript() { | |
| // Formatting helper | |
| const fmtName = (n) => `<span class="text-amber-400 font-bold mx-1 text-xl">${n}</span>`; | |
| SCRIPT = [ | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '歡迎來到 Math City 的重案組,我是負責帶領你的前輩,請問你怎麼稱呼?', | |
| }, | |
| { | |
| type: 'input_name', | |
| speaker: 'SYSTEM', | |
| text: '請輸入你的暱稱...' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: (name) => `${fmtName(name)}!真是個不錯的名字呢,時間緊迫,我就有話直說了。` | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '雖然城裡看似和平,但最近發生了一連串的案件,我們需要你的協助!' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: (name) => `對於這些犯罪,我們已經掌握了一部分的證據,但是緝凶人手不足,${fmtName(name)}看起來很聰明,我們需要你的幫忙。` | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '在三角形中,就像你臉上的<span class="text-yellow-400 font-bold">鼻子</span>、<span class="text-yellow-400 font-bold">嘴巴</span>、<span class="text-yellow-400 font-bold">眼睛</span>一樣,也有獨特的<span class="text-yellow-400 font-bold">特徵</span>,你知道是哪些嗎?' | |
| }, | |
| { | |
| type: 'choice', | |
| question: '你知道三角形的特徵嗎?', | |
| options: [ | |
| { text: '我知道!', next: 'next_step' }, | |
| { text: '不知道...', next: 'next_step' } | |
| ] | |
| }, | |
| { | |
| type: 'action', | |
| action: 'show_suspect_investigation', | |
| speaker: '資深警探', | |
| text: '現在我們來練習一下。看看這張照片,這是一位嫌疑犯,請你找出他的<span class="text-yellow-400 font-bold">特徵點</span>。<br>三角形的特徵點就是所有的邊和角,<span class="text-green-400 font-bold animate-pulse mt-2 mb-2 inline-block bg-slate-800/80 px-3 py-1.5 rounded-lg border-2 border-green-500/60 shadow-[0_0_10px_rgba(34,197,94,0.3)]">👉 請「直接點擊」上方圖片中</span> 的 <span class="text-yellow-400 font-bold">3 個邊</span> 與 <span class="text-yellow-400 font-bold">3 個角</span>!<br><span id="stats-display" class="text-yellow-400 font-mono block text-lg"></span>' | |
| } | |
| ]; | |
| } | |
| next() { | |
| if (this.isTyping) { | |
| this.finishTyping(); | |
| return; | |
| } | |
| if (SCRIPT[this.step] && (SCRIPT[this.step].type === 'action' || SCRIPT[this.step].type === 'input_name' || SCRIPT[this.step].type === 'choice') && !this.actionCompleted) { | |
| return; | |
| } | |
| this.step++; | |
| if (this.step < SCRIPT.length) { | |
| this.processStep(); | |
| } else { | |
| console.log('Script Finished'); | |
| } | |
| } | |
| processStep() { | |
| const data = SCRIPT[this.step]; | |
| let text = typeof data.text === 'function' ? data.text(playerName) : data.text; | |
| if (data.type === 'dialogue') { | |
| this.showDialogue(true); | |
| this.setSpeaker(data.speaker); | |
| this.typeText(text); | |
| this.actionCompleted = true; // Auto-complete purely dialogue steps? No, wait for click. | |
| this.actionCompleted = true; // Actually previous logic relied on next() call, which checks actionCompleted only for actions. | |
| // My logic in next() was: if type is action/input/choice and !actionCompleted, return. | |
| // For dialogue, we don't block. | |
| } else if (data.type === 'action') { | |
| this.showDialogue(true); | |
| this.setSpeaker(data.speaker); | |
| this.typeText(text); | |
| this.handleAction(data.action); | |
| } else if (data.type === 'input_name') { | |
| this.showDialogue(true); | |
| this.setSpeaker(data.speaker); | |
| this.typeText(text); | |
| this.showNameInput(); | |
| } else if (data.type === 'choice') { | |
| this.showDialogue(true); // Keep dialogue visible asking question? Or hide? | |
| // Usually hide for choice or overlay. Let's overlay. | |
| this.showChoices(data.options); | |
| } else if (data.type === 'quiz') { | |
| this.showDialogue(false); | |
| this.showQuiz(data); | |
| } | |
| } | |
| setSpeaker(name) { | |
| this.elName.innerText = name; | |
| const sprite = document.getElementById('speaker-sprite'); | |
| if (name === '資深警探') { | |
| sprite.src = 'Assets/triangle/detective.svg'; // Use our new SVG | |
| sprite.classList.remove('hidden'); | |
| // Add some animation or style if needed | |
| sprite.style.filter = "drop-shadow(0 0 10px #d946ef)"; | |
| } else if (name === 'SYSTEM') { | |
| sprite.classList.add('hidden'); | |
| } else { | |
| // Default behavior for other speakers | |
| sprite.classList.add('hidden'); | |
| } | |
| } | |
| typeText(text) { | |
| this.isTyping = true; | |
| this.elText.innerHTML = ''; | |
| this.fullText = text; | |
| let i = 0; | |
| clearInterval(this.typeInterval); | |
| this.typeInterval = setInterval(() => { | |
| this.elText.innerHTML = this.fullText.substring(0, i + 1); | |
| i++; | |
| if (i === this.fullText.length) { | |
| this.finishTyping(); | |
| } | |
| }, 30); | |
| } | |
| finishTyping() { | |
| clearInterval(this.typeInterval); | |
| this.elText.innerHTML = this.fullText; | |
| this.isTyping = false; | |
| // If it's the investigation step, ensure stats are updated | |
| if (SCRIPT[this.step] && SCRIPT[this.step].action === 'show_suspect_investigation') { | |
| this.updateStatsDisplay(); | |
| } | |
| } | |
| showDialogue(show) { | |
| if (show) this.elDialogue.classList.remove('hidden'); | |
| else this.elDialogue.classList.add('hidden'); | |
| } | |
| // Input Name Logic | |
| showNameInput() { | |
| this.actionCompleted = false; | |
| const inputHtml = ` | |
| <div id="name-input-overlay" class="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm"> | |
| <div class="glass-panel p-8 rounded-xl flex flex-col gap-4 w-full max-w-md"> | |
| <h2 class="text-xl font-bold text-fuchsia-400 text-center">輸入代號 INPUT ID</h2> | |
| <input type="text" id="player-name-input" class="bg-slate-900 border border-fuchsia-500 rounded p-3 text-white text-center text-xl focus:outline-none" placeholder="你的暱稱" maxlength="10"> | |
| <button id="name-submit-btn" class="bg-fuchsia-600 hover:bg-fuchsia-500 text-white font-bold py-3 rounded transition-colors">確認 CONFIRM</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.insertAdjacentHTML('beforeend', inputHtml); | |
| const btn = document.getElementById('name-submit-btn'); | |
| const input = document.getElementById('player-name-input'); | |
| input.focus(); | |
| const submit = () => { | |
| const val = input.value.trim(); | |
| if (val) { | |
| playerName = val; | |
| localStorage.setItem('player_nickname', playerName); | |
| document.getElementById('name-input-overlay').remove(); | |
| this.actionCompleted = true; | |
| this.next(); | |
| } | |
| }; | |
| btn.onclick = submit; | |
| input.onkeypress = (e) => { if (e.key === 'Enter') submit(); }; | |
| } | |
| // Choice Logic | |
| showChoices(options) { | |
| this.actionCompleted = false; | |
| const choiceContainer = document.createElement('div'); | |
| choiceContainer.id = 'choice-overlay'; | |
| choiceContainer.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm'; | |
| let html = '<div class="flex flex-col gap-4 min-w-[300px]">'; | |
| options.forEach((opt, idx) => { | |
| html += `<button class="choice-btn glass-panel px-6 py-4 rounded-lg text-xl font-bold text-white hover:bg-fuchsia-600 transition-colors border-l-4 border-fuchsia-400 hover:scale-105 transform duration-200" data-idx="${idx}">${opt.text}</button>`; | |
| }); | |
| html += '</div>'; | |
| choiceContainer.innerHTML = html; | |
| document.body.appendChild(choiceContainer); | |
| const btns = choiceContainer.querySelectorAll('.choice-btn'); | |
| btns.forEach(btn => { | |
| btn.onclick = () => { | |
| document.getElementById('choice-overlay').remove(); | |
| this.actionCompleted = true; | |
| this.next(); | |
| }; | |
| }); | |
| } | |
| // ====== CASE DATA ====== | |
| caseData = { | |
| case1: { | |
| id: 1, | |
| title: '霓虹暗巷重傷害案', | |
| titleEn: 'NEON ALLEY ASSAULT', | |
| caseImage: 'Assets/triangle/Case/case1.png', | |
| description: '凌晨兩點,幾何酒吧後巷發生了一起惡意鬥毆。犯人使用自己三角形的頭部作為兇器,給了被害人致命一擊,甚至把巷口的鈦合金垃圾桶撞出了一個深深的凹痕。金屬凹痕與路口監視器留下了關鍵的蛛絲馬跡。警方已經鎖定了三位嫌疑犯,請你證明哪一位是犯人,並且說明證明方法!', | |
| evidenceImage: 'Assets/triangle/蛛絲馬跡/12.png', | |
| suspects: [ | |
| { id: 'suspect_1', src: 'Assets/triangle/嫌疑犯/12教宗.png', name: '教宗' }, | |
| { id: 'suspect_2', src: 'Assets/triangle/嫌疑犯/2農夫.png', name: '農夫' }, | |
| { id: 'suspect_3', src: 'Assets/triangle/嫌疑犯/13軍人.png', name: '軍人' } | |
| ], | |
| correctSuspect: 'suspect_1', | |
| correctMethod: 'SSS', | |
| nextCase: 'case2' | |
| }, | |
| case2: { | |
| id: 2, | |
| title: '頂級俱樂部入侵案', | |
| titleEn: 'ELITE CLUB INTRUSION', | |
| caseImage: 'Assets/triangle/Case/case2.png', | |
| description: '只容許上流社會進入的「等腰俱樂部」昨晚遭人強行破壞闖入。犯人沒有用炸藥,而是利用自己天生如利刃般鋒利的「頭頂」,加熱後直接在防彈門上熔出了一個缺口。安檢門的破壞痕跡與監視攝影機拍到了蛛絲馬跡的內容。警方已經鎖定了三位嫌疑犯,請你證明哪一位是犯人,並且說明證明方法!', | |
| evidenceImage: 'Assets/triangle/蛛絲馬跡/3.png', | |
| suspects: [ | |
| { id: 'suspect_1', src: 'Assets/triangle/嫌疑犯/5冰淇淋師傅.png', name: '冰淇淋師傅' }, | |
| { id: 'suspect_2', src: 'Assets/triangle/嫌疑犯/8列車長.png', name: '列車長' }, | |
| { id: 'suspect_3', src: 'Assets/triangle/嫌疑犯/11醫生.png', name: '醫生' } | |
| ], | |
| correctSuspect: 'suspect_2', | |
| correctMethod: 'SAS', | |
| nextCase: 'case3' | |
| }, | |
| case3: { | |
| id: 3, | |
| title: '市政廳塗鴉案', | |
| titleEn: 'CITY HALL GRAFFITI', | |
| caseImage: 'Assets/triangle/Case/case3.png', | |
| description: '街頭塗鴉客「幻影角」昨夜觸發了市政廳的隱藏防盜陷阱——「瞬間定型漆」。在他正貼著牆面作畫時,防盜漆瞬間噴發,雖然他及時逃脫,但牆面上留下了一個完美的「三角形空白剪影」。牆上的空白輪廓與警用無人機拍到了蛛絲馬跡的內容。警方已經鎖定了三位嫌疑犯,請你證明哪一位是犯人,並且說明證明方法!', | |
| evidenceImage: 'Assets/triangle/蛛絲馬跡/17.png', | |
| suspects: [ | |
| { id: 'suspect_1', src: 'Assets/triangle/嫌疑犯/9_DJ.png', name: 'DJ' }, | |
| { id: 'suspect_2', src: 'Assets/triangle/嫌疑犯/13軍人.png', name: '軍人' }, | |
| { id: 'suspect_3', src: 'Assets/triangle/嫌疑犯/11醫生.png', name: '醫生' } | |
| ], | |
| correctSuspect: 'suspect_3', | |
| correctMethod: 'SAS', | |
| nextCase: null | |
| } | |
| }; | |
| handleAction(actionName) { | |
| this.actionCompleted = false; | |
| if (actionName === 'show_suspect_investigation') { | |
| this.startInvestigation(); | |
| } else if (actionName === 'show_sss_sas_images') { | |
| this.showSSSSASImages(); | |
| } else if (actionName.startsWith('show_practice_')) { | |
| this.showPracticeImages(actionName); | |
| } else if (actionName.startsWith('start_case_')) { | |
| const caseNum = actionName.replace('start_case_', ''); | |
| this.startCase('case' + caseNum); | |
| } else if (actionName === 'end_game') { | |
| // Ends... | |
| } | |
| } | |
| // ====== CASE INVESTIGATION SYSTEM ====== | |
| startCase(caseKey) { | |
| const caseInfo = this.caseData[caseKey]; | |
| if (!caseInfo) { console.error('Case not found:', caseKey); return; } | |
| this.currentCase = caseInfo; | |
| this.caseStartTime = null; | |
| this.caseHadError = false; | |
| if (!this.caseGrades) this.caseGrades = {}; | |
| // Phase 1: Show case title card | |
| this.showCaseTitleCard(caseInfo); | |
| } | |
| showCaseTitleCard(caseInfo) { | |
| // Hide any leftover images from previous phases | |
| this.elTarget.classList.add('hidden'); | |
| const wrapper = document.getElementById('suspect-wrapper'); | |
| if (wrapper) wrapper.innerHTML = ''; | |
| // Full screen overlay with case title | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'case-title-overlay'; | |
| overlay.className = 'fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/90 backdrop-blur-sm cursor-pointer'; | |
| overlay.innerHTML = ` | |
| <div class="font-tech text-fuchsia-500 text-lg tracking-[0.5em] mb-4 animate-pulse">CASE FILE #00${caseInfo.id}</div> | |
| <div class="text-5xl font-black text-white mb-4 tracking-wider" style="text-shadow: 0 0 30px rgba(217,70,239,0.6);">${caseInfo.title}</div> | |
| <div class="font-tech text-2xl text-slate-400 tracking-[0.3em]">${caseInfo.titleEn}</div> | |
| <div class="absolute bottom-12 text-fuchsia-400 text-sm animate-bounce font-tech">▼ CLICK TO START</div> | |
| `; | |
| overlay.onclick = () => { | |
| overlay.remove(); | |
| this.showCaseDescription(caseInfo); | |
| }; | |
| document.body.appendChild(overlay); | |
| } | |
| showCaseDescription(caseInfo) { | |
| // Show case image + description in an overlay | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'case-desc-overlay'; | |
| overlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/85 backdrop-blur-sm cursor-pointer'; | |
| overlay.innerHTML = ` | |
| <div class="flex flex-col md:flex-row gap-8 max-w-5xl w-full px-8 items-center"> | |
| <img src="${caseInfo.caseImage}" class="h-[50vh] object-contain rounded-xl border-2 border-fuchsia-500/40 shadow-lg shadow-fuchsia-500/20" alt="Case Image"> | |
| <div class="flex flex-col gap-4 flex-1"> | |
| <div class="font-tech text-fuchsia-400 text-sm tracking-[0.3em]">CASE FILE #00${caseInfo.id}</div> | |
| <h2 class="text-3xl font-black text-white">${caseInfo.title}</h2> | |
| <p class="text-slate-300 text-lg leading-relaxed">${caseInfo.description}</p> | |
| <div class="text-fuchsia-400 text-sm animate-bounce font-tech mt-4">▼ CLICK TO INVESTIGATE</div> | |
| </div> | |
| </div> | |
| `; | |
| overlay.onclick = () => { | |
| overlay.remove(); | |
| this.showCaseSuspectsPhase(caseInfo); | |
| }; | |
| document.body.appendChild(overlay); | |
| } | |
| showCaseSuspectsPhase(caseInfo) { | |
| // Start timer NOW | |
| this.caseStartTime = Date.now(); | |
| // Show evidence + suspects in the main scene area | |
| this.showDialogue(false); // hide dialogue | |
| this.elTarget.classList.remove('hidden'); | |
| document.getElementById('evidence-panel').classList.add('hidden'); | |
| const sprite = document.getElementById('speaker-sprite'); | |
| if (sprite) sprite.classList.add('hidden'); | |
| const wrapper = document.getElementById('suspect-wrapper'); | |
| wrapper.innerHTML = ''; | |
| wrapper.className = 'relative w-full flex flex-col items-center gap-6 py-4'; | |
| // Top row: Evidence image | |
| const evidenceSection = document.createElement('div'); | |
| evidenceSection.className = 'flex flex-col items-center gap-2'; | |
| evidenceSection.innerHTML = ` | |
| <div class="font-tech text-fuchsia-400 text-sm tracking-widest">EVIDENCE / 蛛絲馬跡</div> | |
| <img src="${caseInfo.evidenceImage}" class="h-[35vh] object-contain rounded-xl border-2 border-fuchsia-500/40 shadow-lg shadow-fuchsia-500/20" alt="Evidence"> | |
| `; | |
| // Bottom row: Suspects (clickable) | |
| const suspectSection = document.createElement('div'); | |
| suspectSection.className = 'flex flex-col items-center gap-2'; | |
| suspectSection.innerHTML = `<div class="font-tech text-cyan-400 text-sm tracking-widest">SUSPECTS / 嫌疑犯 <span class="text-xs text-slate-500">(點擊選擇犯人)</span></div>`; | |
| const suspectRow = document.createElement('div'); | |
| suspectRow.className = 'flex gap-6 justify-center items-end'; | |
| caseInfo.suspects.forEach(s => { | |
| const card = document.createElement('div'); | |
| card.className = 'flex flex-col items-center gap-1 cursor-pointer group transition-all duration-300'; | |
| card.innerHTML = ` | |
| <img src="${s.src}" class="h-[32vh] object-contain rounded-xl border-2 border-slate-600 group-hover:border-fuchsia-500 shadow-lg group-hover:shadow-fuchsia-500/30 transition-all duration-300 group-hover:scale-105" alt="${s.name}"> | |
| <span class="text-slate-400 group-hover:text-fuchsia-400 text-sm font-bold transition-colors">${s.name}</span> | |
| `; | |
| card.onclick = () => this.onSuspectClick(s, caseInfo); | |
| suspectRow.appendChild(card); | |
| }); | |
| suspectSection.appendChild(suspectRow); | |
| wrapper.appendChild(evidenceSection); | |
| wrapper.appendChild(suspectSection); | |
| } | |
| onSuspectClick(suspect, caseInfo) { | |
| // Confirm dialog | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'confirm-overlay'; | |
| overlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; | |
| overlay.innerHTML = ` | |
| <div class="glass-panel p-8 rounded-xl flex flex-col gap-6 max-w-md w-full items-center"> | |
| <img src="${suspect.src}" class="h-[20vh] object-contain rounded-lg border border-fuchsia-500/40" alt="${suspect.name}"> | |
| <h3 class="text-2xl font-bold text-white">確認 <span class="text-fuchsia-400">${suspect.name}</span> 是犯人嗎?</h3> | |
| <div class="flex gap-4 w-full"> | |
| <button id="confirm-yes" class="flex-1 bg-fuchsia-600 hover:bg-fuchsia-500 text-white font-bold py-3 rounded-lg text-lg transition-all border border-fuchsia-400/50 hover:scale-105 transform duration-200">確認逮捕</button> | |
| <button id="confirm-no" class="flex-1 bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg text-lg transition-all border border-slate-500/50 hover:scale-105 transform duration-200">再想想</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| document.getElementById('confirm-no').onclick = () => overlay.remove(); | |
| document.getElementById('confirm-yes').onclick = () => { | |
| overlay.remove(); | |
| this.onSuspectConfirmed(suspect, caseInfo); | |
| }; | |
| } | |
| onSuspectConfirmed(suspect, caseInfo) { | |
| const isCorrectSuspect = (suspect.id === caseInfo.correctSuspect); | |
| if (!isCorrectSuspect) { | |
| this.caseHadError = true; | |
| // Show wrong feedback but let them try again | |
| const wrongOverlay = document.createElement('div'); | |
| wrongOverlay.id = 'wrong-suspect-overlay'; | |
| wrongOverlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm cursor-pointer'; | |
| wrongOverlay.innerHTML = ` | |
| <div class="glass-panel p-8 rounded-xl flex flex-col gap-4 max-w-md w-full items-center border-2 border-red-500/50"> | |
| <div class="text-red-500 font-tech text-3xl font-bold">✗ WRONG SUSPECT</div> | |
| <p class="text-slate-300 text-lg text-center">這不是犯人!請再仔細比對蛛絲馬跡和嫌疑犯的特徵。</p> | |
| <div class="text-red-400 text-sm font-tech animate-bounce">▼ CLICK TO RETRY</div> | |
| </div> | |
| `; | |
| wrongOverlay.onclick = () => wrongOverlay.remove(); | |
| document.body.appendChild(wrongOverlay); | |
| return; | |
| } | |
| // Correct suspect! Now ask for proof method | |
| this.askProofMethod(suspect, caseInfo); | |
| } | |
| askProofMethod(suspect, caseInfo) { | |
| const suspectImg = suspect ? suspect.src : caseInfo.suspects.find(s => s.id === caseInfo.correctSuspect).src; | |
| const suspectName = suspect ? suspect.name : caseInfo.suspects.find(s => s.id === caseInfo.correctSuspect).name; | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'proof-overlay'; | |
| overlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; | |
| overlay.innerHTML = ` | |
| <div class="glass-panel p-8 rounded-xl flex flex-col gap-6 max-w-4xl w-full items-center"> | |
| <div class="font-tech text-green-400 text-2xl font-bold">✓ SUSPECT IDENTIFIED</div> | |
| <div class="flex gap-8 justify-center items-start w-full"> | |
| <div class="flex flex-col items-center gap-2"> | |
| <div class="font-tech text-fuchsia-400 text-xs tracking-widest">EVIDENCE / 蛛絲馬跡</div> | |
| <img src="${caseInfo.evidenceImage}" class="h-[30vh] object-contain rounded-xl border-2 border-fuchsia-500/40 shadow-lg shadow-fuchsia-500/20" alt="Evidence"> | |
| </div> | |
| <div class="flex flex-col items-center gap-2"> | |
| <div class="font-tech text-cyan-400 text-xs tracking-widest">SUSPECT / ${suspectName}</div> | |
| <img src="${suspectImg}" class="h-[30vh] object-contain rounded-xl border-2 border-cyan-500/40 shadow-lg shadow-cyan-500/20" alt="Suspect"> | |
| </div> | |
| </div> | |
| <p class="text-slate-300 text-lg text-center">你要用哪種方法來 <span class="text-fuchsia-400 font-bold">證明</span> 這位嫌疑犯就是犯人?</p> | |
| <div class="flex gap-4 w-full max-w-md"> | |
| <button class="proof-btn flex-1 bg-gradient-to-br from-pink-600 to-fuchsia-700 hover:from-pink-500 hover:to-fuchsia-600 text-white font-bold py-4 rounded-lg text-2xl font-tech transition-all border border-fuchsia-400/50 hover:scale-105 transform duration-200 shadow-lg shadow-fuchsia-500/20" data-method="SSS">SSS</button> | |
| <button class="proof-btn flex-1 bg-gradient-to-br from-amber-600 to-yellow-700 hover:from-amber-500 hover:to-yellow-600 text-white font-bold py-4 rounded-lg text-2xl font-tech transition-all border border-yellow-400/50 hover:scale-105 transform duration-200 shadow-lg shadow-yellow-500/20" data-method="SAS">SAS</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| overlay.querySelectorAll('.proof-btn').forEach(btn => { | |
| btn.onclick = () => { | |
| const method = btn.dataset.method; | |
| overlay.remove(); | |
| this.onProofMethodSelected(method, caseInfo); | |
| }; | |
| }); | |
| } | |
| onProofMethodSelected(method, caseInfo) { | |
| const isCorrect = (method === caseInfo.correctMethod); | |
| if (!isCorrect) { | |
| this.caseHadError = true; | |
| // Wrong method, let them pick again | |
| const wrongOverlay = document.createElement('div'); | |
| wrongOverlay.id = 'wrong-method-overlay'; | |
| wrongOverlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm cursor-pointer'; | |
| wrongOverlay.innerHTML = ` | |
| <div class="glass-panel p-8 rounded-xl flex flex-col gap-4 max-w-md w-full items-center border-2 border-red-500/50"> | |
| <div class="text-red-500 font-tech text-3xl font-bold">✗ WRONG METHOD</div> | |
| <p class="text-slate-300 text-lg text-center">這個證明方法不正確!請重新觀察證據中的邊和角。</p> | |
| <div class="text-red-400 text-sm font-tech animate-bounce">▼ CLICK TO RETRY</div> | |
| </div> | |
| `; | |
| wrongOverlay.onclick = () => { | |
| wrongOverlay.remove(); | |
| this.askProofMethod(null, caseInfo); | |
| }; | |
| document.body.appendChild(wrongOverlay); | |
| return; | |
| } | |
| // All correct! Calculate score | |
| const elapsed = Math.floor((Date.now() - this.caseStartTime) / 1000); | |
| const grade = this.calculateGrade(elapsed, this.caseHadError); | |
| this.showCaseResult(caseInfo, elapsed, grade); | |
| } | |
| calculateGrade(seconds, hadError) { | |
| if (hadError) { | |
| // Max B if had any error | |
| if (seconds <= 30) return 'B'; | |
| return 'C'; | |
| } | |
| // All correct, grading by speed | |
| if (seconds <= 10) return 'S'; | |
| if (seconds <= 20) return 'A++'; | |
| if (seconds <= 30) return 'A+'; | |
| if (seconds <= 60) return 'A'; | |
| return 'B'; | |
| } | |
| showCaseResult(caseInfo, seconds, grade) { | |
| const gradeColors = { | |
| 'S': 'from-amber-400 to-yellow-600', | |
| 'A++': 'from-fuchsia-400 to-pink-600', | |
| 'A+': 'from-purple-400 to-violet-600', | |
| 'A': 'from-cyan-400 to-blue-600', | |
| 'B': 'from-green-400 to-emerald-600', | |
| 'C': 'from-slate-400 to-gray-600' | |
| }; | |
| const gradeMessages = { | |
| 'S': '神探降臨!完美破案!', | |
| 'A++': '超凡的推理能力!', | |
| 'A+': '出色的辦案速度!', | |
| 'A': '幹得漂亮!', | |
| 'B': '案件已解決。', | |
| 'C': '勉強過關...' | |
| }; | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'result-overlay'; | |
| overlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/85 backdrop-blur-md cursor-pointer'; | |
| overlay.innerHTML = ` | |
| <div class="flex flex-col items-center gap-6"> | |
| <div class="font-tech text-fuchsia-500 text-lg tracking-[0.5em]">CASE #00${caseInfo.id} CLOSED</div> | |
| <div class="text-3xl font-bold text-white">${caseInfo.title}</div> | |
| <div class="text-8xl font-black bg-gradient-to-br ${gradeColors[grade]} bg-clip-text text-transparent" style="text-shadow: 0 0 40px rgba(217,70,239,0.4); -webkit-text-stroke: 1px rgba(255,255,255,0.1);"> | |
| ${grade} | |
| </div> | |
| <div class="text-xl text-slate-300">${gradeMessages[grade]}</div> | |
| <div class="font-tech text-slate-500 text-sm">耗時 ${seconds} 秒</div> | |
| <div class="text-fuchsia-400 text-sm animate-bounce font-tech mt-4">▼ CLICK TO CONTINUE</div> | |
| </div> | |
| `; | |
| overlay.onclick = () => { | |
| overlay.remove(); | |
| this.caseGrades[caseInfo.id] = grade; | |
| this.onCaseComplete(caseInfo); | |
| }; | |
| document.body.appendChild(overlay); | |
| } | |
| onCaseComplete(caseInfo) { | |
| if (caseInfo.nextCase) { | |
| // Chain to next case | |
| this.startCase(caseInfo.nextCase); | |
| } else { | |
| // All cases done — show summary | |
| this.showFinalSummary(); | |
| } | |
| } | |
| showFinalSummary() { | |
| const gradeOrder = ['S', 'A++', 'A+', 'A', 'B', 'C']; | |
| const gradeScores = { 'S': 100, 'A++': 90, 'A+': 80, 'A': 70, 'B': 50, 'C': 30 }; | |
| const gradeColors = { | |
| 'S': 'text-amber-400', 'A++': 'text-fuchsia-400', 'A+': 'text-purple-400', | |
| 'A': 'text-cyan-400', 'B': 'text-green-400', 'C': 'text-slate-400' | |
| }; | |
| // Calculate overall grade from average score | |
| const grades = this.caseGrades; | |
| const totalScore = Object.values(grades).reduce((sum, g) => sum + (gradeScores[g] || 0), 0); | |
| const avgScore = totalScore / Object.keys(grades).length; | |
| let overallGrade = 'C'; | |
| if (avgScore >= 95) overallGrade = 'S'; | |
| else if (avgScore >= 85) overallGrade = 'A++'; | |
| else if (avgScore >= 75) overallGrade = 'A+'; | |
| else if (avgScore >= 65) overallGrade = 'A'; | |
| else if (avgScore >= 45) overallGrade = 'B'; | |
| // Save to localStorage (only if better than existing) | |
| const storageKey = 'math_city_score_congruence'; | |
| const currentBest = localStorage.getItem(storageKey) || ''; | |
| const currentBestIdx = gradeOrder.indexOf(currentBest); | |
| const newIdx = gradeOrder.indexOf(overallGrade); | |
| if (currentBestIdx === -1 || newIdx < currentBestIdx) { | |
| localStorage.setItem(storageKey, overallGrade); | |
| localStorage.setItem('math_city_score_congruence_val', avgScore.toString()); | |
| } | |
| const caseTitles = { 1: '霓虹暗巷重傷害案', 2: '頂級俱樂部入侵案', 3: '市政廳塗鴉案' }; | |
| // Build case grade cards | |
| let caseCardsHtml = ''; | |
| for (let i = 1; i <= 3; i++) { | |
| const g = grades[i] || 'C'; | |
| caseCardsHtml += ` | |
| <div class="flex items-center gap-4 bg-slate-800/60 rounded-xl px-6 py-3 border border-slate-700"> | |
| <span class="font-tech text-fuchsia-500 text-sm">CASE ${i}</span> | |
| <span class="text-white text-sm flex-1">${caseTitles[i]}</span> | |
| <span class="text-2xl font-black ${gradeColors[g]}">${g}</span> | |
| </div> | |
| `; | |
| } | |
| const overallGradeColors = { | |
| 'S': 'from-amber-400 to-yellow-600', 'A++': 'from-fuchsia-400 to-pink-600', | |
| 'A+': 'from-purple-400 to-violet-600', 'A': 'from-cyan-400 to-blue-600', | |
| 'B': 'from-green-400 to-emerald-600', 'C': 'from-slate-400 to-gray-600' | |
| }; | |
| const hl = (t) => `<span class="text-yellow-400 font-bold">${t}</span>`; | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'final-summary-overlay'; | |
| overlay.className = 'fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-md overflow-y-auto py-8'; | |
| overlay.innerHTML = ` | |
| <div class="flex flex-col items-center gap-6 max-w-3xl w-full px-6"> | |
| <div class="font-tech text-fuchsia-500 text-lg tracking-[0.5em]">MISSION COMPLETE</div> | |
| <div class="text-4xl font-black text-white">辦案總結</div> | |
| <!-- Overall Grade --> | |
| <div class="text-8xl font-black bg-gradient-to-br ${overallGradeColors[overallGrade]} bg-clip-text text-transparent" style="-webkit-text-stroke: 1px rgba(255,255,255,0.1);"> | |
| ${overallGrade} | |
| </div> | |
| <!-- Case Breakdown --> | |
| <div class="flex flex-col gap-3 w-full max-w-lg"> | |
| ${caseCardsHtml} | |
| </div> | |
| <!-- Summary Text --> | |
| <div class="glass-panel rounded-xl p-6 max-w-lg w-full text-slate-300 leading-relaxed text-base space-y-4 border border-fuchsia-500/20"> | |
| <p>恭喜你成功協助警方破案了,未來或許你真的有機會成為一個好警探呢!</p> | |
| <p>三角形全等是你學習數學以來,第一次碰到要${hl('「證明」')}的內容!證明的邏輯對於判斷事情的${hl('正確與否')}非常重要,或許也能提升你的${hl('吵架能力')}(?</p> | |
| <p>一個三角形中,有${hl('3個邊')},${hl('3個角')},但每次都要看${hl('6個')}條件才能證明全等,實在太麻煩了,於是數學家想盡辦法將6個${hl('簡化')}成${hl('3個')}條件即可判斷,除了剛剛學會的${hl('SSS')}和${hl('SAS')}外,${hl('還有其他幾種')}喔!剩下的細節等上課的時候我們再慢慢說吧~</p> | |
| </div> | |
| <!-- Back Button --> | |
| <a href="index.html" class="bg-fuchsia-600 hover:bg-fuchsia-500 text-white font-bold py-4 px-12 rounded-xl text-xl font-tech tracking-widest border border-fuchsia-400/50 transition-all hover:scale-105 transform duration-200 shadow-lg shadow-fuchsia-500/30 inline-flex items-center gap-3" style="text-decoration:none"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg> | |
| 回到 Math City | |
| </a> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| } | |
| showSSSSASImages() { | |
| this.elTarget.classList.remove('hidden'); | |
| document.getElementById('evidence-panel').classList.add('hidden'); // Hide evidence panel | |
| const wrapper = document.getElementById('suspect-wrapper'); | |
| // clear wrapper | |
| wrapper.innerHTML = ''; | |
| // Add images side by side | |
| const container = document.createElement('div'); | |
| container.className = 'flex gap-8 justify-center items-center'; | |
| const img1 = document.createElement('img'); | |
| img1.src = 'Assets/triangle/犯罪證明/紅.png'; | |
| img1.className = 'h-[40vh] object-contain shadow-lg shadow-fuchsia-500/20 rounded-xl border border-slate-700'; | |
| const img2 = document.createElement('img'); | |
| img2.src = 'Assets/triangle/犯罪證明/SAS黃.png'; | |
| img2.className = 'h-[40vh] object-contain shadow-lg shadow-yellow-500/20 rounded-xl border border-slate-700'; | |
| container.appendChild(img1); | |
| container.appendChild(img2); | |
| wrapper.appendChild(container); | |
| this.actionCompleted = true; // No interaction needed, just show | |
| this.next(); // Since it's an action that completes immediately visually | |
| } | |
| showPracticeImages(actionName) { | |
| this.elTarget.classList.remove('hidden'); | |
| document.getElementById('evidence-panel').classList.add('hidden'); | |
| const wrapper = document.getElementById('suspect-wrapper'); | |
| wrapper.innerHTML = ''; | |
| const container = document.createElement('div'); | |
| container.className = 'flex gap-8 justify-center items-center'; | |
| let src1, src2; | |
| if (actionName === 'show_practice_1') { | |
| src1 = 'Assets/triangle/蛛絲馬跡/4.png'; | |
| src2 = 'Assets/triangle/嫌疑犯/2農夫.png'; | |
| } else if (actionName === 'show_practice_2') { | |
| src1 = 'Assets/triangle/蛛絲馬跡/1.png'; | |
| src2 = 'Assets/triangle/嫌疑犯/5冰淇淋師傅.png'; | |
| } | |
| const img1 = document.createElement('img'); | |
| img1.src = src1; | |
| img1.className = 'h-[70vh] object-contain shadow-lg shadow-fuchsia-500/20 rounded-xl border border-slate-700'; // Increased size | |
| const img2 = document.createElement('img'); | |
| img2.src = src2; | |
| img2.className = 'h-[70vh] object-contain shadow-lg shadow-cyan-500/20 rounded-xl border border-slate-700'; // Increased size | |
| container.appendChild(img1); | |
| container.appendChild(img2); | |
| wrapper.appendChild(container); | |
| this.actionCompleted = true; | |
| this.next(); | |
| } | |
| startInvestigation() { | |
| this.elTarget.classList.remove('hidden'); | |
| document.getElementById('evidence-panel').classList.remove('hidden'); | |
| // Switch to the Feature Logic Image | |
| const img = document.getElementById('suspect-image'); | |
| img.src = 'Assets/triangle/嫌疑犯/16造型師.png'; // Use new image with marks | |
| this.foundFeatures.clear(); | |
| this.angleCount = 0; | |
| this.sideCount = 0; | |
| this.updateStatsDisplay(); | |
| this.updateEvidenceCount(); | |
| // Adjusted positions to match "Red/Blue Circles" | |
| const hitboxes = [ | |
| // Angles (Red) | |
| // Top Vertex (Right-ish) - Averaged | |
| { id: 'angle_top', x: 70, y: 26, type: 'Angle', class: 'found-angle' }, | |
| // Bottom Left Vertex - Averaged | |
| { id: 'angle_left', x: 20, y: 53, type: 'Angle', class: 'found-angle' }, | |
| // Bottom Right Vertex - Averaged | |
| { id: 'angle_right', x: 70, y: 53, type: 'Angle', class: 'found-angle' }, | |
| // Sides (Blue) | |
| // Hypotenuse (Midpoint of Top and Left) - Averaged | |
| { id: 'side_hypotenuse', x: 45, y: 38, type: 'Side', class: 'found-side' }, | |
| // Right Side (Midpoint of Top and Right) - Averaged | |
| { id: 'side_right', x: 70, y: 40, type: 'Side', class: 'found-side' }, | |
| // Bottom Side (Midpoint of Left and Right) - Averaged | |
| { id: 'side_bottom', x: 45, y: 53, type: 'Side', class: 'found-side' } | |
| ]; | |
| // Target the wrapper logic | |
| const wrapper = document.getElementById('suspect-wrapper'); | |
| const existing = wrapper.querySelectorAll('.hitbox'); | |
| existing.forEach(e => e.remove()); | |
| hitboxes.forEach(hb => { | |
| const el = document.createElement('div'); | |
| el.className = 'hitbox'; | |
| el.style.left = hb.x + '%'; | |
| el.style.top = hb.y + '%'; | |
| el.style.transform = 'translate(-50%, -50%)'; | |
| el.dataset.id = hb.id; | |
| el.onclick = (e) => this.onFeatureClick(e, el, hb); | |
| wrapper.appendChild(el); | |
| }); | |
| } | |
| onFeatureClick(e, el, hb) { | |
| e.stopPropagation(); | |
| if (this.foundFeatures.has(el.dataset.id)) return; | |
| el.classList.add(hb.class); // Use specific color class | |
| this.foundFeatures.add(el.dataset.id); | |
| if (hb.type === 'Angle') this.angleCount++; | |
| if (hb.type === 'Side') this.sideCount++; | |
| this.updateStatsDisplay(); | |
| this.updateEvidenceCount(); // Keeps the top-right counter too | |
| // this.showFloatingText(e.clientX, e.clientY, hb.type + ' Found!'); | |
| if (hb.type === 'Angle') el.innerText = 'A'; | |
| if (hb.type === 'Side') el.innerText = 'S'; | |
| if (this.foundFeatures.size >= this.requiredFeatures) { | |
| this.completeInvestigation(); | |
| } | |
| } | |
| updateStatsDisplay() { | |
| const display = document.getElementById('stats-display'); | |
| if (display) { | |
| display.innerText = `[已找到邊數:${this.sideCount},已找到角數:${this.angleCount}]`; | |
| } | |
| } | |
| updateEvidenceCount() { | |
| const count = document.getElementById('evidence-count'); | |
| count.innerText = `${this.foundFeatures.size} / ${this.requiredFeatures}`; | |
| } | |
| showFloatingText(x, y, text) { | |
| const el = document.createElement('div'); | |
| el.className = 'fixed pointer-events-none text-white font-bold text-shadow animate-float-up z-50 text-xl'; | |
| el.style.left = x + 'px'; | |
| el.style.top = y + 'px'; | |
| el.innerText = text; | |
| document.body.appendChild(el); | |
| setTimeout(() => el.remove(), 1000); | |
| } | |
| completeInvestigation() { | |
| this.actionCompleted = true; | |
| setTimeout(() => { | |
| this.step++; | |
| // Add next dialogue/logic | |
| const fmtName = (n) => `<span class="text-amber-400 font-bold mx-1 text-xl">${n}</span>`; | |
| const nextPart = [ | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '很好,看來我們真的找對人了,三角形的所有<span class="text-yellow-400 font-bold">特徵</span>就是他的<span class="text-yellow-400 font-bold">三個邊</span>和<span class="text-yellow-400 font-bold">三個角</span>,如果六項特徵全部都符合的話,那兩個三角形一定能<span class="text-yellow-400 font-bold">完全重疊</span>在一起,表示這是<span class="text-yellow-400 font-bold">同一個三角形</span>。' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '但是我們在犯罪現場的證據,並沒有辦法這麼完整,現在我要教你<span class="text-yellow-400 font-bold">2種更好用的</span>辨認方式!' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '首先要教你一些我們的專用術語 <span class="text-yellow-400">S=Side=三角形的邊</span>,<span class="text-yellow-400">A=Angle=三角形的角</span>' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '現在請你告訴我,將「SSS」換成中文是什麼?', | |
| answer: ['邊邊邊', '三邊由', '三邊', '三個邊'], | |
| correctMsg: '沒錯!S就是邊,SSS就是邊邊邊!', | |
| wrongMsg: '不對喔... (提示:S是邊,SSS就是?)' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '那麼,「SAS」換成中文是什麼?', | |
| answer: ['邊角邊'], | |
| correctMsg: '太好了,你學得很快嘛!', | |
| wrongMsg: '再想想... (提示:S是邊,A是角,SAS就是?)' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '接下來我要教你的高級刑偵技術,也就是證明犯人的方法<span class="text-yellow-400 font-bold">SSS</span>和<span class="text-yellow-400 font-bold">SAS</span>' | |
| }, | |
| { | |
| type: 'action', | |
| action: 'show_sss_sas_images', | |
| speaker: '資深警探', | |
| text: '<span class="text-yellow-400 font-bold">SSS</span>:線索中的三個邊長和嫌疑犯的三個邊長完全相等<br><span class="text-yellow-400 font-bold">SAS</span>:線索中的兩個邊和夾起來的角,與嫌疑犯的兩個邊和夾起來的角相等<br><br>只要有找到其中一種,就能幫我們證明他就是犯人!這樣你學會了嗎?' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '請問你剛剛學到的證明方法其中一個是?(英文)', | |
| answer: ['SSS', 'SAS'], | |
| correctMsg: '沒錯!這是其中一個!', | |
| wrongMsg: '請輸入SSS或SAS' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '另一個是?', | |
| answer: ['SSS', 'SAS'], // Logic: User should enter the other one, but simplified game engine checks inclusion. | |
| // To properly 'exclude' the previous answer without complex logic injection, we'll just accept both for now | |
| // but the user phrasing implies they should know the other. | |
| // Given constraints, I'll accept both to avoid getting stuck if they repeat. | |
| correctMsg: '太棒了!你都記住了!', | |
| wrongMsg: '請輸入另一個證明方法 (SSS或SAS)' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '那SSS的中文是?', | |
| answer: ['邊邊邊'], | |
| correctMsg: '完全正確!', | |
| wrongMsg: '提示:S是邊...' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '還有SAS的中文是?', | |
| answer: ['邊角邊'], | |
| correctMsg: '太強了!你已經具備資深警探的潛力了!', | |
| wrongMsg: '提示:A是角...' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '接下來要測驗你剛剛學到的證明方法囉!' | |
| }, | |
| { | |
| type: 'action', | |
| action: 'show_practice_1', | |
| speaker: '資深警探', | |
| text: '請看這兩張圖...' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '請問要用哪個證明方法說明兩者是同一人呢?(SSS或SAS)', | |
| answer: ['SSS'], | |
| correctMsg: '答對了!三邊對應相等!', | |
| wrongMsg: '再仔細看看,是三個邊相等還是兩邊一角?' | |
| }, | |
| { | |
| type: 'action', | |
| action: 'show_practice_2', | |
| speaker: '資深警探', | |
| text: '再來試試這一題...' | |
| }, | |
| { | |
| type: 'quiz', | |
| question: '請問這題要用哪個證明方法說明兩者是同一人呢?(SSS或SAS)', | |
| answer: ['SAS'], | |
| correctMsg: '太厲害了!兩邊一夾角對應相等!', | |
| wrongMsg: '再仔細看看,有幾個邊?有幾個角?' | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: (name) => `太好了,既然${fmtName(name)}成功通過測驗了,接下來有幾個案件要請你幫忙了!` | |
| }, | |
| { | |
| type: 'dialogue', | |
| speaker: '資深警探', | |
| text: '我們會為你的辦案狀況打分數,請以<span class="text-yellow-400 font-bold">正確性</span>為<span class="text-yellow-400 font-bold">優先</span>,速度為次,抓錯犯人可是很傷腦筋的呢~' | |
| }, | |
| { | |
| type: 'action', | |
| action: 'start_case_1', | |
| speaker: 'SYSTEM', | |
| text: '案件載入中...' | |
| } | |
| ]; | |
| // Replace remaining script or append? | |
| // Since SCRIPT is simpler now, we can just splice. | |
| // NOTE: Previous logic used `startInvestigation` at step X. | |
| // We need to inject these steps AFTER the current step. | |
| // Remove old placeholder steps if any. | |
| SCRIPT.splice(this.step, SCRIPT.length - this.step, ...nextPart); | |
| // Reset step index to process the first inserted item | |
| this.processStep(); | |
| }, 1000); | |
| } | |
| appendNextPhase() { | |
| // Deprecated in favor of direct injection in completeInvestigation | |
| } | |
| showQuiz(data) { | |
| const quizLayer = document.getElementById('quiz-layer'); | |
| const quizContent = document.getElementById('quiz-content'); | |
| quizLayer.classList.remove('hidden'); | |
| // Integrated layout matching dialogue style exactly | |
| quizContent.innerHTML = ` | |
| <div class="dialogue-name">資深警探 <span class="text-xs text-slate-500 ml-2 tracking-normal">// AUTHENTICATION REQUIRED</span></div> | |
| <div class="dialogue-text mb-4">${data.question}</div> | |
| <div class="flex gap-4 w-full mt-auto items-end"> | |
| <div class="text-fuchsia-500 font-tech text-xl animate-pulse">></div> | |
| <input type="text" id="quiz-input" class="flex-1 bg-transparent border-b-2 border-fuchsia-500/50 text-white text-xl p-2 focus:outline-none focus:border-fuchsia-400 font-mono tracking-wider placeholder-slate-600" placeholder="輸入答案..." autocomplete="off" inputmode="none"> | |
| <button id="quiz-submit" class="bg-fuchsia-600 hover:bg-fuchsia-500 text-white font-bold px-8 py-2 rounded clip-path-slant text-lg font-tech tracking-widest border border-fuchsia-400/50 transition-all shadow-lg shadow-fuchsia-500/20">CONFIRM</button> | |
| </div> | |
| <div id="quiz-feedback" class="absolute top-6 right-8 font-tech text-xl font-bold"></div> | |
| `; | |
| const input = document.getElementById('quiz-input'); | |
| const btn = document.getElementById('quiz-submit'); | |
| const feedback = document.getElementById('quiz-feedback'); | |
| const checkAnswer = () => { | |
| const val = input.value.trim(); | |
| if (data.answer.includes(val)) { | |
| // Correct | |
| feedback.className = 'text-green-400 font-bold'; | |
| feedback.innerText = 'ACCESS GRANTED'; | |
| input.classList.add('text-green-400'); | |
| input.disabled = true; | |
| keypad.close(); // 關閉小鍵盤 | |
| setTimeout(() => { | |
| quizLayer.classList.add('hidden'); | |
| // Add temporary dialogue for success reaction | |
| this.showDialogue(true); | |
| this.setSpeaker('資深警探'); | |
| this.typeText(data.correctMsg); | |
| }, 1000); | |
| } else { | |
| // Wrong | |
| input.value = ''; | |
| input.classList.add('animate-shake'); | |
| // Clear shake separately | |
| setTimeout(() => { | |
| input.classList.remove('animate-shake'); | |
| }, 500); | |
| // Show persistent wrong message | |
| feedback.className = 'text-red-400 text-lg font-bold'; | |
| feedback.innerText = data.wrongMsg; | |
| } | |
| }; | |
| btn.onclick = checkAnswer; | |
| input.onkeypress = (e) => { if (e.key === 'Enter') checkAnswer(); }; | |
| // 自動開啟小鍵盤(觸控裝置)或點擊輸入框時開啟 | |
| if (isTouchDevice) { | |
| // 觸控裝置自動開啟 | |
| setTimeout(() => { | |
| keypad.open(input, checkAnswer); | |
| }, 300); | |
| } | |
| // 點擊輸入框也可以開啟小鍵盤 | |
| input.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| keypad.open(input, checkAnswer); | |
| }); | |
| input.addEventListener('focus', (e) => { | |
| if (isTouchDevice) { | |
| e.preventDefault(); | |
| keypad.open(input, checkAnswer); | |
| } | |
| }); | |
| } | |
| // Add shake animation style dynamically | |
| addStyles() { | |
| const style = document.createElement('style'); | |
| style.innerHTML = ` | |
| @keyframes float-up { | |
| 0% { transform: translateY(0); opacity: 1; } | |
| 100% { transform: translateY(-50px); opacity: 0; } | |
| } | |
| .animate-float-up { | |
| animation: float-up 1s ease-out forwards; | |
| } | |
| @keyframes shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 25% { transform: translateX(-5px); } | |
| 75% { transform: translateX(5px); } | |
| } | |
| .animate-shake { | |
| animation: shake 0.3s ease-in-out; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| } | |
| // Init Game | |
| window.onload = () => { | |
| window.game = new GameEngine(); | |
| }; | |
| </script> | |
| </body> | |
| </html> |