802MathCity / sequence.html
Lashtw's picture
Upload 102 files
09cfa48 verified
<!DOCTYPE html>
<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 !important;
}
@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 !important;
}
@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>