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>數列峽谷 - Sequence Canyon</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&display=swap" rel="stylesheet"> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| body { | |
| font-family: 'Noto Sans TC', sans-serif; | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #1a1005; | |
| touch-action: none; | |
| -webkit-user-select: none; | |
| user-select: none; | |
| } | |
| :root { | |
| --glass-bg: rgba(69, 26, 3, 0.85); | |
| /* Dark Amber */ | |
| --glass-border: rgba(245, 158, 11, 0.3); | |
| /* Amber Border */ | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| .glass { | |
| background: var(--glass-bg); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| border: 1px solid var(--glass-border); | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); | |
| } | |
| .glass-panel { | |
| background: rgba(15, 23, 42, 0.92); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(71, 85, 105, 0.5); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); | |
| } | |
| /* Input Styling */ | |
| .math-input { | |
| background: rgba(0, 0, 0, 0.5); | |
| border: 2px solid #475569; | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 8px; | |
| font-weight: bold; | |
| font-size: 1.2rem; | |
| outline: none; | |
| transition: all 0.2s; | |
| } | |
| .math-input:focus { | |
| border-color: #f59e0b; | |
| box-shadow: 0 0 10px rgba(245, 158, 11, 0.3); | |
| } | |
| .math-input:disabled { | |
| background: rgba(15, 23, 42, 0.8); | |
| border-color: #64748b; | |
| color: #94a3b8; | |
| } | |
| .math-input.correct { | |
| border-color: #4ade80; | |
| background: rgba(74, 222, 128, 0.2); | |
| color: #fff; | |
| } | |
| .math-input.wrong { | |
| border-color: #ef4444; | |
| animation: shake 0.3s; | |
| } | |
| .step-completed { | |
| opacity: 0.7; | |
| transform: scale(0.95); | |
| pointer-events: none; | |
| border-color: #4ade80 ; | |
| } | |
| @keyframes shake { | |
| 0%, | |
| 100% { | |
| transform: translateX(0); | |
| } | |
| 25% { | |
| transform: translateX(-5px); | |
| } | |
| 75% { | |
| transform: translateX(5px); | |
| } | |
| } | |
| /* Mobile Controls Styling */ | |
| .control-zone { | |
| position: absolute; | |
| bottom: 30px; | |
| z-index: 50; | |
| } | |
| #joystick-zone { | |
| left: 30px; | |
| width: 160px; | |
| height: 60px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 30px; | |
| border: 2px solid rgba(255, 255, 255, 0.2); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| touch-action: none; | |
| } | |
| #joystick-knob { | |
| width: 50px; | |
| height: 50px; | |
| background: rgba(245, 158, 11, 0.8); | |
| border-radius: 50%; | |
| box-shadow: 0 0 15px rgba(245, 158, 11, 0.6); | |
| transform: translate(0px, 0px); | |
| } | |
| #btn-jump { | |
| right: 30px; | |
| width: 110px; | |
| height: 110px; | |
| background: rgba(234, 88, 12, 0.6); | |
| border-radius: 50%; | |
| border: 2px solid rgba(255, 255, 255, 0.3); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| font-weight: bold; | |
| font-size: 1.2rem; | |
| box-shadow: 0 0 20px rgba(234, 88, 12, 0.4); | |
| } | |
| #btn-jump:active { | |
| background: rgba(234, 88, 12, 0.9); | |
| transform: scale(0.95); | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| @keyframes pulse { | |
| 0%, | |
| 100% { | |
| opacity: 1; | |
| transform: scale(1); | |
| } | |
| 50% { | |
| opacity: 0.7; | |
| transform: scale(1.1); | |
| } | |
| } | |
| #ui-tutorial h2 { | |
| text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); | |
| } | |
| .tutorial-hint { | |
| font-size: 1rem; | |
| color: #cbd5e1; | |
| margin-top: 0.5rem; | |
| background-color: rgba(0, 0, 0, 0.5); | |
| display: inline-block; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| } | |
| #screen-ritual { | |
| overflow-y: auto; | |
| justify-content: flex-end; | |
| } | |
| /* Ritual Fade In */ | |
| .fade-in { | |
| animation: ritualFadeIn 1s forwards; | |
| } | |
| @keyframes ritualFadeIn { | |
| from { | |
| opacity: 0; | |
| } | |
| to { | |
| opacity: 1; | |
| } | |
| } | |
| .ritual-content-wrapper { | |
| width: 100%; | |
| max-width: 600px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 10px; | |
| padding-bottom: 20px; | |
| } | |
| /* Story Hint Message */ | |
| #story-hint { | |
| animation: fadeInOut 4s forwards; | |
| } | |
| @keyframes fadeInOut { | |
| 0% { | |
| opacity: 0; | |
| transform: translate(-50%, 20px); | |
| } | |
| 10% { | |
| opacity: 1; | |
| transform: translate(-50%, 0); | |
| } | |
| 90% { | |
| opacity: 1; | |
| transform: translate(-50%, 0); | |
| } | |
| 100% { | |
| opacity: 0; | |
| transform: translate(-50%, -20px); | |
| } | |
| } | |
| #mock-msg { | |
| animation: bounceIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
| } | |
| @keyframes bounceIn { | |
| 0% { | |
| transform: scale(0); | |
| opacity: 0; | |
| } | |
| 100% { | |
| transform: scale(1); | |
| opacity: 1; | |
| } | |
| } | |
| /* Guide Arrow */ | |
| #guide-arrow { | |
| animation: arrowFloat 1.5s infinite ease-in-out; | |
| } | |
| @keyframes arrowFloat { | |
| 0%, | |
| 100% { | |
| transform: translateX(0); | |
| } | |
| 50% { | |
| transform: translateX(-15px); | |
| } | |
| } | |
| /* Gravity Message */ | |
| #gravity-msg { | |
| animation: fadeIn 0.5s forwards; | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(-20px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| /* Altar Dive Hint */ | |
| #altar-hint { | |
| animation: pulse 1s infinite; | |
| } | |
| /* Virtual Keypad */ | |
| #virtual-keypad { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| left: auto; | |
| transform: translateY(120%); | |
| z-index: 1000; | |
| background: rgba(15, 23, 42, 0.95); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| border: 1px solid rgba(245, 158, 11, 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); | |
| touch-action: none; | |
| } | |
| #virtual-keypad.active { | |
| visibility: visible; | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| #virtual-keypad.dragging { | |
| transition: none; | |
| } | |
| .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: 'Noto Sans TC', 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(245, 158, 11, 0.2); | |
| } | |
| .keypad-btn.action { | |
| background: rgba(15, 23, 42, 0.8); | |
| border-color: rgba(245, 158, 11, 0.4); | |
| color: #f59e0b; | |
| } | |
| .keypad-btn.submit { | |
| background: rgba(234, 88, 12, 0.8); | |
| color: white; | |
| grid-column: span 3; | |
| width: 100%; | |
| height: 50px; | |
| font-size: 18px; | |
| margin-top: 4px; | |
| } | |
| </style> | |
| </head> | |
| <body class="w-full h-screen h-[100dvh] text-white bg-slate-900"> | |
| <div id="game-container" class="relative w-full h-full"> | |
| <canvas id="gameCanvas"></canvas> | |
| <!-- HUD --> | |
| <div id="ui-hud" | |
| class="absolute top-4 left-4 right-4 flex justify-between items-start pointer-events-none hidden"> | |
| <div class="glass-panel rounded-xl px-4 py-2 flex flex-col items-center flex-1 mx-2 border-amber-500/30"> | |
| <span class="text-xs text-amber-300 uppercase tracking-widest">當前任務目標</span> | |
| <div class="flex items-center gap-4"> | |
| <span id="hud-sequence" | |
| class="text-xl md:text-2xl font-black text-amber-400 drop-shadow-[0_0_8px_rgba(245,158,11,0.5)]">...</span> | |
| </div> | |
| </div> | |
| <div class="glass-panel rounded-xl px-4 py-2 border-amber-500/30"> | |
| <span class="text-xs text-amber-300 uppercase tracking-widest">蒐集進度</span> | |
| <div class="flex gap-1 mt-1" id="collection-dots"></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-slate-800 transition-colors border border-amber-500/30 pointer-events-auto"> | |
| <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> | |
| <!-- Credits Footer --> | |
| <div class="fixed bottom-1 right-2 text-right text-[10px] text-slate-500/50 pointer-events-none z-50 font-sans"> | |
| <div>遊戲設計:新竹縣精華國中藍星宇老師</div> | |
| <div>臉書社團:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" | |
| class="hover:text-amber-400 pointer-events-auto transition-colors">萬物皆數</a></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(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> | |
| <!-- Story Hint Overlay --> | |
| <div id="story-hint" | |
| class="absolute top-1/3 left-1/2 transform -translate-x-1/2 glass-panel px-6 py-4 rounded-xl hidden text-center pointer-events-none border border-yellow-400/50 shadow-[0_0_30px_rgba(250,204,21,0.3)] z-40"> | |
| <p id="story-hint-title" class="text-yellow-300 font-bold text-xl mb-1">階梯竟然消失了!</p> | |
| <p id="story-hint-desc" class="text-white text-base">是不是需要什麼儀式才能再次出現...</p> | |
| </div> | |
| <!-- Guide Arrow --> | |
| <div id="guide-arrow" | |
| class="absolute top-[30%] right-[20%] hidden z-30 pointer-events-none flex items-center gap-2"> | |
| <div class="text-6xl text-yellow-400 drop-shadow-[0_0_15px_rgba(250,204,21,0.8)]">⬅️</div> | |
| <div class="text-xl font-bold text-yellow-300 bg-slate-900/60 px-3 py-1 rounded border border-yellow-400/50"> | |
| 祭壇在後方</div> | |
| </div> | |
| <!-- Altar Dive Hint --> | |
| <div id="altar-hint" | |
| class="absolute top-[60%] left-1/2 transform -translate-x-1/2 hidden z-30 pointer-events-none flex flex-col items-center"> | |
| <div class="glass-panel px-4 py-2 rounded-xl border border-red-400 bg-red-900/80"> | |
| <p id="altar-hint-text" class="text-2xl font-black text-white">🔥 連點兩下跳躍,下墜打破祭壇!</p> | |
| </div> | |
| </div> | |
| <!-- Tutorial Overlay --> | |
| <div id="ui-tutorial" class="absolute top-24 w-full text-center pointer-events-none hidden z-30"> | |
| <h2 id="tutorial-text" class="text-3xl md:text-4xl font-black text-yellow-400 drop-shadow-xl tracking-wider"> | |
| 教學模式</h2> | |
| <div id="tutorial-subtext" class="tutorial-hint hidden">提示訊息</div> | |
| </div> | |
| <!-- Mobile Controls --> | |
| <div id="mobile-controls" class="hidden"> | |
| <div id="joystick-zone" class="control-zone"> | |
| <div id="joystick-knob"></div> | |
| </div> | |
| <div id="btn-jump" class="control-zone select-none">跳躍</div> | |
| </div> | |
| <!-- Start Screen --> | |
| <div id="screen-start" class="absolute inset-0 flex flex-col items-center justify-center z-50"> | |
| <h1 | |
| class="text-5xl md:text-7xl font-black mb-4 text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-600 drop-shadow-[0_0_15px_rgba(245,158,11,0.5)]"> | |
| 數列峽谷</h1> | |
| <p class="text-slate-300 mb-8 text-lg">操作模式選擇</p> | |
| <div class="flex flex-col md:flex-row gap-6 w-full max-w-2xl px-4"> | |
| <button onclick="selectDevice('mobile')" style="background: rgba(15, 23, 42, 0.92);" | |
| class="flex-1 p-6 rounded-2xl transition-all duration-300 border-2 border-slate-600 hover:border-amber-400 hover:shadow-[0_0_25px_rgba(245,158,11,0.3)] group"> | |
| <div class="text-4xl mb-4">📱</div> | |
| <h3 class="text-xl font-bold text-amber-100 group-hover:text-amber-400 transition-colors">平板/觸控</h3> | |
| <p class="text-sm text-slate-400 mt-2 group-hover:text-amber-200/70 transition-colors">觸控螢幕虛擬搖桿</p> | |
| </button> | |
| <button onclick="selectDevice('desktop')" style="background: rgba(15, 23, 42, 0.92);" | |
| class="flex-1 p-6 rounded-2xl transition-all duration-300 border-2 border-slate-600 hover:border-orange-400 hover:shadow-[0_0_25px_rgba(234,88,12,0.3)] group"> | |
| <div class="text-4xl mb-4">⌨️</div> | |
| <h3 class="text-xl font-bold text-orange-100 group-hover:text-orange-400 transition-colors">電腦/鍵盤</h3> | |
| <p class="text-sm text-slate-400 mt-2 group-hover:text-orange-200/70 transition-colors">方向鍵移動、空白跳躍</p> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Quiz Screen --> | |
| <div id="screen-quiz" | |
| class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900/95 backdrop-blur-md z-50 hidden overflow-y-auto py-8"> | |
| <div class="glass-panel p-6 md:p-8 rounded-2xl w-[95%] max-w-2xl border-t-4 border-amber-500 my-auto"> | |
| <h2 class="text-2xl md:text-3xl font-bold text-amber-400 mb-4">✨ 數列觀念檢測</h2> | |
| <div class="bg-slate-800/50 p-4 md:p-6 rounded-lg mb-6 text-left space-y-4 border border-slate-700"> | |
| <p class="text-lg md:text-xl text-slate-200"><strong | |
| class="text-amber-400 text-xl md:text-2xl">公差</strong> 的意思是:<span | |
| class="border-b-2 border-amber-500">後項 - 前項</span> (固定差距)。</p> | |
| <div class="pl-4 border-l-4 border-slate-600"> | |
| <p class="text-base md:text-lg text-slate-300 font-bold mb-2">範例:首項 2,公差 3</p> | |
| <p class="text-lg md:text-xl font-mono text-cyan-300 leading-relaxed">➜ 2, 5, 8, 11...</p> | |
| <p class="text-base md:text-lg font-mono text-slate-400 leading-relaxed">(2, 2+3, 2+3+3, | |
| 2+3+3+3...)</p> | |
| </div> | |
| </div> | |
| <div class="mb-6"> | |
| <p class="text-lg md:text-xl mb-4 font-bold">請問:若首項是 <span id="quiz-start-val" | |
| class="text-amber-400 text-2xl md:text-3xl mx-1">?</span>,公差是 <span id="quiz-diff-val" | |
| class="text-amber-400 text-2xl md:text-3xl mx-1">?</span></p> | |
| <p class="mb-2 text-slate-300">數列的前四項會是什麼?</p> | |
| <div class="flex justify-center items-center gap-2 md:gap-4 flex-wrap"> | |
| <input type="text" readonly onclick="keypad.open(this)" id="quiz-1" | |
| class="math-input w-16 h-12 md:w-24 md:h-16 text-xl md:text-2xl" placeholder="?"> | |
| <span class="text-slate-500 font-bold text-xl">,</span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="quiz-2" | |
| class="math-input w-16 h-12 md:w-24 md:h-16 text-xl md:text-2xl" placeholder="?"> | |
| <span class="text-slate-500 font-bold text-xl">,</span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="quiz-3" | |
| class="math-input w-16 h-12 md:w-24 md:h-16 text-xl md:text-2xl" placeholder="?"> | |
| <span class="text-slate-500 font-bold text-xl">,</span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="quiz-4" | |
| class="math-input w-16 h-12 md:w-24 md:h-16 text-xl md:text-2xl" placeholder="?"> | |
| <span class="text-slate-500 font-bold text-xl">...</span> | |
| </div> | |
| <p id="quiz-error" class="text-red-400 mt-2 text-lg h-6 font-bold"></p> | |
| </div> | |
| <button onclick="checkQuiz()" | |
| class="w-full py-3 md:py-4 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white font-bold text-xl md:text-2xl transition-all shadow-lg active:scale-95 border border-amber-400/30">送出答案 | |
| (SUBMIT)</button> | |
| </div> | |
| </div> | |
| <!-- Mission Screen --> | |
| <div id="screen-mission" | |
| class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900/90 backdrop-blur-md z-50 hidden"> | |
| <div class="glass-panel p-6 md:p-8 rounded-2xl text-center border-t-4 border-amber-500 w-[90%] max-w-lg"> | |
| <h3 class="text-amber-300 tracking-widest mb-4">任務開始</h3> | |
| <h1 class="text-3xl md:text-4xl font-bold mb-6 text-white">尋找 <span id="mission-highlight" | |
| class="text-amber-400 drop-shadow-md"></span></h1> | |
| <p class="text-slate-300 mb-6 leading-relaxed text-sm md:text-base"> | |
| 地板會依照規律生成。<br>只要符合此規律的數字都是安全的。<br>蒐集完數字後,需要<span | |
| class="text-yellow-400 font-bold border-b border-yellow-400">建造階梯</span>才能進入城堡。</p> | |
| <button onclick="confirmMission()" | |
| class="w-full py-3 md:py-4 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-bold text-xl shadow-[0_0_15px_rgba(245,158,11,0.4)] transition-all active:scale-95">出發 | |
| (START)</button> | |
| </div> | |
| </div> | |
| <!-- Ritual UI --> | |
| <div id="screen-ritual" | |
| class="absolute inset-0 hidden z-40 pointer-events-auto flex flex-col items-center justify-end pb-4 md:pb-10 fade-in pointer-events-none"> | |
| <div class="flex-1 w-full pointer-events-none"><!-- Spacer for canvas --></div> | |
| <div | |
| class="ritual-content-wrapper pointer-events-auto w-[95%] max-w-[600px] bg-slate-900/80 backdrop-blur-md rounded-t-2xl p-4 border-t border-amber-500/50 max-h-[50vh] overflow-y-auto"> | |
| <!-- Step -1: Mission Briefing --> | |
| <div id="ritual-step-mission" class="glass-panel p-6 rounded-2xl w-full text-center transition-all mb-4"> | |
| <h3 class="text-xl md:text-2xl font-bold text-amber-400 mb-4">任務說明</h3> | |
| <p class="text-white text-lg md:text-xl mb-6 leading-relaxed"> | |
| 現在我們需要計算出<span class="text-amber-400 font-bold">製作階梯所需要的方塊數量</span>,<br>才能重新搭建階梯! | |
| </p> | |
| <div class="flex gap-4 justify-center"> | |
| <button onclick="handleMissionContinue()" | |
| class="px-8 py-3 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-bold text-lg shadow-lg transition-all animate-pulse"> | |
| 繼續 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Step 0: Initial Question --> | |
| <div id="ritual-step-0" class="glass-panel p-6 rounded-2xl w-full hidden text-center transition-all mb-4"> | |
| <h3 class="text-xl md:text-2xl font-bold text-amber-400 mb-4">思考時間</h3> | |
| <p class="text-white text-lg md:text-xl mb-6">一個一個算好像不太好算,請問你要一個一個算嗎?</p> | |
| <div class="flex gap-4 justify-center"> | |
| <button onclick="handleRitualChoice('yes')" | |
| class="px-6 py-3 rounded-xl bg-slate-700 hover:bg-slate-600 text-white font-bold border border-slate-500 transition-all"> | |
| 要 | |
| </button> | |
| <button onclick="handleRitualChoice('no')" | |
| class="px-6 py-3 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-bold shadow-lg transition-all"> | |
| 不要 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Step 0-Yes: Hard Mode --> | |
| <div id="ritual-step-0-yes" | |
| class="glass-panel p-6 rounded-2xl w-full hidden text-center transition-all mb-4"> | |
| <h3 class="text-xl md:text-2xl font-bold text-red-400 mb-4">勇者挑戰</h3> | |
| <p class="text-white text-lg mb-4">既然你這麼喜歡算,那就算大一點的!</p> | |
| <div class="flex justify-center mb-6 bg-slate-800/50 p-2 rounded-xl border border-slate-600 w-full overflow-x-auto" | |
| style="max-height: 80vh; overflow-y: auto;"> | |
| <canvas id="hard-mode-canvas" width="1000" height="800" | |
| class="w-full h-auto min-w-[800px]"></canvas> | |
| </div> | |
| <div class="flex flex-col items-center gap-4"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-lg font-bold">方塊數量</span> | |
| <input type="text" onclick="keypad.open(this)" id="hard-input" | |
| class="math-input w-24 h-10 text-lg" placeholder="?"> | |
| </div> | |
| <p id="hard-msg" class="text-red-400 h-6 font-bold"></p> | |
| <div class="flex gap-4"> | |
| <button onclick="checkHardMode()" | |
| class="px-6 py-2 rounded-lg bg-green-600 hover:bg-green-500 text-white font-bold"> | |
| 確認 | |
| </button> | |
| <button onclick="giveUpHardMode()" | |
| class="px-6 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white font-bold border border-slate-400"> | |
| 我認輸 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Step 1: Clone Jutsu (Modified) --> | |
| <div id="ritual-step-1" | |
| class="transition-all duration-500 mb-4 w-full flex flex-col items-center justify-center hidden"> | |
| <p | |
| class="text-white text-lg md:text-xl mb-6 text-center bg-slate-800/60 p-4 rounded-xl border border-amber-500/30"> | |
| 既然不好算,那我們就想辦法把他變好算,<br> | |
| <span class="text-amber-400 font-bold text-2xl">用分身術變成長方形吧!</span> | |
| </p> | |
| <button onclick="triggerRitualAnimation()" | |
| class="btn-magic px-8 py-3 md:px-10 md:py-4 rounded-full bg-gradient-to-r from-amber-500 to-orange-600 text-white font-bold text-lg md:text-xl border-4 border-white/20 shadow-[0_0_20px_rgba(245,158,11,0.6)] hover:scale-105 transition-transform">✨ | |
| 施展分身術</button> | |
| </div> | |
| <!-- Steps Container --> | |
| <div class="w-full flex flex-col items-center gap-4"> | |
| <div id="ritual-step-2" | |
| class="glass-panel p-3 md:p-4 rounded-2xl w-full hidden border border-amber-500 transition-all"> | |
| <div class="original-content"> | |
| <h3 class="text-xl md:text-2xl font-bold text-amber-400 mb-3">步驟 1:觀察長方形邊長</h3> | |
| <div class="flex flex-wrap gap-2 justify-center items-center"> | |
| <div | |
| class="bg-slate-800/50 p-2 rounded-lg flex items-center gap-1 md:gap-2 border border-slate-600"> | |
| <span class="text-amber-300 font-bold text-base md:text-xl whitespace-nowrap">寬度</span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="rit-start" | |
| class="math-input w-16 h-10 md:w-20 md:h-12 text-lg md:text-xl" placeholder="首"> | |
| <span class="text-white text-xl">+</span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="rit-end" | |
| class="math-input w-16 h-10 md:w-20 md:h-12 text-lg md:text-xl" placeholder="末"> | |
| </div> | |
| <div | |
| class="bg-slate-800/50 p-2 rounded-lg flex items-center gap-1 md:gap-2 border border-slate-600"> | |
| <span class="text-yellow-300 font-bold text-base md:text-xl whitespace-nowrap">項數</span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="rit-n" | |
| class="math-input w-16 h-10 md:w-20 md:h-12 text-lg md:text-xl" placeholder="n"> | |
| </div> | |
| <button id="btn-rit-2" onclick="checkRitualDim()" | |
| class="px-6 py-2 md:px-8 rounded-lg bg-amber-600 hover:bg-amber-500 text-white font-bold shadow-lg text-lg md:text-xl whitespace-nowrap">確認</button> | |
| </div> | |
| <p id="ritual-msg-1" class="text-red-400 mt-2 text-sm md:text-base h-5 text-center"></p> | |
| </div> | |
| <div class="summary-content hidden"> | |
| <span class="text-green-400 font-bold text-sm">✅ 步驟 1 完成</span> | |
| <span class="text-slate-300 text-xs md:text-sm ml-2" id="summary-dim"></span> | |
| </div> | |
| </div> | |
| <div id="ritual-step-3" | |
| class="glass-panel p-3 md:p-4 rounded-2xl w-full hidden border border-green-500 transition-all"> | |
| <div class="original-content"> | |
| <h3 class="text-xl md:text-2xl font-bold text-green-400 mb-3">步驟 2:計算長方形總數</h3> | |
| <div class="flex flex-wrap items-center justify-center gap-2 md:gap-3"> | |
| <span class="text-base md:text-xl text-slate-300 font-bold">總磚塊數 (<span | |
| class="text-yellow-400">寬</span>×<span class="text-yellow-400">高</span>) = </span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="rit-total" | |
| class="math-input w-24 h-10 md:w-32 md:h-12 text-lg md:text-xl"> | |
| <button id="btn-rit-3" onclick="checkRitualTotal()" | |
| class="px-6 py-2 md:px-8 rounded-lg bg-green-600 hover:bg-green-500 text-white font-bold shadow-lg text-lg md:text-xl">確認</button> | |
| </div> | |
| <p id="ritual-msg-2" class="text-red-400 mt-2 text-sm md:text-base h-5 text-center"></p> | |
| </div> | |
| <div class="summary-content hidden"> | |
| <span class="text-green-400 font-bold text-sm">✅ 步驟 2 完成</span> | |
| <span class="text-slate-300 text-xs md:text-sm ml-2" id="summary-total"></span> | |
| </div> | |
| </div> | |
| <div id="ritual-step-4" | |
| class="glass-panel p-3 md:p-4 rounded-2xl w-full hidden border border-yellow-500 transition-all"> | |
| <h3 class="text-xl md:text-2xl font-bold text-yellow-400 mb-3">步驟 3:求原本階梯總和</h3> | |
| <p class="text-slate-300 mb-3 text-center text-base md:text-lg">原本階梯是長方形的 <span | |
| class="text-amber-400 font-bold text-xl">一半</span></p> | |
| <div class="flex flex-wrap items-center justify-center gap-2 md:gap-3"> | |
| <span class="text-base md:text-xl font-bold text-amber-300">階梯總和 = </span> | |
| <input type="text" readonly onclick="keypad.open(this)" id="rit-final" | |
| class="math-input w-24 h-10 md:w-32 md:h-12 text-lg md:text-xl"> | |
| <button id="btn-rit-4" onclick="checkRitualFinal()" | |
| class="px-6 py-2 md:px-8 rounded-lg bg-amber-600 hover:bg-amber-500 text-white font-bold shadow-lg text-lg md:text-xl">完成</button> | |
| </div> | |
| <p id="ritual-msg-3" class="text-red-400 mt-2 text-sm md:text-base h-5 text-center"></p> | |
| </div> | |
| <div id="ritual-step-5" class="glass-panel p-3 md:p-4 rounded-2xl w-full hidden text-center"> | |
| <h3 | |
| class="text-xl md:text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-300 to-red-400 mb-2"> | |
| 計算完成!</h3> | |
| <p | |
| class="text-base md:text-lg font-black text-yellow-400 mt-2 mb-4 leading-relaxed bg-slate-800 p-2 rounded"> | |
| 階梯已生成!快爬上去升起旗幟吧! | |
| </p> | |
| <div class="flex gap-4 justify-center"> | |
| <button onclick="finishRitualAndBuild()" | |
| class="px-6 py-2 md:px-8 md:py-3 rounded-lg bg-green-600 hover:bg-green-500 font-bold text-white text-lg md:text-xl shadow-lg animate-bounce"> | |
| 前往城堡 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Review Screen (Level Clear) --> | |
| <div id="screen-review" | |
| class="absolute inset-0 flex flex-col items-center justify-center bg-slate-900/95 backdrop-blur-md z-50 hidden overflow-y-auto"> | |
| <div | |
| class="glass-panel p-8 rounded-2xl max-w-2xl w-[95%] border-t-4 border-amber-500 text-center my-10 max-h-[90vh] overflow-y-auto"> | |
| <h2 class="text-5xl font-black text-amber-400 mb-4 drop-shadow-[0_0_15px_rgba(245,158,11,0.5)]">🏆 | |
| 關卡完成!</h2> | |
| <div class="mb-6"> | |
| <span class="text-slate-300 text-xl block mb-1">最終得分</span> | |
| <span id="final-score" class="text-6xl font-black text-amber-300 drop-shadow-md">0</span> | |
| </div> | |
| <!-- Mock Message for Low Score --> | |
| <div id="mock-msg" | |
| class="hidden mb-6 bg-slate-800 border border-slate-600 p-4 rounded-xl transform rotate-[-2deg]"> | |
| <p class="text-4xl">🤪 <span class="text-slate-300 font-bold">太弱了吧,加油好嗎</span></p> | |
| </div> | |
| <div class="bg-slate-800/80 p-6 rounded-xl text-left space-y-5 mb-8 border border-slate-600"> | |
| <h3 class="text-2xl font-bold text-amber-400 border-b border-slate-600 pb-2">📜 本關核心回顧 (Concept | |
| Review)</h3> | |
| <div> | |
| <p class="text-amber-300 font-bold text-xl">1. 等差數列 (Arithmetic Sequence)</p> | |
| <p class="text-slate-300 text-lg">相鄰兩項的差都相等,這個差稱為<span class="text-white font-bold">「公差」</span>。 | |
| </p> | |
| </div> | |
| <div> | |
| <p class="text-amber-300 font-bold text-xl">2. 公差公式</p> | |
| <p class="text-slate-300 text-lg font-mono bg-slate-900 p-2 rounded inline-block text-cyan-200"> | |
| 公差 = 後項 - 前項 | |
| </p> | |
| </div> | |
| <div> | |
| <p class="text-amber-300 font-bold text-xl">3. 等差級數和 (Sum)</p> | |
| <p class="text-slate-300 text-lg">透過圖形拼貼,我們發現總和是長方形面積的一半:</p> | |
| <p | |
| class="text-amber-400 font-bold font-mono bg-slate-900 p-3 rounded mt-2 text-center text-xl border border-slate-600"> | |
| 總和 = (首項 + 末項) × 項數 ÷ 2 | |
| </p> | |
| </div> | |
| </div> | |
| <a href="index.html" | |
| class="w-full py-4 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white font-bold text-2xl transition-all shadow-lg block active:scale-95"> | |
| 回到 Math City | |
| </a> | |
| </div> | |
| </div> | |
| <!-- Game Over --> | |
| <div id="screen-gameover" | |
| class="absolute inset-0 flex flex-col items-center justify-center bg-black/80 backdrop-blur-md z-50 hidden"> | |
| <h2 class="text-5xl font-bold text-red-500 mb-4">任務失敗</h2> | |
| <p class="text-slate-300 mb-8">計算錯誤或失足墜落</p> | |
| <div class="flex gap-4"> | |
| <button onclick="resetGame()" | |
| class="px-8 py-3 rounded-lg bg-white text-slate-900 font-bold hover:bg-slate-200">再試一次</button> | |
| <a href="index.html" class="px-8 py-3 rounded-lg bg-indigo-600 font-bold">回到 Math City</a> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Core Variables --- | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let width, height; | |
| let lastTime = 0; | |
| // Physics - Scaled by Delta Time (Normalized to 60fps) | |
| const GRAVITY = 0.45; | |
| const JUMP_FORCE = -11.5; | |
| const DIVE_FORCE = 18; | |
| const MOVE_SPEED = 5; | |
| const MAX_JUMPS = 3; | |
| const TOTAL_COLLECT_GOAL = 7; | |
| const COYOTE_DURATION = 8; // Frames (approx 0.13s) | |
| // Game State | |
| const STATE = { START: 0, TUTORIAL: 1, QUIZ: 2, MISSION_BRIEF: 3, PLAYING: 4, RITUAL: 5, CLIMBING: 6, LEVEL_COMPLETE: 7, GAMEOVER: 8, FIREWORKS: 9, HIDDEN_STAGE: 10, HIDDEN_COMPLETE: 11 }; | |
| let gameState = STATE.START; | |
| let controlType = 'desktop'; | |
| let hasPassedTutorial = false; | |
| let isLooping = false; | |
| let levelCompleteTimer = null; | |
| let showGravityHint = false; | |
| let hasShownGravityDialog = false; // Flag for story | |
| let screenShake = 0; | |
| let ritualGlow = 0; | |
| // Math Logic | |
| let sequence = { start: 1, diff: 3, collected: [] }; | |
| let quizData = { start: 3, diff: 2 }; | |
| let score = 0; | |
| // World | |
| let nextStoneNum = 1; | |
| let worldRightEdge = 0; | |
| let generatedTargetCount = 0; | |
| let platforms = []; | |
| let particles = []; | |
| let tutorialObjects = []; | |
| let checkpoints = []; | |
| let levelObjects = []; | |
| let isStairsVanished = false; | |
| let isMagicStairsBuilt = false; | |
| let castleObject = null; | |
| let gravityZone = null; | |
| let minPlayerX = -Infinity; // Player Barrier Check | |
| // Player | |
| let player = { | |
| x: 0, y: 0, w: 36, h: 54, | |
| vx: 0, vy: 0, prevY: 0, | |
| isGrounded: false, isDiving: false, jumpCount: 0, | |
| facingRight: true, | |
| autoWalk: false, | |
| gravityHintTimer: 0, // New timer for bubble | |
| coyoteTimer: 0, // Coyote Time | |
| scaleX: 1, // Animation stretch | |
| scaleY: 1, // Animation squash | |
| visible: false // Hidden on start screen | |
| }; | |
| // Hidden Stage Variables | |
| let hiddenStage = { | |
| sequence: { start: 0, diff: 0 }, | |
| currentLevel: 0, | |
| platforms: [], | |
| cameraScrollSpeed: 0, | |
| timePerLevel: 3.0, | |
| currentTimer: 0, | |
| entrancePlatform: null, | |
| hasStarted: false | |
| }; | |
| // Input | |
| let input = { axisX: 0, jumpPressed: false }; | |
| let lastJumpTime = 0; | |
| let cheatCodeBuffer = ''; | |
| let joystick = { active: false, startX: 0, currentX: 0, id: null }; | |
| // Camera | |
| let cameraX = 0; | |
| let cameraY = 0; | |
| // Tutorial & Ritual | |
| let tutorial = { step: 0, checkpointReached: false, jumpTargetsHit: 0 }; | |
| let ritualPhase = 0; | |
| let ritualAnimProgress = 0; | |
| // --- Audio System (Web Audio API) --- | |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| function playSound(type) { | |
| if (audioCtx.state === 'suspended') audioCtx.resume(); | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| const now = audioCtx.currentTime; | |
| if (type === 'jump') { | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(300, now); | |
| osc.frequency.exponentialRampToValueAtTime(600, now + 0.1); | |
| gain.gain.setValueAtTime(0.3, now); | |
| gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1); | |
| osc.start(now); | |
| osc.stop(now + 0.1); | |
| } else if (type === 'collect') { | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(800, now); | |
| osc.frequency.exponentialRampToValueAtTime(1200, now + 0.1); | |
| gain.gain.setValueAtTime(0.3, now); | |
| gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15); | |
| osc.start(now); | |
| osc.stop(now + 0.15); | |
| } else if (type === 'break') { | |
| osc.type = 'sawtooth'; | |
| osc.frequency.setValueAtTime(100, now); | |
| osc.frequency.linearRampToValueAtTime(50, now + 0.2); | |
| gain.gain.setValueAtTime(0.3, now); | |
| gain.gain.linearRampToValueAtTime(0.01, now + 0.2); | |
| osc.start(now); | |
| osc.stop(now + 0.2); | |
| } else if (type === 'ritual') { | |
| // Chord | |
| [440, 554, 659].forEach((freq, i) => { | |
| const o = audioCtx.createOscillator(); | |
| const g = audioCtx.createGain(); | |
| o.type = 'sine'; | |
| o.connect(g); | |
| g.connect(audioCtx.destination); | |
| o.frequency.value = freq; | |
| g.gain.setValueAtTime(0.1, now); | |
| g.gain.exponentialRampToValueAtTime(0.001, now + 1.5); | |
| o.start(now); | |
| o.stop(now + 1.5); | |
| }); | |
| } else if (type === 'win') { | |
| // Victory Fanfare (Rich C Major 7th Arpeggio with Twinkle) | |
| const notes = [ | |
| { f: 523.25, t: 0 }, // C5 | |
| { f: 659.25, t: 0.1 }, // E5 | |
| { f: 783.99, t: 0.2 }, // G5 | |
| { f: 987.77, t: 0.3 }, // B5 (Maj7) | |
| { f: 1046.50, t: 0.4 }, // C6 | |
| { f: 1318.51, t: 0.6 } // E6 (Twinkle) | |
| ]; | |
| notes.forEach((note) => { | |
| const o = audioCtx.createOscillator(); | |
| const g = audioCtx.createGain(); | |
| o.type = 'triangle'; // Triangle for gamey but softer sound | |
| o.connect(g); | |
| g.connect(audioCtx.destination); | |
| o.frequency.value = note.f; | |
| const startTime = now + note.t; | |
| g.gain.setValueAtTime(0, startTime); | |
| g.gain.linearRampToValueAtTime(0.2, startTime + 0.05); // Attack | |
| g.gain.exponentialRampToValueAtTime(0.001, startTime + 0.8); // Long Decay | |
| o.start(startTime); | |
| o.stop(startTime + 0.8); | |
| }); | |
| } | |
| } | |
| // --- Init --- | |
| function resize() { | |
| const oldHeight = height; | |
| width = canvas.width = window.innerWidth; | |
| height = canvas.height = window.innerHeight; | |
| if (oldHeight && oldHeight !== height) { | |
| const deltaY = height - oldHeight; | |
| player.y += deltaY; | |
| player.prevY += deltaY; | |
| cameraY += deltaY; | |
| platforms.forEach(p => p.y += deltaY); | |
| checkpoints.forEach(p => p.y += deltaY); | |
| tutorialObjects.forEach(o => o.y += deltaY); | |
| levelObjects.forEach(o => o.y += deltaY); | |
| particles.forEach(p => p.y += deltaY); | |
| if (gravityZone) gravityZone.y += deltaY; | |
| } | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| // --- Virtual Keypad System (Reused from Function Game) --- | |
| const keypad = { | |
| element: null, | |
| targetInput: null, | |
| init: function () { | |
| this.element = document.getElementById('virtual-keypad'); | |
| 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(); | |
| } | |
| }); | |
| 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) => { | |
| 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) { | |
| this.targetInput = inputElement; | |
| this.element.classList.add('active'); | |
| // Highlight input | |
| document.querySelectorAll('.math-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; | |
| } | |
| // Auto Advance for Quiz | |
| if (this.targetInput.id.startsWith('quiz-')) { | |
| const idx = parseInt(this.targetInput.id.replace('quiz-', '')) - 1; | |
| if (idx >= 0 && idx < 4) { | |
| const expectedVal = quizData.start + quizData.diff * idx; | |
| if (parseInt(this.targetInput.value) === expectedVal) { | |
| setTimeout(() => this.next(), 150); | |
| } | |
| } | |
| } | |
| }, | |
| 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 === 'quiz-1') this.open(document.getElementById('quiz-2')); | |
| else if (currentId === 'quiz-2') this.open(document.getElementById('quiz-3')); | |
| else if (currentId === 'quiz-3') this.open(document.getElementById('quiz-4')); | |
| else if (currentId === 'quiz-4') { this.close(); checkQuiz(); } | |
| else if (currentId === 'hard-input') { this.close(); checkHardMode(); } | |
| else if (currentId === 'rit-start') { this.open(document.getElementById('rit-end')); } | |
| else if (currentId === 'rit-end') { this.open(document.getElementById('rit-n')); } | |
| else if (currentId === 'rit-n') { this.close(); checkRitualDim(); } | |
| else if (currentId === 'rit-total') { this.close(); checkRitualTotal(); } | |
| else if (currentId === 'rit-final') { this.close(); checkRitualFinal(); } | |
| else this.close(); | |
| } | |
| }; | |
| 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(); | |
| } | |
| } | |
| }); | |
| function selectDevice(type) { | |
| if (audioCtx.state === 'suspended') audioCtx.resume(); | |
| try { | |
| if (document.documentElement.requestFullscreen) { | |
| document.documentElement.requestFullscreen().catch(e => console.log("Fullscreen blocked:", e)); | |
| } else if (document.documentElement.webkitRequestFullscreen) { | |
| document.documentElement.webkitRequestFullscreen(); | |
| } | |
| } catch (e) { console.log("Fullscreen error:", e); } | |
| controlType = type; | |
| document.getElementById('screen-start').classList.add('hidden'); | |
| if (type === 'mobile') { | |
| document.getElementById('mobile-controls').classList.remove('hidden'); | |
| setupTouchControls(); | |
| } else { | |
| setupKeyboardControls(); | |
| } | |
| if (hasPassedTutorial) startQuizPhase(); | |
| else startTutorial(); | |
| } | |
| function updateTutorialUI(text, hint = "") { | |
| document.getElementById('tutorial-text').innerText = text; | |
| const hintEl = document.getElementById('tutorial-subtext'); | |
| if (hint) { hintEl.innerText = hint; hintEl.classList.remove('hidden'); } | |
| else { hintEl.classList.add('hidden'); } | |
| } | |
| // --- Tutorial --- | |
| function startTutorial() { | |
| if (window.keypad) keypad.close(); // Auto-close | |
| gameState = STATE.TUTORIAL; | |
| document.getElementById('ui-hud').classList.remove('hidden'); | |
| document.getElementById('ui-tutorial').classList.remove('hidden'); | |
| resetWorld(); | |
| const groundY = height * 0.7; | |
| // Standard Platform: 0 to 1000 | |
| platforms.push({ x: 0, y: groundY, w: 1000, h: 40, type: 'safe', visited: true }); | |
| // Player Start (Fixed 200) | |
| player.x = 200; | |
| player.y = groundY - 54; | |
| player.isGrounded = true; player.prevY = player.y; cameraX = 0; cameraY = 0; | |
| // Checkpoint Right (Fixed 800) - Guaranteed on platform | |
| checkpoints = [{ x: 800, y: groundY - 40, w: 40, h: 40, active: true, type: 'right' }]; | |
| tutorial.step = 0; | |
| updateTutorialUI("基礎移動", "請往右移動,觸碰黃色光柱"); | |
| if (!isLooping) { lastTime = performance.now(); loop(lastTime); } | |
| } | |
| function advanceTutorialToLeft() { | |
| if (window.keypad) keypad.close(); // Auto-close | |
| playSound('collect'); | |
| tutorial.step = 1; checkpoints = []; | |
| const platform = platforms.find(p => p.type === 'safe' && p.x === 0); | |
| const groundY = platform ? platform.y : height * 0.7; | |
| // Checkpoint Left (Fixed 200) - Symmetric to Start (800) | |
| checkpoints.push({ | |
| x: 200, | |
| y: groundY - 40, | |
| w: 40, | |
| h: 40, | |
| active: true, | |
| type: 'left' | |
| }); | |
| updateTutorialUI("折返跑", "做得好!現在請折返往左移動"); | |
| } | |
| function generateJumpTutorial() { | |
| playSound('collect'); | |
| tutorial.step = 2; updateTutorialUI("跳躍訓練", "分別使用一段、二段、三段跳撞擊方塊"); | |
| platforms = []; checkpoints = []; | |
| const groundY = height * 0.7; | |
| platforms.push({ x: -2000, y: groundY, w: 6000, h: 40, type: 'safe' }); | |
| player.y = groundY - player.h - 1; | |
| player.vy = 0; | |
| player.isGrounded = true; | |
| player.isJumping = false; | |
| const safeStartX = player.x + 300; | |
| tutorialObjects.push({ x: safeStartX, y: groundY - 140, w: 50, h: 50, type: 'target_jump', id: 1, hit: false, color: '#facc15', label: '1' }); | |
| tutorialObjects.push({ x: safeStartX + 300, y: groundY - 290, w: 50, h: 50, type: 'target_jump', id: 2, hit: false, color: '#fbbf24', label: '2' }); | |
| tutorialObjects.push({ x: safeStartX + 600, y: groundY - 430, w: 50, h: 50, type: 'target_jump', id: 3, hit: false, color: '#f59e0b', label: '3' }); | |
| } | |
| // --- Quiz --- | |
| function startQuizPhase() { | |
| gameState = STATE.QUIZ; hasPassedTutorial = true; | |
| document.getElementById('ui-hud').classList.add('hidden'); | |
| document.getElementById('ui-tutorial').classList.add('hidden'); | |
| document.getElementById('screen-quiz').classList.remove('hidden'); | |
| const inputs = document.querySelectorAll('#screen-quiz input'); | |
| inputs.forEach(i => i.value = ''); | |
| document.getElementById('quiz-error').innerText = ''; | |
| // Track quiz attempts | |
| let quizCount = parseInt(localStorage.getItem('sequence_quiz_count') || '0'); | |
| quizCount++; | |
| localStorage.setItem('sequence_quiz_count', quizCount.toString()); | |
| // After 5 attempts, introduce negative differences | |
| const useNegative = quizCount > 5; | |
| const quizPanel = document.querySelector('#screen-quiz .glass-panel'); | |
| const quizTitle = document.querySelector('#screen-quiz h2'); | |
| if (useNegative) { | |
| quizData.start = Math.floor(Math.random() * 5) + 5; | |
| quizData.diff = -(Math.floor(Math.random() * 3) + 2); | |
| quizPanel.classList.remove('border-t-4', 'border-amber-500'); | |
| quizPanel.classList.add('border-t-4', 'border-red-500'); | |
| quizTitle.innerHTML = '🔥 挑戰題:負公差數列'; | |
| } else { | |
| quizData.start = Math.floor(Math.random() * 3) + 1; | |
| quizData.diff = Math.floor(Math.random() * 4) + 2; | |
| quizPanel.classList.remove('border-t-4', 'border-red-500'); | |
| quizPanel.classList.add('border-t-4', 'border-amber-500'); | |
| quizTitle.innerHTML = '✨ 數列觀念檢測'; | |
| } | |
| document.getElementById('quiz-start-val').innerText = quizData.start; | |
| document.getElementById('quiz-diff-val').innerText = quizData.diff; | |
| // Auto open keypad for the first input | |
| setTimeout(() => { | |
| const firstQuizInput = document.getElementById('quiz-1'); | |
| if (firstQuizInput) keypad.open(firstQuizInput); | |
| }, 300); | |
| } | |
| function checkQuiz() { | |
| const v1 = parseInt(document.getElementById('quiz-1').value); | |
| const v2 = parseInt(document.getElementById('quiz-2').value); | |
| const v3 = parseInt(document.getElementById('quiz-3').value); | |
| const v4 = parseInt(document.getElementById('quiz-4').value); | |
| const s = quizData.start; const d = quizData.diff; | |
| if (v1 === s && v2 === (s + d) && v3 === (s + d * 2) && v4 === (s + d * 3)) { | |
| playSound('collect'); | |
| // If this was a negative difference quiz, mark as passed | |
| if (d < 0) { | |
| localStorage.setItem('sequence_negative_passed', 'true'); | |
| } | |
| document.getElementById('screen-quiz').classList.add('hidden'); | |
| startMissionBrief(); | |
| } else { | |
| playSound('break'); | |
| document.getElementById('quiz-error').innerText = `答案不正確!首項是 ${s},公差是 ${d}。`; | |
| } | |
| } | |
| // --- Mission --- | |
| function startMissionBrief() { | |
| gameState = STATE.MISSION_BRIEF; | |
| document.getElementById('ui-hud').classList.remove('hidden'); | |
| document.getElementById('screen-mission').classList.remove('hidden'); | |
| const start = Math.floor(Math.random() * 3) + 1; const diff = Math.floor(Math.random() * 2) + 2; | |
| sequence = { start, diff, collected: [] }; | |
| document.getElementById('mission-highlight').innerText = `首項 ${start}, 公差 ${diff}`; | |
| document.getElementById('hud-sequence').innerText = `首項:${start} 公差:${diff}`; | |
| updateCollectionDots(); | |
| } | |
| function confirmMission() { | |
| playSound('jump'); | |
| document.getElementById('screen-mission').classList.add('hidden'); | |
| gameState = STATE.PLAYING; | |
| resetWorld(); | |
| const startY = height * 0.7; | |
| platforms.push({ x: 0, y: startY, w: 1000, h: 40, type: 'safe', visited: true }); | |
| player.x = 200; player.y = startY - 54; player.isGrounded = true; player.prevY = player.y; cameraX = 0; cameraY = 0; | |
| worldRightEdge = 1000; nextStoneNum = 1; | |
| generatedTargetCount = 0; | |
| if (!isLooping) { lastTime = performance.now(); loop(lastTime); } | |
| } | |
| function spawnNextPlatform() { | |
| if (generatedTargetCount >= TOTAL_COLLECT_GOAL) { | |
| const exists = platforms.some(p => p.type === 'altar_platform' && p.x > cameraX); | |
| if (!exists) spawnEndGameStructure(); | |
| return; | |
| } | |
| const gap = 100 + Math.random() * 60; | |
| const x = worldRightEdge + gap; | |
| const w = 100; const h = 40; | |
| let prevY = platforms[platforms.length - 1].y; | |
| let y = prevY + (Math.random() * 120 - 60); | |
| y = Math.max(height * 0.4, Math.min(height * 0.8, y)); | |
| const val = nextStoneNum++; | |
| const isSafe = (val >= sequence.start) && ((val - sequence.start) % sequence.diff === 0); | |
| const termIndex = (val - sequence.start) / sequence.diff; | |
| const isHint = isSafe && termIndex < 2; | |
| if (isSafe) generatedTargetCount++; | |
| platforms.push({ x, y, w, h, val, type: isSafe ? 'target' : 'trap', visited: false, broken: false, opacity: 1, hint: isHint, seed: Math.random() }); | |
| worldRightEdge = x + w; | |
| } | |
| function spawnEndGameStructure() { | |
| worldRightEdge += 200; | |
| for (let i = 0; i < 2; i++) { | |
| let deco = null; | |
| if (Math.random() < 0.4) { | |
| deco = ['cactus', 'barrel', 'skull', 'rock_pile'][Math.floor(Math.random() * 4)]; | |
| } | |
| platforms.push({ x: worldRightEdge + i * 100, y: height * 0.7, w: 100, h: 40, type: 'safe', decoration: deco, seed: Math.random() }); | |
| } | |
| const ritualX = worldRightEdge + 150; | |
| platforms.push({ x: ritualX - 50, y: height * 0.7, w: 600, h: 40, type: 'safe', isAltarBase: true }); | |
| platforms.push({ x: ritualX, y: height * 0.7 - 60, w: 160, h: 60, type: 'altar_platform', hits: 0, destroyed: false }); | |
| const terms = sequence.collected; | |
| const stairBlockSize = 40; | |
| const n = TOTAL_COLLECT_GOAL; | |
| const maxTerm = sequence.start + (n - 1) * sequence.diff; | |
| const stairTotalW = maxTerm * stairBlockSize; | |
| const stairStartX = ritualX + 300; | |
| const pivotX = stairStartX + stairTotalW; | |
| const floorY = height * 0.7; | |
| platforms.push({ x: stairStartX, y: floorY, w: stairTotalW + 400, h: 40, type: 'safe' }); | |
| for (let i = 0; i < n; i++) { | |
| const val = sequence.start + i * sequence.diff; | |
| const rowW = val * stairBlockSize; | |
| const rowH = stairBlockSize; | |
| const rowY = floorY - (n - i) * rowH; | |
| const rowX = pivotX - rowW; | |
| platforms.push({ | |
| x: rowX, | |
| y: rowY, | |
| w: rowW, | |
| h: rowH, | |
| type: 'temp_stair', | |
| val: val, | |
| blockSize: stairBlockSize | |
| }); | |
| } | |
| const topStairY = floorY - n * stairBlockSize; | |
| const wallX = pivotX; | |
| const wallH = (height * 0.7 - topStairY) + 100; | |
| const wallY = topStairY; | |
| platforms.push({ x: wallX, y: wallY, w: 100, h: wallH, type: 'safe' }); | |
| platforms.push({ x: wallX, y: wallY, w: 1500, h: wallH, type: 'safe' }); | |
| gravityZone = { | |
| x: ritualX + 200, | |
| y: wallY + 100, | |
| w: (wallX - (ritualX + 200)) | |
| }; | |
| const flagX = wallX + 500; | |
| const flagH = 500; | |
| const flagY = wallY - flagH; | |
| levelObjects.push({ type: 'flagpole', x: flagX, y: flagY, h: flagH, active: true }); | |
| castleObject = { type: 'castle', x: flagX + 400, y: wallY - 150 }; | |
| levelObjects.push(castleObject); | |
| worldRightEdge = wallX + 3000; | |
| } | |
| function showStoryHint(title, desc, showArrow = false) { | |
| const hint = document.getElementById('story-hint'); | |
| document.getElementById('story-hint-title').innerText = title; | |
| document.getElementById('story-hint-desc').innerText = desc; | |
| hint.classList.remove('hidden'); | |
| setTimeout(() => { | |
| hint.classList.add('hidden'); | |
| if (showArrow) document.getElementById('guide-arrow').classList.remove('hidden'); | |
| }, 4000); | |
| } | |
| function triggerStairVanish() { | |
| if (isStairsVanished) return; | |
| isStairsVanished = true; | |
| player.isGrounded = false; | |
| player.coyoteTimer = 0; | |
| player.jumpCount = MAX_JUMPS; | |
| platforms = platforms.filter(p => p.type !== 'temp_stair'); | |
| showStoryHint("階梯竟然消失了!", "是不是需要什麼儀式才能再次出現...", true); | |
| createParticles(player.x, player.y, 50, '#22d3ee'); | |
| } | |
| function triggerGravityDialog() { | |
| if (hasShownGravityDialog || isMagicStairsBuilt) return; | |
| hasShownGravityDialog = true; | |
| showStoryHint("階梯竟然消失了!", "是不是需要什麼儀式才能再次出現..."); | |
| } | |
| function updateCollectionDots() { | |
| const container = document.getElementById('collection-dots'); | |
| container.innerHTML = ''; | |
| for (let i = 0; i < TOTAL_COLLECT_GOAL; i++) { | |
| const dot = document.createElement('div'); | |
| const isFilled = i < sequence.collected.length; | |
| dot.className = `w-3 h-3 rounded-full transition-colors ${isFilled ? 'bg-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.8)]' : 'bg-slate-700 border border-slate-500'}`; | |
| container.appendChild(dot); | |
| } | |
| } | |
| // --- Physics --- | |
| function resolveHorizontalCollision(p, dt) { | |
| if (player.x + player.w > p.x && player.x < p.x + p.w) { | |
| if (player.y + player.h > p.y && player.y < p.y + p.h) { | |
| if (player.x + player.w / 2 < p.x + p.w / 2) { | |
| player.x = p.x - player.w - 0.1; | |
| } else { | |
| player.x = p.x + p.w + 0.1; | |
| } | |
| player.vx = 0; | |
| } | |
| } | |
| } | |
| function isPlayerOverAltar() { | |
| const base = platforms.find(p => p.type === 'altar_platform'); | |
| if (!base) return false; | |
| return (player.x + player.w > base.x && player.x < base.x + base.w); | |
| } | |
| function updatePhysics(dt) { | |
| player.scaleX += (1 - player.scaleX) * 0.2 * dt; | |
| player.scaleY += (1 - player.scaleY) * 0.2 * dt; | |
| if (player.isGrounded) { | |
| player.coyoteTimer = COYOTE_DURATION; | |
| } else { | |
| if (player.coyoteTimer > 0) player.coyoteTimer -= dt; | |
| } | |
| // Hidden stage scroll | |
| if (gameState === STATE.HIDDEN_STAGE) { | |
| updateHiddenStage(dt); | |
| } | |
| if (player.autoWalk) { | |
| player.vx = 3; | |
| player.facingRight = true; | |
| } else { | |
| player.vx = input.axisX * MOVE_SPEED; | |
| if (player.vx !== 0) player.facingRight = player.vx > 0; | |
| } | |
| const altarBase = platforms.find(p => p.isAltarBase); | |
| if (altarBase && player.x > altarBase.x) { | |
| minPlayerX = altarBase.x; | |
| } | |
| if (player.x < minPlayerX) { | |
| player.x = minPlayerX; | |
| if (player.vx < 0) player.vx = 0; | |
| } | |
| if (gameState === STATE.PLAYING && player.x < 0) { | |
| player.x = 0; | |
| if (player.vx < 0) player.vx = 0; | |
| if (Math.random() < 0.05) { | |
| particles.push({ | |
| x: player.x + 60, | |
| y: player.y, | |
| life: 1.5, | |
| type: 'text', | |
| text: "往右走 ➔" | |
| }); | |
| } | |
| } | |
| player.prevY = player.y; | |
| player.x += player.vx * dt; | |
| if (gameState === STATE.PLAYING || gameState === STATE.CLIMBING) { | |
| for (let p of platforms) { | |
| if (p.type === 'safe' || p.type === 'magic_stair' || p.type === 'temp_stair' || (p.type === 'altar_platform' && !p.destroyed)) { | |
| if (p.h > 20) resolveHorizontalCollision(p, dt); | |
| } | |
| } | |
| } | |
| const altarHint = document.getElementById('altar-hint'); | |
| const altar = platforms.find(p => p.type === 'altar_platform'); | |
| const isOver = isPlayerOverAltar(); | |
| if (gameState === STATE.PLAYING && altar && isStairsVanished && !altar.destroyed && isOver) { | |
| document.getElementById('guide-arrow').classList.add('hidden'); | |
| const hintText = document.getElementById('altar-hint-text'); | |
| if (altar.hits === 1) { | |
| hintText.innerText = "💥 祭壇出現裂痕了!再下墜一次!"; | |
| altarHint.querySelector('div').className = "glass-panel px-4 py-2 rounded-xl border border-yellow-400 bg-yellow-900/80"; | |
| } else { | |
| hintText.innerText = "🔥 連點兩下跳躍,下墜打破祭壇!"; | |
| altarHint.querySelector('div').className = "glass-panel px-4 py-2 rounded-xl border border-red-400 bg-red-900/80"; | |
| } | |
| if (!player.isDiving) altarHint.classList.remove('hidden'); | |
| else altarHint.classList.add('hidden'); | |
| } else { | |
| altarHint.classList.add('hidden'); | |
| } | |
| if (gravityZone && isStairsVanished && !isMagicStairsBuilt) { | |
| if (player.x > gravityZone.x && player.x < gravityZone.x + gravityZone.w && player.y > gravityZone.y) { | |
| triggerGravityDialog(); | |
| let gravityMult = 5; | |
| if (player.vy < 0 && player.gravityHintTimer <= 0) { | |
| player.gravityHintTimer = 120; | |
| } | |
| player.vy += GRAVITY * gravityMult * dt; | |
| } else { | |
| player.vy += GRAVITY * dt; | |
| } | |
| } else { | |
| player.vy += GRAVITY * dt; | |
| } | |
| if (player.gravityHintTimer > 0) { | |
| player.gravityHintTimer -= dt; | |
| } | |
| const maxFall = player.isDiving ? 20 : 12; | |
| if (player.vy > maxFall) player.vy = maxFall; | |
| if (!player.isGrounded) { | |
| if (Math.abs(player.vy) < 2) { | |
| player.scaleX = 1.1; player.scaleY = 0.9; | |
| } else { | |
| player.scaleX = 0.9; player.scaleY = 1.1; | |
| } | |
| } | |
| player.y += player.vy * dt; | |
| let targetCamX = player.x - width * 0.3; | |
| // Standard game: clamp to 0 | |
| // Hidden Stage: allow negative (unbounded left) | |
| if (gameState !== STATE.HIDDEN_STAGE && !hiddenStage.isWinning) { | |
| if (targetCamX < 0) targetCamX = 0; | |
| } | |
| cameraX += (targetCamX - cameraX) * 0.1 * dt; | |
| let targetCamY = 0; | |
| const isTutorial = gameState === STATE.TUTORIAL; | |
| const ritualBase = platforms.find(p => p.type === 'ritual_base' || p.type === 'altar_platform'); | |
| if (isTutorial || (ritualBase && player.x > ritualBase.x)) { | |
| targetCamY = player.y - height * 0.6; | |
| if (targetCamY > 0) targetCamY = 0; | |
| } | |
| // Standard camera update - Only run if NOT in hidden stage AND NOT in hidden win sequence | |
| if (gameState !== STATE.HIDDEN_STAGE && !hiddenStage.isWinning) { | |
| cameraY += (targetCamY - cameraY) * 0.1 * dt; | |
| } | |
| if (gameState === STATE.TUTORIAL) { | |
| for (let cp of checkpoints) { | |
| if (cp.active && player.x + player.w > cp.x && player.x < cp.x + cp.w) { | |
| if (player.y + player.h > cp.y && player.y < cp.y + cp.h) { | |
| cp.active = false; | |
| createParticles(cp.x + cp.w / 2, cp.y + cp.h / 2, 20, '#fbbf24'); | |
| if (cp.type === 'right') advanceTutorialToLeft(); | |
| else if (cp.type === 'left') setTimeout(generateJumpTutorial, 500); | |
| } | |
| } | |
| } | |
| } | |
| player.isGrounded = false; | |
| if (player.vy >= 0) { | |
| for (let p of platforms) { | |
| if (p.type === 'altar_platform' && p.destroyed) continue; | |
| if (p.broken) continue; | |
| if (player.x + player.w * 0.3 > p.x && player.x + player.w * 0.7 < p.x + p.w) { | |
| const currFeet = player.y + player.h; | |
| const prevFeet = player.prevY + player.h; | |
| if (prevFeet <= p.y + 15 && currFeet >= p.y) { | |
| // Check for Hidden Safe Block Break (Dive) | |
| if (p.type === 'hidden_safe_block' && player.isDiving) { | |
| p.broken = true; | |
| createParticles(p.x + p.w / 2, p.y + p.h / 2, 15, '#a855f7'); | |
| playSound('break'); | |
| player.isGrounded = false; // Continue falling | |
| continue; // Skip landing | |
| } | |
| player.y = p.y - player.h; player.vy = 0; player.isGrounded = true; player.jumpCount = 0; handleLanding(p); | |
| } | |
| } | |
| } | |
| } | |
| if (gameState === STATE.PLAYING || gameState === STATE.CLIMBING || gameState === STATE.HIDDEN_STAGE) { | |
| for (let obj of levelObjects) { | |
| if (obj.type === 'flagpole' && obj.active) { | |
| if (player.x + player.w > obj.x && player.x < obj.x + 10) { | |
| if (player.y < obj.y + obj.h && player.y > obj.y - 50) { | |
| obj.active = false; | |
| let distFromTop = player.y - obj.y; | |
| if (distFromTop < 0) distFromTop = 0; | |
| let ratio = 1 - (distFromTop / obj.h); | |
| score = Math.floor(ratio * 1000); | |
| if (score < 0) score = 0; | |
| particles.push({ x: player.x, y: player.y - 50, life: 2.0, type: 'text', text: `+${score}` }); | |
| player.autoWalk = true; | |
| player.vy = 2; | |
| gameState = STATE.CLIMBING; | |
| createParticles(player.x, player.y, 50, '#fbbf24'); | |
| playSound('collect'); | |
| } | |
| } | |
| } | |
| else if (obj.type === 'hidden_flagpole' && obj.active) { | |
| // Same collision logic as normal flagpole | |
| if (player.x + player.w > obj.x && player.x < obj.x + 10) { | |
| if (player.y < obj.y + obj.h && player.y > obj.y - 50) { | |
| obj.active = false; | |
| let distFromTop = player.y - obj.y; | |
| if (distFromTop < 0) distFromTop = 0; | |
| let ratio = 1 - (distFromTop / obj.h); | |
| const hScore = Math.floor(ratio * 1000); // Max 1000 | |
| // Save score immediately | |
| localStorage.setItem('math_city_hidden_score_sequence', hScore.toString()); | |
| particles.push({ x: player.x, y: player.y - 50, life: 2.0, type: 'text', text: `+${hScore}` }); | |
| // Stay in CLIMBING state, keep hiddenStage.isWinning for camera control | |
| gameState = STATE.CLIMBING; | |
| hiddenStage.hasStarted = false; // Stop auto-scroll | |
| player.autoWalk = true; | |
| player.vy = 2; | |
| createParticles(player.x, player.y, 50, '#a855f7'); // Purple particles | |
| playSound('collect'); | |
| } | |
| } | |
| } | |
| if (obj.type === 'castle' && player.autoWalk) { | |
| if (player.x >= obj.x + 50) { | |
| player.x = obj.x + 50; | |
| player.autoWalk = false; | |
| player.vx = 0; | |
| player.visible = false; | |
| spawnFireworksForScore(score); | |
| gameState = STATE.FIREWORKS; | |
| levelCompleteTimer = setTimeout(showLevelComplete, 3500); | |
| } | |
| } | |
| else if (obj.type === 'hidden_castle' && player.autoWalk) { | |
| if (player.x >= obj.x + 50) { | |
| player.x = obj.x + 50; | |
| player.autoWalk = false; | |
| player.vx = 0; | |
| player.visible = false; | |
| triggerHiddenStageComplete(); | |
| } | |
| } | |
| } | |
| } | |
| if (gameState === STATE.TUTORIAL && player.vy < 0) { | |
| for (let obj of tutorialObjects) { | |
| if (obj.hit) continue; | |
| if (player.x + player.w > obj.x && player.x < obj.x + obj.w) { | |
| if (player.y >= obj.y + obj.h && player.y <= obj.y + obj.h + 30) { | |
| player.vy = 2; obj.hit = true; | |
| createParticles(obj.x + obj.w / 2, obj.y + obj.h / 2, 10, obj.color); | |
| playSound('collect'); | |
| tutorial.jumpTargetsHit++; | |
| if (tutorial.jumpTargetsHit === 3) setTimeout(startQuizPhase, 500); | |
| } | |
| } | |
| } | |
| } | |
| // Fix: Don't trigger fall death if Climbing or Winning Hidden Stage | |
| if (player.y > height + 500 && gameState !== STATE.HIDDEN_STAGE && gameState !== STATE.CLIMBING && !hiddenStage.isWinning) { | |
| if (gameState === STATE.TUTORIAL) { | |
| playSound('break'); | |
| player.y = height * 0.5; | |
| player.vy = 0; | |
| player.isDiving = false; | |
| if (tutorial.step === 2) { | |
| const nextTarget = tutorialObjects.find(t => !t.hit); | |
| if (nextTarget) { | |
| player.x = nextTarget.x - 300; | |
| } else if (tutorialObjects.length > 0) { | |
| player.x = tutorialObjects[0].x - 300; | |
| } else { | |
| player.x = 200; | |
| } | |
| } else { | |
| player.x = 200; | |
| } | |
| } else { | |
| playSound('break'); | |
| triggerGameOver(); | |
| } | |
| } | |
| if (gameState === STATE.PLAYING && worldRightEdge < cameraX + width + 300) { | |
| spawnNextPlatform(); | |
| } | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| let p = particles[i]; | |
| if (p.type === 'text') { | |
| p.y -= 1 * dt; | |
| p.life -= 0.02 * dt; | |
| } else if (p.type === 'error_text') { | |
| p.y -= 0.5 * dt; | |
| p.life -= 0.015 * dt; | |
| } else if (p.type === 'smoke') { | |
| p.x += p.vx * dt * 0.5; | |
| p.y += p.vy * dt * 0.5; | |
| p.life -= 0.01 * dt; | |
| p.vx *= 0.95; p.vy *= 0.95; | |
| } else if (p.type === 'arrow') { | |
| p.y += 2 * dt; p.life -= 0.05 * dt; | |
| } else { | |
| p.x += p.vx * dt; | |
| p.y += p.vy * dt; | |
| p.vy += 0.05 * dt; | |
| p.life -= 0.01 * dt; | |
| } | |
| if (p.life <= 0) particles.splice(i, 1); | |
| } | |
| } | |
| function handleLanding(p) { | |
| player.scaleX = 1.3; player.scaleY = 0.7; | |
| const wasDiving = player.isDiving; | |
| if (player.isDiving) player.isDiving = false; | |
| // Hidden Stage platforms | |
| if (gameState === STATE.HIDDEN_STAGE) { | |
| handleHiddenLanding(p); | |
| return; | |
| } | |
| if (p.type === 'temp_stair') { | |
| triggerStairVanish(); | |
| return; | |
| } | |
| if (p.type === 'altar_platform' && wasDiving && !p.destroyed) { | |
| p.hits = (p.hits || 0) + 1; | |
| if (p.hits === 1) { | |
| createParticles(p.x + p.w / 2, p.y, 30, '#a855f7'); | |
| playSound('break'); | |
| screenShake = 10; | |
| } else if (p.hits >= 2) { | |
| p.destroyed = true; | |
| createParticles(p.x + p.w / 2, p.y + p.h / 2, 80, '#facc15'); | |
| createParticles(p.x + p.w / 2, p.y + p.h / 2, 50, '#334155'); | |
| playSound('break'); | |
| screenShake = 20; | |
| setTimeout(startRitual, 1500); | |
| } | |
| return; | |
| } | |
| if (p.visited) return; | |
| p.visited = true; | |
| if (p.type === 'trap') { | |
| p.broken = true; createParticles(p.x + p.w / 2, p.y + p.h / 2, 15, '#94a3b8'); createParticles(player.x + player.w / 2, player.y + player.h, 10, '#ef4444'); | |
| if (sequence.collected.length > 0) { | |
| let lastVal = sequence.collected[sequence.collected.length - 1]; | |
| let diff = p.val - lastVal; | |
| let msg = `${p.val} - ${lastVal} = ${diff} (≠ ${sequence.diff})`; | |
| particles.push({ x: p.x + p.w / 2, y: p.y - 40, life: 2.5, type: 'error_text', text: msg }); | |
| } else { | |
| particles.push({ x: p.x + p.w / 2, y: p.y - 40, life: 2.5, type: 'error_text', text: `不是 ${sequence.start}` }); | |
| } | |
| player.isGrounded = false; | |
| player.y += 5; | |
| player.vy = 5; | |
| player.coyoteTimer = 0; | |
| player.jumpCount = MAX_JUMPS; | |
| playSound('break'); | |
| } else if (p.type === 'target') { | |
| p.visualState = 'success'; createParticles(player.x + player.w / 2, player.y + player.h, 10, '#22d3ee'); | |
| sequence.collected.push(p.val); updateCollectionDots(); | |
| playSound('collect'); | |
| } | |
| } | |
| // --- Controls --- | |
| function performJump() { | |
| if (player.autoWalk) return; | |
| if (player.isGrounded || player.coyoteTimer > 0 || player.jumpCount < MAX_JUMPS) { | |
| player.vy = JUMP_FORCE; | |
| player.isGrounded = false; | |
| player.isDiving = false; | |
| player.coyoteTimer = 0; | |
| player.scaleX = 0.8; player.scaleY = 1.2; | |
| player.jumpCount++; | |
| createParticles(player.x + player.w / 2, player.y + player.h, 5, '#fff', player.vx * 0.5); | |
| playSound('jump'); | |
| } | |
| } | |
| function performDive() { | |
| if (player.autoWalk) return; | |
| // Special case: allow dive when standing on hidden entrance | |
| if (gameState === STATE.CLIMBING && hiddenStage.entrancePlatform && player.isGrounded) { | |
| const ep = hiddenStage.entrancePlatform; | |
| // Check if player is standing on the entrance platform | |
| if (player.x + player.w > ep.x && player.x < ep.x + ep.w && | |
| player.y + player.h >= ep.y && player.y + player.h <= ep.y + ep.h + 5) { | |
| setTimeout(() => { | |
| startHiddenStage(); | |
| }, 200); | |
| return; | |
| } | |
| } | |
| if (!player.isGrounded) { | |
| // Check if diving on hidden entrance (second from top stair) | |
| if (gameState === STATE.CLIMBING && hiddenStage.entrancePlatform) { | |
| const ep = hiddenStage.entrancePlatform; | |
| if (player.x + player.w > ep.x && player.x < ep.x + ep.w && player.y < ep.y + ep.h && player.y > ep.y - 100) { | |
| // Trigger hidden stage! | |
| setTimeout(() => { | |
| startHiddenStage(); | |
| }, 200); | |
| return; | |
| } | |
| } | |
| player.vy = DIVE_FORCE; player.isDiving = true; player.jumpCount = MAX_JUMPS; | |
| createParticles(player.x + player.w / 2, player.y, 8, '#a855f7'); | |
| playSound('jump'); | |
| } | |
| } | |
| function handleActionInput() { | |
| const now = Date.now(); | |
| // Only allow dive after reaching altar area (isStairsVanished means passed altar) | |
| // OR if in Hidden Stage | |
| if (now - lastJumpTime < 300 && (isStairsVanished || gameState === STATE.HIDDEN_STAGE)) { | |
| performDive(); | |
| } else { | |
| performJump(); | |
| } | |
| lastJumpTime = now; | |
| } | |
| function setupKeyboardControls() { | |
| window.addEventListener('keydown', (e) => { | |
| if (e.repeat) return; | |
| if (e.code === 'ArrowLeft') input.axisX = -1; if (e.code === 'ArrowRight') input.axisX = 1; | |
| if (e.code === 'Space') { e.preventDefault(); if (!input.jumpPressed) { input.jumpPressed = true; handleActionInput(); } } | |
| }); | |
| window.addEventListener('keyup', (e) => { | |
| if (e.code === 'ArrowLeft' && input.axisX === -1) input.axisX = 0; | |
| if (e.code === 'ArrowRight' && input.axisX === 1) input.axisX = 0; | |
| if (e.code === 'Space') input.jumpPressed = false; | |
| }); | |
| } | |
| function setupTouchControls() { | |
| const zone = document.getElementById('joystick-zone'); | |
| const knob = document.getElementById('joystick-knob'); | |
| const btn = document.getElementById('btn-jump'); | |
| const maxDist = 50; | |
| const threshold = 10; // New threshold for digital feeling | |
| zone.addEventListener('touchstart', (e) => { | |
| joystick.active = true; joystick.id = e.changedTouches[0].identifier; joystick.startX = e.changedTouches[0].clientX; | |
| }, { passive: false }); | |
| window.addEventListener('touchmove', (e) => { | |
| if (!joystick.active) return; | |
| for (let i = 0; i < e.changedTouches.length; i++) { | |
| if (e.changedTouches[i].identifier === joystick.id) { | |
| let cx = e.changedTouches[i].clientX; | |
| let diff = cx - joystick.startX; | |
| // Limit visual movement | |
| let visualDiff = diff; | |
| if (visualDiff > maxDist) visualDiff = maxDist; | |
| if (visualDiff < -maxDist) visualDiff = -maxDist; | |
| knob.style.transform = `translate(${visualDiff}px, 0px)`; | |
| // Digital Input Logic | |
| if (diff > threshold) input.axisX = 1; | |
| else if (diff < -threshold) input.axisX = -1; | |
| else input.axisX = 0; | |
| } | |
| } | |
| }, { passive: false }); | |
| const endJoy = (e) => { | |
| for (let i = 0; i < e.changedTouches.length; i++) { | |
| if (e.changedTouches[i].identifier === joystick.id) { | |
| joystick.active = false; knob.style.transform = `translate(0px, 0px)`; input.axisX = 0; | |
| } | |
| } | |
| }; | |
| window.addEventListener('touchend', endJoy); window.addEventListener('touchcancel', endJoy); | |
| btn.addEventListener('touchstart', (e) => { e.preventDefault(); handleActionInput(); }, { passive: false }); | |
| } | |
| // --- Visuals --- | |
| function createParticles(x, y, count, color, inertiaX = 0) { | |
| for (let i = 0; i < count; i++) { | |
| particles.push({ | |
| x, y, color, | |
| vx: (Math.random() - 0.5) * 8 + inertiaX, | |
| vy: (Math.random() - 0.5) * 8 - 2, | |
| life: 1.0 | |
| }); | |
| } | |
| } | |
| function spawnFireworksForScore(s) { | |
| let count = 0; | |
| let colors = []; | |
| let type = 'normal'; | |
| // Determine Target Location | |
| let targetX = cameraX + width / 2; | |
| let targetY = cameraY + height / 3; | |
| let rangeX = width; | |
| let rangeY = height / 2; | |
| if (gameState === STATE.HIDDEN_STAGE || hiddenStage.isWinning) { | |
| const hiddenCastle = levelObjects.find(o => o.type === 'hidden_castle'); | |
| if (hiddenCastle) { | |
| targetX = hiddenCastle.x + 100; // Center | |
| targetY = hiddenCastle.y - 100; // Slightly above | |
| rangeX = 400; | |
| rangeY = 400; | |
| } | |
| } else { | |
| const castle = levelObjects.find(o => o.type === 'castle'); | |
| if (castle) { | |
| targetX = castle.x + 100; | |
| targetY = castle.y - 100; | |
| rangeX = 400; | |
| rangeY = 400; | |
| } | |
| } | |
| if (s >= 900) { type = 'ultra'; count = 300; colors = ['#f00', '#0f0', '#00f', '#ff0', '#f0f', '#0ff', '#fff']; } | |
| else if (s >= 700) { type = 'fancy'; count = 150; colors = ['#fbbf24', '#f59e0b', '#fff']; } | |
| else if (s >= 500) { type = 'normal'; count = 80; colors = ['#22d3ee', '#fff']; } | |
| else if (s >= 300) { type = 'small'; count = 40; colors = ['#fff']; } | |
| else { type = 'smoke'; count = 60; colors = ['#334155', '#475569', '#1e293b']; } | |
| if (type === 'smoke') { | |
| document.getElementById('mock-msg').classList.remove('hidden'); | |
| for (let k = 0; k < count; k++) { | |
| particles.push({ | |
| x: targetX + (Math.random() - 0.5) * 100, | |
| y: targetY + 200, | |
| vx: (Math.random() - 0.5) * 2, | |
| vy: -Math.random() * 1.5 - 0.5, | |
| life: 3.0, | |
| color: colors[Math.floor(Math.random() * colors.length)], | |
| type: 'smoke' | |
| }); | |
| } | |
| } else { | |
| for (let k = 0; k < count; k++) { | |
| particles.push({ | |
| x: targetX + (Math.random() - 0.5) * rangeX, | |
| y: targetY + (Math.random() - 0.5) * rangeY, | |
| vx: (Math.random() - 0.5) * 6, | |
| vy: (Math.random() - 0.5) * 6 - 3, | |
| life: 2.0 + Math.random(), | |
| color: colors[Math.floor(Math.random() * colors.length)], | |
| type: 'firework' | |
| }); | |
| } | |
| playSound('win'); | |
| } | |
| } | |
| function drawBackground() { | |
| const gradient = ctx.createLinearGradient(0, 0, 0, height); | |
| gradient.addColorStop(0, '#1a1005'); | |
| gradient.addColorStop(0.5, '#451a03'); | |
| gradient.addColorStop(1, '#78350f'); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, width, height); | |
| const sunX = width * 0.5; | |
| const sunY = height * 0.65; | |
| const sunRadius = 150; | |
| const sunGrad = ctx.createLinearGradient(sunX, sunY - sunRadius, sunX, sunY + sunRadius); | |
| sunGrad.addColorStop(0, '#facc15'); | |
| sunGrad.addColorStop(0.5, '#fb923c'); | |
| sunGrad.addColorStop(1, '#db2777'); | |
| ctx.save(); | |
| ctx.fillStyle = sunGrad; | |
| ctx.shadowBlur = 40; ctx.shadowColor = '#f59e0b'; | |
| ctx.beginPath(); | |
| ctx.arc(sunX, sunY, sunRadius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| ctx.fillStyle = '#24243e'; | |
| for (let y = sunY - 40; y < sunY + sunRadius; y += 12) { | |
| const thickness = (y - (sunY - 40)) / 10; | |
| if (thickness > 0) ctx.fillRect(sunX - sunRadius, y, sunRadius * 2, thickness); | |
| } | |
| ctx.restore(); | |
| ctx.save(); | |
| ctx.strokeStyle = 'rgba(232, 121, 249, 0.3)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, sunY); ctx.lineTo(width, sunY); | |
| const fov = 800; | |
| const horizonY = sunY; | |
| const gridOffset = (cameraX % 100); | |
| for (let x = -width; x < width * 2; x += 100) { | |
| let drawX = x - gridOffset; | |
| ctx.moveTo(drawX, horizonY); | |
| ctx.lineTo(drawX - (drawX - width / 2) * 3, height); | |
| } | |
| let totalDist = height - horizonY; | |
| for (let i = 0; i < 10; i++) { | |
| let y = horizonY + (totalDist * (i / 10) ** 2); | |
| ctx.moveTo(0, y); ctx.lineTo(width, y); | |
| } | |
| ctx.stroke(); | |
| ctx.restore(); | |
| ctx.fillStyle = '#0f172a'; | |
| ctx.strokeStyle = '#0891b2'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, height); | |
| for (let x = 0; x <= width; x += 20) { | |
| let mX = (x + cameraX * 0.05); | |
| let noise = Math.sin(mX * 0.003) * 80 + Math.sin(mX * 0.01) * 40; | |
| let hY = height * 0.6 - Math.abs(noise); | |
| if (hY > height * 0.6) hY = height * 0.6; | |
| ctx.lineTo(x, hY); | |
| } | |
| ctx.lineTo(width, height); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| ctx.strokeStyle = '#22d3ee'; | |
| ctx.fillStyle = '#1e293b'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, height); | |
| for (let x = 0; x <= width; x += 10) { | |
| let mX = (x + cameraX * 0.1); | |
| let noise = Math.sin(mX * 0.005) * 60 + Math.sin(mX * 0.013) * 30 + Math.sin(mX * 0.023) * 10; | |
| let hY = height * 0.65 - Math.abs(noise) - 20; | |
| if (hY > height * 0.65) hY = height * 0.65; | |
| ctx.lineTo(x, hY); | |
| } | |
| ctx.lineTo(width, height); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| } | |
| const decorations = ['grass', 'flower', 'mushroom', 'skull']; | |
| function drawDecoration(type, x, y, seed) { | |
| ctx.save(); | |
| // Decorations should be ABOVE the platform (y - height) | |
| // Adjust y to be the top of the platform | |
| // Previously they were drawn relative to platform top-left but some logic was weird. | |
| // Platform drawing passes (x + offset, y) | |
| ctx.translate(x, y); // x, y passed is usually top-left or top-center of platform. | |
| // Fix Skull alignment: user wants them above and aligned | |
| if (type === 'skull') { | |
| ctx.translate(0, -15); // Move UP by 15px to sit on top | |
| } | |
| if (type === 'grass') { | |
| const flicker = Math.random() > 0.95; | |
| if (flicker) ctx.translate((Math.random() - 0.5) * 5, 0); | |
| ctx.shadowBlur = 10; | |
| if (type === 'cactus') { | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.shadowColor = '#0f0'; | |
| ctx.lineWidth = 2; | |
| ctx.fillStyle = 'rgba(0, 255, 0, 0.1)'; | |
| ctx.beginPath(); | |
| ctx.rect(10, -50, 10, 50); | |
| ctx.moveTo(10, -35); ctx.lineTo(-5, -35); ctx.lineTo(-5, -45); ctx.lineTo(10, -45); | |
| ctx.moveTo(20, -25); ctx.lineTo(35, -25); ctx.lineTo(35, -35); ctx.lineTo(20, -35); | |
| ctx.stroke(); | |
| ctx.fill(); | |
| } else if (type === 'barrel') { | |
| ctx.strokeStyle = '#f59e0b'; | |
| ctx.shadowColor = '#f59e0b'; | |
| ctx.lineWidth = 2; | |
| ctx.fillStyle = 'rgba(245, 158, 11, 0.2)'; | |
| ctx.beginPath(); | |
| ctx.moveTo(5, -30); ctx.lineTo(29, -30); ctx.lineTo(29, 0); ctx.lineTo(5, 0); ctx.closePath(); | |
| ctx.moveTo(5, -30); ctx.lineTo(29, 0); | |
| ctx.moveTo(29, -30); ctx.lineTo(5, 0); | |
| ctx.stroke(); | |
| ctx.fill(); | |
| } else if (type === 'skull') { | |
| ctx.strokeStyle = '#e2e8f0'; | |
| ctx.shadowColor = '#fff'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, 0); ctx.lineTo(10, -15); ctx.lineTo(20, 0); ctx.closePath(); | |
| ctx.moveTo(0, -10); ctx.lineTo(-10, -20); | |
| ctx.moveTo(20, -10); ctx.lineTo(30, -20); | |
| ctx.stroke(); | |
| } else if (type === 'rock_pile') { | |
| ctx.strokeStyle = '#a855f7'; | |
| ctx.shadowColor = '#a855f7'; | |
| ctx.fillStyle = 'rgba(168, 85, 247, 0.2)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, 0); ctx.lineTo(7, -15); ctx.lineTo(15, 0); ctx.closePath(); | |
| ctx.moveTo(12, 0); ctx.lineTo(18, -10); ctx.lineTo(24, 0); ctx.closePath(); | |
| ctx.stroke(); | |
| ctx.fill(); | |
| } | |
| } // Closing grass block | |
| ctx.restore(); | |
| } | |
| function drawGravelTexture(x, y, w, h, seed) { | |
| let localSeed = seed * 1000; | |
| const pseudoRand = () => { | |
| localSeed = (localSeed * 9301 + 49297) % 233280; | |
| return localSeed / 233280; | |
| }; | |
| ctx.fillStyle = 'rgba(0,0,0,0.2)'; | |
| for (let i = 0; i < 8; i++) { | |
| let rx = pseudoRand() * (w - 10) + 5; | |
| let ry = pseudoRand() * (h - 5); | |
| let s = pseudoRand() * 4 + 2; | |
| ctx.fillRect(x + rx, y + ry, s, s); | |
| } | |
| } | |
| function drawFloatingLabel(ctx, text, x, y, alpha, fontSize = "24px") { | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = `bold ${fontSize} "Noto Sans TC"`; | |
| ctx.textAlign = 'center'; | |
| ctx.shadowColor = '#000'; | |
| ctx.shadowBlur = 4; | |
| ctx.fillText(text, x, y); | |
| ctx.restore(); | |
| } | |
| function draw() { | |
| drawBackground(); | |
| ctx.save(); ctx.translate(-cameraX, -cameraY); | |
| if (screenShake > 0 && gameState !== STATE.RITUAL) { | |
| ctx.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake); | |
| } | |
| if (gameState === STATE.TUTORIAL) { | |
| for (let cp of checkpoints) { | |
| if (cp.active) { | |
| ctx.shadowBlur = 20; ctx.shadowColor = '#facc15'; | |
| ctx.fillStyle = 'rgba(250, 204, 21, 0.3)'; ctx.fillRect(cp.x, cp.y, cp.w, cp.h); | |
| ctx.fillStyle = 'rgba(250, 204, 21, 0.8)'; ctx.fillRect(cp.x + 10, cp.y, 20, cp.h); | |
| ctx.shadowBlur = 0; ctx.fillStyle = '#fff'; ctx.font = '20px Arial'; ctx.textAlign = 'center'; | |
| ctx.fillText(cp.type === 'right' ? '➡️' : '⬅️', cp.x + cp.w / 2, cp.y - 10); | |
| } | |
| } | |
| } | |
| for (let obj of levelObjects) { | |
| if (obj.type === 'flagpole') { | |
| ctx.fillStyle = '#94a3b8'; ctx.fillRect(obj.x, obj.y, 10, obj.h); | |
| ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(obj.x + 5, obj.y, 10, 0, Math.PI * 2); ctx.fill(); | |
| let flagY = obj.active ? obj.y + 10 : (player.y - 20); | |
| if (!obj.active && flagY > obj.y + obj.h - 40) flagY = obj.y + obj.h - 40; | |
| if (flagY < obj.y + 10) flagY = obj.y + 10; | |
| ctx.fillStyle = '#ef4444'; | |
| ctx.beginPath(); ctx.moveTo(obj.x + 10, flagY); ctx.lineTo(obj.x + 60, flagY + 20); ctx.lineTo(obj.x + 10, flagY + 40); ctx.fill(); | |
| // Score Hint Text | |
| if (obj.active) { | |
| ctx.fillStyle = '#fbbf24'; | |
| ctx.font = 'bold 20px "Noto Sans TC"'; | |
| ctx.textAlign = 'center'; | |
| // Text bouncing slightly | |
| const bounce = Math.sin(Date.now() * 0.005) * 5; | |
| ctx.fillText("跳得越高,分數越高!", obj.x + 150, obj.y + obj.h / 2 + bounce); | |
| } | |
| } | |
| else if (obj.type === 'castle') { | |
| const cx = obj.x; const cy = obj.y; | |
| ctx.fillStyle = '#fbbf24'; | |
| ctx.fillRect(cx, cy, 200, 150); | |
| ctx.fillRect(cx - 30, cy + 30, 30, 120); | |
| ctx.fillRect(cx + 200, cy + 30, 30, 120); | |
| ctx.fillStyle = '#ef4444'; | |
| ctx.beginPath(); ctx.moveTo(cx - 40, cy + 30); ctx.lineTo(cx - 15, cy - 20); ctx.lineTo(cx + 10, cy + 30); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(cx + 190, cy + 30); ctx.lineTo(cx + 215, cy - 20); ctx.lineTo(cx + 240, cy + 30); ctx.fill(); | |
| ctx.fillStyle = '#3f2c22'; ctx.beginPath(); ctx.arc(cx + 100, cy + 150, 40, Math.PI, 0); ctx.fill(); | |
| } | |
| else if (obj.type === 'hidden_flagpole') { | |
| // Hell Flagpole | |
| ctx.fillStyle = '#7f1d1d'; ctx.fillRect(obj.x, obj.y, 10, obj.h); // Dark Red Pole | |
| ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(obj.x + 5, obj.y, 10, 0, Math.PI * 2); ctx.fill(); | |
| let flagY = obj.active ? obj.y + 10 : (player.y - 20); | |
| if (!obj.active && flagY > obj.y + obj.h - 40) flagY = obj.y + obj.h - 40; | |
| if (flagY < obj.y + 10) flagY = obj.y + 10; | |
| // Purple/Black Flag | |
| ctx.fillStyle = '#581c87'; | |
| ctx.beginPath(); ctx.moveTo(obj.x + 10, flagY); ctx.lineTo(obj.x + 60, flagY + 20); ctx.lineTo(obj.x + 10, flagY + 40); ctx.fill(); | |
| // Score Hint Text | |
| if (obj.active) { | |
| ctx.fillStyle = '#a855f7'; | |
| ctx.font = 'bold 20px "Noto Sans TC"'; | |
| ctx.textAlign = 'center'; | |
| const bounce = Math.sin(Date.now() * 0.005) * 5; | |
| ctx.fillText("跳得越高,分數越高!", obj.x + 150, obj.y + obj.h / 2 + bounce); | |
| } | |
| } | |
| else if (obj.type === 'hidden_castle') { | |
| // Hell Castle | |
| const cx = obj.x; const cy = obj.y; | |
| ctx.fillStyle = '#1a0505'; // Dark Brick | |
| ctx.fillRect(cx, cy, 200, 150); | |
| ctx.fillRect(cx - 30, cy + 30, 30, 120); | |
| ctx.fillRect(cx + 200, cy + 30, 30, 120); | |
| // Roofs (Dark Red) | |
| ctx.fillStyle = '#7f1d1d'; | |
| ctx.beginPath(); ctx.moveTo(cx - 40, cy + 30); ctx.lineTo(cx - 15, cy - 20); ctx.lineTo(cx + 10, cy + 30); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(cx + 190, cy + 30); ctx.lineTo(cx + 215, cy - 20); ctx.lineTo(cx + 240, cy + 30); ctx.fill(); | |
| // Door (Black + Purple Arc) | |
| ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(cx + 100, cy + 150, 40, Math.PI, 0); ctx.fill(); | |
| ctx.strokeStyle = '#a855f7'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(cx + 100, cy + 150, 40, Math.PI, 0); ctx.stroke(); | |
| } | |
| } | |
| for (let p of platforms) { | |
| if (p.opacity <= 0) continue; if (p.broken) { p.y += 5; p.opacity -= 0.05; } | |
| ctx.globalAlpha = p.opacity; | |
| // REMOVED 'hidden_safe_block' from here so it hits the specific else-if below | |
| if (p.type === 'safe' || p.type === 'target' || (p.type === 'trap' && !p.broken) || p.type === 'hidden_safe') { | |
| let borderColor = '#22d3ee'; | |
| let glowColor = '#22d3ee'; | |
| if (p.type === 'safe' || p.type === 'hidden_safe') { | |
| borderColor = '#22d3ee'; glowColor = '#22d3ee'; | |
| } else if (p.type === 'target') { | |
| if (p.visualState === 'success' || p.visited || p.hint) { | |
| borderColor = '#f59e0b'; glowColor = '#f59e0b'; | |
| } else { | |
| borderColor = '#22d3ee'; glowColor = '#22d3ee'; | |
| } | |
| } else if (p.type === 'trap') { | |
| borderColor = '#22d3ee'; glowColor = '#22d3ee'; | |
| } | |
| if (p.type === 'safe' && p.h > 100) { | |
| ctx.fillStyle = '#0f0c29'; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeStyle = '#a855f7'; | |
| ctx.lineWidth = 3; | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| } else { | |
| ctx.fillStyle = 'rgba(15, 23, 42, 0.8)'; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.shadowBlur = 10; ctx.shadowColor = glowColor; | |
| ctx.fillStyle = borderColor; | |
| ctx.fillRect(p.x, p.y, p.w, 4); | |
| ctx.shadowBlur = 0; | |
| ctx.strokeStyle = borderColor; ctx.lineWidth = 2; | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| ctx.fillStyle = 'rgba(255,255,255,0.05)'; | |
| for (let i = 0; i < p.w; i += 20) ctx.fillRect(p.x + i, p.y, 1, p.h); | |
| } | |
| if (p.type === 'target' && p.visualState !== 'success' && p.hint) { | |
| const time = Date.now(); | |
| const pulse = (Math.sin(time * 0.008) + 1) * 0.5; | |
| ctx.shadowBlur = 20 + pulse * 20; | |
| ctx.shadowColor = '#f59e0b'; | |
| ctx.strokeRect(p.x - 2, p.y - 2, p.w + 4, p.h + 4); | |
| ctx.shadowBlur = 0; | |
| } | |
| if (p.decoration) { | |
| drawDecoration(p.decoration, p.x + p.w / 2 - 15, p.y, p.seed || 0.5); | |
| } | |
| } | |
| else if (p.type === 'ritual_base') { | |
| ctx.fillStyle = '#0f0c29'; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeStyle = '#a855f7'; | |
| ctx.lineWidth = 3; | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| ctx.fillStyle = '#a855f7'; | |
| ctx.fillRect(p.x + 10, p.y + 10, p.w - 20, 2); | |
| } | |
| else if (p.type === 'temp_stair' || p.type === 'magic_stair') { | |
| ctx.fillStyle = 'rgba(34, 211, 238, 0.4)'; | |
| ctx.shadowBlur = 10; ctx.shadowColor = '#22d3ee'; | |
| // Special glow for hidden entrance | |
| if (p.isHiddenEntrance) { | |
| const time = Date.now(); | |
| const pulse = (Math.sin(time * 0.005) + 1) * 0.5; | |
| ctx.shadowBlur = 20 + pulse * 20; | |
| ctx.shadowColor = '#a855f7'; | |
| } | |
| } | |
| else if (p.type === 'hidden_safe') { | |
| // Handled above with standard safe platform style | |
| } | |
| else if (p.type === 'hidden_start' || p.type === 'hidden_correct' || p.type === 'hidden_wrong') { | |
| // Hell Style Visuals (Dark, Skulls, Small Numbers) | |
| ctx.fillStyle = '#1a0505'; // Dark red/black background | |
| ctx.strokeStyle = '#7f1d1d'; // Dark red border | |
| ctx.lineWidth = 2; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| // Skulls on sides | |
| ctx.save(); | |
| drawDecoration('skull', p.x + 15, p.y + p.h / 2 + 5, 0.5); | |
| drawDecoration('skull', p.x + p.w - 15, p.y + p.h / 2 + 5, 0.5); | |
| ctx.restore(); | |
| // Value Text (Smaller) | |
| if (p.val !== undefined) { | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = 'bold 24px "Noto Sans TC", Arial'; // Smaller font | |
| ctx.textAlign = 'center'; | |
| ctx.shadowColor = 'rgba(0,0,0,0.5)'; | |
| ctx.shadowBlur = 4; | |
| ctx.fillText(p.val, p.x + p.w / 2, p.y + p.h / 2 + 8); | |
| ctx.shadowBlur = 0; | |
| } | |
| } | |
| else if (p.type === 'hidden_safe_block') { | |
| // Force redraw over generic style if needed, or rely on this coming *after* generic checks | |
| // Note: Generic check at top allows 'hidden_safe_block', which draws cyan border | |
| // We need to override it here completely. | |
| ctx.fillStyle = 'rgba(107, 33, 168, 0.4)'; // Purple Transparent | |
| ctx.strokeStyle = '#a855f7'; | |
| ctx.lineWidth = 3; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = 'bold 16px "Noto Sans TC"'; | |
| ctx.textAlign = 'center'; | |
| // Text updated to "請擊破" as requested previously | |
| ctx.fillText('請擊破', p.x + p.w / 2, p.y + p.h / 2 + 5); | |
| } | |
| else if (p.type === 'hidden_final') { | |
| // Final Platform (Hell Style - Obsidian & Gold) | |
| const grad = ctx.createLinearGradient(p.x, p.y, p.x, p.y + p.h); | |
| grad.addColorStop(0, '#1f2937'); // Dark slate/obsidian | |
| grad.addColorStop(1, '#000000'); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| // Golden Trim | |
| ctx.shadowColor = '#fbbf24'; ctx.shadowBlur = 15; | |
| ctx.strokeStyle = '#fbbf24'; ctx.lineWidth = 3; | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| ctx.shadowBlur = 0; | |
| // No text, just special style | |
| } | |
| else if (p.type === 'dive_target') ctx.fillStyle = p.visualState === 'success' ? '#22c55e' : '#eab308'; | |
| else if (p.type === 'trap' && p.broken) { | |
| ctx.fillStyle = '#7f1d1d'; | |
| ctx.strokeStyle = '#ef4444'; | |
| ctx.lineWidth = 2; | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| // Glitchy X | |
| ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + p.w, p.y + p.h); | |
| ctx.moveTo(p.x + p.w, p.y); ctx.lineTo(p.x, p.y + p.h); | |
| ctx.stroke(); | |
| } | |
| // Enhanced Glow for Hints (First 2 terms) | |
| if (p.type === 'target' && p.visualState !== 'success' && p.hint) { | |
| const time = Date.now(); | |
| const pulse = (Math.sin(time * 0.005) + 1) * 0.5; // 0 to 1 | |
| ctx.shadowBlur = 15 + pulse * 15; // Pulsating glow | |
| ctx.shadowColor = '#22d3ee'; | |
| } | |
| else if (p.type === 'altar_platform') { | |
| if (p.destroyed) { | |
| ctx.fillStyle = '#475569'; | |
| ctx.strokeStyle = '#1e293b'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(p.x, height * 0.7); | |
| ctx.lineTo(p.x + 20, height * 0.7 - 5); | |
| ctx.lineTo(p.x + 40, height * 0.7); | |
| ctx.lineTo(p.x + 80, height * 0.7 - 8); | |
| ctx.lineTo(p.x + 120, height * 0.7); | |
| ctx.fill(); | |
| const shards = [ | |
| { x: p.x + 10, y: height * 0.7 - 10, w: 20, h: 10, r: 0.2 }, | |
| { x: p.x + 50, y: height * 0.7 - 5, w: 15, h: 8, r: -0.1 }, | |
| { x: p.x + 90, y: height * 0.7 - 12, w: 25, h: 12, r: 0.3 }, | |
| { x: p.x + 130, y: height * 0.7 - 8, w: 10, h: 10, r: -0.2 } | |
| ]; | |
| for (let s of shards) { | |
| ctx.save(); | |
| ctx.translate(s.x, s.y); | |
| ctx.rotate(s.r); | |
| ctx.fillStyle = '#64748b'; | |
| ctx.fillRect(-s.w / 2, -s.h / 2, s.w, s.h); | |
| ctx.strokeRect(-s.w / 2, -s.h / 2, s.w, s.h); | |
| ctx.restore(); | |
| } | |
| } else { | |
| const grad = ctx.createLinearGradient(p.x, p.y, p.x, p.y + p.h); | |
| grad.addColorStop(0, '#7f1d1d'); | |
| grad.addColorStop(1, '#000000'); | |
| ctx.shadowBlur = 40; ctx.shadowColor = 'rgba(124, 58, 237, 0.9)'; | |
| ctx.fillStyle = grad; ctx.fillRect(p.x, p.y, p.w, p.h); | |
| if (p.hits === 1) { | |
| ctx.strokeStyle = '#000'; ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(p.x + 30, p.y); ctx.lineTo(p.x + 50, p.y + 40); ctx.lineTo(p.x + 70, p.y + 20); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = '#e2e8f0'; | |
| ctx.beginPath(); ctx.arc(p.x + 10, p.y, 15, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(p.x + 5, p.y, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(p.x + 15, p.y, 4, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#e2e8f0'; | |
| ctx.beginPath(); ctx.arc(p.x + p.w - 10, p.y, 15, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(p.x + p.w - 15, p.y, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(p.x + p.w - 5, p.y, 4, 0, Math.PI * 2); ctx.fill(); | |
| const time = Date.now(); | |
| const flicker = Math.sin(time * 0.05) * 5; | |
| ctx.fillStyle = '#fde68a'; ctx.fillRect(p.x + 25, p.y - 15, 10, 15); | |
| ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(p.x + 30, p.y - 20, 5 + Math.random() * 3, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#fde68a'; ctx.fillRect(p.x + p.w - 35, p.y - 15, 10, 15); | |
| ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(p.x + p.w - 30, p.y - 20, 5 + Math.random() * 3, 0, Math.PI * 2); ctx.fill(); | |
| ctx.shadowBlur = 30; ctx.shadowColor = '#d8b4fe'; | |
| ctx.fillStyle = '#a855f7'; ctx.beginPath(); ctx.arc(p.x + p.w / 2, p.y - 30, 18, 0, Math.PI * 2); ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| } | |
| } | |
| if (p.type === 'ritual_base' || p.type === 'dive_target') ctx.fillRect(p.x, p.y, p.w, p.h); | |
| if (p.type === 'temp_stair' || p.type === 'magic_stair') { | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| // Draw block divisions | |
| if (p.val && p.blockSize) { | |
| const numBlocks = Math.floor(p.w / p.blockSize); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.3)'; | |
| ctx.lineWidth = 1; | |
| for (let i = 1; i < numBlocks; i++) { | |
| const lineX = p.x + i * p.blockSize; | |
| ctx.beginPath(); | |
| ctx.moveTo(lineX, p.y); | |
| ctx.lineTo(lineX, p.y + p.h); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| if (p.type === 'hidden_safe') { | |
| ctx.fillRect(p.x, p.y, p.w, p.h); | |
| ctx.strokeStyle = '#22c55e'; ctx.lineWidth = 2; | |
| ctx.strokeRect(p.x, p.y, p.w, p.h); | |
| } | |
| else if (p.type === 'hidden_start' || p.type === 'hidden_correct' || p.type === 'hidden_wrong') { | |
| // Previously handled above with custom visuals | |
| } | |
| ctx.shadowBlur = 0; | |
| ctx.fillStyle = '#fff'; ctx.font = 'bold 20px "Noto Sans TC"'; ctx.textAlign = 'center'; | |
| if (p.text) { | |
| ctx.fillText(p.text, p.x + p.w / 2, p.y + 25); | |
| if (p.visualState === 'success') ctx.fillText("✅", p.x + p.w / 2 + 60, p.y + 25); | |
| } else if (p.val && p.type !== 'magic_stair' && p.type !== 'temp_stair' && !p.type.startsWith('hidden_')) { | |
| ctx.fillText(p.val, p.x + p.w / 2, p.y + 25); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| for (let obj of tutorialObjects) { | |
| if (obj.hit) continue; | |
| ctx.fillStyle = obj.color; ctx.shadowBlur = 15; ctx.shadowColor = obj.color; | |
| ctx.fillRect(obj.x, obj.y, obj.w, obj.h); ctx.shadowBlur = 0; | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; | |
| ctx.fillText(obj.label, obj.x + obj.w / 2, obj.y + 30); | |
| } | |
| if (player.visible !== false) { | |
| ctx.shadowBlur = 20; ctx.shadowColor = player.isDiving ? '#a855f7' : '#6366f1'; | |
| ctx.fillStyle = player.isDiving ? '#d8b4fe' : '#818cf8'; | |
| let drawW = player.w * player.scaleX; | |
| let drawH = player.h * player.scaleY; | |
| let drawX = player.x + (player.w - drawW) / 2; | |
| let drawY = player.y + (player.h - drawH); | |
| ctx.fillRect(drawX, drawY, drawW, drawH); | |
| ctx.fillStyle = '#fff'; ctx.shadowBlur = 15; ctx.shadowColor = '#60a5fa'; | |
| let lookDir = player.facingRight ? 1 : -1; | |
| let visorX = drawX + (player.facingRight ? drawW * 0.4 : 0); | |
| let visorY = drawY + 12 * player.scaleY; | |
| let visorW = drawW * 0.6; | |
| let visorH = 8 * player.scaleY; | |
| ctx.fillRect(visorX, visorY, visorW, visorH); | |
| ctx.shadowBlur = 0; | |
| if (player.gravityHintTimer > 20) { | |
| ctx.save(); | |
| ctx.translate(player.x + player.w / 2, player.y - 25); | |
| const opacity = Math.min(1, player.gravityHintTimer / 60); | |
| ctx.globalAlpha = opacity; | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; | |
| ctx.strokeStyle = '#a855f7'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.roundRect(-140, -55, 280, 45, 15); ctx.stroke(); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(-5, -10); ctx.lineTo(5, -10); ctx.lineTo(0, 0); ctx.fill(); | |
| ctx.fillStyle = '#6b21a8'; ctx.font = 'bold 16px "Noto Sans TC"'; ctx.textAlign = 'center'; | |
| ctx.fillText("🔮 好像有什麼神祕的力量在壓著我...", 0, -25); | |
| ctx.restore(); | |
| } | |
| } | |
| for (let p of particles) { | |
| if (p.type === 'text') { | |
| ctx.font = 'bold 40px Arial'; | |
| ctx.fillStyle = `rgba(255, 215, 0, ${p.life})`; | |
| ctx.fillText(p.text, p.x, p.y); | |
| } else if (p.type === 'error_text') { | |
| ctx.font = 'bold 24px "Noto Sans TC", Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillStyle = `rgba(239, 68, 68, ${p.life})`; | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${p.life})`; | |
| ctx.lineWidth = 3; | |
| ctx.strokeText(p.text, p.x, p.y); | |
| ctx.fillText(p.text, p.x, p.y); | |
| } else if (p.type === 'smoke') { | |
| ctx.globalAlpha = p.life * 0.5; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, 15 + (3 - p.life) * 10, 0, Math.PI * 2); ctx.fill(); | |
| } else if (p.type === 'arrow') { | |
| ctx.fillStyle = p.color; | |
| ctx.font = '20px Arial'; | |
| ctx.fillText('↓', p.x, p.y); | |
| } else if (p.type === 'firework') { | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'lighter'; // Additive blending for glow | |
| const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 8); | |
| gradient.addColorStop(0, p.color); | |
| gradient.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = gradient; | |
| ctx.globalAlpha = p.life < 1 ? p.life : 1; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.fill(); | |
| ctx.restore(); | |
| } else { | |
| ctx.globalAlpha = p.life < 1 ? p.life : 1; | |
| ctx.fillStyle = p.color; | |
| ctx.fillRect(p.x, p.y, 4, 4); | |
| } | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.restore(); // Restore to screen space (End of Camera Block) | |
| // Hidden Stage UI (Fixed Screen Space) - Moved here | |
| if (gameState === STATE.HIDDEN_STAGE) { | |
| const hudW = 280; | |
| const hudX = (width - hudW) / 2; | |
| const hudY = 20; | |
| // Quest-like box | |
| ctx.fillStyle = 'rgba(15, 23, 42, 0.95)'; | |
| ctx.strokeStyle = 'rgba(34, 211, 238, 0.5)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.roundRect(hudX, hudY, hudW, 80, 12); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.textAlign = 'center'; | |
| ctx.font = 'bold 22px "Noto Sans TC"'; | |
| ctx.shadowColor = '#22d3ee'; | |
| ctx.shadowBlur = 5; | |
| ctx.fillText(`隱藏關卡`, hudX + hudW / 2, hudY + 30); | |
| ctx.shadowBlur = 0; | |
| ctx.font = 'bold 20px "Noto Sans TC"'; | |
| ctx.fillStyle = '#fbbf24'; | |
| ctx.fillText(`首項: ${hiddenStage.sequence.start} 公差: ${hiddenStage.sequence.diff}`, hudX + hudW / 2, hudY + 60); | |
| // Hidden Score Display | |
| const hiddenScore = parseInt(localStorage.getItem('math_city_hidden_score_sequence') || '0'); | |
| if (hiddenScore > 0) { | |
| ctx.font = 'bold 16px "Noto Sans TC"'; | |
| ctx.fillStyle = '#a855f7'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText(`隱藏分數: ${hiddenScore}`, width - 20, 30); | |
| } | |
| } | |
| if (gameState === STATE.RITUAL && ritualPhase > 0) { | |
| // ctx.restore() already called above, so we are in screen space. | |
| // But Ritual uses full screen overlay, so it's fine. | |
| // Wait, previous code had ctx.restore() inside the block? | |
| // Let's check the original code structure. | |
| // Original: | |
| // if (hidden stage) { draw HUD } | |
| // if (ritual) { ctx.restore(); ... } | |
| // ctx.restore(); (end of draw) | |
| // Now we moved HUD after the FIRST restore (which was implicitly at end of drawCamera) | |
| // We need to be careful about conflicting restore calls. | |
| // Let's look at the "Original Code" context again. | |
| // Line 2587 is the final ctx.restore() of draw(). | |
| // Line 2009 is ctx.save() ctx.translate(-camera). | |
| // So everything between 2009 and 2587 is in camera space. | |
| // My plan: | |
| // 1. Close the camera space block EARLY (before HIDDEN_STAGE check). | |
| // 2. Draw HUD in screen space. | |
| // 3. Handle Ritual (which also expects screen space? or handles its own restore?) | |
| // The original code has `if (ritualPhase > 0) { ctx.restore(); ... return; }` | |
| // This implies Ritual early-exits the function and handles its own coordinate space? | |
| // Actually it does `ctx.restore()` then draws overlay then `return`. | |
| // This acts as the "End" of the draw function for that frame. | |
| // So I should just REMOVE the HUD block from inside the camera-space part, | |
| // and place it at the very end of `draw()` function, just before consistent return or end. | |
| ctx.fillStyle = 'rgba(0,0,0,0.85)'; | |
| ctx.fillRect(0, 0, width, height); | |
| const terms = sequence.collected; | |
| const n = terms.length; | |
| const maxVal = terms[n - 1] + terms[0]; | |
| const areaW = width * 0.8; | |
| const areaH = height * 0.4; | |
| const sizeW = areaW / maxVal; | |
| const sizeH = areaH / n; | |
| const blockSize = Math.min(sizeW, sizeH, 40); | |
| const totalDrawW = maxVal * blockSize; | |
| const totalDrawH = n * blockSize; | |
| const startX = (width - totalDrawW) / 2; | |
| const startY = (height * 0.15); | |
| ctx.save(); | |
| if (screenShake > 0) { | |
| ctx.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake); | |
| } | |
| ctx.shadowBlur = 0; | |
| let currentGhostRot = 0; | |
| let ghostOffsetX = 0; | |
| let labelAlpha = 0; | |
| if (ritualPhase === 1.5) { | |
| const sepDist = 3 * blockSize; | |
| if (ritualAnimProgress < 0.35) { | |
| // Phase 1: Move Left (0.0 to 0.35) | |
| const t = ritualAnimProgress / 0.35; | |
| const ease = 1 - Math.pow(1 - t, 3); | |
| ghostOffsetX = -sepDist * ease; | |
| } else if (ritualAnimProgress < 0.65) { | |
| // Phase 2: Rotate (0.35 to 0.65) | |
| ghostOffsetX = -sepDist; | |
| const t = (ritualAnimProgress - 0.35) / 0.3; | |
| const ease = t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t; | |
| currentGhostRot = Math.PI * ease; | |
| } else { | |
| // Phase 3: Move Right to Snap (0.65 to 1.0) | |
| currentGhostRot = Math.PI; | |
| const t = (ritualAnimProgress - 0.65) / 0.35; | |
| const ease = 1 - Math.pow(1 - t, 3); | |
| ghostOffsetX = -sepDist * (1 - ease); | |
| // Labels Fade In during Phase 3 | |
| labelAlpha = t; | |
| } | |
| } else if (ritualPhase >= 2) { | |
| currentGhostRot = Math.PI; | |
| ghostOffsetX = 0; | |
| labelAlpha = 0; // Handled by static bracket in Phase 2 | |
| } | |
| // Draw Original | |
| if (ritualPhase >= 2 && ritualGlow > 0) { | |
| ctx.shadowBlur = ritualGlow; ctx.shadowColor = "#fff"; | |
| } | |
| for (let i = 0; i < n; i++) { | |
| const val = terms[i]; | |
| const rowY = startY + i * blockSize; | |
| const rowX = startX + (maxVal - val) * blockSize; | |
| ctx.fillStyle = '#22d3ee'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; | |
| for (let b = 0; b < val; b++) { | |
| ctx.fillRect(rowX + b * blockSize, rowY, blockSize, blockSize); | |
| ctx.strokeRect(rowX + b * blockSize, rowY, blockSize, blockSize); | |
| } | |
| ctx.shadowBlur = 0; | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = 'bold 18px Arial'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(val, rowX + val * blockSize + 10, rowY + blockSize / 2 + 6); | |
| if (ritualPhase >= 2 && ritualGlow > 0) { ctx.shadowBlur = ritualGlow; ctx.shadowColor = "#fff"; } | |
| } | |
| if (ritualPhase >= 1.5) { | |
| const centerX = startX + totalDrawW / 2; | |
| const centerY = startY + totalDrawH / 2; | |
| ctx.save(); | |
| ctx.translate(centerX + ghostOffsetX, centerY); | |
| ctx.rotate(currentGhostRot); | |
| ctx.translate(-centerX, -centerY); | |
| // Draw Ghost (Feedback #5: Purple color #a855f7) | |
| for (let i = 0; i < n; i++) { | |
| const val = terms[i]; | |
| const rowY = startY + i * blockSize; | |
| const rowX = startX + (maxVal - val) * blockSize; | |
| // Use Purple + white border for ghost | |
| ctx.fillStyle = '#a855f7'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; | |
| for (let b = 0; b < val; b++) { | |
| ctx.fillRect(rowX + b * blockSize, rowY, blockSize, blockSize); | |
| ctx.strokeRect(rowX + b * blockSize, rowY, blockSize, blockSize); | |
| } | |
| } | |
| ctx.restore(); | |
| if (labelAlpha > 0) { | |
| // Position text relative to the combined shape | |
| // Top label (Width) | |
| drawFloatingLabel(ctx, "寬度:(首項 + 末項)", startX + totalDrawW / 2, startY - 40, labelAlpha); | |
| // Left label (Height) with subtext | |
| drawFloatingLabel(ctx, "高度:項數 n", startX - 70, startY + totalDrawH / 2 - 12, labelAlpha); | |
| drawFloatingLabel(ctx, "(有幾層階梯)", startX - 70, startY + totalDrawH / 2 + 18, labelAlpha, "16px"); | |
| } | |
| } | |
| ctx.restore(); | |
| if (ritualPhase >= 2) { | |
| drawFloatingLabel(ctx, "寬度:(首項 + 末項)", startX + totalDrawW / 2, startY - 40, 1); | |
| drawFloatingLabel(ctx, "高度:項數 n", startX - 70, startY + totalDrawH / 2 - 12, 1); | |
| drawFloatingLabel(ctx, "(有幾層階梯)", startX - 70, startY + totalDrawH / 2 + 18, 1, "16px"); | |
| } | |
| return; | |
| } | |
| } | |
| function updateHiddenAtmosphere(dt) { | |
| // Spawn embers | |
| if (Math.random() < 0.2) { | |
| embers.push({ | |
| x: Math.random() * width, | |
| y: height + 20, | |
| vx: (Math.random() - 0.5) * 1, | |
| vy: -(Math.random() * 2 + 1), | |
| size: Math.random() * 3 + 1, | |
| alpha: 1, | |
| life: Math.random() * 3 + 2 | |
| }); | |
| } | |
| for (let i = embers.length - 1; i >= 0; i--) { | |
| let p = embers[i]; | |
| p.x += p.vx * dt; | |
| p.y += p.vy * dt; | |
| p.alpha -= 0.005 * dt; | |
| if (p.alpha <= 0 || p.y < -50) embers.splice(i, 1); | |
| } | |
| } | |
| function drawHiddenAtmosphere() { | |
| // Embers | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| for (let p of embers) { | |
| ctx.fillStyle = `rgba(239, 68, 68, ${p.alpha})`; // Redish embers | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| // Vignette (Hellish Overlay) | |
| const grad = ctx.createRadialGradient(width / 2, height / 2, height * 0.3, width / 2, height / 2, height); | |
| grad.addColorStop(0, 'rgba(0,0,0,0)'); | |
| grad.addColorStop(1, 'rgba(0,0,0,0.6)'); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(0, 0, width, height); | |
| // Red Pulse Background Tint | |
| const pulse = (Math.sin(Date.now() * 0.002) + 1) * 0.5; // 0 to 1 | |
| ctx.fillStyle = `rgba(127, 29, 29, ${0.05 + pulse * 0.05})`; // Subtle red tint | |
| ctx.fillRect(0, 0, width, height); | |
| } | |
| function loop(timestamp) { | |
| if (!lastTime) lastTime = timestamp; | |
| const deltaTime = timestamp - lastTime; | |
| lastTime = timestamp; | |
| const dt = Math.min(deltaTime / 16.67, 3); | |
| isLooping = true; | |
| if (screenShake > 0) { | |
| screenShake *= 0.6; | |
| if (screenShake < 0.5) screenShake = 0; | |
| } | |
| if (ritualGlow > 0) { | |
| ritualGlow -= 2 * dt; | |
| if (ritualGlow < 0) ritualGlow = 0; | |
| } | |
| if (gameState === STATE.HIDDEN_STAGE && !hiddenStage.isWinning) updateHiddenStage(dt); | |
| if (gameState === STATE.HIDDEN_STAGE || hiddenStage.currentLevel > 0) updateHiddenAtmosphere(dt); | |
| // Hidden stage win: smooth camera follow during CLIMBING state | |
| if (hiddenStage.isWinning && (gameState === STATE.CLIMBING || gameState === STATE.HIDDEN_STAGE)) { | |
| let targetY = player.y - height * 0.7; | |
| cameraY += (targetY - cameraY) * 0.08 * dt; | |
| let targetX = player.x - width * 0.3; | |
| cameraX += (targetX - cameraX) * 0.08 * dt; | |
| } | |
| if (gameState === STATE.HIDDEN_COMPLETE) { | |
| const castle = levelObjects.find(o => o.type === 'hidden_castle'); | |
| if (castle) { | |
| const targetCamX = castle.x + 100 - width / 2; | |
| const targetCamY = castle.y - height / 2; | |
| cameraX += (targetCamX - cameraX) * 0.05 * dt; | |
| cameraY += (targetCamY - cameraY) * 0.05 * dt; | |
| } | |
| updateHiddenAtmosphere(dt); | |
| // Update particles (fireworks) in HIDDEN_COMPLETE state | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| let p = particles[i]; | |
| if (p.type === 'firework') { | |
| p.x += p.vx * dt; | |
| p.y += p.vy * dt; | |
| p.vy += 0.05 * dt; | |
| p.life -= 0.01 * dt; | |
| } else if (p.type === 'text') { | |
| p.y -= 1 * dt; | |
| p.life -= 0.02 * dt; | |
| } else if (p.type === 'smoke') { | |
| p.x += p.vx * dt * 0.5; | |
| p.y += p.vy * dt * 0.5; | |
| p.life -= 0.01 * dt; | |
| } else { | |
| p.x += (p.vx || 0) * dt; | |
| p.y += (p.vy || 0) * dt; | |
| p.life -= 0.01 * dt; | |
| } | |
| if (p.life <= 0) particles.splice(i, 1); | |
| } | |
| } | |
| if (gameState === STATE.TUTORIAL || gameState === STATE.PLAYING || gameState === STATE.CLIMBING || gameState === STATE.FIREWORKS || gameState === STATE.HIDDEN_STAGE) updatePhysics(dt); | |
| if (gameState === STATE.RITUAL && ritualPhase === 1.5) { | |
| ritualAnimProgress += 0.004 * dt; | |
| if (ritualAnimProgress >= 1) { | |
| ritualPhase = 2; | |
| screenShake = 15; | |
| ritualGlow = 60; | |
| playSound('ritual'); | |
| document.getElementById('ritual-step-2').classList.remove('hidden'); | |
| document.getElementById('rit-start').focus(); | |
| } | |
| } | |
| if (gameState === STATE.HIDDEN_STAGE || gameState === STATE.HIDDEN_COMPLETE) drawHiddenAtmosphere(); | |
| draw(); | |
| if (gameState !== STATE.GAMEOVER) { | |
| requestAnimationFrame(loop); | |
| } else { | |
| isLooping = false; | |
| } | |
| } | |
| let embers = []; // Hidden Stage Particles | |
| function resetWorld() { | |
| platforms = []; particles = []; tutorialObjects = []; checkpoints = []; levelObjects = []; embers = []; | |
| player.vx = 0; player.vy = 0; input.axisX = 0; player.autoWalk = false; player.visible = true; | |
| cameraX = 0; cameraY = 0; isStairsVanished = false; isMagicStairsBuilt = false; score = 0; | |
| player.gravityHintTimer = 0; screenShake = 0; ritualGlow = 0; | |
| player.coyoteTimer = 0; player.scaleX = 1; player.scaleY = 1; player.jumpCount = 0; | |
| hasShownGravityDialog = false; | |
| hiddenStage.isWinning = false; | |
| hiddenStage.hasStarted = false; | |
| hiddenStage.currentLevel = 0; | |
| minPlayerX = -Infinity; // Reset Barrier | |
| document.getElementById('mock-msg').classList.add('hidden'); | |
| document.getElementById('guide-arrow').classList.add('hidden'); | |
| const hiddenOverlay = document.getElementById('hidden-instructions'); | |
| if (hiddenOverlay) hiddenOverlay.style.display = 'none'; | |
| const hiddenSummary = document.getElementById('hidden-summary'); | |
| if (hiddenSummary) hiddenSummary.style.display = 'none'; | |
| if (levelCompleteTimer) clearTimeout(levelCompleteTimer); | |
| } | |
| function resetGame() { | |
| document.getElementById('screen-gameover').classList.add('hidden'); | |
| document.getElementById('screen-ritual').classList.add('hidden'); | |
| document.getElementById('screen-review').classList.add('hidden'); | |
| const hasCompletedBefore = localStorage.getItem('math_city_score_sequence') && parseInt(localStorage.getItem('math_city_score_sequence')) > 0; | |
| if (hasCompletedBefore) { | |
| // Skip tutorial, go straight to quiz | |
| hasPassedTutorial = true; | |
| if (controlType === 'mobile') document.getElementById('mobile-controls').classList.remove('hidden'); | |
| const startY = height * 0.7; | |
| platforms = []; | |
| particles = []; | |
| // Create decorations | |
| const decorations = ['cactus', 'barrel', 'skull', 'rock']; | |
| for (let i = 0; i < 10; i++) { | |
| let deco = null; | |
| if (Math.random() < 0.3) deco = decorations[Math.floor(Math.random() * decorations.length)]; | |
| platforms.push({ x: i * 100, y: startY, w: 100, h: 40, type: 'safe', visited: true, decoration: deco, seed: Math.random() }); | |
| } | |
| player.x = 200; player.y = startY - 54; player.isGrounded = true; player.prevY = player.y; player.jumpCount = 0; | |
| worldRightEdge = 1000; nextStoneNum = 1; | |
| generatedTargetCount = 0; | |
| draw(); | |
| startQuizPhase(); | |
| } else { | |
| if (controlType === 'mobile') document.getElementById('mobile-controls').classList.remove('hidden'); | |
| resetWorld(); | |
| const startY = height * 0.7; | |
| // Split initial ground into chunks for details (decorations) | |
| for (let i = 0; i < 10; i++) { | |
| let deco = null; | |
| if (Math.random() < 0.4) { | |
| deco = ['cactus', 'barrel', 'skull', 'rock_pile'][Math.floor(Math.random() * 4)]; | |
| } | |
| platforms.push({ x: i * 100, y: startY, w: 100, h: 40, type: 'safe', visited: true, decoration: deco, seed: Math.random() }); | |
| } | |
| player.x = 200; player.y = startY - 54; player.isGrounded = true; player.prevY = player.y; | |
| worldRightEdge = 1000; nextStoneNum = 1; | |
| generatedTargetCount = 0; | |
| draw(); | |
| document.getElementById('ui-hud').classList.add('hidden'); | |
| document.getElementById('screen-start').classList.remove('hidden'); | |
| document.getElementById('mobile-controls').classList.add('hidden'); | |
| gameState = STATE.START; | |
| } | |
| if (!isLooping) { lastTime = performance.now(); loop(lastTime); } | |
| } | |
| function triggerGameOver() { | |
| gameState = STATE.GAMEOVER; | |
| document.getElementById('screen-gameover').classList.remove('hidden'); | |
| document.getElementById('mobile-controls').classList.add('hidden'); | |
| } | |
| function startRitual() { | |
| document.getElementById('guide-arrow').classList.add('hidden'); | |
| document.getElementById('altar-hint').classList.add('hidden'); | |
| gameState = STATE.RITUAL; | |
| ritualPhase = 1; | |
| document.getElementById('ui-hud').classList.add('hidden'); | |
| document.getElementById('mobile-controls').classList.add('hidden'); | |
| document.getElementById('screen-ritual').classList.remove('hidden'); | |
| const inputs = document.querySelectorAll('#screen-ritual input'); | |
| inputs.forEach(i => { i.value = ''; i.disabled = false; }); | |
| document.querySelectorAll('.step-completed').forEach(el => el.classList.remove('step-completed')); | |
| document.querySelectorAll('.summary-content').forEach(el => el.classList.add('hidden')); | |
| document.querySelectorAll('.original-content').forEach(el => el.style.display = 'block'); | |
| document.querySelectorAll('[id^=ritual-msg]').forEach(p => p.innerText = ''); | |
| document.getElementById('ritual-step-mission').classList.remove('hidden'); | |
| document.getElementById('ritual-step-0').classList.add('hidden'); | |
| document.getElementById('ritual-step-0-yes').classList.add('hidden'); | |
| document.getElementById('ritual-step-1').classList.add('hidden'); | |
| document.getElementById('ritual-step-2').classList.add('hidden'); | |
| document.getElementById('ritual-step-3').classList.add('hidden'); | |
| document.getElementById('ritual-step-4').classList.add('hidden'); | |
| document.getElementById('ritual-step-5').classList.add('hidden'); | |
| } | |
| function handleMissionContinue() { | |
| playSound('collect'); | |
| document.getElementById('ritual-step-mission').classList.add('hidden'); | |
| document.getElementById('ritual-step-0').classList.remove('hidden'); | |
| } | |
| function drawHardModeStairs(canvasId, a1, d, n) { | |
| const c = document.getElementById(canvasId); | |
| if (!c) return; | |
| const ctx = c.getContext('2d'); | |
| ctx.clearRect(0, 0, c.width, c.height); | |
| // Settings for drawing | |
| const padding = 20; | |
| const textSpace = 40; // Space for numbers | |
| const w = c.width - padding - textSpace; | |
| const h = c.height - padding * 2; | |
| const startX = padding; | |
| const startY = c.height - padding; | |
| const lastVal = a1 + (n - 1) * d; | |
| // Calculate Block Size to fill space | |
| let blockW = w / n; | |
| let blockH = h / lastVal; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; // Stronger stroke for "cut open" look | |
| ctx.lineWidth = 1; | |
| ctx.textAlign = 'left'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.font = 'bold 12px Arial'; | |
| for (let i = 0; i < n; i++) { | |
| const val = a1 + i * d; | |
| const colX = startX + i * blockW; | |
| // Draw valid blocks for this column | |
| // Optimization: If too many blocks, batch drawing might be needed, but for n=30, val=90, loop is ~2700. JS is fine. | |
| ctx.fillStyle = '#22d3ee'; // Cyan-400 | |
| for (let b = 0; b < val; b++) { | |
| const rowY = startY - (b + 1) * blockH; | |
| // Draw distinct blocks | |
| ctx.fillRect(colX, rowY, blockW, blockH); | |
| ctx.strokeRect(colX, rowY, blockW, blockH); | |
| } | |
| // Draw Number on Right of the column top | |
| if (blockH > 10 || (i % 2 === 0) || i === n - 1) { // Show if legible or even indices | |
| ctx.fillStyle = '#fff'; | |
| const topY = startY - val * blockH; | |
| if (i === n - 1) { // Always show last | |
| ctx.fillText(val, colX + blockW + 5, topY + (blockH / 2)); | |
| } else if (i % 5 === 4) { // Show every 5th item (4, 9, 14, 19, 24, 29) | |
| ctx.fillText(val, colX + blockW + 2, topY + (blockH / 2)); | |
| } | |
| } | |
| } | |
| } | |
| function handleRitualChoice(choice) { | |
| playSound('collect'); // Reuse sound | |
| if (choice === 'yes') { | |
| document.getElementById('ritual-step-0').classList.add('hidden'); | |
| document.getElementById('ritual-step-0-yes').classList.remove('hidden'); | |
| // Draw visualize Hard Mode | |
| drawHardModeStairs('hard-mode-canvas', sequence.start, sequence.diff, 30); | |
| // Setup specific display for inputs is no longer needing text params, but we clear inputs | |
| document.getElementById('hard-input').value = ''; | |
| document.getElementById('hard-msg').innerText = ''; | |
| } else { | |
| document.getElementById('ritual-step-0').classList.add('hidden'); | |
| document.getElementById('ritual-step-1').classList.remove('hidden'); | |
| } | |
| } | |
| function giveUpHardMode() { | |
| playSound('break'); // Helper sound? | |
| document.getElementById('ritual-step-0-yes').classList.add('hidden'); | |
| document.getElementById('ritual-step-1').classList.remove('hidden'); | |
| } | |
| function checkHardMode() { | |
| const val = parseInt(document.getElementById('hard-input').value); | |
| const n = 30; | |
| // Sum = n/2 * [2a1 + (n-1)d] | |
| const expected = (n * (2 * sequence.start + (n - 1) * sequence.diff)) / 2; | |
| if (val === expected) { | |
| playSound('collect'); | |
| document.getElementById('hard-msg').className = "text-green-400 h-6 font-bold"; | |
| document.getElementById('hard-msg').innerText = "太厲害了!完全正確!但我們還是用分身術吧 XD"; | |
| setTimeout(() => { | |
| giveUpHardMode(); | |
| }, 2000); | |
| } else { | |
| playSound('break'); | |
| document.getElementById('hard-msg').className = "text-red-400 h-6 font-bold"; | |
| document.getElementById('hard-msg').innerText = "算錯囉... 是不是覺得很難算?"; | |
| } | |
| } | |
| function triggerRitualAnimation() { | |
| document.getElementById('ritual-step-1').classList.add('hidden'); | |
| setTimeout(() => { | |
| ritualPhase = 2; // Will be set to 1.5 in next loop logic actually, wait. | |
| // Correction: Set to 1.5 to start animation loop | |
| ritualPhase = 1.5; | |
| ritualAnimProgress = 0; | |
| }, 500); | |
| } | |
| function checkRitualDim() { | |
| const s = parseInt(document.getElementById('rit-start').value); | |
| const e = parseInt(document.getElementById('rit-end').value); | |
| const nInput = parseInt(document.getElementById('rit-n').value); | |
| const terms = sequence.collected; | |
| const realStart = terms[0]; | |
| const realEnd = terms[terms.length - 1]; | |
| const realN = terms.length; | |
| if (((s === realStart && e === realEnd) || (s === realEnd && e === realStart)) && nInput === realN) { | |
| playSound('collect'); | |
| const step2 = document.getElementById('ritual-step-2'); | |
| step2.querySelector('.original-content').style.display = 'none'; | |
| step2.querySelector('.summary-content').classList.remove('hidden'); | |
| step2.classList.add('step-compact'); | |
| document.getElementById('summary-dim').innerText = `寬度: ${s + e}, 項數: ${nInput}`; | |
| document.getElementById('ritual-step-3').classList.remove('hidden'); | |
| document.getElementById('ritual-step-3').scrollIntoView({ behavior: 'smooth' }); | |
| } else { | |
| playSound('break'); | |
| document.getElementById('ritual-msg-1').innerText = "數字不對喔,請對照上方圖形再試試看!"; | |
| } | |
| } | |
| function checkRitualTotal() { | |
| const t = parseInt(document.getElementById('rit-total').value); | |
| const terms = sequence.collected; | |
| const n = terms.length; | |
| const sum = (terms[0] + terms[n - 1]) * n; | |
| if (t === sum) { | |
| playSound('collect'); | |
| const step3 = document.getElementById('ritual-step-3'); | |
| step3.querySelector('.original-content').style.display = 'none'; | |
| step3.querySelector('.summary-content').classList.remove('hidden'); | |
| step3.classList.add('step-compact'); | |
| document.getElementById('summary-total').innerText = `總數: ${t}`; | |
| document.getElementById('ritual-step-4').classList.remove('hidden'); | |
| document.getElementById('ritual-step-4').scrollIntoView({ behavior: 'smooth' }); | |
| } else { | |
| playSound('break'); | |
| document.getElementById('ritual-msg-2').innerText = "計算錯誤,寬度 × 高度 是多少呢?"; | |
| } | |
| } | |
| function checkRitualFinal() { | |
| const f = parseInt(document.getElementById('rit-final').value); | |
| const terms = sequence.collected; | |
| const n = terms.length; | |
| const sum = (terms[0] + terms[n - 1]) * n; | |
| if (f === sum / 2) { | |
| playSound('collect'); | |
| document.getElementById('ritual-step-4').classList.add('hidden'); | |
| document.getElementById('ritual-step-5').classList.remove('hidden'); | |
| } else { | |
| playSound('break'); | |
| document.getElementById('ritual-msg-3').innerText = "記得長方形包含兩份階梯,所以要除以 2 喔!"; | |
| } | |
| } | |
| function finishRitualAndBuild() { | |
| if (window.keypad) keypad.close(); // Auto-close keypad | |
| gameState = STATE.CLIMBING; | |
| isMagicStairsBuilt = true; // IMPORTANT: Disable gravity zone | |
| document.getElementById('screen-ritual').classList.add('hidden'); | |
| if (controlType === 'mobile') document.getElementById('mobile-controls').classList.remove('hidden'); | |
| // Remove dialog | |
| document.getElementById('story-hint').classList.add('hidden'); | |
| hasShownGravityDialog = true; // Prevent it from triggering again just in case | |
| // Note: Altar is now destroyed, so we don't need to do anything to it here | |
| const base = platforms.find(p => p.type === 'ritual_base'); | |
| // We use ritualX in spawnEndGameStructure, but we don't have a direct reference here unless we search | |
| // The magic stairs are relative to the wall position which is calculated based on terms | |
| const terms = sequence.collected; | |
| const blockSize = 40; | |
| const n = TOTAL_COLLECT_GOAL; | |
| const maxTerm = Math.max(...terms); | |
| // Re-calculate positions (same logic as spawnEndGameStructure) | |
| // We need to find the "pivotX" | |
| // Find the wall | |
| const wall = platforms.find(p => p.h > 100 && p.type === 'safe' && p.y < height * 0.7); | |
| if (wall) { | |
| const pivotX = wall.x; | |
| const floorY = height * 0.7; | |
| for (let i = 0; i < n; i++) { | |
| const val = terms[i]; | |
| const rowW = val * blockSize; | |
| const rowH = blockSize; | |
| const rowY = floorY - (n - i) * rowH; | |
| const rowX = pivotX - rowW; | |
| const stairPlatform = { | |
| x: rowX, | |
| y: rowY, | |
| w: rowW, | |
| h: rowH, | |
| type: 'magic_stair', | |
| val: val, | |
| blockSize: blockSize | |
| }; | |
| // Mark second from top as hidden entrance if unlocked | |
| if (i === 1 && canAccessHiddenStage()) { | |
| stairPlatform.isHiddenEntrance = true; | |
| hiddenStage.entrancePlatform = stairPlatform; | |
| } | |
| platforms.push(stairPlatform); | |
| } | |
| } | |
| } | |
| // --- Hidden Stage Functions --- | |
| function canAccessHiddenStage() { | |
| const negativePassed = localStorage.getItem('sequence_negative_passed') === 'true'; | |
| const flagCompleted = (localStorage.getItem('math_city_score_sequence') || '0') > '0'; | |
| return negativePassed && flagCompleted; | |
| } | |
| function startHiddenStage() { | |
| if (window.keypad) keypad.close(); | |
| // Generate negative difference sequence early (for instructions display) | |
| hiddenStage.sequence.start = Math.floor(Math.random() * 5) + 5; | |
| hiddenStage.sequence.diff = -(Math.floor(Math.random() * 3) + 2); | |
| hiddenStage.currentLevel = 0; | |
| hiddenStage.currentTimer = 0; | |
| hiddenStage.cameraScrollSpeed = 0; | |
| hiddenStage.hasStarted = false; | |
| hiddenStage.isWinning = false; | |
| hiddenStage.baseScrollSpeed = 1.5; | |
| // Show instruction overlay first | |
| showHiddenStageInstructions(); | |
| } | |
| function showHiddenStageInstructions() { | |
| // Create instruction overlay | |
| let overlay = document.getElementById('hidden-instructions'); | |
| if (!overlay) { | |
| overlay = document.createElement('div'); | |
| overlay.id = 'hidden-instructions'; | |
| overlay.style.cssText = 'position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.92);backdrop-filter:blur(8px);'; | |
| document.body.appendChild(overlay); | |
| } | |
| overlay.innerHTML = ` | |
| <div style="max-width:560px;width:92%;background:rgba(26,5,5,0.95);border:2px solid #7f1d1d;border-radius:20px;padding:36px 32px;text-align:center;box-shadow:0 0 40px rgba(168,85,247,0.3);"> | |
| <h2 style="font-size:34px;font-weight:900;color:#a855f7;margin-bottom:10px;text-shadow:0 0 15px rgba(168,85,247,0.6);">🔥 隱藏關卡</h2> | |
| <p style="color:#fbbf24;font-size:22px;font-weight:bold;margin-bottom:22px;">首項: ${hiddenStage.sequence.start} 公差: ${hiddenStage.sequence.diff}</p> | |
| <div style="text-align:left;background:rgba(0,0,0,0.4);border-radius:12px;padding:20px 24px;margin-bottom:28px;border:1px solid rgba(127,29,29,0.5);"> | |
| <p style="color:#f87171;font-weight:bold;font-size:20px;margin-bottom:14px;">⚔️ 遊戲方式:</p> | |
| <ol style="color:#e2e8f0;font-size:18px;line-height:2.2;padding-left:24px;margin:0;"> | |
| <li>選擇<span style="color:#fbbf24;font-weight:bold;">首項數字</span>的位置,向下擊破方塊</li> | |
| <li>選擇等差數列中的<span style="color:#22d3ee;font-weight:bold;">下一個數字</span>跳下</li> | |
| <li>畫面會不斷往上,<span style="color:#ef4444;font-weight:bold;">請小心不要被推出畫面!</span></li> | |
| <li>跳完 15 關後到達旗子即可過關</li> | |
| </ol> | |
| </div> | |
| <button onclick="confirmHiddenStageStart()" style="width:100%;padding:16px 0;border-radius:14px;background:linear-gradient(135deg,#7f1d1d,#581c87);color:white;font-weight:bold;font-size:22px;border:2px solid #a855f7;cursor:pointer;box-shadow:0 0 20px rgba(168,85,247,0.4);transition:all 0.3s;" onmouseover="this.style.boxShadow='0 0 30px rgba(168,85,247,0.7)';this.style.transform='scale(1.03)'" onmouseout="this.style.boxShadow='0 0 20px rgba(168,85,247,0.4)';this.style.transform='scale(1)'">⚡ 開始挑戰</button> | |
| </div> | |
| `; | |
| overlay.style.display = 'flex'; | |
| } | |
| function confirmHiddenStageStart() { | |
| const overlay = document.getElementById('hidden-instructions'); | |
| if (overlay) overlay.style.display = 'none'; | |
| gameState = STATE.HIDDEN_STAGE; | |
| document.getElementById('ui-hud').classList.add('hidden'); | |
| if (controlType === 'mobile') document.getElementById('mobile-controls').classList.remove('hidden'); | |
| let screenPlayerX = player.x - cameraX; | |
| cameraX = 0; | |
| cameraY = 0; | |
| player.x = screenPlayerX; | |
| player.y = height * 0.2; | |
| player.vy = 0; | |
| player.isGrounded = false; | |
| player.visible = true; | |
| player.autoWalk = false; | |
| minPlayerX = -Infinity; | |
| platforms = []; | |
| particles = []; | |
| tutorialObjects = []; | |
| document.getElementById('ui-tutorial').classList.add('hidden'); | |
| document.getElementById('guide-arrow').classList.add('hidden'); | |
| document.getElementById('story-hint').classList.add('hidden'); | |
| const safeY = height * 0.3 + player.h + 10; | |
| const startY = safeY + 150; | |
| const terms = []; | |
| for (let i = 0; i < 5; i++) { | |
| terms.push(hiddenStage.sequence.start + i * hiddenStage.sequence.diff); | |
| } | |
| const platformWidth = 120; | |
| const shuffled = terms.sort(() => Math.random() - 0.5); | |
| const dropZones = []; | |
| const centerX = player.x + player.w / 2; | |
| for (let i = 0; i < 5; i++) { | |
| const spacing = 220; | |
| const px = Math.round(centerX + (i - 2) * spacing - platformWidth / 2); | |
| dropZones.push({ x: px, val: shuffled[i] }); | |
| } | |
| // 1. Create Breakable Blocks at Drop Zones | |
| for (let zone of dropZones) { | |
| platforms.push({ | |
| x: zone.x, | |
| y: safeY, | |
| w: platformWidth, | |
| h: 40, | |
| type: 'hidden_safe_block', | |
| canBreak: true, | |
| visited: false | |
| }); | |
| platforms.push({ | |
| x: zone.x, | |
| y: startY, | |
| w: platformWidth, | |
| h: 40, | |
| type: zone.val === hiddenStage.sequence.start ? 'hidden_start' : 'hidden_wrong', | |
| val: zone.val, | |
| visited: false | |
| }); | |
| } | |
| // 2. Fill the gaps - sort and align precisely to prevent gaps | |
| dropZones.sort((a, b) => a.x - b.x); | |
| const farLeft = -1000; | |
| const farRight = Math.round(width + 1000); | |
| // Before first block | |
| if (dropZones.length > 0 && dropZones[0].x > farLeft) { | |
| platforms.push({ | |
| x: farLeft, | |
| y: safeY, | |
| w: dropZones[0].x - farLeft, | |
| h: 40, | |
| type: 'hidden_safe', | |
| visited: true | |
| }); | |
| } | |
| // Between blocks - ensure pixel-perfect alignment | |
| for (let i = 0; i < dropZones.length - 1; i++) { | |
| const gapStart = dropZones[i].x + platformWidth; | |
| const gapEnd = dropZones[i + 1].x; | |
| if (gapEnd > gapStart) { | |
| platforms.push({ | |
| x: gapStart, | |
| y: safeY, | |
| w: gapEnd - gapStart, | |
| h: 40, | |
| type: 'hidden_safe', | |
| visited: true | |
| }); | |
| } | |
| } | |
| // After last block | |
| if (dropZones.length > 0) { | |
| const lastEnd = dropZones[dropZones.length - 1].x + platformWidth; | |
| platforms.push({ | |
| x: lastEnd, | |
| y: safeY, | |
| w: farRight - lastEnd, | |
| h: 40, | |
| type: 'hidden_safe', | |
| visited: true | |
| }); | |
| } | |
| playSound('ritual'); | |
| } | |
| function generateHiddenPlatforms(fromPlatform) { | |
| const currentVal = fromPlatform.val; | |
| const correctNext = currentVal + hiddenStage.sequence.diff; | |
| const wrongNext = currentVal - hiddenStage.sequence.diff; | |
| const baseY = fromPlatform.y + 150; | |
| const centerX = fromPlatform.x + fromPlatform.w / 2; | |
| // Randomly decide which side is correct | |
| const correctOnLeft = Math.random() < 0.5; | |
| // Updated Spacing: Directly aligned to Entrance Grid (Spacing 220) | |
| // Left offset: -220 from center. Right offset: +220 from center. | |
| // Platform width 120. Half width = 60. | |
| // px = CenterX +/- 220 - 60. | |
| // Removed Math.max clamping to allow infinite left scrolling as intended. | |
| const leftPlatform = { | |
| x: centerX - 220 - 60, // Center - 280 | |
| y: baseY, | |
| w: 120, | |
| h: 40, | |
| type: correctOnLeft ? 'hidden_correct' : 'hidden_wrong', | |
| val: correctOnLeft ? correctNext : wrongNext, | |
| visited: false | |
| }; | |
| const rightPlatform = { | |
| x: centerX + 220 - 60, // Center + 160 | |
| y: baseY, | |
| w: 120, | |
| h: 40, | |
| type: correctOnLeft ? 'hidden_wrong' : 'hidden_correct', | |
| val: correctOnLeft ? wrongNext : correctNext, | |
| visited: false | |
| }; | |
| platforms.push(leftPlatform, rightPlatform); | |
| } | |
| function updateHiddenStage(dt) { | |
| // Only scroll if game has started | |
| if (hiddenStage.hasStarted) { | |
| // Descend: Camera moves DOWN (increases Y) | |
| cameraY += hiddenStage.cameraScrollSpeed * dt; | |
| // Checking if player hit Top of screen (too slow) | |
| // If player.y < cameraY (above screen), fail | |
| // Checking if player hit Top of screen (too slow) | |
| // Relaxed fail condition: -300 instead of -100 to allow more buffer | |
| // Checking if player hit Top of screen (too slow) | |
| // Relaxed fail condition | |
| if (player.y - cameraY < -300) { | |
| triggerHiddenStageFail("動作太慢了!"); | |
| return; | |
| } | |
| // Dynamic Fail Height (Player falls too far below camera) | |
| // Instead of fixed "height + 500", we check relative to camera | |
| if (player.y > cameraY + height + 200) { | |
| triggerHiddenStageFail("掉落深淵!"); | |
| return; | |
| } | |
| hiddenStage.currentTimer += dt / 60; | |
| // Constant speed as requested (acceleration removed) | |
| // if (hiddenStage.currentTimer > hiddenStage.timePerLevel) { | |
| // hiddenStage.cameraScrollSpeed += 0.05 * dt; | |
| // } | |
| } else { | |
| // Auto Start trigger: If player descends (leaves safe platform) | |
| const safeY = height * 0.3 + player.h + 10; | |
| if (player.y > safeY + 100) { | |
| hiddenStage.hasStarted = true; | |
| hiddenStage.cameraScrollSpeed = 1.2; // Slower speed for better readability | |
| } | |
| } | |
| // Win State Camera Adjustment | |
| if (hiddenStage.isWinning) { | |
| // Target: Player at 80% screen height (Lower part of screen) | |
| // ScreenY = PlayerY - CameraY => CameraY = PlayerY - ScreenY | |
| let targetY = player.y - height * 0.7; // Aim for 70% down | |
| // Smoothly interpolate | |
| cameraY += (targetY - cameraY) * 0.1 * dt; | |
| } | |
| } | |
| function handleHiddenLanding(p) { | |
| if (p.visited) return; | |
| p.visited = true; | |
| if (p.type === 'hidden_safe') { | |
| // Landed on safe platform - waiting for player to break it | |
| createParticles(player.x + player.w / 2, player.y + player.h, 10, '#a855f7'); | |
| playSound('collect'); | |
| } else if (p.type === 'hidden_start') { | |
| // Correct start platform (first level) | |
| // Remove safe blocks | |
| platforms = platforms.filter(plat => plat.type !== 'hidden_safe' && plat.type !== 'hidden_safe_block'); | |
| hiddenStage.currentLevel++; | |
| createParticles(player.x + player.w / 2, player.y + player.h, 15, '#22d3ee'); | |
| playSound('collect'); | |
| generateHiddenPlatforms(p); | |
| } else if (p.type === 'hidden_correct') { | |
| hiddenStage.currentLevel++; | |
| hiddenStage.currentTimer = 0; | |
| createParticles(player.x + player.w / 2, player.y + player.h, 15, '#22d3ee'); | |
| playSound('collect'); | |
| if (hiddenStage.currentLevel >= 15) { | |
| // Spawn Final Platform | |
| const centerX = player.x + player.w / 2; | |
| const baseY = p.y + 150; | |
| platforms.push({ | |
| x: centerX - 750, | |
| y: baseY, | |
| w: 1500, | |
| h: 40, | |
| type: 'hidden_final', | |
| visited: false | |
| }); | |
| } else { | |
| generateHiddenPlatforms(p); | |
| } | |
| } else if (p.type === 'hidden_wrong') { | |
| p.broken = true; | |
| createParticles(p.x + p.w / 2, p.y + p.h / 2, 20, '#ef4444'); | |
| playSound('break'); | |
| setTimeout(() => { | |
| triggerHiddenStageFail("選擇了錯誤的平台!"); | |
| }, 300); | |
| } else if (p.type === 'hidden_final') { | |
| triggerHiddenWin(p); | |
| } | |
| } // Closing handleHiddenLanding | |
| function spawnHiddenFinal(finalPlat) { | |
| // Logic moved to handleHiddenLanding inline, but keeping this if needed or empty | |
| // Actually handleHiddenLanding calls push directly. | |
| // But let's use this for the castle generation to keep it clean. | |
| // Wait, handleHiddenLanding ALREADY pushes the platform. | |
| // We need to add Castle and Flagpole to levelObjects. | |
| // The platform is pushed in handleHiddenLanding logic. | |
| // We can find the last platform (which is hidden_final) and add objects relative to it. | |
| if (finalPlat && finalPlat.type === 'hidden_final') { | |
| // Add Flagpole at end | |
| const flagX = finalPlat.x + finalPlat.w - 400; | |
| const flagH = 500; | |
| const flagY = finalPlat.y - flagH; | |
| levelObjects = []; // Clear old objects | |
| levelObjects.push({ type: 'hidden_flagpole', x: flagX, y: flagY, h: flagH, active: true }); | |
| // Add Hell Castle | |
| levelObjects.push({ type: 'hidden_castle', x: flagX + 200, y: finalPlat.y - 150 }); | |
| } | |
| } | |
| function triggerHiddenWin(finalPlat) { | |
| hiddenStage.hasStarted = false; // Stop scroll | |
| hiddenStage.isWinning = true; // Start camera adjustment | |
| // Clear previous sequence platforms to prevent accidental failure | |
| platforms = platforms.filter(p => p.type === 'hidden_final' || p.type === 'hidden_safe'); | |
| // Immediately snap camera near player to prevent jarring jump | |
| cameraY = player.y - height * 0.7; | |
| cameraX = player.x - width * 0.3; | |
| spawnHiddenFinal(finalPlat); | |
| } | |
| function triggerHiddenStageFail(reason) { | |
| gameState = STATE.GAMEOVER; | |
| document.getElementById('mobile-controls').classList.add('hidden'); | |
| const gameoverScreen = document.getElementById('screen-gameover'); | |
| gameoverScreen.querySelector('h2').innerText = '隱藏關卡失敗'; | |
| gameoverScreen.querySelector('p').innerText = reason; | |
| gameoverScreen.classList.remove('hidden'); | |
| } | |
| function triggerHiddenStageComplete() { | |
| gameState = STATE.HIDDEN_COMPLETE; | |
| spawnFireworksForScore(1000); | |
| playSound('win'); | |
| setTimeout(() => { | |
| showHiddenStageSummary(); | |
| gameState = STATE.LEVEL_COMPLETE; | |
| }, 3000); | |
| } | |
| function showHiddenStageSummary() { | |
| const hScore = localStorage.getItem('math_city_hidden_score_sequence') || '0'; | |
| const start = hiddenStage.sequence.start; | |
| const diff = hiddenStage.sequence.diff; | |
| const terms = []; | |
| for (let i = 0; i < 5; i++) terms.push(start + i * diff); | |
| let overlay = document.getElementById('hidden-summary'); | |
| if (!overlay) { | |
| overlay = document.createElement('div'); | |
| overlay.id = 'hidden-summary'; | |
| overlay.style.cssText = 'position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.92);backdrop-filter:blur(8px);overflow-y:auto;padding:20px 0;'; | |
| document.body.appendChild(overlay); | |
| } | |
| overlay.innerHTML = ` | |
| <div style="max-width:620px;width:94%;background:rgba(15,23,42,0.96);border:2px solid #a855f7;border-radius:20px;padding:32px 28px;text-align:center;box-shadow:0 0 50px rgba(168,85,247,0.3);margin:auto;"> | |
| <h2 style="font-size:34px;font-weight:900;color:#a855f7;margin-bottom:6px;text-shadow:0 0 15px rgba(168,85,247,0.5);">🎉 隱藏關卡完成!</h2> | |
| <p style="color:#fbbf24;font-size:20px;font-weight:bold;margin-bottom:20px;">隱藏分數:<span style="font-size:36px;color:#fbbf24;text-shadow:0 0 10px rgba(251,191,36,0.5);">${hScore}</span></p> | |
| <div style="text-align:left;background:rgba(0,0,0,0.4);border-radius:14px;padding:20px 24px;margin-bottom:20px;border:1px solid rgba(168,85,247,0.3);"> | |
| <h3 style="font-size:22px;font-weight:900;color:#fbbf24;margin-bottom:14px;border-bottom:1px solid rgba(168,85,247,0.3);padding-bottom:8px;">📜 隱藏知識回顧</h3> | |
| <div style="margin-bottom:16px;"> | |
| <p style="color:#f87171;font-weight:bold;font-size:19px;margin-bottom:6px;">🔻 公差可以是負的!</p> | |
| <p style="color:#e2e8f0;font-size:16px;line-height:1.8;">等差數列不一定會越來越大!<br>當<span style="color:#ef4444;font-weight:bold;">公差為負數</span>時,數列會<span style="color:#ef4444;font-weight:bold;">越來越小</span>。</p> | |
| </div> | |
| <div style="margin-bottom:16px;"> | |
| <p style="color:#22d3ee;font-weight:bold;font-size:19px;margin-bottom:6px;">📐 本關範例</p> | |
| <p style="color:#e2e8f0;font-size:16px;line-height:1.8;">首項 = <span style="color:#fbbf24;font-weight:bold;">${start}</span>,公差 = <span style="color:#ef4444;font-weight:bold;">${diff}</span></p> | |
| <p style="font-family:monospace;color:#22d3ee;font-size:20px;font-weight:bold;background:rgba(0,0,0,0.3);padding:8px 14px;border-radius:8px;margin-top:6px;text-align:center;">${terms.join(', ')} ...</p> | |
| <p style="color:#94a3b8;font-size:14px;margin-top:6px;text-align:center;">每一項都比前一項少 ${Math.abs(diff)}</p> | |
| </div> | |
| <div style="margin-bottom:8px;"> | |
| <p style="color:#a855f7;font-weight:bold;font-size:19px;margin-bottom:6px;">💡 關鍵觀念</p> | |
| <div style="color:#e2e8f0;font-size:16px;line-height:2;"> | |
| <p>• 公差 = <span style="color:#fbbf24;">後項 − 前項</span>(可正可負)</p> | |
| <p>• 公差 > 0 → 數列<span style="color:#22c55e;font-weight:bold;">遞增 📈</span></p> | |
| <p>• 公差 < 0 → 數列<span style="color:#ef4444;font-weight:bold;">遞減 📉</span></p> | |
| <p>• 公差 = 0 → 每一項都<span style="color:#94a3b8;font-weight:bold;">相同</span></p> | |
| </div> | |
| </div> | |
| </div> | |
| <a href="index.html" style="display:block;width:100%;padding:14px 0;border-radius:14px;background:linear-gradient(135deg,#7f1d1d,#581c87);color:white;font-weight:bold;font-size:20px;border:2px solid #a855f7;text-decoration:none;box-shadow:0 0 20px rgba(168,85,247,0.4);transition:all 0.3s;" onmouseover="this.style.boxShadow='0 0 30px rgba(168,85,247,0.7)';this.style.transform='scale(1.03)'" onmouseout="this.style.boxShadow='0 0 20px rgba(168,85,247,0.4)';this.style.transform='scale(1)'">回到 Math City</a> | |
| </div> | |
| `; | |
| overlay.style.display = 'flex'; | |
| } | |
| function showLevelComplete() { | |
| gameState = STATE.LEVEL_COMPLETE; | |
| // playSound('win'); // Moved to spawnFireworksForScore for immediate feedback | |
| document.getElementById('mobile-controls').classList.add('hidden'); | |
| document.getElementById('ui-hud').classList.add('hidden'); | |
| const scoreEl = document.getElementById('final-score'); | |
| scoreEl.innerText = score; | |
| // Save Score for Map | |
| const currentBest = parseInt(localStorage.getItem('math_city_score_sequence') || '0'); | |
| if (score > currentBest) { | |
| localStorage.setItem('math_city_score_sequence', score.toString()); | |
| } | |
| document.getElementById('screen-review').classList.remove('hidden'); | |
| } | |
| // Cheat Code Detection | |
| document.addEventListener('keypress', (e) => { | |
| cheatCodeBuffer += e.key.toLowerCase(); | |
| if (cheatCodeBuffer.length > 10) { | |
| cheatCodeBuffer = cheatCodeBuffer.slice(-10); | |
| } | |
| if (cheatCodeBuffer.includes('opopop')) { | |
| // Direct entry to hidden stage | |
| cheatCodeBuffer = ''; | |
| // Visual feedback | |
| particles.push({ | |
| x: width / 2, | |
| y: height / 2, | |
| life: 3.0, | |
| type: 'text', | |
| text: '✨ 進入隱藏關卡!' | |
| }); | |
| playSound('collect'); | |
| // Enter hidden stage directly | |
| setTimeout(() => { | |
| startHiddenStage(); | |
| }, 500); | |
| } | |
| }); | |
| // Start Loop Immediately for Background | |
| requestAnimationFrame(loop); | |
| </script> | |
| </body> | |
| </html> |