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"> | |
| <title>能源核心 - The Energy Core</title> | |
| <!-- Google Fonts --> | |
| <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"> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| :root { | |
| --glass-bg: rgba(8, 51, 68, 0.85); | |
| /* Dark Cyan */ | |
| --glass-border: rgba(6, 182, 212, 0.3); | |
| /* Cyan Border */ | |
| } | |
| body { | |
| font-family: 'Noto Sans TC', sans-serif; | |
| background-color: #051015; | |
| /* Slate 900 */ | |
| color: white; | |
| overflow: hidden; | |
| /* No scrollbars */ | |
| -webkit-user-select: none; | |
| user-select: none; | |
| /* Global Background */ | |
| background-image: url('Assets/index/functionbg.png'); | |
| background-size: cover; | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| background-attachment: fixed; | |
| } | |
| /* Dark overlay for the whole page */ | |
| #global-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| height: 100dvh; | |
| background: rgba(5, 16, 21, 0.6); | |
| /* Semi-transparent Slate 900 */ | |
| z-index: -1; | |
| } | |
| /* Canvas as fixed background behind UI but above bg image */ | |
| #gameCanvas { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| height: 100dvh; | |
| z-index: 0; | |
| } | |
| /* Removed legacy .dialogue-default and #formula-banner positioning */ | |
| /* Layout is now handled by #dialogue-container utility classes in HTML */ | |
| #dialogue-box.translate-y-full { | |
| transform: translateY(150%); | |
| } | |
| /* Tech UI Styling */ | |
| .glass-panel { | |
| background: var(--glass-bg); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.5); | |
| } | |
| .font-tech { | |
| font-family: 'Orbitron', sans-serif; | |
| } | |
| /* Input Styling */ | |
| .tech-input { | |
| background: rgba(15, 23, 42, 0.8); | |
| border: 1px solid #475569; | |
| color: #22d3ee; | |
| font-family: 'Orbitron', sans-serif; | |
| transition: all 0.3s ease; | |
| text-align: center; | |
| } | |
| .tech-input:focus { | |
| outline: none; | |
| border-color: #22d3ee; | |
| box-shadow: 0 0 10px rgba(34, 211, 238, 0.3); | |
| } | |
| /* Hide Scrollbar completely */ | |
| ::-webkit-scrollbar { | |
| display: none; | |
| } | |
| * { | |
| -ms-overflow-style: none; | |
| scrollbar-width: none; | |
| } | |
| /* Virtual Keypad */ | |
| #virtual-keypad { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| left: auto; | |
| /* Remove centering */ | |
| transform: translateY(120%); | |
| /* Start hidden down */ | |
| z-index: 1000; | |
| background: rgba(15, 23, 42, 0.95); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| border: 1px solid rgba(34, 211, 238, 0.3); | |
| border-radius: 20px; | |
| padding: 16px; | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 12px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); | |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| /* Dragging */ | |
| touch-action: none; | |
| } | |
| #virtual-keypad.active { | |
| transform: translateX(-50%) translateY(0); | |
| } | |
| #virtual-keypad.dragging { | |
| transition: none; | |
| transform: none; | |
| /* Controlled by JS during drag */ | |
| } | |
| .keypad-handle { | |
| grid-column: span 3; | |
| height: 20px; | |
| margin-bottom: 5px; | |
| background: rgba(255, 255, 255, 0.2); | |
| 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.4); | |
| border-radius: 2px; | |
| } | |
| .keypad-btn { | |
| width: 60px; | |
| height: 60px; | |
| border-radius: 12px; | |
| background: rgba(30, 41, 59, 0.6); | |
| border: 1px solid rgba(148, 163, 184, 0.2); | |
| color: white; | |
| font-size: 24px; | |
| font-weight: bold; | |
| font-family: 'Orbitron', sans-serif; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.1s; | |
| user-select: none; | |
| } | |
| .keypad-btn:active { | |
| transform: scale(0.95); | |
| background: rgba(34, 211, 238, 0.2); | |
| } | |
| .keypad-btn.action { | |
| background: rgba(15, 23, 42, 0.8); | |
| border-color: rgba(34, 211, 238, 0.4); | |
| color: #22d3ee; | |
| } | |
| .keypad-btn.submit { | |
| background: rgba(6, 182, 212, 0.8); | |
| color: white; | |
| grid-column: span 3; | |
| width: 100%; | |
| height: 50px; | |
| font-size: 18px; | |
| margin-top: 4px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Global Dark Overlay --> | |
| <div id="global-overlay"></div> | |
| <!-- Background Canvas --> | |
| <canvas id="gameCanvas"></canvas> | |
| <!-- UI HUD --> | |
| <div class="fixed top-4 right-4 z-50"> | |
| <a href="index.html" | |
| class="glass-panel rounded-xl px-3 py-2 flex items-center justify-center text-amber-400 hover:bg-slate-800 transition-colors border border-amber-500/30 pointer-events-auto shadow-lg 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> | |
| <!-- Main UI Container --> | |
| <div id="ui-container" class="fixed inset-0 z-10 pointer-events-none"> | |
| <!-- | |
| Dialogue Container: | |
| - Anchored Vertically Centered Left (Desktop/Tablet) | |
| - Flex Column to stack Banner on top of Box | |
| --> | |
| <div id="dialogue-container" | |
| class="fixed top-1/2 left-4 right-4 md:left-12 md:right-auto md:w-auto -translate-y-1/2 flex flex-col gap-4 items-center md:items-start pointer-events-none z-40"> | |
| <!-- Formula Banner --> | |
| <div id="formula-banner" class="hidden pointer-events-auto w-full transition-opacity duration-500"> | |
| <div | |
| class="glass-panel px-6 py-4 md:px-8 md:py-5 rounded-3xl border-2 border-cyan-500/30 flex items-center justify-center gap-6 shadow-[0_0_20px_rgba(34,211,238,0.2)]"> | |
| <span | |
| class="text-base md:text-xl text-slate-400 font-bold tracking-widest whitespace-nowrap">核心運作法則</span> | |
| <div class="flex items-center gap-1 md:gap-2 text-3xl md:text-4xl font-bold font-tech"> | |
| <span class="text-cyan-400">y</span> | |
| <span class="text-slate-500">=</span> | |
| <span class="text-slate-200">2</span> | |
| <span class="text-amber-400">x</span> | |
| <span class="text-slate-500">+</span> | |
| <span class="text-slate-200">3</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Interactive Dialogue Box --> | |
| <div id="dialogue-box" | |
| class="pointer-events-auto w-full glass-panel rounded-3xl p-6 md:p-8 transform transition-all duration-500 translate-y-20 opacity-0 min-h-[200px] flex flex-col justify-center shadow-[0_0_50px_rgba(0,0,0,0.5)] bg-slate-900/90 frame-border"> | |
| <!-- Dynamic Content Injected Here --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Start Overlay --> | |
| <div id="start-overlay" | |
| class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm"> | |
| <div class="text-center relative z-10"> | |
| <h1 | |
| class="text-6xl md:text-8xl font-black text-white mb-2 tracking-widest drop-shadow-[0_0_15px_rgba(34,211,238,0.8)]"> | |
| 能源核心 | |
| </h1> | |
| <h2 | |
| class="text-3xl md:text-4xl font-bold font-tech text-cyan-400 mb-8 tracking-[0.5em] uppercase opacity-80"> | |
| Energy Core | |
| </h2> | |
| <button onclick="startGame()" | |
| class="group relative px-10 py-4 bg-cyan-600/80 hover:bg-cyan-500 text-white font-bold rounded-xl transition-all shadow-[0_0_20px_rgba(6,182,212,0.4)] text-3xl tracking-widest border border-cyan-400/50 backdrop-blur-sm overflow-hidden"> | |
| <span class="relative z-10">進入核心</span> | |
| <div | |
| class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:animate-shimmer"> | |
| </div> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Credits --> | |
| <div class="fixed top-2 right-2 text-right text-[10px] text-slate-500 z-50"> | |
| <div>Designed by L.Star</div> | |
| </div> | |
| <!-- Virtual Keypad HTML --> | |
| <div id="virtual-keypad" onclick="event.stopPropagation()"> | |
| <div class="keypad-handle"></div> | |
| <div class="keypad-btn" onclick="keypad.input(1)">1</div> | |
| <div class="keypad-btn" onclick="keypad.input(2)">2</div> | |
| <div class="keypad-btn" onclick="keypad.input(3)">3</div> | |
| <div class="keypad-btn" onclick="keypad.input(4)">4</div> | |
| <div class="keypad-btn" onclick="keypad.input(5)">5</div> | |
| <div class="keypad-btn" onclick="keypad.input(6)">6</div> | |
| <div class="keypad-btn" onclick="keypad.input(7)">7</div> | |
| <div class="keypad-btn" onclick="keypad.input(8)">8</div> | |
| <div class="keypad-btn" onclick="keypad.input(9)">9</div> | |
| <div class="keypad-btn action" onclick="keypad.input('-')">-</div> | |
| <div class="keypad-btn" onclick="keypad.input(0)">0</div> | |
| <div class="keypad-btn action" onclick="keypad.backspace()">⌫</div> | |
| <div class="keypad-btn submit" onclick="keypad.next()">下一格 / 完成 (ENTER)</div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let width, height; | |
| // --- Virtual Keypad System --- | |
| const keypad = { | |
| element: null, | |
| targetInput: null, | |
| init: function () { | |
| this.element = document.getElementById('virtual-keypad'); | |
| // Prevent click bubbling from keypad to document (stops accidental closes if logic fails) | |
| 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.startX = 0; this.startY = 0; | |
| this.offsetX = 0; this.offsetY = 0; | |
| const startDrag = (e) => { | |
| // Prevent text selection/scrolling on touch | |
| 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; | |
| // Add listeners to document for smooth dragging outside element | |
| 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(); // Critical for stopping scroll on mobile | |
| const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX; | |
| const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY; | |
| // Absolute positioning | |
| this.element.style.transform = 'none'; | |
| this.element.style.bottom = 'auto'; // Clear bottom | |
| this.element.style.right = 'auto'; // Clear right | |
| 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); | |
| }; | |
| // Attach start listeners | |
| if (handle) { | |
| handle.addEventListener('mousedown', startDrag); | |
| handle.addEventListener('touchstart', startDrag, { passive: false }); | |
| handle.style.cursor = 'grab'; | |
| } | |
| }, | |
| open: function (inputElement) { | |
| this.targetInput = inputElement; | |
| this.element.classList.add('active'); | |
| // Highlight input | |
| document.querySelectorAll('.tech-input').forEach(el => el.classList.remove('border-amber-400', 'ring-2', 'ring-amber-400')); | |
| inputElement.classList.add('border-amber-400', 'ring-2', 'ring-amber-400'); | |
| }, | |
| close: function () { | |
| this.element.classList.remove('active'); | |
| if (this.targetInput) { | |
| this.targetInput.classList.remove('border-amber-400', 'ring-2', 'ring-amber-400'); | |
| // Trigger change event if needed | |
| this.targetInput.dispatchEvent(new Event('change')); | |
| this.targetInput = null; | |
| } | |
| }, | |
| input: function (val) { | |
| if (!this.targetInput) return; | |
| let currentVal = this.targetInput.value; | |
| if (val === '-') { | |
| if (currentVal.startsWith('-')) this.targetInput.value = currentVal.substring(1); | |
| else this.targetInput.value = '-' + currentVal; | |
| } else { | |
| this.targetInput.value = currentVal + val; | |
| } | |
| }, | |
| backspace: function () { | |
| if (!this.targetInput) return; | |
| this.targetInput.value = this.targetInput.value.slice(0, -1); | |
| }, | |
| next: function () { | |
| if (!this.targetInput) return; | |
| const currentId = this.targetInput.id; | |
| if (currentId === 'final-a') { | |
| this.open(document.getElementById('final-b')); | |
| return; | |
| } | |
| this.close(); | |
| if (currentId === 'ans-input') { | |
| if (currentState === STATE.PHASE2_Q) checkQAnswer(); | |
| else if (currentState === STATE.PHASE3_CRISIS) checkFinalAnswer(); | |
| else if (currentState === STATE.PHASE4_FIND_B) checkPhase4B(); | |
| else if (currentState === STATE.PHASE4_FIND_A) checkPhase4A(); | |
| else if (currentState === STATE.PHASE4_VERIFY) checkPhase4Verify(); | |
| } else if (currentId === 'test-input') { | |
| runPhase4Test(); | |
| } else if (currentId === 'test-input-a') { | |
| runPhase4TestA(); | |
| } else if (currentId === 'test-input-verify') { | |
| if (typeof runPhase4VerifyManual === 'function') runPhase4VerifyManual(); | |
| } else if (currentId === 'final-b') { | |
| unlockCore(); | |
| } | |
| } | |
| }; | |
| // Initialize immediately | |
| keypad.init(); | |
| document.addEventListener('keydown', (e) => { | |
| if (keypad.element && keypad.element.classList.contains('active') && keypad.targetInput) { | |
| if (e.key >= '0' && e.key <= '9') { | |
| keypad.input(e.key); | |
| } else if (e.key === '-' || e.key === '_') { | |
| keypad.input('-'); | |
| } else if (e.key === 'Backspace') { | |
| keypad.backspace(); | |
| } else if (e.key === 'Enter' || e.key === 'Tab') { | |
| e.preventDefault(); | |
| keypad.next(); | |
| } else if (e.key === 'Escape') { | |
| keypad.close(); | |
| } | |
| } | |
| }); | |
| // Formula: y = ax + b | |
| let currentA = 2; | |
| let currentB = 3; | |
| const STATE = { | |
| INIT: 0, | |
| STORY_BRIEF: 1, | |
| PHASE1_INTRO: 2, | |
| PHASE2_Q: 3, // General Question State | |
| PHASE2_COORDINATE: 3.5, // New Step: Coordinate Confirmation | |
| PHASE2_PATTERN: 4, | |
| PHASE2_PREDICTION_INTRO: 4.5, | |
| PHASE3_CRISIS: 5, | |
| SUCCESS: 6, | |
| PHASE4_INTRO: 8, | |
| PHASE4_TEST_B: 8.5, | |
| PHASE4_FIND_B: 9, | |
| PHASE4_TEST_A: 9.5, | |
| PHASE4_FIND_A: 10, | |
| PHASE4_VERIFY: 11, | |
| PHASE4_VERIFY_TEST: 11.5, | |
| PHASE5_UNLOCK: 12, | |
| PHASE6_INTRO: 13, | |
| PHASE6_GAME: 14, | |
| PHASE7_SUMMARY: 15, | |
| FAIL: 7 | |
| }; | |
| const checkPoints = [2, 5, 1, 3, 4]; // The sequence of Xs to check | |
| let collectedPoints = []; | |
| let particles = []; | |
| let graphScale = { xMax: 10, yMax: 20 }; | |
| let corePulse = 1.0; | |
| let coreRotation = 0; | |
| // --- Phase 6: Rhythm Game State --- | |
| let rhythmState = { | |
| active: false, | |
| energy: 0, // 0 to 100 | |
| score: 0, | |
| combo: 0, | |
| maxCombo: 0, | |
| circles: [], // Array of falling circles | |
| lastSpawnTime: 0, | |
| spawnInterval: 1200, // ms | |
| texts: [] // Floating texts (Perfect/Good) | |
| }; | |
| const CORE_RADIUS = 80; // Acceptance radius (Matches visual core size) | |
| // --- State Variables --- | |
| let qStep = 0; | |
| let currentState = STATE.INIT; | |
| // Guide Arrow State | |
| let guideArrowState = { | |
| active: false, | |
| startTime: 0, | |
| targetX: 0, | |
| targetY: 0 | |
| }; | |
| // --- Audio --- | |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| function playSound(type) { | |
| if (audioCtx.state === 'suspended') audioCtx.resume(); | |
| const now = audioCtx.currentTime; | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| if (type === 'tick') { | |
| osc.frequency.setValueAtTime(800, now); | |
| osc.frequency.exponentialRampToValueAtTime(1200, now + 0.05); | |
| gain.gain.setValueAtTime(0.05, now); | |
| gain.gain.linearRampToValueAtTime(0, now + 0.05); | |
| osc.start(now); osc.stop(now + 0.05); | |
| } else if (type === 'success') { | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(440, now); | |
| osc.frequency.setValueAtTime(659, now + 0.1); // E | |
| osc.frequency.setValueAtTime(880, now + 0.2); // A | |
| gain.gain.setValueAtTime(0.2, now); | |
| gain.gain.linearRampToValueAtTime(0, now + 0.5); | |
| osc.start(now); osc.stop(now + 0.5); | |
| } else if (type === 'alarm') { | |
| osc.type = 'sawtooth'; | |
| osc.frequency.setValueAtTime(200, now); | |
| osc.frequency.linearRampToValueAtTime(100, now + 0.3); | |
| gain.gain.setValueAtTime(0.2, now); | |
| gain.gain.linearRampToValueAtTime(0, now + 0.3); | |
| osc.start(now); osc.stop(now + 0.3); | |
| } else if (type === 'hit_perfect') { | |
| // High pitch ping | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(1200, now); | |
| osc.frequency.exponentialRampToValueAtTime(800, now + 0.1); | |
| gain.gain.setValueAtTime(0.3, now); | |
| gain.gain.linearRampToValueAtTime(0, now + 0.1); | |
| osc.start(now); osc.stop(now + 0.1); | |
| } else if (type === 'hit_good') { | |
| // Mid pitch | |
| osc.type = 'triangle'; | |
| osc.frequency.setValueAtTime(600, now); | |
| gain.gain.setValueAtTime(0.2, now); | |
| gain.gain.linearRampToValueAtTime(0, now + 0.1); | |
| osc.start(now); osc.stop(now + 0.1); | |
| } | |
| } | |
| // --- BGM System --- | |
| let bgmNodes = []; | |
| let isBgmPlaying = false; | |
| function startRhythmBGM() { | |
| if (isBgmPlaying) return; | |
| isBgmPlaying = true; | |
| if (audioCtx.state === 'suspended') audioCtx.resume(); | |
| // Simple "Techno" Loop using Oscillator interactions | |
| const baseFreq = 110; // A2 | |
| const oscLow = audioCtx.createOscillator(); | |
| const oscHigh = audioCtx.createOscillator(); | |
| const gainLow = audioCtx.createGain(); | |
| const gainHigh = audioCtx.createGain(); | |
| oscLow.type = 'square'; | |
| oscLow.frequency.value = baseFreq; | |
| oscHigh.type = 'sawtooth'; | |
| oscHigh.frequency.value = baseFreq * 2; | |
| // LFO for rhythmic pulsing | |
| const lfo = audioCtx.createOscillator(); | |
| lfo.type = 'square'; | |
| lfo.frequency.value = 4; // 240 BPM pulses (fast) | |
| const lfoGain = audioCtx.createGain(); | |
| lfoGain.gain.value = 0.5; | |
| lfo.connect(lfoGain); | |
| lfoGain.connect(gainLow.gain); | |
| lfoGain.connect(gainHigh.gain); | |
| oscLow.connect(gainLow); | |
| oscHigh.connect(gainHigh); | |
| gainLow.connect(audioCtx.destination); | |
| gainHigh.connect(audioCtx.destination); | |
| // Base volume | |
| gainLow.gain.setValueAtTime(0.1, audioCtx.currentTime); | |
| gainHigh.gain.setValueAtTime(0.05, audioCtx.currentTime); | |
| oscLow.start(); | |
| oscHigh.start(); | |
| lfo.start(); | |
| bgmNodes = [oscLow, oscHigh, lfo]; | |
| } | |
| function stopRhythmBGM() { | |
| if (!isBgmPlaying) return; | |
| bgmNodes.forEach(node => { | |
| try { node.stop(); } catch (e) { } | |
| try { node.disconnect(); } catch (e) { } | |
| }); | |
| bgmNodes = []; | |
| isBgmPlaying = false; | |
| } | |
| // --- Lifecycle --- | |
| function startGame() { | |
| // Robust Audio Unlock | |
| if (audioCtx.state === 'suspended') { | |
| audioCtx.resume().then(() => { | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| gain.gain.value = 0; | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| osc.start(0); | |
| osc.stop(0.1); | |
| console.log("Audio Context Resumed"); | |
| playSound('tick'); | |
| }); | |
| } else { | |
| playSound('tick'); | |
| } | |
| // Try to enter fullscreen | |
| try { | |
| if (document.documentElement.requestFullscreen) { | |
| document.documentElement.requestFullscreen().catch(e => { | |
| console.log("Fullscreen request failed (likely user denied):", e); | |
| }); | |
| } else if (document.documentElement.webkitRequestFullscreen) { /* Safari */ | |
| document.documentElement.webkitRequestFullscreen(); | |
| } else if (document.documentElement.msRequestFullscreen) { /* IE11 */ | |
| document.documentElement.msRequestFullscreen(); | |
| } | |
| } catch (err) { | |
| console.log("Fullscreen not supported or allowed"); | |
| } | |
| document.getElementById('start-overlay').classList.add('hidden'); | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| // Input for Rhythm Game | |
| window.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space' && currentState === STATE.PHASE6_GAME) { | |
| handleRhythmInput(); | |
| } | |
| }); | |
| window.addEventListener('mousedown', (e) => { | |
| if (currentState === STATE.PHASE6_GAME) { | |
| handleRhythmInput(); | |
| } | |
| }); | |
| window.addEventListener('touchstart', (e) => { | |
| if (currentState === STATE.PHASE6_GAME) { | |
| handleRhythmInput(); | |
| e.preventDefault(); // Prevent zoom/scroll | |
| } | |
| }, { passive: false }); | |
| loop(); | |
| enterPhase(STATE.STORY_BRIEF); | |
| } | |
| function resize() { | |
| const dpr = window.devicePixelRatio || 1; | |
| width = window.innerWidth; | |
| height = window.innerHeight; | |
| // Fix resolution on high-DPI displays | |
| canvas.width = width * dpr; | |
| canvas.height = height * dpr; | |
| canvas.style.width = width + 'px'; | |
| canvas.style.height = height + 'px'; | |
| // Reset scale before applying new one | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| ctx.scale(dpr, dpr); | |
| } | |
| function drawGuideArrows() { | |
| const now = Date.now(); | |
| const elapsed = (now - guideArrowState.startTime) / 1000; | |
| const duration = 2.0; // Show for 2 seconds | |
| if (elapsed > duration) { | |
| guideArrowState.active = false; | |
| return; | |
| } | |
| const tx = guideArrowState.targetX; | |
| const ty = guideArrowState.targetY; | |
| const isDesktop = width > 768; | |
| // Convert graph coords to canvas coords | |
| // Logic sync with drawGraph | |
| const padding = 60; | |
| const gw = isDesktop ? width * 0.53 : Math.min(600, width - 40); | |
| const gh = isDesktop ? height * 0.6 : Math.min(400, height * 0.5); | |
| // On Desktop, position graph on the right side (Start at 44% width) | |
| // On Mobile, center it | |
| const gx = isDesktop ? (width * 0.44) : (width - gw) / 2; | |
| const gy = isDesktop ? height * 0.2 : height * 0.1; | |
| const ox = gx + padding; | |
| const oy = gy + gh - padding; | |
| const drawW = gw - padding * 2; | |
| const drawH = gh - padding * 2; | |
| const unitX = drawW / graphScale.xMax; | |
| const unitY = drawH / graphScale.yMax; | |
| const canvasX = ox + tx * unitX; | |
| const canvasY = oy - ty * unitY; | |
| const progress = Math.min(elapsed * 2, 1); // Animate in quickly | |
| ctx.save(); | |
| ctx.lineWidth = 4; | |
| ctx.lineCap = 'round'; | |
| // Draw X-axis Arrow (Amber, Up) | |
| ctx.strokeStyle = '#F59E0B'; // Amber-500 | |
| ctx.fillStyle = '#F59E0B'; | |
| ctx.beginPath(); | |
| ctx.moveTo(canvasX, oy); // Start from X-axis | |
| ctx.lineTo(canvasX, oy - (oy - canvasY) * progress); | |
| ctx.stroke(); | |
| // Draw Arrowhead if fully extended | |
| if (progress > 0.8) { | |
| ctx.beginPath(); | |
| ctx.moveTo(canvasX, canvasY + 10); | |
| ctx.lineTo(canvasX - 5, canvasY + 20); | |
| ctx.lineTo(canvasX + 5, canvasY + 20); | |
| ctx.fill(); | |
| } | |
| // Draw Y-axis Arrow (Cyan, Right) | |
| ctx.strokeStyle = '#22D3EE'; // Cyan-400 | |
| ctx.fillStyle = '#22D3EE'; | |
| ctx.beginPath(); | |
| ctx.moveTo(ox, canvasY); // Start from Y-axis | |
| ctx.lineTo(ox + (canvasX - ox) * progress, canvasY); | |
| ctx.stroke(); | |
| // Draw Arrowhead | |
| if (progress > 0.8) { | |
| ctx.beginPath(); | |
| ctx.moveTo(canvasX - 10, canvasY); | |
| ctx.lineTo(canvasX - 20, canvasY - 5); | |
| ctx.lineTo(canvasX - 20, canvasY + 5); | |
| ctx.fill(); | |
| } | |
| // Highlight Point | |
| if (progress > 0.9) { | |
| ctx.beginPath(); | |
| ctx.arc(canvasX, canvasY, 8, 0, Math.PI * 2); | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#F59E0B'; | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| // --- State Management --- | |
| function enterPhase(phase) { | |
| if (window.keypad) keypad.close(); // Auto-close keypad on any phase change | |
| currentState = phase; | |
| updateUI(); | |
| } | |
| function updateUI(errorMsg = null) { | |
| const box = document.getElementById('dialogue-box'); | |
| const container = document.getElementById('dialogue-container'); | |
| const formulaBanner = document.getElementById('formula-banner'); | |
| // Default Container State: | |
| // Desktop: Fixed Left Side (Start 2%, Width 46%). Vertically Centered. | |
| // Reduced gap: Left ends at 40%, Right starts at 48%. | |
| container.className = "fixed z-40 left-4 right-4 top-1/2 -translate-y-1/2 md:translate-y-0 md:top-0 md:bottom-0 md:left-[5%] md:right-auto md:w-[35%] flex flex-col gap-6 justify-center items-center pointer-events-none"; | |
| // Default Box State | |
| box.className = "pointer-events-auto w-full glass-panel rounded-3xl p-6 md:p-8 transform transition-all duration-500 min-h-[200px] flex flex-col justify-center shadow-[0_0_50px_rgba(0,0,0,0.5)] bg-slate-900/90 frame-border"; | |
| // Specific overrides | |
| if (currentState === STATE.PHASE7_SUMMARY) { | |
| // Summary: Center Screen | |
| container.className = "fixed inset-0 flex items-center justify-center pointer-events-auto z-50"; | |
| box.className = "w-[95%] md:w-[800px] glass-panel rounded-3xl p-8 transition-all duration-500 min-h-[400px] flex flex-col justify-start shadow-[0_0_80px_rgba(0,0,0,0.8)] bg-slate-900/95 frame-border relative"; | |
| } else if (currentState === STATE.PHASE6_GAME) { | |
| box.classList.add('hidden'); | |
| container.classList.add('hidden'); | |
| } else if (currentState === STATE.STORY_BRIEF) { | |
| // Story Brief: Remove fixed width, use full container width | |
| // box.classList.add('md:w-[600px]'); // REMOVED | |
| box.classList.remove('hidden'); | |
| container.classList.remove('hidden'); | |
| } else { | |
| // Normal Phase: Allow full width (controlled by container) | |
| // box.classList.add('md:w-[480px]'); // REMOVED | |
| formulaBanner.className = "hidden pointer-events-auto w-full transition-opacity duration-500"; | |
| box.classList.remove('hidden'); | |
| container.classList.remove('hidden'); | |
| } | |
| box.classList.remove('translate-y-full', 'opacity-0'); | |
| let html = ''; | |
| switch (currentState) { | |
| case STATE.STORY_BRIEF: | |
| formulaBanner.classList.add('hidden'); | |
| html = ` | |
| <h2 class="text-4xl font-bold text-amber-400 mb-6">MATH CITY 電力危機</h2> | |
| <p class="text-slate-200 text-3xl leading-relaxed mb-8"> | |
| 能源核心是整個城市的電力來源,但現在供電系統出了一些問題,需要你來協助修復。<br><br> | |
| 但在正式維修前,我們需要先檢測你的運算能力。 | |
| </p> | |
| <button onclick="enterPhase(STATE.PHASE1_INTRO)" class="w-full py-5 bg-cyan-600 hover:bg-cyan-500 text-white rounded-xl font-bold text-3xl shadow-lg"> | |
| 開始檢測能力 | |
| </button> | |
| `; | |
| break; | |
| case STATE.PHASE1_INTRO: | |
| formulaBanner.classList.remove('hidden'); | |
| html = ` | |
| <h2 class="text-2xl md:text-3xl font-bold text-cyan-400 mb-4">核心運作法則確認</h2> | |
| <div class="text-center bg-slate-800/80 p-6 rounded-xl border border-cyan-500/30 mb-8"> | |
| <div class="font-tech text-2xl md:text-4xl mb-6"> | |
| <span class="text-cyan-400">y</span> = 2<span class="text-amber-400">x</span> + 3 | |
| </div> | |
| <p class="text-slate-300 text-lg md:text-xl leading-relaxed font-bold"> | |
| 每投入 <span class="text-amber-400 font-bold text-2xl md:text-4xl mx-2 border-b-2 border-amber-400/50">x</span> 顆晶石,<br>就會產生 <span class="text-cyan-400 font-bold text-2xl md:text-4xl mx-2 border-b-2 border-cyan-400/50">y</span> 點電力。 | |
| </p> | |
| </div> | |
| <button onclick="startQPhase()" class="w-full py-5 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-bold text-xl border border-slate-500"> | |
| 我準備好了 (NEXT) | |
| </button> | |
| `; | |
| break; | |
| case STATE.PHASE2_Q: | |
| formulaBanner.classList.remove('hidden'); | |
| const xVal = checkPoints[qStep]; | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2 text-center">能力檢測 (${qStep + 1}/${checkPoints.length})</h3> | |
| <div class="grid grid-cols-2 gap-6 mb-2 mt-4"> | |
| <!-- Left: Question --> | |
| <div class="flex flex-col gap-4"> | |
| <div class="text-cyan-400 font-bold text-xl border-b border-cyan-500/30 pb-2">1. 算出電力 (y)</div> | |
| <p class="text-slate-300 text-lg"> | |
| 若投入 <span class="text-amber-400 font-bold text-2xl">${xVal}</span> <span class="text-slate-400 text-base">(<span class="text-amber-400">x=${xVal}</span>)</span> 顆晶石... | |
| </p> | |
| <div class="flex items-center justify-center gap-2 flex-grow"> | |
| <span class="font-tech text-3xl text-cyan-400">y = </span> | |
| <div class="relative w-32"> | |
| <input id="ans-input" type="text" readonly onclick="keypad.open(this)" class="tech-input w-full p-2 text-3xl rounded-lg" placeholder="?" > | |
| <div class="absolute inset-0 border-2 border-slate-500/50 rounded-lg pointer-events-none"></div> | |
| </div> | |
| </div> | |
| <button onclick="checkQAnswer()" class="w-full h-16 bg-cyan-600 hover:bg-cyan-500 text-white rounded-xl font-bold text-xl shadow-lg flex items-center justify-center"> | |
| 確認電力 | |
| </button> | |
| </div> | |
| <!-- Right: Placeholder (Visible but Inactive) --> | |
| <div class="flex flex-col gap-4 opacity-50 grayscale"> | |
| <div class="text-slate-400 font-bold text-xl border-b border-slate-500/30 pb-2">2. 坐標描點</div> | |
| <div class="bg-slate-800/30 p-4 rounded-xl border border-slate-700/30 flex flex-col items-center justify-center flex-grow"> | |
| <div class="text-sm text-slate-500 mb-1">對應座標</div> | |
| <div class="font-tech text-lg md:text-xl font-bold text-slate-500"> | |
| (<span class="text-amber-400">${xVal}</span>, <span class="text-cyan-400">y</span>) | |
| </div> | |
| </div> | |
| <button class="w-full h-16 bg-slate-700 text-slate-500 rounded-xl font-bold text-xl border border-slate-600 cursor-not-allowed flex items-center justify-center"> | |
| 描繪點 (PLOT) | |
| </button> | |
| </div> | |
| </div> | |
| ${errorMsg ? `<div class="text-red-400 text-center font-bold mt-2 border-2 border-red-400 border-dashed animate-pulse bg-slate-900/50 p-2 rounded">${errorMsg}</div>` : ''} | |
| `; | |
| break; | |
| case STATE.PHASE2_COORDINATE: | |
| formulaBanner.classList.remove('hidden'); | |
| const xValC = checkPoints[qStep]; | |
| const yValC = 2 * xValC + 3; | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2 text-center">能力檢測 (${qStep + 1}/${checkPoints.length})</h3> | |
| <div class="grid grid-cols-2 gap-6 mb-2 mt-4"> | |
| <!-- Left: Math Result (Confirmed) --> | |
| <div class="flex flex-col gap-4 opacity-60 grayscale-[0.5] pointer-events-none"> | |
| <div class="text-cyan-400 font-bold text-xl border-b border-cyan-500/30 pb-2">1. 算出電力 (y)</div> | |
| <p class="text-slate-300 text-lg"> | |
| 若投入 <span class="text-amber-400 font-bold text-2xl">${xValC}</span> <span class="text-slate-400 text-base">(<span class="text-amber-400">x=${xValC}</span>)</span> 顆晶石... | |
| </p> | |
| <div class="flex items-center justify-center gap-2 flex-grow"> | |
| <span class="font-tech text-3xl text-cyan-400">y = </span> | |
| <div class="relative w-32"> | |
| <input type="text" class="tech-input w-full p-2 text-3xl rounded-lg bg-slate-800 text-cyan-400 border-cyan-500" value="${yValC}" disabled> | |
| </div> | |
| </div> | |
| <button class="w-full h-16 bg-slate-700 text-slate-400 rounded-xl font-bold text-xl border border-slate-600 flex items-center justify-center"> | |
| 已確認 | |
| </button> | |
| </div> | |
| <!-- Right: Coordinate (Two States) --> | |
| <div class="flex flex-col gap-4"> | |
| <div class="text-amber-400 font-bold text-xl border-b border-amber-500/30 pb-2">2. 坐標描點</div> | |
| <!-- Step 1: Pre-substitution --> | |
| <div id="coord-step-1" class="flex flex-col gap-4 h-full"> | |
| <div class="bg-slate-800/80 p-4 rounded-xl border border-cyan-500/50 flex flex-col items-center justify-center flex-grow"> | |
| <div class="text-sm text-cyan-300 font-bold mb-2 tracking-widest">對應座標</div> | |
| <div class="font-tech text-2xl md:text-4xl font-bold z-10 mb-2"> | |
| (<span class="text-amber-400">${xValC}</span>, <span class="text-cyan-400">y</span>) | |
| </div> | |
| </div> | |
| <button onclick="substituteY()" class="w-full h-16 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold text-xl shadow-lg transition-colors flex items-center justify-center"> | |
| 將 y 代入 | |
| </button> | |
| </div> | |
| <!-- Step 2: Post-substitution (Hidden initially) --> | |
| <div id="coord-step-2" class="hidden flex flex-col gap-4 h-full"> | |
| <div class="bg-slate-800/80 p-4 rounded-xl border border-cyan-500 shadow-[0_0_20px_rgba(34,211,238,0.2)] flex flex-col items-center justify-center relative overflow-hidden transform transition-all flex-grow animate-pulse-once"> | |
| <div class="text-sm text-cyan-300 font-bold mb-2 tracking-widest">對應座標</div> | |
| <div class="font-tech text-2xl md:text-4xl font-bold z-10 mb-2"> | |
| (<span class="text-amber-400">${xValC}</span>, <span class="text-cyan-400">${yValC}</span>) | |
| </div> | |
| <div class="absolute inset-0 bg-cyan-500/10 animate-pulse"></div> | |
| </div> | |
| <button onclick="plotCoordinate()" class="w-full h-16 bg-amber-500 hover:bg-amber-400 text-white rounded-xl font-bold text-xl shadow-lg animate-pulse whitespace-nowrap flex items-center justify-center"> | |
| 描繪點 (PLOT) | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE2_PATTERN: | |
| formulaBanner.classList.remove('hidden'); // Ensure banner is visible | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-4">規律分析</h3> | |
| <p class="text-slate-300 mb-6">觀察圖表上的這些點,它們呈現什麼樣的排列規律?</p> | |
| <div class="grid grid-cols-3 gap-4"> | |
| <button onclick="checkPattern('A')" class="p-4 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-600 font-bold text-slate-300 transition-all hover:border-cyan-400 hover:text-white"> | |
| 雜亂無章 | |
| </button> | |
| <button onclick="checkPattern('B')" class="p-4 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-600 font-bold text-slate-300 transition-all hover:border-cyan-400 hover:text-white"> | |
| 圓形分佈 | |
| </button> | |
| <button onclick="checkPattern('C')" class="p-4 bg-slate-800 hover:bg-slate-700 rounded-xl border border-slate-600 font-bold text-slate-300 transition-all hover:border-cyan-400 hover:text-white"> | |
| 一條直線 | |
| </button> | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE2_PREDICTION_INTRO: | |
| formulaBanner.classList.remove('hidden'); // Keep banner visible | |
| html = ` | |
| <h3 class="text-xl font-bold text-green-400 mb-4">分析完成</h3> | |
| <div class="bg-slate-800/80 p-6 rounded-xl border border-green-500/30 mb-6"> | |
| <p class="text-slate-200 text-lg leading-relaxed font-bold"> | |
| 沒錯,所有點都連成一條直線...<br> | |
| <span class="text-cyan-400 block mt-2 text-3xl">也就是我們能預測未來!</span> | |
| </p> | |
| </div> | |
| <button onclick="enterPhase(STATE.PHASE3_CRISIS)" class="w-full py-4 bg-red-600 hover:bg-red-500 text-white rounded-xl font-bold text-xl shadow-lg animate-pulse"> | |
| 進入緊急狀況演練 (EMERGENCY DRILL) | |
| </button> | |
| `; | |
| break; | |
| case STATE.PHASE3_CRISIS: | |
| formulaBanner.classList.remove('hidden'); | |
| html = ` | |
| <div class="border-l-4 border-red-500 pl-6"> | |
| <h3 class="text-3xl font-bold text-red-500 mb-2 blink-red">狀況:工業用電暴增!</h3> | |
| <p class="text-slate-200 mb-6 text-xl"> | |
| 因為工業區需求激增,系統現在需要供給 <span class="text-cyan-400 font-bold text-4xl">203</span> 點電力 <span class="text-slate-400 text-base">(<span class="text-cyan-400 font-bold">y=203</span>)</span>。 | |
| <br>根據 y=2x+3,請問要投入多少晶石 <span class="text-amber-400 font-bold">(x)</span> 才足夠? | |
| </p> | |
| <div class="flex items-center gap-2 mt-4 bg-slate-800/50 p-4 rounded-xl flex-wrap"> | |
| <span class="font-tech text-amber-400 text-4xl whitespace-nowrap">x = </span> | |
| <input id="ans-input" type="text" readonly onclick="keypad.open(this)" class="tech-input flex-1 min-w-[100px] p-2 rounded text-4xl text-amber-400" placeholder="?"> | |
| <button onclick="checkFinalAnswer()" class="px-6 py-3 bg-red-600 hover:bg-red-500 text-white rounded font-bold shadow-[0_0_15px_rgba(239,68,68,0.5)] whitespace-nowrap flex-shrink-0"> | |
| 投入晶石 | |
| </button> | |
| </div> | |
| ${errorMsg ? `<div class="text-amber-300 text-center font-bold mt-2 text-xl border-2 border-amber-300 border-dashed animate-pulse bg-slate-900/50 p-2 rounded">${errorMsg}</div>` : ''} | |
| </div> | |
| `; | |
| break; | |
| case STATE.SUCCESS: | |
| html = ` | |
| <div class="text-center"> | |
| <h2 class="text-2xl md:text-2xl font-bold text-green-400 mb-2 font-tech">SYSTEM STABILIZED</h2> | |
| <p class="text-slate-300 mb-6 text-xl">預測成功!核心運作恢復正常。</p> | |
| <!-- Teaching Content --> | |
| <div class="bg-slate-800/80 p-4 rounded-xl border border-cyan-500/30 mb-6 text-left"> | |
| <h3 class="text-lg md:text-2xl font-bold text-white mb-2 text-center">任務小結:什麼是「函數」?</h3> | |
| <p class="text-slate-200 text-base md:text-xl leading-relaxed mb-3"> | |
| 剛剛我們使用的「能量轉換法則」,在數學上就叫做<span class="text-amber-400 font-bold text-xl md:text-2xl mx-1">「函數」(Function)</span>。 | |
| </p> | |
| <p class="text-slate-300 text-base md:text-xl leading-relaxed"> | |
| 函數就像一座<span class="text-cyan-400 font-bold">工廠</span>:<br> | |
| 它會把原料 <span class="font-bold text-amber-400">x (晶石)</span>,藉由固定的規則,<br>轉換成產品 <span class="font-bold text-cyan-400">y (電力)</span>。 | |
| </p> | |
| </div> | |
| <div class="flex flex-col gap-3 max-w-md mx-auto"> | |
| <a href="index.html" class="block w-full py-3 bg-slate-600 hover:bg-slate-500 text-white rounded-xl font-bold text-lg transition-all border border-slate-500 hover:border-white"> | |
| 回到 Math City | |
| </a> | |
| <button onclick="enterPhase(STATE.PHASE4_INTRO)" class="relative overflow-hidden w-full py-3 bg-red-600 hover:bg-red-500 text-white rounded-xl font-bold text-lg shadow-lg group border border-red-400"> | |
| <span class="relative z-10 flex items-center justify-center gap-2"> | |
| ⚠️ 修復電力系統 (進階挑戰) | |
| </span> | |
| <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:animate-shimmer"></div> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE4_INTRO: | |
| formulaBanner.classList.remove('hidden'); // Ensure banner is visible | |
| html = ` | |
| <div class="border-l-4 border-amber-500 pl-6"> | |
| <h3 class="text-3xl font-bold text-amber-500 mb-2 blink-red">警告:公式異變!</h3> | |
| <p class="text-slate-200 mb-6 text-xl"> | |
| 檢測到晶石純度下降,能量轉換公式已改變!<br> | |
| 系統無法自動運作,你必須重新推導公式。 | |
| </p> | |
| <div class="bg-slate-800/50 p-4 rounded-xl mb-4 border border-slate-600"> | |
| <span class="font-tech text-4xl block text-center tracking-widest"> | |
| y = <span class="text-amber-400 animate-pulse">?</span>x + <span class="text-cyan-400 animate-pulse">?</span> | |
| </span> | |
| </div> | |
| <button onclick="startPhase4Deduction()" class="w-full py-3 bg-amber-600 hover:bg-amber-500 text-white rounded-xl font-bold shadow-lg"> | |
| 開始推導 (Start Deduction) | |
| </button> | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE4_TEST_B: | |
| formulaBanner.classList.remove('hidden'); // Ensure banner is visible | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2">步驟 1:數據校準 b</h3> | |
| <p class="text-slate-300 mb-4"> | |
| 你需要測試不同的晶石投入量,來推導出新的公式。 | |
| <br><span class="text-base text-slate-400">提示:什麼時候 <span class="text-red-400">a</span> 會消失不見? (試試投入 0 顆)</span> | |
| </p> | |
| <div class="flex items-center gap-2 mt-4 bg-slate-800/50 p-4 rounded-xl justify-center flex-wrap"> | |
| <span class="font-tech text-amber-400 text-3xl whitespace-nowrap">投入 x = </span> | |
| <input id="test-input" type="text" readonly onclick="keypad.open(this)" class="tech-input w-24 p-2 rounded text-3xl text-amber-400 text-center" placeholder="0" value="0"> | |
| <button onclick="runPhase4Test()" class="px-6 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded font-bold shadow-lg whitespace-nowrap"> | |
| 啟動運作 | |
| </button> | |
| ${errorMsg ? `<div class="text-amber-400 text-center font-bold mt-2 text-xl border-2 border-amber-400 border-dashed animate-pulse p-2 rounded">${errorMsg}</div>` : ''} | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE4_FIND_B: | |
| formulaBanner.classList.remove('hidden'); | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2">步驟 1:尋找初始值 (b)</h3> | |
| <p class="text-slate-300 mb-4 text-2xl"> | |
| 當投入 <span class="text-amber-400 font-bold">0</span> 顆時, | |
| 電力顯示為 <span class="text-cyan-400 font-bold">5</span>。 | |
| <br><span class="font-tech text-3xl block mt-2"><span class="text-cyan-400">5</span> = a × <span class="text-amber-400">0</span> + b</span> | |
| <span class="font-tech text-3xl block"><span class="text-cyan-400">5</span> = b</span> | |
| </p> | |
| <div class="flex items-center justify-center gap-2 text-3xl font-tech mb-6 bg-slate-800 p-4 rounded-lg"> | |
| <span>b = </span> | |
| <input id="ans-input" type="text" readonly onclick="keypad.open(this)" class="w-24 bg-slate-700 text-cyan-400 text-center rounded border border-slate-500 focus:border-cyan-400 outline-none p-1" placeholder="?"> | |
| </div> | |
| ${errorMsg ? `<div class="text-red-400 text-center font-bold mb-4 border-2 border-red-400 border-dashed animate-pulse bg-slate-900/50 p-2 rounded">${errorMsg}</div>` : ''} | |
| <button onclick="checkPhase4B()" class="w-full py-3 bg-cyan-600 hover:bg-cyan-500 text-white rounded-xl font-bold"> | |
| 確認校準 Check | |
| </button> | |
| `; | |
| break; | |
| case STATE.PHASE4_TEST_A: | |
| formulaBanner.classList.remove('hidden'); | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2">步驟 2:數據校準 a</h3> | |
| <p class="text-slate-300 mb-4"> | |
| 現在已知 b=5。接下來需要找出變化率 a。 | |
| <br><span class="text-base text-slate-400">提示:試試看投入 1 顆晶石?</span> | |
| </p> | |
| <div class="flex items-center gap-2 mt-4 bg-slate-800/50 p-4 rounded-xl justify-center flex-wrap"> | |
| <span class="font-tech text-amber-400 text-3xl whitespace-nowrap">投入 x = </span> | |
| <input id="test-input-a" type="text" readonly onclick="keypad.open(this)" class="tech-input w-24 p-2 rounded text-3xl text-amber-400 text-center" placeholder="1" value="1"> | |
| <button onclick="runPhase4TestA()" class="px-6 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded font-bold shadow-lg whitespace-nowrap"> | |
| 啟動運作 | |
| </button> | |
| </div> | |
| ${errorMsg ? `<div class="text-amber-400 text-center font-bold mt-2 text-xl border-2 border-amber-400 border-dashed animate-pulse p-2 rounded">${errorMsg}</div>` : ''} | |
| `; | |
| break; | |
| case STATE.PHASE4_FIND_A: | |
| formulaBanner.classList.remove('hidden'); | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2">步驟 2:解出 a (Solve for a)</h3> | |
| <p class="text-slate-300 mb-4"> | |
| 測試投入 <span class="text-amber-400 font-bold">${lastTestAX}</span> 顆晶石, | |
| 電力變為 <span class="text-cyan-400 font-bold">${lastTestAY}</span>。 | |
| </p> | |
| <div class="bg-slate-800/50 p-4 rounded-xl mb-4 text-center border border-slate-700"> | |
| <span class="text-3xl font-tech block mt-2 text-white"><span class="text-cyan-400">${lastTestAY}</span> = a <span class="text-amber-400">× ${lastTestAX}</span> + 5</span> | |
| </div> | |
| <p class="text-slate-300 mb-4 text-center">請問 <span class="text-amber-400 font-bold">a</span> 是多少?</p> | |
| <div class="flex flex-wrap gap-2 mb-2 items-center justify-center text-3xl font-tech text-amber-400"> | |
| <span>a =</span> | |
| <input id="ans-input" type="text" readonly onclick="keypad.open(this)" class="tech-input w-24 p-2 rounded text-3xl text-center text-amber-400" placeholder="?"> | |
| <button onclick="checkPhase4A()" class="w-full md:w-auto px-8 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded-xl font-bold shadow-lg transition-transform hover:scale-105 whitespace-nowrap text-lg font-sans">確認</button> | |
| </div> | |
| ${errorMsg ? `<div class="text-red-400 text-center font-bold mb-2 border-2 border-red-400 border-dashed animate-pulse bg-slate-900/50 p-2 rounded">${errorMsg}</div>` : ''} | |
| `; | |
| break; | |
| case STATE.PHASE4_VERIFY: | |
| formulaBanner.classList.remove('hidden'); | |
| html = ` | |
| <h3 class="text-3xl font-bold text-white mb-2">步驟 3:最終驗算</h3> | |
| <p class="text-slate-300 mb-4"> | |
| 假設公式為 <span class="font-bold text-white">y = 3x + 5</span>。 | |
| <br>若投入 <span class="text-amber-400 font-bold">10</span> 顆晶石,電力應為多少? | |
| </p> | |
| <div class="flex flex-wrap gap-2 mt-6 items-center justify-center text-3xl font-tech text-white"> | |
| <span>y =</span> | |
| <input id="ans-input" type="text" readonly onclick="keypad.open(this)" class="tech-input w-24 p-2 rounded text-3xl text-center text-white" placeholder="?"> | |
| <button onclick="checkPhase4Verify()" class="w-full md:w-auto px-8 py-2 bg-green-600 hover:bg-green-500 text-white rounded-xl font-bold shadow-lg transition-transform hover:scale-105 whitespace-nowrap text-lg font-sans">驗證</button> | |
| </div> | |
| ${errorMsg ? `<div class="text-red-400 text-center font-bold mt-4 border-2 border-red-400 border-dashed animate-pulse bg-slate-900/50 p-2 rounded">${errorMsg}</div>` : ''} | |
| `; | |
| break; | |
| case STATE.PHASE5_UNLOCK: | |
| formulaBanner.classList.remove('hidden'); // Ensure banner is visible | |
| html = ` | |
| <div class="text-center w-full"> | |
| <h3 class="text-3xl font-bold text-amber-400 mb-6 drop-shadow-lg">輸入核心密碼 (Core Override)</h3> | |
| <div class="flex items-center justify-center gap-2 text-4xl md:text-4xl font-tech font-bold mb-8 bg-slate-900/80 p-6 rounded-3xl border-2 border-slate-700 w-full max-w-lg mx-auto"> | |
| <span class="text-cyan-400">y</span> | |
| <span class="text-slate-500">=</span> | |
| <input id="final-a" type="text" readonly onclick="keypad.open(this)" class="w-20 bg-slate-800 text-amber-400 text-center rounded border border-slate-600 focus:border-amber-400 outline-none p-2" placeholder="?"> | |
| <span class="text-amber-400">x</span> | |
| <span class="text-slate-500">+</span> | |
| <input id="final-b" type="text" readonly onclick="keypad.open(this)" class="w-20 bg-slate-800 text-cyan-400 text-center rounded border border-slate-600 focus:border-cyan-400 outline-none p-2" placeholder="?"> | |
| </div> | |
| <button onclick="unlockCore()" class="w-full py-4 bg-gradient-to-r from-amber-600 to-red-600 text-white rounded-xl font-bold text-xl shadow-lg hover:shadow-amber-500/50 transition-all border border-amber-500/30"> | |
| 解鎖大門 (UNLOCK) | |
| </button> | |
| ${errorMsg ? `<div class="text-red-400 text-center font-bold mt-4 border-2 border-red-400 border-dashed animate-pulse bg-slate-900/50 p-2 rounded text-xl">${errorMsg}</div>` : ''} | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE6_INTRO: | |
| formulaBanner.classList.add('hidden'); | |
| html = ` | |
| <h2 class="text-3xl font-bold text-cyan-400 mb-4">核心同步程序 (Core Synchronization)</h2> | |
| <p class="text-slate-200 text-xl leading-relaxed mb-6"> | |
| 核心已解鎖,但能量極不穩定!<br> | |
| 我們需要透過<span class="text-amber-400 font-bold">手動脈衝</span>來穩定核心頻率。 | |
| </p> | |
| <div class="bg-slate-800/80 p-5 rounded-xl border border-cyan-500/50 mb-6 text-center shadow-[0_0_15px_rgba(6,182,212,0.2)]"> | |
| <p class="text-amber-400 text-xl font-bold mb-3 border-b border-cyan-500/30 pb-2">🕹️ 脈衝守護操作說明</p> | |
| <p class="text-lg text-slate-200 leading-relaxed"> | |
| 外圍會有<span class="text-cyan-400 font-bold">彩色光圈</span>向內縮小,<br> | |
| 請在光圈<span class="text-amber-400 font-bold bg-amber-900/40 px-2 py-1 rounded">正好重疊</span>到<span class="text-white font-bold">中心白色圓環</span>的瞬間,<br> | |
| <span class="text-green-400 font-bold">點擊螢幕任意處</span> 或按下 <span class="text-green-400 font-bold px-2 py-1 bg-slate-700 rounded border border-slate-500">空白鍵</span>! | |
| </p> | |
| </div> | |
| <button onclick="startRhythmGame()" class="w-full py-4 bg-cyan-600 hover:bg-cyan-500 text-white rounded-xl font-bold text-xl shadow-lg animate-pulse"> | |
| 開始同步 (START) | |
| </button> | |
| `; | |
| break; | |
| case STATE.PHASE6_GAME: | |
| // Hide dialogue box during game, only show overlay info via Canvas or minimal DOM | |
| box.classList.add('opacity-0', 'translate-y-20'); | |
| formulaBanner.classList.add('hidden'); | |
| // We will render UI on canvas or absolute overlay | |
| break; | |
| case STATE.PHASE7_SUMMARY: | |
| // Break out of the container layout for Full Screen Center | |
| // We modify the box styling directly to be fixed center | |
| box.className = "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[95%] md:w-[70%] glass-panel rounded-3xl p-8 transition-all duration-500 min-h-[400px] max-h-[90vh] overflow-y-auto flex flex-col justify-start shadow-[0_0_80px_rgba(0,0,0,0.8)] bg-slate-900/95 frame-border z-50 pointer-events-auto"; | |
| const currentScore = rhythmState.score; | |
| const highScore = localStorage.getItem('math_city_score_function') || 0; | |
| html = ` | |
| <div class="text-center w-full h-full flex flex-col items-center"> | |
| <h2 class="text-xl md:text-2xl font-black text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-green-400 mb-6 drop-shadow-[0_0_15px_rgba(34,211,238,0.6)] tracking-widest"> | |
| 任務完成 SYSTEM RESTORED | |
| </h2> | |
| <div class="text-amber-400 font-tech text-base md:text-2xl mb-10 tracking-[0.5em]">MISSION ACCOMPLISHED</div> | |
| <!-- Score Board --> | |
| <div class="flex gap-16 mb-10 bg-slate-800/80 px-16 py-8 rounded-3xl border-2 border-slate-600 transform scale-110"> | |
| <div class="text-center"> | |
| <div class="text-xl text-slate-400 uppercase tracking-widest mb-2">本次得分</div> | |
| <div class="text-4xl font-black text-cyan-400 font-tech shimmer">${currentScore}</div> | |
| </div> | |
| <div class="w-px bg-slate-500"></div> | |
| <div class="text-center"> | |
| <div class="text-xl text-slate-400 uppercase tracking-widest mb-2">歷史最高</div> | |
| <div class="text-4xl font-black text-amber-400 font-tech">${highScore}</div> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-12 w-full text-left mb-12 max-w-6xl"> | |
| <!-- Insight 1 --> | |
| <div class="bg-slate-800/50 p-10 rounded-3xl border border-cyan-500/20 hover:border-cyan-500/50 transition-colors"> | |
| <h3 class="flex items-center gap-4 text-4xl font-bold text-cyan-400 mb-6"> | |
| <span class="text-5xl">🔮</span> 預測未來 (Prediction) | |
| </h3> | |
| <p class="text-slate-300 text-3xl leading-relaxed mb-4"> | |
| 「當我們知道規則 <span class="bg-slate-900 px-3 rounded text-cyan-300 font-tech">y=ax+b</span> 時,就能精準預測未來的結果。」 | |
| </p> | |
| <div class="text-xl text-slate-500 border-t border-slate-700 pt-4"> | |
| 回顧:就像你在階段三,算出需要投入多少晶石才能救急。 | |
| </div> | |
| </div> | |
| <!-- Insight 2 --> | |
| <div class="bg-slate-800/50 p-10 rounded-3xl border border-amber-500/20 hover:border-amber-500/50 transition-colors"> | |
| <h3 class="flex items-center gap-4 text-4xl font-bold text-amber-400 mb-6"> | |
| <span class="text-5xl">📐</span> 數學建模 (Modeling) | |
| </h3> | |
| <p class="text-slate-300 text-3xl leading-relaxed mb-4"> | |
| 「當規則改變或未知時,我們也能透過觀察數據的變化,反推回公式本身。」 | |
| </p> | |
| <div class="text-xl text-slate-500 border-t border-slate-700 pt-4"> | |
| 回顧:就像你在階段四,透過測試 x=0 和 x=1,重新找回消失的 a 與 b。 | |
| </div> | |
| </div> | |
| </div> | |
| <p class="text-slate-400 italic mb-12 text-2xl max-w-3xl whitespace-nowrap"> | |
| 「這就是科學家與工程師的超能力:觀察數據 <span class="text-white">→</span> 找出規則 <span class="text-white">→</span> 預測未來。」 | |
| </p> | |
| <a href="index.html" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-6 px-20 rounded-full text-4xl shadow-[0_0_40px_rgba(79,70,229,0.6)] transition-all transform hover:scale-105 hover:rotate-1"> | |
| 回到 Math City | |
| </a> | |
| </div> | |
| `; | |
| break; | |
| case STATE.PHASE4_VERIFY_TEST: | |
| html = ` | |
| <h3 class="text-3xl font-bold text-amber-400 mb-2 font-tech tracking-widest border-b border-amber-500/30 pb-2"> | |
| <span class="mr-2">⚡</span> 最終驗證 FINAL VERIFICATION | |
| </h3> | |
| <p class="text-slate-300 leading-relaxed mb-6"> | |
| 公式推導完成:<span class="text-white font-bold">y = 3x + 5</span> | |
| <br>現在,請實際投入 <span class="text-amber-400">10</span> 顆晶石來驗證預測是否準確。 | |
| </p> | |
| <div class="flex items-center gap-4 mb-2"> | |
| <span class="text-3xl font-tech text-amber-500">x =</span> | |
| <div class="relative flex-1"> | |
| <input type="text" readonly onclick="keypad.open(this)" id="test-input-verify" class="tech-input w-full p-4 rounded-xl text-3xl font-bold" placeholder="輸入 10"> | |
| <div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">晶石</div> | |
| </div> | |
| </div> | |
| <button onclick="runPhase4VerifyManual()" class="w-full mt-4 py-3 bg-cyan-600/80 hover:bg-cyan-500 text-white font-bold rounded-xl transition-all shadow-[0_0_15px_rgba(6,182,212,0.3)]"> | |
| 啟動測試 / VERIFY | |
| </button> | |
| `; | |
| break; | |
| } | |
| box.innerHTML = html; | |
| // Focus input if available | |
| const input = box.querySelector('input'); | |
| if (input) input.focus(); | |
| } | |
| // --- Logic Operations --- | |
| function startQPhase() { | |
| if (window.keypad) keypad.close(); | |
| qStep = 0; | |
| enterPhase(STATE.PHASE2_Q); | |
| } | |
| function checkQAnswer() { | |
| const input = document.getElementById('ans-input'); | |
| const val = parseInt(input.value); | |
| const targetX = checkPoints[qStep]; | |
| const correctY = 2 * targetX + 3; | |
| if (isNaN(val)) return; | |
| if (val === 222) { // Cheat code | |
| playSound('success'); | |
| // Skip everything -> Success | |
| addPoint(100, 203); // fake the final point | |
| enterPhase(STATE.SUCCESS); | |
| return; | |
| } | |
| if (val === correctY) { | |
| playSound('success'); | |
| // Old: addPoint(targetX, correctY); | |
| // New: Go to Coordinate Confirmation Step | |
| enterPhase(STATE.PHASE2_COORDINATE); | |
| } else { | |
| playSound('alarm'); | |
| input.classList.add('animate-shake'); | |
| setTimeout(() => input.classList.remove('animate-shake'), 500); | |
| // Show hint | |
| updateUI(`提示: 2 × ${targetX} + 3 = ?`); | |
| } | |
| } | |
| function substituteY() { | |
| playSound('swish'); // Optional sound | |
| document.getElementById('coord-step-1').classList.add('hidden'); | |
| const step2 = document.getElementById('coord-step-2'); | |
| step2.classList.remove('hidden'); | |
| // Add a small pop animation or effect if desired | |
| } | |
| function plotCoordinate() { | |
| const targetX = checkPoints[qStep]; | |
| const correctY = 2 * targetX + 3; | |
| addPoint(targetX, correctY); | |
| // Trigger Animation | |
| guideArrowState.active = true; | |
| guideArrowState.startTime = Date.now(); | |
| guideArrowState.targetX = targetX; | |
| guideArrowState.targetY = correctY; | |
| playSound('tick'); | |
| qStep++; | |
| if (qStep < checkPoints.length) { | |
| // Next question | |
| enterPhase(STATE.PHASE2_Q); | |
| } else { | |
| // All questions done | |
| setTimeout(() => enterPhase(STATE.PHASE2_PATTERN), 500); | |
| } | |
| } | |
| function checkFinalAnswer() { | |
| const input = document.getElementById('ans-input'); | |
| const val = parseInt(input.value); | |
| if (val === 100 || val === 222) { // 222 is cheat code | |
| playSound('success'); | |
| graphScale.xMax = 110; | |
| graphScale.yMax = 220; | |
| addPoint(100, 203); | |
| enterPhase(STATE.SUCCESS); | |
| } else { | |
| playSound('alarm'); | |
| input.classList.add('animate-shake'); | |
| setTimeout(() => input.classList.remove('animate-shake'), 500); | |
| input.value = ''; | |
| // Show hint: 203 - 3 = 200, so 2x = 200 | |
| updateUI("提示: 203 = 2x + 3"); | |
| } | |
| } | |
| function checkPattern(choice) { | |
| if (choice === 'C') { | |
| playSound('success'); | |
| enterPhase(STATE.PHASE2_PREDICTION_INTRO); | |
| } else { | |
| alert("再仔細看看...?"); | |
| } | |
| } | |
| function startPhase4Deduction() { | |
| // Reset graph for new formula (Scale reduced for visibility) | |
| collectedPoints = []; | |
| currentA = 3; | |
| currentB = 5; | |
| // Adjust scale for Phase 4 (Close up view) | |
| graphScale.xMax = 15; | |
| graphScale.yMax = 40; | |
| // Do NOT add point yet. Wait for manual test. | |
| enterPhase(STATE.PHASE4_TEST_B); | |
| // Update Banner to y=ax+b style | |
| document.querySelector('#formula-banner .font-tech').innerHTML = | |
| `<span class="text-cyan-400">y</span><span class="text-slate-500">=</span><span class="text-red-500 animate-pulse">a</span><span class="text-amber-400">x</span><span class="text-slate-500">+</span><span class="text-red-500 animate-pulse">b</span>`; | |
| } | |
| function runPhase4Test() { | |
| const input = document.getElementById('test-input'); | |
| const x = parseFloat(input.value); | |
| if (isNaN(x)) return; | |
| if (x === 0) { | |
| const y = 3 * x + 5; | |
| addPoint(x, y); | |
| playSound('tick'); | |
| setTimeout(() => { | |
| enterPhase(STATE.PHASE4_FIND_B); | |
| }, 1000); | |
| } else { | |
| playSound('alarm'); | |
| const y = 3 * x + 5; | |
| // Don't plot point to avoid confusing them? Or plot it but don't proceed? | |
| // User said "don't plot point... show hint". | |
| // Hint display logic | |
| const hintY = 14; | |
| // Wait, if x=3, y=14. Formula y=ax+b becomes 14 = 3a + b. | |
| updateUI(`錯誤!若投入 ${x} 顆,電力為 ${y}。<br>公式變為 <span class="text-amber-400 font-tech">${y} = ${x}a + b</span>。<br>a 沒有消失,無法算出 b。只有什麼數字才能讓 a 消失?`); | |
| } | |
| } | |
| function checkPhase4B() { | |
| const val = parseInt(document.getElementById('ans-input').value); | |
| if (val === 5) { | |
| playSound('success'); | |
| // Update Banner: y = ?x + 5 | |
| // User requested: 5 is white (slate-200), x amber, a blinking red | |
| document.querySelector('#formula-banner .font-tech').innerHTML = | |
| `<span class="text-cyan-400">y</span><span class="text-slate-500">=</span><span class="text-red-500 animate-pulse">a</span><span class="text-amber-400">x</span><span class="text-slate-500">+</span><span class="text-slate-200">5</span>`; | |
| // Go to Step 2 Manual Test | |
| enterPhase(STATE.PHASE4_TEST_A); | |
| } else { | |
| playSound('alarm'); | |
| updateUI("錯誤!當 x=0 時,y=5。公式 y = ax + b 變成 5 = a(0) + b,所以 b 是...?"); | |
| } | |
| } | |
| // Global var to store X logic for Phase 4 Step 2 | |
| let lastTestAX = 1; | |
| let lastTestAY = 8; | |
| function runPhase4TestA() { | |
| const input = document.getElementById('test-input-a'); | |
| const x = parseInt(input.value); | |
| if (isNaN(x) || x < 1 || x > 9) { | |
| playSound('alarm'); | |
| updateUI("為了方便觀察,請輸入 1 到 9 之間的整數。"); | |
| input.value = ''; | |
| return; | |
| } | |
| const y = 3 * x + 5; | |
| addPoint(x, y); | |
| playSound('tick'); | |
| // Store for next step text | |
| lastTestAX = x; | |
| lastTestAY = y; | |
| setTimeout(() => { | |
| enterPhase(STATE.PHASE4_FIND_A); | |
| }, 1000); | |
| } | |
| function checkPhase4A() { | |
| const val = parseInt(document.getElementById('ans-input').value); | |
| if (val === 3) { | |
| playSound('success'); | |
| // Update Banner: y = 3x + 5 | |
| document.querySelector('#formula-banner .font-tech').innerHTML = | |
| `<span class="text-cyan-400">y</span><span class="text-slate-500">=</span><span class="text-slate-200">3</span><span class="text-amber-400">x</span><span class="text-slate-500">+</span><span class="text-slate-200">5</span>`; | |
| enterPhase(STATE.PHASE4_VERIFY); | |
| } else { | |
| playSound('alarm'); | |
| updateUI(`錯誤,${lastTestAY} = ${lastTestAX}a + 5 則 a = ?`); | |
| } | |
| } | |
| function checkPhase4Verify() { | |
| const val = parseInt(document.getElementById('ans-input').value); | |
| if (val === 35) { | |
| playSound('success'); | |
| // Auto verification visual | |
| if (window.keypad) keypad.close(); | |
| // Add point without particles, but with blink | |
| addPoint(10, 35, false, true); | |
| updateUI("預測正確!投入 10 顆晶石,電力確實為 35!<br><span class='text-amber-400 text-base'>(確認點連成一線...)</span>"); | |
| // RESTORE INPUT VALUE because updateUI cleared it | |
| setTimeout(() => { | |
| const input = document.getElementById('ans-input'); | |
| if (input) input.value = "35"; | |
| }, 50); | |
| // Disable button | |
| const btn = document.querySelector('#dialogue-box button'); | |
| if (btn) { | |
| btn.disabled = true; | |
| btn.classList.add('opacity-50', 'cursor-not-allowed', 'pointer-events-none'); | |
| btn.innerHTML = '驗證成功 Verified'; | |
| } | |
| // Add guide text to look at the graph | |
| const inputContainer = document.getElementById('ans-input')?.parentElement?.parentElement; // Adjusted selector | |
| if (inputContainer) { | |
| const guide = document.createElement('div'); | |
| guide.className = "text-center text-green-400 font-bold mt-2 text-xl animate-pulse"; | |
| guide.innerHTML = "請觀察圖表上的點 (10,35)..."; | |
| inputContainer.insertAdjacentElement('afterend', guide); | |
| } | |
| // Delay for user to see the result matches the line before unlocking | |
| // Extended delay to 3.5s to ensure they see the point on the line | |
| // Delay for user to see the result matches the line before unlocking | |
| // Extended delay to 3.5s to ensure they see the point on the line | |
| setTimeout(() => { | |
| const guide = document.querySelector('.text-green-400.animate-pulse'); | |
| if (guide) guide.remove(); | |
| enterPhase(STATE.PHASE5_UNLOCK); | |
| }, 3500); | |
| } else { | |
| playSound('alarm'); | |
| // Use on-screen hint instead of alert | |
| updateUI("驗算失敗!請檢查公式: y = 3×10 + 5 = ?"); | |
| } | |
| } | |
| function unlockCore() { | |
| const a = parseInt(document.getElementById('final-a').value); | |
| const b = parseInt(document.getElementById('final-b').value); | |
| if (a === 3 && b === 5) { | |
| if (window.keypad) keypad.close(); | |
| playSound('success'); | |
| // Instead of alert, go to Phase 6 | |
| enterPhase(STATE.PHASE6_INTRO); | |
| } else { | |
| playSound('alarm'); | |
| const box = document.querySelector('.bg-slate-900\\/80'); // Target container | |
| if (box) box.classList.add('animate-shake'); | |
| setTimeout(() => box && box.classList.remove('animate-shake'), 500); | |
| // Show hint on screen (simulated by updateUI call inside loop, but here distinct) | |
| // Just let user retry, maybe add text | |
| // Since updateUI overwrites everything, we just re-render PHASE5_UNLOCK with Error? | |
| // But unlockCore is called from button. We can append error. | |
| const inputs = document.querySelectorAll('#final-a, #final-b'); | |
| inputs.forEach(i => i.value = ''); | |
| updateUI("密碼錯誤!提示:y = 3x + 5 (a=3, b=5)"); | |
| } | |
| } | |
| // --- Phase 6: Rhythm Game Logic --- | |
| function startRhythmGame() { | |
| rhythmState.energy = 0; | |
| rhythmState.score = 0; | |
| rhythmState.combo = 0; | |
| rhythmState.circles = []; | |
| rhythmState.active = true; | |
| // startRhythmBGM(); // Disabled by user request | |
| enterPhase(STATE.PHASE6_GAME); | |
| } | |
| function updateRhythm(dt) { | |
| if (!rhythmState.active) return; | |
| const now = Date.now(); | |
| // Spawn circles | |
| if (now - rhythmState.lastSpawnTime > rhythmState.spawnInterval) { | |
| spawnRhythmCircle(); | |
| rhythmState.lastSpawnTime = now; | |
| // Speed up slightly as energy grows | |
| rhythmState.spawnInterval = Math.max(600, 1200 - (rhythmState.energy * 5)); | |
| } | |
| // Update circles | |
| // Radius shrinks: starts at say 300, target 60 (CORE_RADIUS) | |
| // Speed depends on spawnInterval roughly | |
| for (let i = rhythmState.circles.length - 1; i >= 0; i--) { | |
| const c = rhythmState.circles[i]; | |
| const speed = 150 * dt; // pixels per second-ish | |
| c.r -= speed; | |
| // Miss condition | |
| if (c.r < CORE_RADIUS - 20) { | |
| // Miss | |
| rhythmState.circles.splice(i, 1); | |
| handleMiss(); | |
| } | |
| } | |
| // Check win | |
| if (rhythmState.energy >= 100) { | |
| rhythmState.active = false; | |
| stopRhythmBGM(); // Stop music | |
| playSound('success'); // Big success sound later | |
| // Save High Score | |
| const currentBest = parseInt(localStorage.getItem('math_city_score_function') || '0'); | |
| if (rhythmState.score > currentBest) { | |
| localStorage.setItem('math_city_score_function', rhythmState.score.toString()); | |
| } | |
| setTimeout(() => enterPhase(STATE.PHASE7_SUMMARY), 1500); // 1.5s delay for victory effect | |
| } | |
| } | |
| function drawRhythmBackground() { | |
| // Cyber Tunnel Effect | |
| const cx = width / 2; | |
| const cy = height / 2; | |
| const time = Date.now() / 1000; | |
| // Clear with dark blue tint | |
| ctx.fillStyle = '#051015'; | |
| ctx.fillRect(0, 0, width, height); | |
| // Rotating Grid | |
| ctx.save(); | |
| ctx.translate(cx, cy); | |
| // Slowly rotate the whole world | |
| ctx.rotate(time * 0.1); | |
| ctx.lineWidth = 1; | |
| ctx.strokeStyle = 'rgba(6, 182, 212, 0.15)'; // Faint Cyan | |
| // Radial lines | |
| const rays = 12; | |
| for (let i = 0; i < rays; i++) { | |
| const angle = (i / rays) * Math.PI * 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, 0); | |
| // Draw far out to cover screen corners | |
| ctx.lineTo(Math.cos(angle) * width, Math.sin(angle) * width); | |
| ctx.stroke(); | |
| } | |
| // Expanding Concentric Hexagons (Tunnel) | |
| const speed = 100; // pixels per second expansion | |
| const spacing = 200; | |
| const offset = (Date.now() % 2000) / 2000 * spacing; // 0 to 200 | |
| for (let r = offset; r < width; r += spacing) { | |
| if (r < 10) continue; // too small | |
| const alpha = Math.min(1, r / 300); // Fade in from center | |
| ctx.strokeStyle = `rgba(6, 182, 212, ${alpha * 0.3})`; | |
| ctx.beginPath(); | |
| for (let i = 0; i < 6; i++) { | |
| const a = i * Math.PI / 3; | |
| const x = r * Math.cos(a); | |
| const y = r * Math.sin(a); | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| function spawnRhythmCircle() { | |
| rhythmState.circles.push({ | |
| r: 400, // Start radius (outside screen mostly) | |
| color: Math.random() > 0.5 ? '#22d3ee' : '#f59e0b' // Cyan or Amber | |
| }); | |
| } | |
| function handleRhythmInput() { | |
| // Check the smallest circle | |
| if (rhythmState.circles.length === 0) return; | |
| // Sort by radius, usually the first one (index 0) is smallest due to spawn order | |
| // But let's find the one closest to CORE_RADIUS | |
| let targetIndex = -1; | |
| let minDiff = 999; | |
| rhythmState.circles.forEach((c, index) => { | |
| const diff = Math.abs(c.r - CORE_RADIUS); | |
| if (diff < minDiff) { | |
| minDiff = diff; | |
| targetIndex = index; | |
| } | |
| }); | |
| if (targetIndex !== -1) { | |
| const diff = minDiff; | |
| const tolerance = 30; // pixels tolerance | |
| if (diff < 10) { | |
| // Perfect | |
| handleHit('PERFECT', targetIndex); | |
| } else if (diff < tolerance) { | |
| // Good | |
| handleHit('GOOD', targetIndex); | |
| } else { | |
| // Too early/late logic if we want, or just ignore if it's way off | |
| // If it's somewhat close but not hit, maybe miss? | |
| // Let's just ignore clicks that are way off so we don't punish accidental clicks too much | |
| } | |
| } | |
| } | |
| function handleHit(type, index) { | |
| rhythmState.circles.splice(index, 1); | |
| if (type === 'PERFECT') { | |
| rhythmState.energy = Math.min(100, rhythmState.energy + 10); | |
| // Combo Bonus: +30 per combo | |
| const bonus = rhythmState.combo * 30; | |
| rhythmState.score += (500 + bonus); | |
| rhythmState.combo++; | |
| spawnFloatingText(`PERFECT! +${500 + bonus}`, '#fbbf24'); // Gold | |
| playSound('hit_perfect'); | |
| playSound('hit_perfect'); | |
| } else { | |
| // Fix: Good also gives 10 energy to prevent "extending game" exploit | |
| rhythmState.energy = Math.min(100, rhythmState.energy + 10); | |
| // Combo Bonus: +10 per combo | |
| const bonus = rhythmState.combo * 10; | |
| rhythmState.score += (50 + bonus); | |
| rhythmState.combo++; | |
| spawnFloatingText(`GOOD +${50 + bonus}`, '#22d3ee'); // Blue | |
| playSound('hit_good'); | |
| } | |
| // Core pulse | |
| corePulse = 1.5; | |
| } | |
| function handleMiss() { | |
| rhythmState.energy = Math.max(0, rhythmState.energy - 2); | |
| rhythmState.combo = 0; | |
| // Penalize score to prevent farming (losing energy to re-gain points) | |
| // Deduction = Potential gain of a Perfect Hit (500) | |
| rhythmState.score = Math.max(0, rhythmState.score - 500); | |
| spawnFloatingText("MISS", '#ef4444'); | |
| playSound('alarm'); | |
| } | |
| function spawnFloatingText(text, color) { | |
| rhythmState.texts.push({ | |
| text: text, | |
| x: width / 2, | |
| y: height / 2 - 80, | |
| life: 1.0, | |
| color: color | |
| }); | |
| } | |
| function addPoint(x, y, spawnParticles = true, blink = false) { | |
| if (!collectedPoints.find(p => p.x === x)) { | |
| collectedPoints.push({ x, y, blink }); | |
| // Spawn particle effect | |
| if (spawnParticles) { | |
| for (let i = 0; i < 10; i++) { | |
| particles.push({ | |
| x: width / 2, | |
| y: height / 2, | |
| vx: (Math.random() - 0.5) * 10, | |
| vy: (Math.random() - 0.5) * 10, | |
| life: 1.0, | |
| color: '#fbbf24' | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // --- Canvas Rendering (Same as before but simplified) --- | |
| function loop() { | |
| ctx.clearRect(0, 0, width, height); // Clear for transparency | |
| // Special update for Rhythm Game | |
| if (currentState === STATE.PHASE6_GAME) { | |
| updateRhythm(0.016); // ~60fps fixed dt | |
| } | |
| drawCore(); | |
| drawGraph(); | |
| updateParticles(); | |
| // Draw Rhythm Overlay | |
| if (currentState === STATE.PHASE6_GAME) { | |
| drawRhythmGame(); | |
| } | |
| requestAnimationFrame(loop); | |
| } | |
| function drawRhythmGame() { | |
| const cx = width / 2; | |
| const cy = height / 2; | |
| // Draw Target Ring (CORE_RADIUS) | |
| ctx.lineWidth = 4; | |
| ctx.strokeStyle = '#ffffff'; | |
| ctx.globalAlpha = 0.5; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, CORE_RADIUS, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1.0; | |
| // Draw Falling Circles | |
| rhythmState.circles.forEach(c => { | |
| ctx.lineWidth = 3; | |
| ctx.strokeStyle = c.color; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, c.r, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| }); | |
| // Draw Energy Bar (Top Center) | |
| const barW = 300; | |
| const barH = 20; | |
| const barX = cx - barW / 2; | |
| const barY = 50; | |
| // Background | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; | |
| ctx.fillRect(barX, barY, barW, barH); | |
| ctx.strokeStyle = '#aaa'; | |
| ctx.strokeRect(barX, barY, barW, barH); | |
| // Fill | |
| const fillW = (rhythmState.energy / 100) * barW; | |
| const fillCol = rhythmState.energy > 80 ? '#fbbf24' : '#22d3ee'; | |
| ctx.fillStyle = fillCol; | |
| ctx.fillRect(barX, barY, fillW, barH); | |
| // Text: Energy | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '20px Orbitron'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(`ENERGY: ${Math.floor(rhythmState.energy)}%`, cx, barY - 10); | |
| // Text: Combo | |
| if (rhythmState.combo > 1) { | |
| ctx.fillStyle = '#f59e0b'; | |
| ctx.font = 'bold 40px Orbitron'; | |
| ctx.fillText(`${rhythmState.combo} COMBO`, cx, cy + 150); | |
| } | |
| // Floating Texts | |
| drawFloatingTexts(cx, cy); | |
| } | |
| function drawFloatingTexts(cx, cy) { | |
| for (let i = rhythmState.texts.length - 1; i >= 0; i--) { | |
| const t = rhythmState.texts[i]; | |
| t.life -= 0.02; | |
| t.y -= 1; // Rise up | |
| if (t.life <= 0) { | |
| rhythmState.texts.splice(i, 1); | |
| continue; | |
| } | |
| ctx.save(); | |
| ctx.globalAlpha = t.life; | |
| ctx.fillStyle = t.color; | |
| ctx.font = 'bold 30px "Noto Sans TC"'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(t.text, t.x, t.y); | |
| ctx.restore(); | |
| } | |
| } | |
| function drawCore() { | |
| // Move core to center in Phase 6+ (Rhythm Game & Summary) | |
| let targetCx = width * 0.15; | |
| if (currentState >= STATE.PHASE6_INTRO) { | |
| targetCx = width / 2; | |
| } | |
| // Simple ease-in if we wanted, but direct assignment is safer for state changes | |
| const cx = targetCx; | |
| const cy = height * 0.5; | |
| if (width < 768) ctx.globalAlpha = 0.2; | |
| // Victory Effect: Big pulse if game won but not yet in summary | |
| if (currentState >= STATE.PHASE6_GAME && rhythmState.energy >= 100) { | |
| if (currentState === STATE.PHASE6_GAME) coreRotation += 0.2; // Spin faster only during game end | |
| const pulse = 1 + Math.sin(Date.now() / 50) * 0.2; // Rapid pulse | |
| ctx.shadowBlur = 50 * pulse; | |
| ctx.shadowColor = '#fbbf24'; // Amber glow | |
| } else { | |
| coreRotation += 0.01; | |
| ctx.shadowBlur = 0; | |
| } | |
| ctx.save(); | |
| ctx.translate(cx, cy); | |
| ctx.rotate(coreRotation); | |
| ctx.strokeStyle = '#0891b2'; | |
| ctx.lineWidth = 4; | |
| ctx.beginPath(); | |
| ctx.arc(0, 0, 80, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // Inner Hexagon | |
| ctx.rotate(-coreRotation * 2); | |
| ctx.fillStyle = rhythmState.energy >= 100 ? '#fbbf24' : '#22d3ee'; // Turn Gold on win | |
| ctx.beginPath(); | |
| for (let i = 0; i < 6; i++) { | |
| ctx.lineTo(50 * Math.cos(i * Math.PI / 3), 50 * Math.sin(i * Math.PI / 3)); | |
| } | |
| ctx.fill(); | |
| ctx.restore(); | |
| ctx.globalAlpha = 1.0; | |
| ctx.shadowBlur = 0; // Reset | |
| } | |
| function drawGraph() { | |
| const padding = 60; | |
| // Desktop: Graph takes up right 55% of screen. Mobile: Centered. | |
| const isDesktop = width > 768; | |
| const gw = isDesktop ? width * 0.53 : Math.min(600, width - 40); | |
| const gh = isDesktop ? height * 0.6 : Math.min(400, height * 0.5); | |
| // On Desktop, position graph on the right side (Start at 44% width) | |
| // On Mobile, center it | |
| const gx = isDesktop ? (width * 0.44) : (width - gw) / 2; | |
| const gy = isDesktop ? height * 0.2 : height * 0.1; | |
| // BG - Darker background for graph area to make it pop against the global image | |
| ctx.fillStyle = 'rgba(5, 16, 21, 0.85)'; | |
| ctx.fillRect(gx, gy, gw, gh); | |
| ctx.strokeStyle = '#475569'; | |
| ctx.strokeRect(gx, gy, gw, gh); | |
| const ox = gx + padding; | |
| const oy = gy + gh - padding; | |
| const drawW = gw - padding * 2; | |
| const drawH = gh - padding * 2; | |
| const unitX = drawW / graphScale.xMax; | |
| const unitY = drawH / graphScale.yMax; | |
| ctx.beginPath(); | |
| ctx.strokeStyle = '#94a3b8'; | |
| ctx.lineWidth = 2; | |
| ctx.moveTo(ox, oy); ctx.lineTo(ox + drawW, oy); | |
| ctx.moveTo(ox, oy); ctx.lineTo(ox, oy - drawH); | |
| ctx.stroke(); | |
| // Grid | |
| ctx.textAlign = 'center'; | |
| ctx.font = 'bold 20px "Orbitron", sans-serif'; | |
| const jumpX = graphScale.xMax > 20 ? 10 : 1; | |
| for (let i = 0; i <= graphScale.xMax; i += jumpX) { | |
| const x = ox + i * unitX; | |
| ctx.fillStyle = '#fbbf24'; // Amber X | |
| ctx.fillText(i, x, oy + 20); | |
| if (i > 0) { | |
| ctx.beginPath(); ctx.moveTo(x, oy); ctx.lineTo(x, oy - drawH); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.stroke(); | |
| } | |
| } | |
| const jumpY = graphScale.yMax > 50 ? 20 : 2; | |
| for (let i = 0; i <= graphScale.yMax; i += jumpY) { | |
| const y = oy - i * unitY; | |
| ctx.fillStyle = '#22d3ee'; // Cyan Y | |
| ctx.fillText(i, ox - 20, y + 4); | |
| if (i > 0) { | |
| ctx.beginPath(); ctx.moveTo(ox, y); ctx.lineTo(ox + drawW, y); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.stroke(); | |
| } | |
| } | |
| ctx.fillStyle = '#fbbf24'; ctx.font = 'bold 16px sans-serif'; | |
| ctx.fillText("晶石數 (x)", ox + drawW / 2, oy + 40); | |
| ctx.save(); | |
| ctx.translate(ox - 45, oy - drawH / 2); | |
| ctx.rotate(-Math.PI / 2); | |
| ctx.fillStyle = '#22d3ee'; | |
| ctx.fillText("電力 (y)", 0, 0); | |
| ctx.restore(); | |
| if (currentState >= STATE.PHASE2_PREDICTION_INTRO && currentState < STATE.PHASE4_INTRO) { | |
| ctx.beginPath(); | |
| ctx.strokeStyle = '#22d3ee'; | |
| ctx.lineWidth = 2; | |
| const endX = graphScale.xMax; | |
| const endY = 2 * endX + 3; | |
| ctx.moveTo(ox, oy - 3 * unitY); | |
| ctx.lineTo(ox + endX * unitX, oy - endY * unitY); | |
| ctx.stroke(); | |
| } | |
| // Phase 4: Draw line y = 3x + 5 (After finding a) | |
| if (currentState >= STATE.PHASE4_VERIFY) { | |
| ctx.beginPath(); | |
| ctx.strokeStyle = '#fbbf24'; // Amber Line for new formula | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([5, 5]); // Dashed line to show it's a prediction/new model | |
| const endX = graphScale.xMax; | |
| const endY = 3 * endX + 5; | |
| ctx.moveTo(ox, oy - 5 * unitY); // Start at (0, 5) | |
| ctx.lineTo(ox + endX * unitX, oy - endY * unitY); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); // Reset dash | |
| } | |
| collectedPoints.forEach(p => { | |
| const px = ox + p.x * unitX; | |
| const py = oy - p.y * unitY; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 6, 0, Math.PI * 2); | |
| ctx.fillStyle = '#fbbf24'; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#fff'; | |
| ctx.stroke(); | |
| // Blinking Effect (Requested) | |
| if (p.blink) { | |
| const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 150); | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 12 + 4 * Math.sin(Date.now() / 150), 0, Math.PI * 2); | |
| ctx.strokeStyle = `rgba(34, 211, 238, ${alpha})`; // Cyan pulsing ring | |
| ctx.lineWidth = 3; | |
| ctx.stroke(); | |
| } | |
| // Color coded coordinates: (x, y) | |
| ctx.font = 'bold 24px Orbitron'; // Point Numbers Increased | |
| const textY = py - 24; // Position higher up for larger font | |
| // Measure text to center it | |
| const strX = `${p.x}`; | |
| const strY = `${p.y}`; | |
| const w1 = ctx.measureText("(").width; | |
| const wX = ctx.measureText(strX).width; | |
| const wComma = ctx.measureText(", ").width; | |
| const wY = ctx.measureText(strY).width; | |
| const w2 = ctx.measureText(")").width; | |
| const totalW = w1 + wX + wComma + wY + w2; | |
| let cursorX = px - totalW / 2; | |
| ctx.fillStyle = '#cbd5e1'; ctx.fillText("(", cursorX + w1 / 2, textY); cursorX += w1; | |
| ctx.fillStyle = '#fbbf24'; ctx.fillText(strX, cursorX + wX / 2, textY); cursorX += wX; // Amber X | |
| ctx.fillStyle = '#cbd5e1'; ctx.fillText(", ", cursorX + wComma / 2, textY); cursorX += wComma; | |
| ctx.fillStyle = '#22d3ee'; ctx.fillText(strY, cursorX + wY / 2, textY); cursorX += wY; // Cyan Y | |
| ctx.fillStyle = '#cbd5e1'; ctx.fillText(")", cursorX + w2 / 2, textY); | |
| }); | |
| // Draw Guide Arrows if active (Coordinate Animation) | |
| if (guideArrowState.active && (currentState === STATE.PHASE2_COORDINATE || currentState === STATE.PHASE2_Q)) { | |
| drawGuideArrows(); | |
| } | |
| } | |
| function updateParticles() { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| let p = particles[i]; | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.life -= 0.02; | |
| ctx.globalAlpha = p.life; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| if (p.life <= 0) particles.splice(i, 1); | |
| } | |
| ctx.globalAlpha = 1.0; | |
| } | |
| </script> | |
| <!-- Credits --> | |
| <div class="fixed bottom-4 right-4 text-right text-slate-500 text-xs z-50 pointer-events-auto select-none"> | |
| <div class="mb-1">遊戲設計:新竹縣精華國中藍星宇</div> | |
| <div>FB教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" | |
| class="hover:text-cyan-400 transition-colors">萬物皆數</a></div> | |
| </div> | |
| <script> | |
| // --- Main Game Loop (Overridden/Appended) --- | |
| let lastTime = Date.now(); | |
| function loop() { | |
| const now = Date.now(); | |
| const dt = (now - lastTime) / 1000; | |
| lastTime = now; | |
| ctx.clearRect(0, 0, width, height); | |
| if (currentState === STATE.PHASE6_GAME) { | |
| // Rhythm Game Mode | |
| updateRhythm(dt); | |
| drawRhythmBackground(); | |
| drawRhythmGame(); | |
| drawCore(); | |
| } else if (currentState >= STATE.PHASE6_INTRO) { | |
| // Intro & Summary Mode (Use Rhythm BG) | |
| drawRhythmBackground(); | |
| drawCore(); | |
| } else { | |
| // Standard Graph Mode | |
| drawGraph(); | |
| drawCore(); | |
| } | |
| updateParticles(); | |
| requestAnimationFrame(loop); | |
| } | |
| </script> | |
| </body> | |
| </html> |