|
| 1 | +(function () { |
| 2 | + "use strict"; |
| 3 | + |
| 4 | + const canvas = document.getElementById("gameCanvas"); |
| 5 | + const ctx = canvas.getContext("2d"); |
| 6 | + if (!ctx.roundRect) { |
| 7 | + ctx.roundRect = function (x, y, w, h, r) { |
| 8 | + r = Math.min(r, w / 2, h / 2); |
| 9 | + this.beginPath(); |
| 10 | + this.moveTo(x + r, y); |
| 11 | + this.lineTo(x + w - r, y); |
| 12 | + this.quadraticCurveTo(x + w, y, x + w, y + r); |
| 13 | + this.lineTo(x + w, y + h - r); |
| 14 | + this.quadraticCurveTo(x + w, y + h, x + w - r, y + h); |
| 15 | + this.lineTo(x + r, y + h); |
| 16 | + this.quadraticCurveTo(x, y + h, x, y + h - r); |
| 17 | + this.lineTo(x, y + r); |
| 18 | + this.quadraticCurveTo(x, y, x + r, y); |
| 19 | + }; |
| 20 | + } |
| 21 | + const scoreEl = document.getElementById("scoreEl"); |
| 22 | + const highScoreEl = document.getElementById("highScoreEl"); |
| 23 | + const startOverlay = document.getElementById("startOverlay"); |
| 24 | + const gameOverOverlay = document.getElementById("gameOverOverlay"); |
| 25 | + const startBtn = document.getElementById("startBtn"); |
| 26 | + const restartBtn = document.getElementById("restartBtn"); |
| 27 | + const finalScoreEl = document.getElementById("finalScoreEl"); |
| 28 | + const btnLeft = document.getElementById("btnLeft"); |
| 29 | + const btnRight = document.getElementById("btnRight"); |
| 30 | + |
| 31 | + const CANVAS_WIDTH = 480; |
| 32 | + const CANVAS_HEIGHT = 600; |
| 33 | + const GRAVITY = 0.45; |
| 34 | + const JUMP_FORCE = -12; |
| 35 | + const MOVE_SPEED = 5; |
| 36 | + const PLATFORM_MIN_WIDTH = 60; |
| 37 | + const PLATFORM_MAX_WIDTH = 120; |
| 38 | + const PLATFORM_HEIGHT = 14; |
| 39 | + const PLATFORM_GAP_MIN = 50; |
| 40 | + const PLATFORM_GAP_MAX = 120; |
| 41 | + const PLAYER_WIDTH = 36; |
| 42 | + const PLAYER_HEIGHT = 40; |
| 43 | + const CAMERA_LEAD = 0.4; |
| 44 | + |
| 45 | + let animationId = null; |
| 46 | + let player = null; |
| 47 | + let platforms = []; |
| 48 | + let cameraY = 0; |
| 49 | + let startCameraY = 0; |
| 50 | + let score = 0; |
| 51 | + let highScore = parseInt(localStorage.getItem("doodle-high-score") || "0", 10); |
| 52 | + let gameRunning = false; |
| 53 | + let keys = { left: false, right: false }; |
| 54 | + let time = 0; |
| 55 | + |
| 56 | + function setPixelRatio() { |
| 57 | + const dpr = Math.min(window.devicePixelRatio || 1, 2); |
| 58 | + const rect = canvas.getBoundingClientRect(); |
| 59 | + canvas.width = CANVAS_WIDTH * dpr; |
| 60 | + canvas.height = CANVAS_HEIGHT * dpr; |
| 61 | + canvas.style.width = rect.width + "px"; |
| 62 | + canvas.style.height = rect.height + "px"; |
| 63 | + ctx.scale(dpr, dpr); |
| 64 | + } |
| 65 | + |
| 66 | + function createPlatform(x, y, width, type) { |
| 67 | + return { |
| 68 | + x, |
| 69 | + y, |
| 70 | + width, |
| 71 | + height: PLATFORM_HEIGHT, |
| 72 | + type: type || "normal", |
| 73 | + moveDir: type === "moving" ? (Math.random() > 0.5 ? 1 : -1) : 0, |
| 74 | + moveRange: type === "moving" ? 40 + Math.random() * 40 : 0, |
| 75 | + startX: x, |
| 76 | + }; |
| 77 | + } |
| 78 | + |
| 79 | + function initPlatforms() { |
| 80 | + platforms = []; |
| 81 | + let y = CANVAS_HEIGHT - 80; |
| 82 | + for (let i = 0; i < 10; i++) { |
| 83 | + const width = |
| 84 | + PLATFORM_MIN_WIDTH + Math.random() * (PLATFORM_MAX_WIDTH - PLATFORM_MIN_WIDTH); |
| 85 | + let x = Math.random() * (CANVAS_WIDTH - width); |
| 86 | + let type = "normal"; |
| 87 | + if (i === 0) { |
| 88 | + x = (CANVAS_WIDTH - width) / 2; |
| 89 | + } else { |
| 90 | + const typeRand = Math.random(); |
| 91 | + if (typeRand < 0.15) type = "break"; |
| 92 | + else if (typeRand < 0.35) type = "moving"; |
| 93 | + } |
| 94 | + platforms.push(createPlatform(x, y, width, type)); |
| 95 | + y -= PLATFORM_GAP_MIN + Math.random() * (PLATFORM_GAP_MAX - PLATFORM_GAP_MIN); |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + function addPlatformsAbove(topY) { |
| 100 | + let lastY = platforms.length ? Math.min(...platforms.map((p) => p.y)) : topY; |
| 101 | + while (lastY > topY - CANVAS_HEIGHT - 200) { |
| 102 | + lastY -= PLATFORM_GAP_MIN + Math.random() * (PLATFORM_GAP_MAX - PLATFORM_GAP_MIN); |
| 103 | + const width = |
| 104 | + PLATFORM_MIN_WIDTH + Math.random() * (PLATFORM_MAX_WIDTH - PLATFORM_MIN_WIDTH); |
| 105 | + const x = Math.random() * (CANVAS_WIDTH - width); |
| 106 | + const typeRand = Math.random(); |
| 107 | + let type = "normal"; |
| 108 | + if (typeRand < 0.12) type = "break"; |
| 109 | + else if (typeRand < 0.32) type = "moving"; |
| 110 | + platforms.push(createPlatform(x, lastY, width, type)); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + function resetGame() { |
| 115 | + cameraY = 0; |
| 116 | + score = 0; |
| 117 | + time = 0; |
| 118 | + keys.left = false; |
| 119 | + keys.right = false; |
| 120 | + if (btnLeft) btnLeft.classList.remove("active"); |
| 121 | + if (btnRight) btnRight.classList.remove("active"); |
| 122 | + initPlatforms(); |
| 123 | + const firstPlatform = platforms[0]; |
| 124 | + player = { |
| 125 | + x: (CANVAS_WIDTH - PLAYER_WIDTH) / 2, |
| 126 | + y: firstPlatform.y - PLAYER_HEIGHT - 2, |
| 127 | + vx: 0, |
| 128 | + vy: 0, |
| 129 | + width: PLAYER_WIDTH, |
| 130 | + height: PLAYER_HEIGHT, |
| 131 | + }; |
| 132 | + startCameraY = player.y - CANVAS_HEIGHT * CAMERA_LEAD; |
| 133 | + gameRunning = true; |
| 134 | + scoreEl.textContent = "0"; |
| 135 | + highScoreEl.textContent = highScore; |
| 136 | + } |
| 137 | + |
| 138 | + function drawPlayer(screenY) { |
| 139 | + const x = player.x; |
| 140 | + const y = player.y - cameraY; |
| 141 | + if (y < -PLAYER_HEIGHT - 20 || y > CANVAS_HEIGHT + 20) return; |
| 142 | + |
| 143 | + ctx.save(); |
| 144 | + ctx.translate(x + PLAYER_WIDTH / 2, y + PLAYER_HEIGHT / 2); |
| 145 | + if (keys.left) ctx.scale(-1, 1); |
| 146 | + ctx.translate(-(x + PLAYER_WIDTH / 2), -(y + PLAYER_HEIGHT / 2)); |
| 147 | + |
| 148 | + ctx.fillStyle = "#2d3436"; |
| 149 | + ctx.beginPath(); |
| 150 | + ctx.roundRect(x, y, PLAYER_WIDTH, PLAYER_HEIGHT, 8); |
| 151 | + ctx.fill(); |
| 152 | + |
| 153 | + ctx.fillStyle = "#fff"; |
| 154 | + ctx.beginPath(); |
| 155 | + ctx.arc(x + 12, y + 14, 6, 0, Math.PI * 2); |
| 156 | + ctx.arc(x + PLAYER_WIDTH - 12, y + 14, 6, 0, Math.PI * 2); |
| 157 | + ctx.fill(); |
| 158 | + |
| 159 | + ctx.fillStyle = "#2d3436"; |
| 160 | + ctx.beginPath(); |
| 161 | + ctx.arc(x + 12, y + 14, 3, 0, Math.PI * 2); |
| 162 | + ctx.arc(x + PLAYER_WIDTH - 12, y + 14, 3, 0, Math.PI * 2); |
| 163 | + ctx.fill(); |
| 164 | + |
| 165 | + ctx.restore(); |
| 166 | + } |
| 167 | + |
| 168 | + function drawPlatform(p) { |
| 169 | + const y = p.y - cameraY; |
| 170 | + if (y < -PLATFORM_HEIGHT - 20 || y > CANVAS_HEIGHT + 50) return; |
| 171 | + |
| 172 | + const x = p.x; |
| 173 | + const w = p.width; |
| 174 | + const h = p.height; |
| 175 | + |
| 176 | + if (p.type === "normal") { |
| 177 | + ctx.fillStyle = "#6bcb77"; |
| 178 | + ctx.strokeStyle = "#4ade80"; |
| 179 | + } else if (p.type === "break") { |
| 180 | + ctx.fillStyle = "#c9a959"; |
| 181 | + ctx.strokeStyle = "#b8860b"; |
| 182 | + } else { |
| 183 | + ctx.fillStyle = "#4d96ff"; |
| 184 | + ctx.strokeStyle = "#6eb5ff"; |
| 185 | + } |
| 186 | + |
| 187 | + ctx.lineWidth = 2; |
| 188 | + ctx.beginPath(); |
| 189 | + ctx.roundRect(x, y, w, h, 6); |
| 190 | + ctx.fill(); |
| 191 | + ctx.stroke(); |
| 192 | + } |
| 193 | + |
| 194 | + function gameOver() { |
| 195 | + gameRunning = false; |
| 196 | + if (animationId) cancelAnimationFrame(animationId); |
| 197 | + finalScoreEl.textContent = score; |
| 198 | + gameOverOverlay.classList.remove("hidden"); |
| 199 | + } |
| 200 | + |
| 201 | + function gameLoop() { |
| 202 | + if (!gameRunning) return; |
| 203 | + |
| 204 | + time++; |
| 205 | + const dt = 1; |
| 206 | + |
| 207 | + if (keys.left) player.vx = -MOVE_SPEED; |
| 208 | + else if (keys.right) player.vx = MOVE_SPEED; |
| 209 | + else player.vx *= 0.85; |
| 210 | + player.x += player.vx; |
| 211 | + player.x = Math.max(0, Math.min(CANVAS_WIDTH - player.width, player.x)); |
| 212 | + player.vy += GRAVITY; |
| 213 | + player.y += player.vy; |
| 214 | + |
| 215 | + for (let i = platforms.length - 1; i >= 0; i--) { |
| 216 | + const p = platforms[i]; |
| 217 | + if (p.type === "moving") { |
| 218 | + p.x = p.startX + Math.sin((time + p.startX) * 0.03) * p.moveRange * p.moveDir; |
| 219 | + p.x = Math.max(0, Math.min(CANVAS_WIDTH - p.width, p.x)); |
| 220 | + } |
| 221 | + |
| 222 | + const py = p.y - cameraY; |
| 223 | + if (py > CANVAS_HEIGHT + 100) { |
| 224 | + platforms.splice(i, 1); |
| 225 | + continue; |
| 226 | + } |
| 227 | + |
| 228 | + const playerBottom = player.y + player.height; |
| 229 | + const platformTop = p.y; |
| 230 | + const overlapX = |
| 231 | + player.x + player.width > p.x && player.x < p.x + p.width; |
| 232 | + if ( |
| 233 | + overlapX && |
| 234 | + playerBottom >= platformTop - 2 && |
| 235 | + playerBottom <= platformTop + 12 && |
| 236 | + player.vy >= 0 |
| 237 | + ) { |
| 238 | + player.vy = JUMP_FORCE; |
| 239 | + player.y = platformTop - player.height - 1; |
| 240 | + if (p.type === "break") platforms.splice(i, 1); |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + const targetCameraY = player.y - CANVAS_HEIGHT * CAMERA_LEAD; |
| 245 | + if (targetCameraY < cameraY) { |
| 246 | + cameraY = targetCameraY; |
| 247 | + const newScore = Math.max(0, Math.floor((startCameraY - cameraY) / 8)); |
| 248 | + if (newScore > score) { |
| 249 | + score = newScore; |
| 250 | + scoreEl.textContent = score; |
| 251 | + if (score > highScore) { |
| 252 | + highScore = score; |
| 253 | + highScoreEl.textContent = highScore; |
| 254 | + localStorage.setItem("doodle-high-score", String(highScore)); |
| 255 | + } |
| 256 | + } |
| 257 | + addPlatformsAbove(cameraY); |
| 258 | + } |
| 259 | + |
| 260 | + if (player.y - cameraY > CANVAS_HEIGHT + 50) { |
| 261 | + gameOver(); |
| 262 | + return; |
| 263 | + } |
| 264 | + |
| 265 | + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); |
| 266 | + |
| 267 | + platforms.forEach(drawPlatform); |
| 268 | + drawPlayer(); |
| 269 | + |
| 270 | + animationId = requestAnimationFrame(gameLoop); |
| 271 | + } |
| 272 | + |
| 273 | + function startGame() { |
| 274 | + startOverlay.classList.add("hidden"); |
| 275 | + gameOverOverlay.classList.add("hidden"); |
| 276 | + resetGame(); |
| 277 | + gameLoop(); |
| 278 | + } |
| 279 | + |
| 280 | + startBtn.addEventListener("click", startGame); |
| 281 | + restartBtn.addEventListener("click", startGame); |
| 282 | + |
| 283 | + document.addEventListener("keydown", function (e) { |
| 284 | + if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") keys.left = true; |
| 285 | + if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") keys.right = true; |
| 286 | + if (e.key === " ") e.preventDefault(); |
| 287 | + }); |
| 288 | + |
| 289 | + document.addEventListener("keyup", function (e) { |
| 290 | + if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") keys.left = false; |
| 291 | + if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") keys.right = false; |
| 292 | + }); |
| 293 | + |
| 294 | + function setKeyLeft(value) { |
| 295 | + keys.left = value; |
| 296 | + if (btnLeft) btnLeft.classList.toggle("active", value); |
| 297 | + } |
| 298 | + function setKeyRight(value) { |
| 299 | + keys.right = value; |
| 300 | + if (btnRight) btnRight.classList.toggle("active", value); |
| 301 | + } |
| 302 | + if (btnLeft) { |
| 303 | + btnLeft.addEventListener("pointerdown", function (e) { |
| 304 | + e.preventDefault(); |
| 305 | + setKeyLeft(true); |
| 306 | + }); |
| 307 | + btnLeft.addEventListener("pointerup", function () { setKeyLeft(false); }); |
| 308 | + btnLeft.addEventListener("pointerleave", function () { setKeyLeft(false); }); |
| 309 | + } |
| 310 | + if (btnRight) { |
| 311 | + btnRight.addEventListener("pointerdown", function (e) { |
| 312 | + e.preventDefault(); |
| 313 | + setKeyRight(true); |
| 314 | + }); |
| 315 | + btnRight.addEventListener("pointerup", function () { setKeyRight(false); }); |
| 316 | + btnRight.addEventListener("pointerleave", function () { setKeyRight(false); }); |
| 317 | + } |
| 318 | + |
| 319 | + canvas.addEventListener("click", function () { |
| 320 | + if (gameRunning) return; |
| 321 | + if (!startOverlay.classList.contains("hidden")) startGame(); |
| 322 | + }); |
| 323 | + |
| 324 | + window.addEventListener("resize", setPixelRatio); |
| 325 | + setPixelRatio(); |
| 326 | + highScoreEl.textContent = highScore; |
| 327 | +})(); |
0 commit comments