@@ -734,7 +818,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 };
+ 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;
@@ -779,9 +863,22 @@
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
@@ -792,9 +889,6 @@
let tutorial = { step: 0, checkpointReached: false, jumpTargetsHit: 0 };
let ritualPhase = 0;
let ritualAnimProgress = 0;
- let ghostOffsetX = 0;
- let ghostOffsetY = 0;
- let ghostRotation = 0;
// --- Audio System (Web Audio API) ---
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
@@ -883,32 +977,16 @@
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
- // Resize Logic for Bottom Anchoring
if (oldHeight && oldHeight !== height) {
const deltaY = height - oldHeight;
- // Shift Player
player.y += deltaY;
- player.prevY += deltaY; // Important for collision physics logic
-
- // Shift Camera (to avoid quick jumps)
+ player.prevY += deltaY;
cameraY += deltaY;
-
- // Shift Platforms
platforms.forEach(p => p.y += deltaY);
-
- // Shift CHeckpoints
checkpoints.forEach(p => p.y += deltaY);
-
- // Shift Tutorial Objects
tutorialObjects.forEach(o => o.y += deltaY);
-
- // Shift Level Objects (Flags, Castle)
levelObjects.forEach(o => o.y += deltaY);
-
- // Shift Particles
particles.forEach(p => p.y += deltaY);
-
- // Shift Gravity Zone
if (gravityZone) gravityZone.y += deltaY;
}
}
@@ -921,7 +999,6 @@
targetInput: null,
init: function () {
this.element = document.getElementById('virtual-keypad');
- // Prevent click bubbling from keypad to document (stops accidental closes if logic fails)
this.element.addEventListener('click', (e) => e.stopPropagation());
// Auto-close when clicking outside
@@ -929,19 +1006,17 @@
if (this.element.classList.contains('active') &&
!this.element.contains(e.target) &&
e.target !== this.targetInput &&
- !e.target.classList.contains('keypad-btn')) { /* Fix for clicking buttons */
+ !e.target.classList.contains('keypad-btn')) {
this.close();
}
});
- // --- Drag Logic ---
const handle = this.element.querySelector('.keypad-handle');
this.isDragging = false;
this.startX = 0; this.startY = 0;
this.offsetX = 0; this.offsetY = 0;
const startDrag = (e) => {
- // Prevent text selection/scrolling on touch
if (e.type === 'touchstart' && e.cancelable) e.preventDefault();
this.isDragging = true;
@@ -954,7 +1029,6 @@
this.offsetX = clientX - rect.left;
this.offsetY = clientY - rect.top;
- // Add listeners to document
document.addEventListener(e.type.includes('mouse') ? 'mousemove' : 'touchmove', doDrag, { passive: false });
document.addEventListener(e.type.includes('mouse') ? 'mouseup' : 'touchend', endDrag);
};
@@ -1020,17 +1094,15 @@
this.targetInput.value = this.targetInput.value.slice(0, -1);
}
};
- // Initialize immediately
keypad.init();
function selectDevice(type) {
if (audioCtx.state === 'suspended') audioCtx.resume();
- // Try to enter fullscreen on interaction
try {
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(e => console.log("Fullscreen blocked:", e));
- } else if (document.documentElement.webkitRequestFullscreen) { /* Safari */
+ } else if (document.documentElement.webkitRequestFullscreen) {
document.documentElement.webkitRequestFullscreen();
}
} catch (e) { console.log("Fullscreen error:", e); }
@@ -1084,8 +1156,6 @@
playSound('collect');
tutorial.step = 1; checkpoints = [];
- // Fix Floating Issue: Find the actual existing safe platform
- // Do NOT recalculate from height * 0.7 which might change on resize
const platform = platforms.find(p => p.type === 'safe' && p.x === 0);
const groundY = platform ? platform.y : height * 0.7;
@@ -1105,30 +1175,20 @@
function generateJumpTutorial() {
playSound('collect');
tutorial.step = 2; updateTutorialUI("跳躍訓練", "分別使用一段、二段、三段跳撞擊方塊");
- // Clear platforms to prevent overlap ("extra platform" fix)
platforms = []; checkpoints = [];
- // Determine start X relative to player to ensure continuity
- const startX = Math.max(player.x + 400, 600);
const groundY = height * 0.7;
-
- // Create a MASSIVE platform to prevent falling anywhere
platforms.push({ x: -2000, y: groundY, w: 6000, h: 40, type: 'safe' });
- // FORCE Player Stats to prevent falling (Feedback #2)
player.y = groundY - player.h - 1;
player.vy = 0;
player.isGrounded = true;
player.isJumping = false;
- // Recalculate startX based on new platform start
const safeStartX = player.x + 300;
-
- // Adjusted Heights: Restored to enforce Double/Triple jumps
- // 140 (Single), 290 (Double Only), 430 (Triple Only)
- tutorialObjects.push({ x: safeStartX, y: groundY - 140, w: 50, h: 50, type: 'target_jump', id: 1, hit: false, color: '#facc15', label: '1' }); // Yellow-400
- tutorialObjects.push({ x: safeStartX + 300, y: groundY - 290, w: 50, h: 50, type: 'target_jump', id: 2, hit: false, color: '#fbbf24', label: '2' }); // Amber-400
- tutorialObjects.push({ x: safeStartX + 600, y: groundY - 430, w: 50, h: 50, type: 'target_jump', id: 3, hit: false, color: '#f59e0b', label: '3' }); // Amber-500
+ 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 ---
@@ -1140,7 +1200,31 @@
const inputs = document.querySelectorAll('#screen-quiz input');
inputs.forEach(i => i.value = '');
document.getElementById('quiz-error').innerText = '';
- quizData.start = Math.floor(Math.random() * 3) + 1; quizData.diff = Math.floor(Math.random() * 4) + 2;
+
+ // 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;
}
@@ -1153,6 +1237,12 @@
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 {
@@ -1208,16 +1298,13 @@
if (isSafe) generatedTargetCount++;
- // Numbered Platform (Gravel Texture, NO Decoration)
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() {
- // Gap adjustment (Feedback #1)
worldRightEdge += 200;
- // Bridge to fill gap before Altar (Split for deco)
for (let i = 0; i < 2; i++) {
let deco = null;
if (Math.random() < 0.4) {
@@ -1226,13 +1313,8 @@
platforms.push({ x: worldRightEdge + i * 100, y: height * 0.7, w: 100, h: 40, type: 'safe', decoration: deco, seed: Math.random() });
}
- // Altar location
const ritualX = worldRightEdge + 150;
-
- // Long solid base platform under the altar area
platforms.push({ x: ritualX - 50, y: height * 0.7, w: 600, h: 40, type: 'safe', isAltarBase: true });
-
- // The Altar Object itself (raised)
platforms.push({ x: ritualX, y: height * 0.7 - 60, w: 160, h: 60, type: 'altar_platform', hits: 0, destroyed: false });
const terms = sequence.collected;
@@ -1242,14 +1324,12 @@
const maxTerm = sequence.start + (n - 1) * sequence.diff;
const stairTotalW = maxTerm * stairBlockSize;
- const stairStartX = ritualX + 300; // CONNECTED
+ const stairStartX = ritualX + 300;
const pivotX = stairStartX + stairTotalW;
const floorY = height * 0.7;
- // Safety floor bridge to wall
platforms.push({ x: stairStartX, y: floorY, w: stairTotalW + 400, h: 40, type: 'safe' });
- // Build Phantom Stairs
for (let i = 0; i < n; i++) {
const val = sequence.start + i * sequence.diff;
const rowW = val * stairBlockSize;
@@ -1277,11 +1357,10 @@
platforms.push({ x: wallX, y: wallY, w: 100, h: wallH, type: 'safe' });
platforms.push({ x: wallX, y: wallY, w: 1500, h: wallH, type: 'safe' });
- // Modified Gravity Zone: Starts AFTER the Altar to allow jumping on it
gravityZone = {
- x: ritualX + 200, // Shifted right to clear the altar area
- y: wallY + 100, // Trigger if below this
- w: (wallX - (ritualX + 200)) // Width from start of stairs up to wall
+ x: ritualX + 200,
+ y: wallY + 100,
+ w: (wallX - (ritualX + 200))
};
const flagX = wallX + 500;
@@ -1295,42 +1374,35 @@
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;
- // Fix: Disable jumping (including coyote time) to force fall
player.isGrounded = false;
player.coyoteTimer = 0;
player.jumpCount = MAX_JUMPS;
platforms = platforms.filter(p => p.type !== 'temp_stair');
-
- const hint = document.getElementById('story-hint');
- document.getElementById('story-hint-title').innerText = "階梯竟然消失了!"; // Feedback #1
- document.getElementById('story-hint-desc').innerText = "是不是需要什麼儀式才能再次出現...";
- hint.classList.remove('hidden');
-
- setTimeout(() => {
- hint.classList.add('hidden');
- document.getElementById('guide-arrow').classList.remove('hidden');
- }, 4000);
-
+ showStoryHint("階梯竟然消失了!", "是不是需要什麼儀式才能再次出現...", true);
createParticles(player.x, player.y, 50, '#22d3ee');
}
function triggerGravityDialog() {
- if (hasShownGravityDialog || isMagicStairsBuilt) return; // Don't show if stairs built
+ if (hasShownGravityDialog || isMagicStairsBuilt) return;
hasShownGravityDialog = true;
-
- const hint = document.getElementById('story-hint');
- document.getElementById('story-hint-title').innerText = "階梯竟然消失了!";
- document.getElementById('story-hint-desc').innerText = "是不是需要什麼儀式才能再次出現...";
- hint.classList.remove('hidden');
-
- setTimeout(() => {
- hint.classList.add('hidden');
- }, 4000);
+ showStoryHint("階梯竟然消失了!", "是不是需要什麼儀式才能再次出現...");
}
function updateCollectionDots() {
@@ -1365,17 +1437,20 @@
}
function updatePhysics(dt) {
- // Restore Squash/Stretch to 1
player.scaleX += (1 - player.scaleX) * 0.2 * dt;
player.scaleY += (1 - player.scaleY) * 0.2 * dt;
- // Update Coyote Time
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;
@@ -1384,27 +1459,18 @@
if (player.vx !== 0) player.facingRight = player.vx > 0;
}
- // --- Feedback #2: Altar Area Barrier ---
const altarBase = platforms.find(p => p.isAltarBase);
- if (altarBase) {
- // If player is past the start of the base, update minPlayerX to lock them in
- if (player.x > altarBase.x) {
- // Lock player to not go left of altar base
- minPlayerX = altarBase.x;
- }
+ if (altarBase && player.x > altarBase.x) {
+ minPlayerX = altarBase.x;
}
- // Enforce Barrier
if (player.x < minPlayerX) {
player.x = minPlayerX;
if (player.vx < 0) player.vx = 0;
}
- // --- Feedback #1: Left Boundary Barrier at Start ---
if (gameState === STATE.PLAYING && player.x < 0) {
player.x = 0;
if (player.vx < 0) player.vx = 0;
-
- // Show floating text hint
if (Math.random() < 0.05) {
particles.push({
x: player.x + 60,
@@ -1428,42 +1494,33 @@
}
}
- // Show Altar Hint (Logic Optimized)
const altarHint = document.getElementById('altar-hint');
const altar = platforms.find(p => p.type === 'altar_platform');
- // Feedback #4: Show hints immediately when Over Altar (grounded or not)
const isOver = isPlayerOverAltar();
if (gameState === STATE.PLAYING && altar && isStairsVanished && !altar.destroyed && isOver) {
- document.getElementById('guide-arrow').classList.add('hidden'); // Feedback #4: Hide arrow immediately
-
+ 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 = "🔥 連點兩下跳躍,下墜打破祭壇!";
+ hintText.innerText = "🔥 連點兩下跳躍,下墜打破祭壇!";
altarHint.querySelector('div').className = "glass-panel px-4 py-2 rounded-xl border border-red-400 bg-red-900/80";
}
-
- // Only show if not diving to avoid cluttering screen during action
if (!player.isDiving) altarHint.classList.remove('hidden');
else altarHint.classList.add('hidden');
} else {
altarHint.classList.add('hidden');
}
- // Unconditional Gravity Zone Trigger
if (gravityZone && isStairsVanished && !isMagicStairsBuilt) {
if (player.x > gravityZone.x && player.x < gravityZone.x + gravityZone.w && player.y > gravityZone.y) {
- // Trigger gravity dialog if player tries to jump or is just in it
triggerGravityDialog();
let gravityMult = 5;
- // Trigger bubble only on jump start, and use timer (Feedback #3)
if (player.vy < 0 && player.gravityHintTimer <= 0) {
- player.gravityHintTimer = 120; // Approx 2 seconds (120 * dt of 1)
+ player.gravityHintTimer = 120;
}
-
player.vy += GRAVITY * gravityMult * dt;
} else {
player.vy += GRAVITY * dt;
@@ -1472,7 +1529,6 @@
player.vy += GRAVITY * dt;
}
- // Decrement Gravity Hint Timer (Feedback #3)
if (player.gravityHintTimer > 0) {
player.gravityHintTimer -= dt;
}
@@ -1480,13 +1536,10 @@
const maxFall = player.isDiving ? 20 : 12;
if (player.vy > maxFall) player.vy = maxFall;
- // Squash/Stretch in Air
if (!player.isGrounded) {
if (Math.abs(player.vy) < 2) {
- // Apex
player.scaleX = 1.1; player.scaleY = 0.9;
} else {
- // Falling/Rising
player.scaleX = 0.9; player.scaleY = 1.1;
}
}
@@ -1494,7 +1547,11 @@
player.y += player.vy * dt;
let targetCamX = player.x - width * 0.3;
- if (targetCamX < 0) targetCamX = 0;
+ // 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;
@@ -1505,7 +1562,10 @@
targetCamY = player.y - height * 0.6;
if (targetCamY > 0) targetCamY = 0;
}
- cameraY += (targetCamY - cameraY) * 0.1 * dt;
+ // 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) {
@@ -1523,21 +1583,28 @@
player.isGrounded = false;
if (player.vy >= 0) {
for (let p of platforms) {
- // Ignore collision for destroyed altar platform
if (p.type === 'altar_platform' && p.destroyed) continue;
-
if (p.broken) continue;
- if (player.x + player.w * 0.2 > p.x && player.x + player.w * 0.8 < p.x + p.w) {
+ 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) {
+ 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) {
@@ -1558,6 +1625,30 @@
}
}
}
+ 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;
@@ -1570,40 +1661,46 @@
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;
- // Relaxed hit detection window (30px instead of 20px)
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++;
-
- // Directly start Quiz after 3 hits
if (tutorial.jumpTargetsHit === 3) setTimeout(startQuizPhase, 500);
}
}
}
}
- if (player.y > height + 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;
- // Smart Respawn: Respawn near the current objective
if (tutorial.step === 2) {
- // Find first un-hit target
const nextTarget = tutorialObjects.find(t => !t.hit);
if (nextTarget) {
- player.x = nextTarget.x - 300; // Respawn before the target
+ player.x = nextTarget.x - 300;
} else if (tutorialObjects.length > 0) {
player.x = tutorialObjects[0].x - 300;
} else {
@@ -1621,14 +1718,12 @@
spawnNextPlatform();
}
- // Particle Updates (Logic Only)
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') {
- // New Error Feedback Particle
p.y -= 0.5 * dt;
p.life -= 0.015 * dt;
} else if (p.type === 'smoke') {
@@ -1649,33 +1744,34 @@
}
function handleLanding(p) {
- // Landing Squash
player.scaleX = 1.3; player.scaleY = 0.7;
- // playSound('land'); // Removed as requested
-
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;
}
- // Altar Break Logic
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; // Mark destroyed
+ 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'); // Grey smoke
+ createParticles(p.x + p.w / 2, p.y + p.h / 2, 50, '#334155');
playSound('break');
screenShake = 20;
- setTimeout(startRitual, 1500); // Delayed Ritual Start for visual effect
+ setTimeout(startRitual, 1500);
}
return;
}
@@ -1685,8 +1781,6 @@
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');
-
- // --- Error Feedback Logic ---
if (sequence.collected.length > 0) {
let lastVal = sequence.collected[sequence.collected.length - 1];
let diff = p.val - lastVal;
@@ -1695,14 +1789,11 @@
} else {
particles.push({ x: p.x + p.w / 2, y: p.y - 40, life: 2.5, type: 'error_text', text: `不是 ${sequence.start}` });
}
-
- // --- Feedback #3: Immediate Fall (No Jump possible) ---
player.isGrounded = false;
player.y += 5;
player.vy = 5;
- player.coyoteTimer = 0; // Prevent coyote jump
- player.jumpCount = MAX_JUMPS; // Exhaust double jumps
-
+ 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');
@@ -1714,16 +1805,12 @@
// --- Controls ---
function performJump() {
if (player.autoWalk) return;
- // Coyote Time Check: Allow jump if timer > 0 OR have double jumps
if (player.isGrounded || player.coyoteTimer > 0 || player.jumpCount < MAX_JUMPS) {
player.vy = JUMP_FORCE;
player.isGrounded = false;
player.isDiving = false;
- player.coyoteTimer = 0; // Consume Coyote Time
-
- // Jump Stretch
+ 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');
@@ -1731,7 +1818,33 @@
}
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');
@@ -1739,8 +1852,9 @@
}
function handleActionInput() {
const now = Date.now();
- // Double Tap Logic - Modified: Only dive if over Altar
- if (now - lastJumpTime < 300 && isPlayerOverAltar()) {
+ // 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();
@@ -1817,8 +1931,29 @@
let colors = [];
let type = 'normal';
- let baseX = castleObject ? castleObject.x + 100 : cameraX + width / 2;
- let baseY = castleObject ? castleObject.y : cameraY + height / 3;
+ // 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']; }
@@ -1830,8 +1965,8 @@
document.getElementById('mock-msg').classList.remove('hidden');
for (let k = 0; k < count; k++) {
particles.push({
- x: baseX + (Math.random() - 0.5) * 100,
- y: castleObject ? castleObject.y + 100 : baseY + 200,
+ 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,
@@ -1842,39 +1977,35 @@
} else {
for (let k = 0; k < count; k++) {
particles.push({
- x: baseX + (Math.random() - 0.5) * 300,
- y: baseY - Math.random() * 200,
- vx: (Math.random() - 0.5) * 6, // Slower initial
+ 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'); // Play victory sound properly with fireworks!
+ playSound('win');
}
}
function drawBackground() {
- // Synthwave Sky Gradient (Deep Purple -> Neon Pink -> Orange)
const gradient = ctx.createLinearGradient(0, 0, 0, height);
- gradient.addColorStop(0, '#0f0c29'); // Deep Black-Blue
- gradient.addColorStop(0.5, '#302b63'); // Dark Purple
- gradient.addColorStop(1, '#24243e'); // Dark Blue-Grey
-
+ gradient.addColorStop(0, '#1a1005');
+ gradient.addColorStop(0.5, '#451a03');
+ gradient.addColorStop(1, '#78350f');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
- // --- Retrowave Sun ---
- const sunX = width * 0.5; // Center Sun
- const sunY = height * 0.65; // Horizon line
+ const sunX = width * 0.5;
+ const sunY = height * 0.65;
const sunRadius = 150;
- // Sun Gradient
const sunGrad = ctx.createLinearGradient(sunX, sunY - sunRadius, sunX, sunY + sunRadius);
- sunGrad.addColorStop(0, '#facc15'); // Yellow
- sunGrad.addColorStop(0.5, '#fb923c'); // Orange
- sunGrad.addColorStop(1, '#db2777'); // Magenta
+ sunGrad.addColorStop(0, '#facc15');
+ sunGrad.addColorStop(0.5, '#fb923c');
+ sunGrad.addColorStop(1, '#db2777');
ctx.save();
ctx.fillStyle = sunGrad;
@@ -1884,55 +2015,39 @@
ctx.fill();
ctx.shadowBlur = 0;
- // Sun Stripes (The "Blinds" effect)
- ctx.fillStyle = '#24243e'; // Match bottom sky color to "cut" the sun
+ ctx.fillStyle = '#24243e';
for (let y = sunY - 40; y < sunY + sunRadius; y += 12) {
- const thickness = (y - (sunY - 40)) / 10; // Thicker at bottom
+ const thickness = (y - (sunY - 40)) / 10;
if (thickness > 0) ctx.fillRect(sunX - sunRadius, y, sunRadius * 2, thickness);
}
ctx.restore();
- // --- Perspective Grid ---
ctx.save();
- ctx.strokeStyle = 'rgba(232, 121, 249, 0.3)'; // Neon Fuchsia faint
+ ctx.strokeStyle = 'rgba(232, 121, 249, 0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
-
- // Horizon Line
ctx.moveTo(0, sunY); ctx.lineTo(width, sunY);
- // Vertical Lines (Perspective)
const fov = 800;
const horizonY = sunY;
- // Shift grid with cameraX to feel movement
const gridOffset = (cameraX % 100);
for (let x = -width; x < width * 2; x += 100) {
- // Determine x pos on horizon (vanishing point is center ideally, but for 2D side scroller we fake it)
- // Let's make them parallel but slanted for style, or just perspective from bottom center?
- // Standard Synthwave grid usually moves TOWARDS horizon. Since this is scrolling sidewards:
- // We draw vertical lines that scroll with parallax
let drawX = x - gridOffset;
ctx.moveTo(drawX, horizonY);
- ctx.lineTo(drawX - (drawX - width / 2) * 3, height); // Fake perspective fanning out
+ ctx.lineTo(drawX - (drawX - width / 2) * 3, height);
}
- // Horizontal Lines (Approaching viewer)
- // In a side scroller, these are static horizontal lines usually, or they scroll Y?
- // Let's keep them static horizontal for the "ground plane" effect
let totalDist = height - horizonY;
for (let i = 0; i < 10; i++) {
- let y = horizonY + (totalDist * (i / 10) ** 2); // Quadratic spacing
+ let y = horizonY + (totalDist * (i / 10) ** 2);
ctx.moveTo(0, y); ctx.lineTo(width, y);
}
ctx.stroke();
ctx.restore();
- // Distant Mountains (Wireframe Style - Static & Layered)
-
- // Layer 1: Back Mountains (Darker, Slower Parallax)
ctx.fillStyle = '#0f172a';
- ctx.strokeStyle = '#0891b2'; // Cyan-700
+ ctx.strokeStyle = '#0891b2';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, height);
@@ -1947,25 +2062,16 @@
ctx.fill();
ctx.stroke();
- // Layer 2: Front Mountains (Lighter edges, Faster Parallax)
- ctx.strokeStyle = '#22d3ee'; // Cyan-400
- ctx.fillStyle = '#1e293b'; // Slate-800
+ 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);
-
- // Summit multiple sine waves (Deterministic Noise)
- let noise = Math.sin(mX * 0.005) * 60
- + Math.sin(mX * 0.013) * 30
- + Math.sin(mX * 0.023) * 10;
-
+ 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;
-
- // Jagged look via regular sampling (No Math.random)
ctx.lineTo(x, hY);
}
ctx.lineTo(width, height);
@@ -1973,86 +2079,80 @@
ctx.stroke();
}
+ const decorations = ['grass', 'flower', 'mushroom', 'skull'];
function drawDecoration(type, x, y, seed) {
- // Cyber/Holographic Decorations
ctx.save();
- ctx.translate(x, y);
-
- // Hologram Flicker
- const flicker = Math.random() > 0.95;
- if (flicker) ctx.translate((Math.random() - 0.5) * 5, 0);
+ // 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.shadowBlur = 10;
-
- if (type === 'cactus') {
- // Neon Wireframe Cactus
- ctx.strokeStyle = '#0f0'; // Green
- ctx.shadowColor = '#0f0';
- ctx.lineWidth = 2;
- ctx.fillStyle = 'rgba(0, 255, 0, 0.1)';
-
- ctx.beginPath();
- // Main
- ctx.rect(10, -50, 10, 50);
- // Arm 1
- ctx.moveTo(10, -35); ctx.lineTo(-5, -35); ctx.lineTo(-5, -45); ctx.lineTo(10, -45);
- // Arm 2
- ctx.moveTo(20, -25); ctx.lineTo(35, -25); ctx.lineTo(35, -35); ctx.lineTo(20, -35);
+ ctx.translate(x, y); // x, y passed is usually top-left or top-center of platform.
- ctx.stroke();
- ctx.fill();
-
- } else if (type === 'barrel') {
- // Digital Canister
- ctx.strokeStyle = '#f59e0b'; // Amber
- 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();
- // X mark
- ctx.moveTo(5, -30); ctx.lineTo(29, 0);
- ctx.moveTo(29, -30); ctx.lineTo(5, 0);
- ctx.stroke();
- ctx.fill();
+ // Fix Skull alignment: user wants them above and aligned
+ if (type === 'skull') {
+ ctx.translate(0, -15); // Move UP by 15px to sit on top
+ }
- } else if (type === 'skull') {
- // Glitch Skull (Triangle/Poly)
- ctx.strokeStyle = '#e2e8f0';
- ctx.shadowColor = '#fff';
- ctx.lineWidth = 2;
+ if (type === 'grass') {
+ const flicker = Math.random() > 0.95;
+ if (flicker) ctx.translate((Math.random() - 0.5) * 5, 0);
+ ctx.shadowBlur = 10;
- 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') {
- // Low Poly Rocks
- ctx.strokeStyle = '#a855f7'; // Purple
- ctx.shadowColor = '#a855f7';
- ctx.fillStyle = 'rgba(168, 85, 247, 0.2)';
- ctx.lineWidth = 2;
+ 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();
- 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();
- }
+ } 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) {
- // Deterministic random using seed (simple hash)
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;
@@ -2062,11 +2162,22 @@
}
}
+ 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);
- // Apply Shake (Only apply global shake if NOT in ritual mode to avoid background shaking)
if (screenShake > 0 && gameState !== STATE.RITUAL) {
ctx.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake);
}
@@ -2114,62 +2225,85 @@
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;
- if (p.type === 'safe' || p.type === 'target' || (p.type === 'trap' && !p.broken)) {
- // Modern Cyber Platforms
- // Base: Dark semi-transparent fill
- // Border: Neon Glowing Line
- let borderColor = '#22d3ee'; // Default Safe: Cyan
+ // 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') {
+ if (p.type === 'safe' || p.type === 'hidden_safe') {
borderColor = '#22d3ee'; glowColor = '#22d3ee';
} else if (p.type === 'target') {
- // User Request: Turn Amber if visited/hinted
if (p.visualState === 'success' || p.visited || p.hint) {
- borderColor = '#f59e0b'; glowColor = '#f59e0b'; // Found/Hint -> Amber
+ borderColor = '#f59e0b'; glowColor = '#f59e0b';
} else {
- // Hidden -> Cyan
borderColor = '#22d3ee'; glowColor = '#22d3ee';
}
} else if (p.type === 'trap') {
borderColor = '#22d3ee'; glowColor = '#22d3ee';
}
- // Draw Base
- // Feature: Render End Game Walls (Tall Safe Platforms) as Solid
if (p.type === 'safe' && p.h > 100) {
- ctx.fillStyle = '#0f0c29'; // Solid Dark
+ ctx.fillStyle = '#0f0c29';
ctx.fillRect(p.x, p.y, p.w, p.h);
- // Solid Border
- ctx.strokeStyle = '#a855f7'; // Purple Edge
+ ctx.strokeStyle = '#a855f7';
ctx.lineWidth = 3;
ctx.strokeRect(p.x, p.y, p.w, p.h);
} else {
- // Regular Floating Platforms (Glassy)
ctx.fillStyle = 'rgba(15, 23, 42, 0.8)';
ctx.fillRect(p.x, p.y, p.w, p.h);
-
- // Top Highlight (Neon Bar)
ctx.shadowBlur = 10; ctx.shadowColor = glowColor;
ctx.fillStyle = borderColor;
- ctx.fillRect(p.x, p.y, p.w, 4); // Top 4px glow
+ ctx.fillRect(p.x, p.y, p.w, 4);
ctx.shadowBlur = 0;
-
- // Border Stroke
ctx.strokeStyle = borderColor; ctx.lineWidth = 2;
ctx.strokeRect(p.x, p.y, p.w, p.h);
-
- // Grid Texture overlay
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);
}
- // Extra Glow for Hint (First 2 terms)
if (p.type === 'target' && p.visualState !== 'success' && p.hint) {
const time = Date.now();
const pulse = (Math.sin(time * 0.008) + 1) * 0.5;
@@ -2184,23 +2318,88 @@
}
}
else if (p.type === 'ritual_base') {
- // Solid Opaque Structure (No Transparency)
- ctx.fillStyle = '#0f0c29'; // Solid Dark Deep Blue
+ ctx.fillStyle = '#0f0c29';
ctx.fillRect(p.x, p.y, p.w, p.h);
-
- // Solid Border
- ctx.strokeStyle = '#a855f7'; // Purple Edge
+ ctx.strokeStyle = '#a855f7';
ctx.lineWidth = 3;
ctx.strokeRect(p.x, p.y, p.w, p.h);
-
- // Minimal internal detail
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') {
- // Feedback #5: Unified Style (Pale Blue, White Border)
- ctx.fillStyle = 'rgba(34, 211, 238, 0.4)'; // Transparent cyan
+ 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';
@@ -2224,14 +2423,11 @@
ctx.shadowColor = '#22d3ee';
}
- // Draw Altar Platform
else if (p.type === 'altar_platform') {
if (p.destroyed) {
- // DEBRIS DRAWING
- ctx.fillStyle = '#475569'; // Rubble Grey
+ 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);
@@ -2256,13 +2452,10 @@
ctx.strokeRect(-s.w / 2, -s.h / 2, s.w, s.h);
ctx.restore();
}
-
} else {
- // Draw Altar (Normal)
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);
@@ -2297,29 +2490,43 @@
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') {
- // FIX: Actually DRAW the fill (fillRect)
ctx.fillRect(p.x, p.y, p.w, p.h);
-
- // Feedback #5: Unified Style (White Border)
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
ctx.strokeRect(p.x, p.y, p.w, p.h);
+
+ // Draw block divisions
if (p.val && p.blockSize) {
- ctx.beginPath();
- ctx.strokeStyle = 'rgba(255,255,255,0.5)';
- for (let i = 1; i < p.val; i++) {
- const lx = p.x + i * (p.w / p.val);
- ctx.moveTo(lx, p.y); ctx.lineTo(lx, p.y + p.h);
+ 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();
}
- 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') ctx.fillText(p.val, p.x + p.w / 2, 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;
}
@@ -2331,67 +2538,49 @@
ctx.fillText(obj.label, obj.x + obj.w / 2, obj.y + 30);
}
- // Player
if (player.visible !== false) {
ctx.shadowBlur = 20; ctx.shadowColor = player.isDiving ? '#a855f7' : '#6366f1';
ctx.fillStyle = player.isDiving ? '#d8b4fe' : '#818cf8';
- // --- SQUASH AND STRETCH DRAWING ---
let drawW = player.w * player.scaleX;
let drawH = player.h * player.scaleY;
-
- // Adjust position so scaling happens from bottom center
let drawX = player.x + (player.w - drawW) / 2;
let drawY = player.y + (player.h - drawH);
-
- // Body
ctx.fillRect(drawX, drawY, drawW, drawH);
- // --- CYBER VISOR ---
ctx.fillStyle = '#fff'; ctx.shadowBlur = 15; ctx.shadowColor = '#60a5fa';
let lookDir = player.facingRight ? 1 : -1;
-
- // Start X of Visor
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;
-
- // Gravity Zone Visual (On Player) (Feedback #3: Timer Based)
- if (player.gravityHintTimer > 20) { // Show if time > 20 (approx 0.3s remaining), keeps it flickering out nicely
+ if (player.gravityHintTimer > 20) {
ctx.save();
ctx.translate(player.x + player.w / 2, player.y - 25);
- const opacity = Math.min(1, player.gravityHintTimer / 60); // Fade out
+ const opacity = Math.min(1, player.gravityHintTimer / 60);
ctx.globalAlpha = opacity;
-
- // Bubble
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();
- // Pointer
ctx.beginPath(); ctx.moveTo(-5, -10); ctx.lineTo(5, -10); ctx.lineTo(0, 0); ctx.fill();
- // Text
ctx.fillStyle = '#6b21a8'; ctx.font = 'bold 16px "Noto Sans TC"'; ctx.textAlign = 'center';
ctx.fillText("🔮 好像有什麼神祕的力量在壓著我...", 0, -25);
ctx.restore();
}
}
- // Draw particles (Only Draw Here)
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') {
- // New Error Feedback Particle
ctx.font = 'bold 24px "Noto Sans TC", Arial';
ctx.textAlign = 'center';
- ctx.fillStyle = `rgba(239, 68, 68, ${p.life})`; // Red
+ 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);
@@ -2423,9 +2612,76 @@
}
ctx.globalAlpha = 1;
- // RITUAL PHASE DRAWING
+ 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();
+ // 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);
@@ -2445,27 +2701,12 @@
const startX = (width - totalDrawW) / 2;
const startY = (height * 0.15);
- // Shake transform inside ritual save block
- ctx.save(); // New save for shake
+ ctx.save();
if (screenShake > 0) {
ctx.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake);
}
-
ctx.shadowBlur = 0;
- function drawFloatingLabel(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();
- }
-
- // Animation Phase logic for ghost
let currentGhostRot = 0;
let ghostOffsetX = 0;
let labelAlpha = 0;
@@ -2550,31 +2791,73 @@
}
ctx.restore();
- // Draw Dynamic Labels (Phase 3)
if (labelAlpha > 0) {
// Position text relative to the combined shape
// Top label (Width)
- drawFloatingLabel("(首項 + 末項)", startX + totalDrawW / 2, startY - 40, labelAlpha);
+ drawFloatingLabel(ctx, "寬度:(首項 + 末項)", startX + totalDrawW / 2, startY - 40, labelAlpha);
// Left label (Height) with subtext
- drawFloatingLabel("項數 n", startX - 70, startY + totalDrawH / 2 - 12, labelAlpha);
- drawFloatingLabel("(有幾層階梯)", startX - 70, startY + totalDrawH / 2 + 18, labelAlpha, "16px");
+ drawFloatingLabel(ctx, "高度:項數 n", startX - 70, startY + totalDrawH / 2 - 12, labelAlpha);
+ drawFloatingLabel(ctx, "(有幾層階梯)", startX - 70, startY + totalDrawH / 2 + 18, labelAlpha, "16px");
}
}
- ctx.restore(); // Restore shake save
+ ctx.restore();
if (ritualPhase >= 2) {
- // drawBracket calls removed as requested to use persistent floating labels instead
- drawFloatingLabel("(首項 + 末項)", startX + totalDrawW / 2, startY - 40, 1);
- // Updated labels with subtext
- drawFloatingLabel("項數 n", startX - 70, startY + totalDrawH / 2 - 12, 1);
- drawFloatingLabel("(有幾層階梯)", startX - 70, startY + totalDrawH / 2 + 18, 1, "16px");
+ 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) {
@@ -2595,7 +2878,53 @@
if (ritualGlow < 0) ritualGlow = 0;
}
- if (gameState === STATE.TUTORIAL || gameState === STATE.PLAYING || gameState === STATE.CLIMBING || gameState === STATE.FIREWORKS) updatePhysics(dt);
+ 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;
@@ -2609,6 +2938,7 @@
}
}
+ if (gameState === STATE.HIDDEN_STAGE || gameState === STATE.HIDDEN_COMPLETE) drawHiddenAtmosphere();
draw();
if (gameState !== STATE.GAMEOVER) {
requestAnimationFrame(loop);
@@ -2617,16 +2947,25 @@
}
}
+ let embers = []; // Hidden Stage Particles
+
function resetWorld() {
- platforms = []; particles = []; tutorialObjects = []; checkpoints = []; levelObjects = [];
+ 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;
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);
}
@@ -2635,7 +2974,30 @@
document.getElementById('screen-ritual').classList.add('hidden');
document.getElementById('screen-review').classList.add('hidden');
- if (hasPassedTutorial) {
+ 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;
+ 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;
+ worldRightEdge = 1000; nextStoneNum = 1;
+ generatedTargetCount = 0;
+
+ draw();
+ startQuizPhase();
+ } else {
if (controlType === 'mobile') document.getElementById('mobile-controls').classList.remove('hidden');
resetWorld();
@@ -2655,9 +3017,6 @@
generatedTargetCount = 0;
draw();
-
- startQuizPhase();
- } else {
document.getElementById('ui-hud').classList.add('hidden');
document.getElementById('screen-start').classList.remove('hidden');
document.getElementById('mobile-controls').classList.add('hidden');
@@ -2689,13 +3048,123 @@
document.querySelectorAll('.original-content').forEach(el => el.style.display = 'block');
document.querySelectorAll('[id^=ritual-msg]').forEach(p => p.innerText = '');
- document.getElementById('ritual-step-1').classList.remove('hidden');
+ 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(() => {
@@ -2798,7 +3267,6 @@
const wall = platforms.find(p => p.h > 100 && p.type === 'safe' && p.y < height * 0.7);
if (wall) {
- // The wall X is the pivotX
const pivotX = wall.x;
const floorY = height * 0.7;
@@ -2806,11 +3274,10 @@
const val = terms[i];
const rowW = val * blockSize;
const rowH = blockSize;
- // Row 0 is top (smallest val)
const rowY = floorY - (n - i) * rowH;
const rowX = pivotX - rowW;
- platforms.push({
+ const stairPlatform = {
x: rowX,
y: rowY,
w: rowW,
@@ -2818,9 +3285,435 @@
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 = `
+
+
🔥 隱藏關卡
+
首項: ${hiddenStage.sequence.start} 公差: ${hiddenStage.sequence.diff}
+
+
⚔️ 遊戲方式:
+
+ - 選擇首項數字的位置,向下擊破方塊
+ - 選擇等差數列中的下一個數字跳下
+ - 畫面會不斷往上,請小心不要被推出畫面!
+ - 跳完 15 關後到達旗子即可過關
+
+
+
+
+ `;
+ 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 = `
+
+
🎉 隱藏關卡完成!
+
隱藏分數:${hScore}
+
+
+
📜 隱藏知識回顧
+
+
+
🔻 公差可以是負的!
+
等差數列不一定會越來越大!
當公差為負數時,數列會越來越小。
+
+
+
+
📐 本關範例
+
首項 = ${start},公差 = ${diff}
+
${terms.join(', ')} ...
+
每一項都比前一項少 ${Math.abs(diff)}
+
+
+
+
💡 關鍵觀念
+
+
• 公差 = 後項 − 前項(可正可負)
+
• 公差 > 0 → 數列遞增 📈
+
• 公差 < 0 → 數列遞減 📉
+
• 公差 = 0 → 每一項都相同
+
+
+
+
+
回到 Math City
+
+ `;
+ overlay.style.display = 'flex';
}
function showLevelComplete() {
@@ -2840,6 +3733,33 @@
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);