Created
March 25, 2025 22:38
-
-
Save gary149/3954c94c8d6024efe4fab3471c12a877 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Super Platformer Adventure</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| height: 100vh; | |
| background-color: #5c94fc; /* Bright Sky blue background */ | |
| font-family: 'Arial', sans-serif; | |
| color: white; | |
| overflow: hidden; /* Prevent scrollbars */ | |
| } | |
| #gameContainer { | |
| position: relative; | |
| width: 800px; /* Fixed width for the game area */ | |
| height: 480px; /* Fixed height */ | |
| overflow: hidden; /* Ensure canvas stays within container */ | |
| box-shadow: 0 0 10px rgba(0,0,0,0.5); | |
| border: 3px solid black; | |
| } | |
| #gameCanvas { | |
| display: block; /* Remove potential extra space below canvas */ | |
| background-color: #5c94fc; /* Match body background */ | |
| } | |
| #controlsInfo { | |
| margin-top: 10px; | |
| font-size: 0.9em; | |
| text-align: center; | |
| background-color: rgba(0,0,0,0.3); | |
| padding: 5px; | |
| border-radius: 3px; | |
| max-width: 800px; | |
| } | |
| #status { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| font-size: 1.5em; | |
| font-weight: bold; | |
| text-shadow: 2px 2px 3px rgba(0,0,0,0.7); | |
| z-index: 10; /* Ensure status is above canvas */ | |
| } | |
| /* Basic responsiveness */ | |
| @media (max-width: 820px) { | |
| #gameContainer { | |
| width: 100%; | |
| height: 60vw; /* Adjust height based on width */ | |
| max-height: 100vh; /* Prevent excessive height */ | |
| border: none; | |
| box-shadow: none; | |
| } | |
| #status { | |
| font-size: 1.2em; | |
| top: 5px; | |
| left: 5px; | |
| } | |
| #controlsInfo { | |
| font-size: 0.8em; | |
| width: 95%; | |
| } | |
| } | |
| @media (orientation: portrait) and (max-height: 500px) { | |
| #gameContainer { | |
| height: 80vh; /* Adjust height for short landscape */ | |
| } | |
| #controlsInfo { display: none; } /* Hide controls on very small screens */ | |
| } | |
| /* Style for virtual buttons on mobile */ | |
| .touch-controls { | |
| display: none; /* Hidden by default */ | |
| position: fixed; | |
| bottom: 10px; | |
| width: 100%; | |
| padding: 0 10px; | |
| box-sizing: border-box; | |
| z-index: 20; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .touch-controls button { | |
| background-color: rgba(255, 255, 255, 0.5); | |
| border: 2px solid rgba(0, 0, 0, 0.5); | |
| border-radius: 50%; | |
| width: 60px; | |
| height: 60px; | |
| font-size: 24px; | |
| font-weight: bold; | |
| color: rgba(0,0,0,0.7); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| user-select: none; /* Prevent text selection */ | |
| -webkit-user-select: none; /* iOS Safari */ | |
| -webkit-tap-highlight-color: transparent; /* Remove tap highlight */ | |
| } | |
| .touch-controls .move { | |
| display: flex; | |
| gap: 20px; | |
| } | |
| @media (hover: none) and (pointer: coarse) { | |
| .touch-controls { | |
| display: flex; /* Show on touch devices */ | |
| } | |
| #controlsInfo { | |
| display: none; /* Hide text controls info */ | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="gameContainer"> | |
| <div id="status">Lives: 3 | Score: 0</div> | |
| <canvas id="gameCanvas"></canvas> | |
| </div> | |
| <div id="controlsInfo"> | |
| Use Arrow Keys (Left/Right) to Move, Arrow Up to Jump. Stomp on enemies! | |
| </div> | |
| <!-- Virtual Controls for Touch --> | |
| <div class="touch-controls"> | |
| <div class="move"> | |
| <button id="btnLeft">β</button> | |
| <button id="btnRight">βΆ</button> | |
| </div> | |
| <button id="btnUp">β²</button> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const statusDisplay = document.getElementById('status'); | |
| const gameContainer = document.getElementById('gameContainer'); | |
| // Game constants | |
| const GRAVITY = 0.6; | |
| const TILE_SIZE = 32; | |
| const PLAYER_SPEED = 4; | |
| const JUMP_FORCE = 13; | |
| const ENEMY_SPEED = 0.8; | |
| const PLAYER_ANIM_SPEED = 4; // Lower number = faster animation | |
| const ENEMY_ANIM_SPEED = 10; | |
| const CLOUD_SPEED_FACTOR = 0.3; // How much slower clouds move than camera | |
| // Game state | |
| let lives = 3; | |
| let score = 0; | |
| let keys = { left: false, right: false, up: false }; | |
| let player; | |
| let enemies = []; | |
| let clouds = []; | |
| let levelData; // Will be populated in init | |
| let cameraX = 0; | |
| let gameOver = false; | |
| let gameWon = false; | |
| let levelWidth; | |
| let levelHeight; | |
| let frameCount = 0; // For animation timing | |
| // --- ASSET SIMULATION using Canvas Drawing --- | |
| // This section simulates sprites. In a real game, you'd load images. | |
| const Sprites = { | |
| drawMarioLike(ctx, x, y, w, h, state, frame, facingRight) { | |
| ctx.save(); | |
| ctx.translate(x + w / 2, y + h / 2); | |
| if (!facingRight) { | |
| ctx.scale(-1, 1); // Flip horizontally if facing left | |
| } | |
| ctx.translate(-w / 2, -h / 2); | |
| // Body (Red) | |
| ctx.fillStyle = '#E60012'; | |
| ctx.fillRect(w * 0.15, h * 0.4, w * 0.7, h * 0.5); | |
| // Overalls (Blue) | |
| ctx.fillStyle = '#0078F8'; | |
| ctx.fillRect(w * 0.2, h * 0.55, w * 0.6, h * 0.35); // Main part | |
| ctx.fillRect(w * 0.1, h * 0.45, w * 0.2, h * 0.1); // Strap Left | |
| ctx.fillRect(w * 0.7, h * 0.45, w * 0.2, h * 0.1); // Strap Right | |
| // Head (Skin tone) | |
| ctx.fillStyle = '#FFDCB8'; | |
| ctx.fillRect(w * 0.25, h * 0.1, w * 0.5, h * 0.3); | |
| // Hat (Red) | |
| ctx.fillStyle = '#E60012'; | |
| ctx.fillRect(w * 0.2, 0, w * 0.6, h * 0.2); | |
| // Boots (Brown) | |
| ctx.fillStyle = '#8B4513'; | |
| ctx.fillRect(0, h * 0.9, w * 0.4, h * 0.1); | |
| ctx.fillRect(w * 0.6, h * 0.9, w * 0.4, h * 0.1); | |
| // Simple Animation (Legs moving when running) | |
| if (state === 'run' && frame % (PLAYER_ANIM_SPEED * 2) < PLAYER_ANIM_SPEED) { | |
| ctx.fillRect(0, h * 0.8, w * 0.4, h * 0.1); // Left leg forward | |
| } else if (state === 'run') { | |
| ctx.fillRect(w * 0.6, h * 0.8, w * 0.4, h * 0.1); // Right leg forward | |
| } | |
| // Eye (Simple) | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(w * 0.6, h * 0.2, w * 0.1, h * 0.1); | |
| ctx.restore(); | |
| }, | |
| drawGoombaLike(ctx, x, y, w, h, alive, frame) { | |
| if (!alive) { // Squashed | |
| ctx.fillStyle = '#8B4513'; // Darker brown | |
| ctx.fillRect(x, y + h * 0.6, w, h * 0.4); // Flat shape | |
| ctx.fillStyle = '#A0522D'; // Lighter top | |
| ctx.fillRect(x, y + h * 0.6, w, h * 0.1); | |
| } else { | |
| // Body (Brown) | |
| ctx.fillStyle = '#996633'; // Goomba brown | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w * 0.1, y + h); // Bottom left | |
| ctx.lineTo(x, y + h * 0.5); // Mid left | |
| ctx.quadraticCurveTo(x + w / 2, y - h * 0.1, x + w, y + h * 0.5); // Top curve | |
| ctx.lineTo(x + w * 0.9, y + h); // Bottom right | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Feet (Darker Brown) - Animated slightly | |
| const feetFrame = Math.floor(frame / ENEMY_ANIM_SPEED) % 2; | |
| const footOffset = feetFrame === 0 ? w * 0.1 : w * 0.3; | |
| ctx.fillStyle = '#663300'; | |
| ctx.fillRect(x + footOffset, y + h * 0.9, w * 0.2, h * 0.15); | |
| ctx.fillRect(x + w - footOffset - w * 0.2, y + h * 0.9, w * 0.2, h * 0.15); | |
| // Eyes (Angry) | |
| ctx.fillStyle = 'white'; | |
| ctx.fillRect(x + w * 0.2, y + h * 0.3, w * 0.2, h * 0.15); // Left white | |
| ctx.fillRect(x + w * 0.6, y + h * 0.3, w * 0.2, h * 0.15); // Right white | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(x + w * 0.25, y + h * 0.32, w * 0.1, h * 0.1); // Left pupil | |
| ctx.fillRect(x + w * 0.65, y + h * 0.32, w * 0.1, h * 0.1); // Right pupil | |
| // Eyebrows | |
| ctx.fillRect(x + w * 0.15, y + h * 0.25, w * 0.3, h * 0.05); | |
| ctx.fillRect(x + w * 0.55, y + h * 0.25, w * 0.3, h * 0.05); | |
| } | |
| }, | |
| drawGround(ctx, x, y, w, h) { | |
| ctx.fillStyle = '#D2691E'; // Dirt brown | |
| ctx.fillRect(x, y, w, h); | |
| ctx.fillStyle = '#228B22'; // Grass green top | |
| ctx.fillRect(x, y, w, h * 0.3); | |
| // Add subtle texture | |
| ctx.fillStyle = 'rgba(0,0,0,0.1)'; | |
| for (let i = 0; i < 3; i++) { | |
| ctx.fillRect(x + Math.random() * w * 0.8, y + h * 0.3 + Math.random() * h * 0.6, w * 0.1, h * 0.1); | |
| } | |
| }, | |
| drawBrick(ctx, x, y, w, h) { | |
| ctx.fillStyle = '#B22222'; // Brick red | |
| ctx.fillRect(x, y, w, h); | |
| ctx.strokeStyle = '#8B0000'; // Darker mortar lines | |
| ctx.lineWidth = 2; | |
| // Horizontal lines | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y + h / 2); | |
| ctx.lineTo(x + w, y + h / 2); | |
| // Vertical lines (offset) | |
| ctx.moveTo(x + w / 2, y); | |
| ctx.lineTo(x + w / 2, y + h / 2); | |
| ctx.moveTo(x + w / 4, y + h / 2); | |
| ctx.lineTo(x + w / 4, y + h); | |
| ctx.moveTo(x + w * 3/4, y + h / 2); | |
| ctx.lineTo(x + w * 3/4, y + h); | |
| ctx.stroke(); | |
| }, | |
| drawPipe(ctx, x, y, w, h, type) { | |
| ctx.fillStyle = '#00A800'; // Pipe green | |
| ctx.fillRect(x, y, w, h); | |
| // Add highlight/shadow for 3D effect | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; // Highlight | |
| ctx.fillRect(x + w * 0.1, y, w * 0.3, h); | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; // Shadow | |
| ctx.fillRect(x + w * 0.6, y, w * 0.3, h); | |
| // Pipe top has a rim | |
| if (type === 4 || type === 5) { // Pipe Top Left or Right | |
| ctx.fillStyle = '#008000'; // Darker green rim base | |
| ctx.fillRect(x, y, w, h * 0.3); | |
| ctx.fillStyle = '#00A800'; // Main color rim top | |
| ctx.fillRect(x, y, w, h * 0.2); | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; // Highlight on rim | |
| ctx.fillRect(x + w * 0.1, y, w * 0.8, h * 0.1); | |
| } | |
| // Add border lines | |
| ctx.strokeStyle = '#006400'; // Dark green border | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(x, y, w, h); | |
| }, | |
| drawCloud(ctx, x, y, w, h) { | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; // Semi-transparent white | |
| ctx.beginPath(); | |
| const step = w / 5; | |
| ctx.moveTo(x, y + h); | |
| ctx.quadraticCurveTo(x + step * 0.5, y + h * 0.4, x + step * 1.5, y + h * 0.6); | |
| ctx.quadraticCurveTo(x + step * 2.5, y + h * 0.1, x + step * 3.5, y + h * 0.5); | |
| ctx.quadraticCurveTo(x + step * 4.5, y + h * 0.3, x + step * 5, y + h * 0.8); | |
| ctx.lineTo(x+w, y+h); // Flat bottom-ish | |
| ctx.closePath(); | |
| ctx.fill(); | |
| }, | |
| drawGoal(ctx, x, y, w, h) { | |
| // Pole | |
| ctx.fillStyle = '#C0C0C0'; // Silver pole | |
| ctx.fillRect(x + w * 0.45, y, w * 0.1, h); | |
| // Flag | |
| ctx.fillStyle = '#00A800'; // Green flag | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w * 0.5, y + h * 0.1); | |
| ctx.lineTo(x + w * 0.9, y + h * 0.3); | |
| ctx.lineTo(x + w * 0.5, y + h * 0.5); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| }; | |
| // Level Definition | |
| // 0: empty, 1: ground, 2: brick, 3: goal | |
| // 4: pipe top-left, 5: pipe top-right, 6: pipe stem-left, 7: pipe stem-right | |
| const levelLayout = [ | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], | |
| [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] | |
| ]; | |
| function isSolidTile(tileType) { | |
| return tileType === 1 || tileType === 2 || tileType >= 4; // Ground, Brick, Pipes are solid | |
| } | |
| function resizeCanvas() { | |
| const containerWidth = gameContainer.clientWidth; | |
| // Maintain aspect ratio (e.g., 800 / 480 = 5/3) | |
| const containerHeight = containerWidth * (480 / 800); | |
| gameContainer.style.height = `${containerHeight}px`; | |
| canvas.width = containerWidth; | |
| canvas.height = containerHeight; | |
| // Optional: Adjust TILE_SIZE or redraw based on new size if needed | |
| // For simplicity, we keep TILE_SIZE constant and scale rendering if necessary | |
| // Or just let the larger container show more of the world. | |
| // We'll keep TILE_SIZE fixed relative to the 800x480 design. | |
| // Scaling factor might be needed for drawing if we wanted elements to resize. | |
| } | |
| function initGame() { | |
| resizeCanvas(); // Initial sizing | |
| levelData = levelLayout; // Use the defined layout | |
| levelHeight = TILE_SIZE * levelData.length; | |
| levelWidth = TILE_SIZE * levelData[0].length; | |
| player = { | |
| x: TILE_SIZE * 2, | |
| y: levelHeight - TILE_SIZE * 5, // Start higher up | |
| width: TILE_SIZE * 0.8, | |
| height: TILE_SIZE * 0.95, | |
| vx: 0, // velocity x | |
| vy: 0, // velocity y | |
| onGround: false, | |
| facingRight: true, | |
| animationState: 'idle', // 'idle', 'run', 'jump' | |
| animationFrame: 0 | |
| }; | |
| enemies = [ | |
| // Type 'goomba' added for potential sprite switching later | |
| { type: 'goomba', x: TILE_SIZE * 10, y: levelHeight - TILE_SIZE * 2, width: TILE_SIZE * 0.9, height: TILE_SIZE * 0.9, vx: -ENEMY_SPEED, vy: 0, alive: true, animationFrame: 0 }, | |
| { type: 'goomba', x: TILE_SIZE * 25, y: levelHeight - TILE_SIZE * 2, width: TILE_SIZE * 0.9, height: TILE_SIZE * 0.9, vx: -ENEMY_SPEED, vy: 0, alive: true, animationFrame: 0 }, | |
| { type: 'goomba', x: TILE_SIZE * 46, y: levelHeight - TILE_SIZE * 5, width: TILE_SIZE * 0.9, height: TILE_SIZE * 0.9, vx: -ENEMY_SPEED, vy: 0, alive: true, animationFrame: 0 }, | |
| { type: 'goomba', x: TILE_SIZE * 55, y: levelHeight - TILE_SIZE * 2, width: TILE_SIZE * 0.9, height: TILE_SIZE * 0.9, vx: ENEMY_SPEED, vy: 0, alive: true, animationFrame: 0 }, | |
| ]; | |
| // Initialize Clouds | |
| clouds = []; | |
| for (let i = 0; i < 15; i++) { | |
| clouds.push({ | |
| x: Math.random() * levelWidth * 1.5, // Spread clouds wider than level initially | |
| y: Math.random() * levelHeight * 0.4 + TILE_SIZE, // Clouds in upper part | |
| width: TILE_SIZE * (Math.random() * 3 + 2), // Varying sizes | |
| height: TILE_SIZE * (Math.random() * 1 + 1), | |
| speed: (Math.random() * 0.3 + 0.1) * CLOUD_SPEED_FACTOR // Varying speeds | |
| }); | |
| } | |
| lives = 3; | |
| score = 0; | |
| gameOver = false; | |
| gameWon = false; | |
| cameraX = 0; | |
| frameCount = 0; | |
| updateStatus(); | |
| // Ensure game loop doesn't start multiple times if init is called again | |
| if (!gameLoop._isRunning) { | |
| gameLoop(); | |
| } | |
| } | |
| gameLoop._isRunning = false; // Add a flag to the function object | |
| function updateStatus() { | |
| statusDisplay.textContent = `Lives: ${lives} | Score: ${score}`; | |
| } | |
| function handleInput() { | |
| player.vx = 0; | |
| let running = false; | |
| if (keys.left) { | |
| player.vx = -PLAYER_SPEED; | |
| player.facingRight = false; | |
| running = true; | |
| } | |
| if (keys.right) { | |
| player.vx = PLAYER_SPEED; | |
| player.facingRight = true; | |
| running = true; | |
| } | |
| // Jump | |
| if (keys.up && player.onGround) { | |
| player.vy = -JUMP_FORCE; | |
| player.onGround = false; | |
| } | |
| // Update animation state | |
| if (!player.onGround) { | |
| player.animationState = 'jump'; | |
| } else if (running) { | |
| player.animationState = 'run'; | |
| } else { | |
| player.animationState = 'idle'; | |
| } | |
| } | |
| function checkCollision(rect1, rect2) { | |
| return rect1.x < rect2.x + rect2.width && | |
| rect1.x + rect1.width > rect2.x && | |
| rect1.y < rect2.y + rect2.height && | |
| rect1.y + rect1.height > rect2.y; | |
| } | |
| function resolveTileCollision(entity, tileX, tileY, tileType) { | |
| const tile = { | |
| x: tileX * TILE_SIZE, | |
| y: tileY * TILE_SIZE, | |
| width: TILE_SIZE, | |
| height: TILE_SIZE | |
| }; | |
| if (!checkCollision(entity, tile)) return { collided: false }; | |
| // Determine collision side | |
| const overlapX = (entity.x + entity.width / 2) - (tile.x + tile.width / 2); | |
| const overlapY = (entity.y + entity.height / 2) - (tile.y + tile.height / 2); | |
| const combinedHalfWidths = (entity.width + tile.width) / 2; | |
| const combinedHalfHeights = (entity.height + tile.height) / 2; | |
| let collisionSide = null; // 'top', 'bottom', 'left', 'right' (relative to the entity) | |
| // Use penetration depth to determine primary collision axis | |
| const penetrationX = combinedHalfWidths - Math.abs(overlapX); | |
| const penetrationY = combinedHalfHeights - Math.abs(overlapY); | |
| if (penetrationY < penetrationX) { | |
| // Vertical collision is primary | |
| if (overlapY > 0) { // Entity center is below tile center -> hit from top (landed) | |
| entity.y = tile.y + tile.height; // Snap entity top to tile bottom | |
| if(entity.vy < 0) entity.vy = 0; // Stop upward movement if hitting bottom of tile | |
| collisionSide = 'top'; | |
| } else { // Entity center is above tile center -> hit from bottom (landed) | |
| entity.y = tile.y - entity.height; // Snap entity bottom to tile top | |
| if(entity.vy > 0) { | |
| entity.vy = 0; // Stop downward movement | |
| entity.onGround = true; // Landed | |
| } | |
| collisionSide = 'bottom'; | |
| } | |
| } else { | |
| // Horizontal collision is primary | |
| if (overlapX > 0) { // Entity center is right of tile center -> hit from left | |
| entity.x = tile.x + tile.width; // Snap entity left to tile right | |
| if (entity.vx < 0) entity.vx = 0; | |
| collisionSide = 'left'; | |
| } else { // Entity center is left of tile center -> hit from right | |
| entity.x = tile.x - entity.width; // Snap entity right to tile left | |
| if (entity.vx > 0) entity.vx = 0; | |
| collisionSide = 'right'; | |
| } | |
| // Reverse enemy direction on horizontal wall hit | |
| if (entity !== player) entity.vx *= -1; | |
| } | |
| return { collided: true, side: collisionSide, tileType: tileType }; | |
| } | |
| function updateEntity(entity) { | |
| // Apply gravity | |
| entity.vy += GRAVITY; | |
| // --- Horizontal Movement and Collision --- | |
| entity.x += entity.vx; | |
| let startCol = Math.floor(entity.x / TILE_SIZE); | |
| let endCol = Math.floor((entity.x + entity.width) / TILE_SIZE); | |
| let startRow = Math.floor(entity.y / TILE_SIZE); | |
| let endRow = Math.floor((entity.y + entity.height) / TILE_SIZE); | |
| for (let r = startRow; r <= endRow; r++) { | |
| for (let c = startCol; c <= endCol; c++) { | |
| if (r >= 0 && r < levelData.length && c >= 0 && c < levelData[0].length) { | |
| const tileType = levelData[r][c]; | |
| if (isSolidTile(tileType)) { | |
| resolveTileCollision(entity, c, r, tileType); | |
| } else if (tileType === 3 && entity === player) { // Goal check for player only | |
| const goalTile = { x: c * TILE_SIZE, y: r * TILE_SIZE, width: TILE_SIZE, height: TILE_SIZE }; | |
| if(checkCollision(player, goalTile)) gameWon = true; | |
| } | |
| } | |
| } | |
| } | |
| // --- Vertical Movement and Collision --- | |
| // Assume not on ground before vertical check | |
| if (entity === player) entity.onGround = false; | |
| entity.y += entity.vy; | |
| startCol = Math.floor(entity.x / TILE_SIZE); | |
| endCol = Math.floor((entity.x + entity.width) / TILE_SIZE); | |
| startRow = Math.floor(entity.y / TILE_SIZE); | |
| endRow = Math.floor((entity.y + entity.height) / TILE_SIZE); | |
| for (let r = startRow; r <= endRow; r++) { | |
| for (let c = startCol; c <= endCol; c++) { | |
| if (r >= 0 && r < levelData.length && c >= 0 && c < levelData[0].length) { | |
| const tileType = levelData[r][c]; | |
| if (isSolidTile(tileType)) { | |
| resolveTileCollision(entity, c, r, tileType); | |
| } else if (tileType === 3 && entity === player) { // Goal check again | |
| const goalTile = { x: c * TILE_SIZE, y: r * TILE_SIZE, width: TILE_SIZE, height: TILE_SIZE }; | |
| if(checkCollision(player, goalTile)) gameWon = true; | |
| } | |
| } | |
| } | |
| } | |
| // --- Boundary Checks --- | |
| // Prevent falling through explicit floor (less critical if level bottom is solid) | |
| if (entity.y + entity.height > levelHeight) { | |
| entity.y = levelHeight - entity.height; | |
| entity.vy = 0; | |
| if (entity === player) { | |
| entity.onGround = true; | |
| // playerDied(); // Optional: Fall out of world if bottom isn't solid | |
| } | |
| } | |
| // Fall out of world check (if bottom is open) | |
| if (entity.y > levelHeight + TILE_SIZE * 2) { | |
| if (entity === player) { | |
| playerDied(); | |
| } else { | |
| entity.alive = false; // Enemy fell out | |
| } | |
| } | |
| // Prevent going off left edge | |
| if (entity.x < 0) { | |
| entity.x = 0; | |
| if (entity === player) entity.vx = 0; | |
| else entity.vx *= -1; // Enemy turns around | |
| } | |
| // Prevent going too far right (optional, camera handles player) | |
| // if (entity.x + entity.width > levelWidth) { | |
| // entity.x = levelWidth - entity.width; | |
| // if (entity === player) entity.vx = 0; | |
| // else entity.vx *= -1; | |
| // } | |
| // --- Enemy Specific Logic --- | |
| if (entity !== player && entity.alive) { | |
| // Simple Edge Detection: Check if tile below and ahead is empty | |
| let checkTileX = (entity.vx > 0) ? Math.floor((entity.x + entity.width) / TILE_SIZE) : Math.floor(entity.x / TILE_SIZE); | |
| let checkAheadX = (entity.vx > 0) ? Math.floor((entity.x + entity.width + 1) / TILE_SIZE) : Math.floor((entity.x - 1) / TILE_SIZE); | |
| let checkBelowY = Math.floor((entity.y + entity.height + 1) / TILE_SIZE); // Tile just below enemy feet | |
| if (checkBelowY >= 0 && checkBelowY < levelData.length && checkAheadX >= 0 && checkAheadX < levelData[0].length) { | |
| const aheadTileBelowType = levelData[checkBelowY][checkAheadX]; | |
| if (!isSolidTile(aheadTileBelowType)) { // If there's no ground ahead | |
| entity.vx *= -1; // Turn around | |
| // Nudge slightly to prevent getting stuck | |
| entity.x += entity.vx > 0 ? 1 : -1; | |
| } | |
| } else { | |
| // Turn around if near level boundary (or calculation goes out of bounds) | |
| entity.vx *= -1; | |
| entity.x += entity.vx > 0 ? 1 : -1; | |
| } | |
| entity.animationFrame++; // Update enemy animation frame | |
| } | |
| } | |
| function updatePlayerSpecific() { | |
| updateEntity(player); // Use the common update logic | |
| player.animationFrame++; // Update player animation frame | |
| // Check collisions with enemies | |
| enemies.forEach((enemy) => { | |
| if (!enemy.alive) return; | |
| if (checkCollision(player, enemy)) { | |
| // Check for stomp (player falling, slightly above enemy midsection) | |
| if (player.vy > 0 && (player.y + player.height) < (enemy.y + enemy.height * 0.5)) { | |
| // Stomp successful | |
| enemy.alive = false; // Mark as squashed | |
| enemy.vx = 0; // Stop moving | |
| player.vy = -JUMP_FORCE / 1.5; // Small bounce | |
| score += 100; | |
| updateStatus(); | |
| // Optional: remove enemy visually after a delay? No, just draw squashed. | |
| } else { | |
| // Player hit enemy from side or bottom - player gets hurt | |
| playerDied(); | |
| } | |
| } | |
| }); | |
| } | |
| function updateEnemies() { | |
| enemies.forEach(enemy => { | |
| if (enemy.alive) { | |
| updateEntity(enemy); // Use the common update logic | |
| } | |
| // Note: Enemy<->Enemy collision is not handled | |
| }); | |
| } | |
| function updateClouds() { | |
| clouds.forEach(cloud => { | |
| cloud.x -= cloud.speed; // Move clouds slowly left (relative to world) | |
| // Wrap clouds around | |
| if (cloud.x + cloud.width < 0) { | |
| cloud.x = levelWidth + Math.random() * 100; // Reappear on the right | |
| cloud.y = Math.random() * levelHeight * 0.4 + TILE_SIZE; // Randomize height slightly | |
| } | |
| }); | |
| } | |
| function playerDied() { | |
| if (gameOver || gameWon) return; // Prevent multiple triggers in one frame | |
| // Add brief visual feedback (e.g., flash player color - omitted for brevity) | |
| lives--; | |
| updateStatus(); | |
| if (lives <= 0) { | |
| gameOver = true; | |
| } else { | |
| // Reset player position (simple respawn) with slight delay/invincibility (conceptual) | |
| // For now, just reset instantly | |
| player.x = TILE_SIZE * 2; | |
| player.y = levelHeight - TILE_SIZE * 5; // Start a bit higher | |
| player.vx = 0; | |
| player.vy = 0; | |
| player.onGround = false; // Need to fall again | |
| // TODO: Add brief invincibility state/timer? | |
| } | |
| } | |
| function updateCamera() { | |
| // Target camera X to center player, clamped within level bounds | |
| const targetCameraX = player.x - canvas.width / 2 + player.width / 2; | |
| // Smooth camera movement (lerp) | |
| const lerpFactor = 0.1; | |
| cameraX += (targetCameraX - cameraX) * lerpFactor; | |
| // Clamp camera position | |
| cameraX = Math.max(0, Math.min(cameraX, levelWidth - canvas.width)); | |
| // Handle edge case where level is narrower than canvas | |
| if (levelWidth < canvas.width) { | |
| cameraX = (levelWidth - canvas.width) / 2; | |
| } | |
| } | |
| function drawBackground() { | |
| // Sky color is set on canvas style, but could draw gradients etc. here | |
| ctx.fillStyle = '#5c94fc'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Draw Clouds with Parallax | |
| clouds.forEach(cloud => { | |
| // Adjust cloud X based on camera movement but slower | |
| const parallaxX = cloud.x - cameraX * CLOUD_SPEED_FACTOR; | |
| // Only draw if visible within the canvas plus some buffer | |
| if (parallaxX + cloud.width > 0 && parallaxX < canvas.width) { | |
| Sprites.drawCloud(ctx, parallaxX, cloud.y, cloud.width, cloud.height); | |
| } | |
| }); | |
| } | |
| function drawLevel() { | |
| const startCol = Math.floor(cameraX / TILE_SIZE); | |
| const endCol = Math.min(startCol + Math.ceil(canvas.width / TILE_SIZE) + 1, levelData[0].length); | |
| const startRow = 0; // Always draw all rows vertically | |
| const endRow = levelData.length; | |
| for (let r = startRow; r < endRow; r++) { | |
| for (let c = startCol; c < endCol; c++) { | |
| const tileType = levelData[r][c]; | |
| if (tileType === 0) continue; // Skip empty tiles | |
| const tileX = Math.floor(c * TILE_SIZE - cameraX); // Use Math.floor for crisp pixels | |
| const tileY = Math.floor(r * TILE_SIZE); | |
| switch (tileType) { | |
| case 1: // Ground | |
| Sprites.drawGround(ctx, tileX, tileY, TILE_SIZE, TILE_SIZE); | |
| break; | |
| case 2: // Brick | |
| Sprites.drawBrick(ctx, tileX, tileY, TILE_SIZE, TILE_SIZE); | |
| break; | |
| case 3: // Goal | |
| Sprites.drawGoal(ctx, tileX, tileY, TILE_SIZE, TILE_SIZE); | |
| break; | |
| case 4: // Pipe Top Left | |
| case 5: // Pipe Top Right | |
| case 6: // Pipe Stem Left | |
| case 7: // Pipe Stem Right | |
| Sprites.drawPipe(ctx, tileX, tileY, TILE_SIZE, TILE_SIZE, tileType); | |
| break; | |
| // Add more cases for other tile types if needed | |
| } | |
| } | |
| } | |
| } | |
| function drawPlayer() { | |
| Sprites.drawMarioLike( | |
| ctx, | |
| Math.floor(player.x - cameraX), // Floor for crispness | |
| Math.floor(player.y), | |
| player.width, | |
| player.height, | |
| player.animationState, | |
| player.animationFrame, | |
| player.facingRight | |
| ); | |
| } | |
| function drawEnemies() { | |
| enemies.forEach(enemy => { | |
| // Only draw if on screen | |
| const screenX = enemy.x - cameraX; | |
| if (screenX + enemy.width > 0 && screenX < canvas.width) { | |
| Sprites.drawGoombaLike( | |
| ctx, | |
| Math.floor(screenX), | |
| Math.floor(enemy.y), | |
| enemy.width, | |
| enemy.height, | |
| enemy.alive, | |
| enemy.animationFrame | |
| ); | |
| } | |
| }); | |
| } | |
| function drawGameOverScreen() { | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.font = `${Math.min(48, canvas.width / 10)}px Arial`; // Responsive font size | |
| ctx.fillStyle = 'red'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('GAME OVER', canvas.width / 2, canvas.height / 2 - 20); | |
| ctx.font = `${Math.min(24, canvas.width / 20)}px Arial`; | |
| ctx.fillStyle = 'white'; | |
| ctx.fillText('Press R or Tap to Restart', canvas.width / 2, canvas.height / 2 + 30); | |
| } | |
| function drawGameWonScreen() { | |
| ctx.fillStyle = 'rgba(0, 80, 0, 0.7)'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.font = `${Math.min(48, canvas.width / 10)}px Arial`; | |
| ctx.fillStyle = 'gold'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2 - 30); | |
| ctx.font = `${Math.min(24, canvas.width / 18)}px Arial`; | |
| ctx.fillStyle = 'white'; | |
| ctx.fillText(`Final Score: ${score}`, canvas.width / 2, canvas.height / 2 + 20); | |
| ctx.font = `${Math.min(20, canvas.width / 25)}px Arial`; | |
| ctx.fillText('Press R or Tap to Restart', canvas.width / 2, canvas.height / 2 + 60); | |
| } | |
| function gameLoop() { | |
| gameLoop._isRunning = true; | |
| frameCount++; | |
| if (gameOver) { | |
| drawBackground(); | |
| drawLevel(); | |
| drawPlayer(); // Draw player in final state | |
| drawEnemies(); // Draw enemies in final state | |
| drawGameOverScreen(); | |
| requestAnimationFrame(gameLoop); // Keep listening for restart | |
| return; | |
| } | |
| if (gameWon) { | |
| drawBackground(); | |
| drawLevel(); | |
| drawPlayer(); | |
| drawEnemies(); | |
| drawGameWonScreen(); | |
| requestAnimationFrame(gameLoop); // Keep listening for restart | |
| return; | |
| } | |
| // 1. Handle Input | |
| handleInput(); | |
| // 2. Update Game State | |
| updatePlayerSpecific(); | |
| updateEnemies(); | |
| updateClouds(); | |
| updateCamera(); | |
| // 3. Render Graphics | |
| drawBackground(); | |
| drawLevel(); | |
| drawEnemies(); | |
| drawPlayer(); // Draw player last so they are on top | |
| // Request next frame | |
| requestAnimationFrame(gameLoop); | |
| } | |
| // --- Event Listeners --- | |
| window.addEventListener('keydown', (e) => { | |
| // Prevent default browser behavior for arrow keys and space | |
| if (['ArrowLeft', 'ArrowRight', 'ArrowUp', ' '].includes(e.key)) { | |
| e.preventDefault(); | |
| } | |
| switch (e.key) { | |
| case 'ArrowLeft': keys.left = true; break; | |
| case 'ArrowRight': keys.right = true; break; | |
| case 'ArrowUp': | |
| case ' ': keys.up = true; break; // Space bar for jump | |
| case 'r': | |
| case 'R': | |
| if (gameOver || gameWon) { | |
| initGame(); // Restart game | |
| } | |
| break; | |
| } | |
| }); | |
| window.addEventListener('keyup', (e) => { | |
| switch (e.key) { | |
| case 'ArrowLeft': keys.left = false; break; | |
| case 'ArrowRight': keys.right = false; break; | |
| case 'ArrowUp': | |
| case ' ': keys.up = false; break; | |
| } | |
| }); | |
| // Resize Listener | |
| window.addEventListener('resize', resizeCanvas); | |
| // --- Touch Controls --- | |
| const btnLeft = document.getElementById('btnLeft'); | |
| const btnRight = document.getElementById('btnRight'); | |
| const btnUp = document.getElementById('btnUp'); | |
| function handleTouchStart(e) { | |
| e.preventDefault(); // Prevent scrolling/zooming | |
| const target = e.target; | |
| if (target === btnLeft) keys.left = true; | |
| else if (target === btnRight) keys.right = true; | |
| else if (target === btnUp) keys.up = true; | |
| else if (gameOver || gameWon) { // Tap anywhere to restart | |
| initGame(); | |
| } | |
| } | |
| function handleTouchEnd(e) { | |
| e.preventDefault(); | |
| // Find which button touch ended on (if any) | |
| // We reset all keys on any touch end for simplicity, | |
| // prevents issues with multiple touches ending simultaneously. | |
| // A more robust solution would track individual touch points (e.g., e.changedTouches) | |
| keys.left = false; | |
| keys.right = false; | |
| keys.up = false; | |
| } | |
| const touchControlsDiv = document.querySelector('.touch-controls'); | |
| touchControlsDiv.addEventListener('touchstart', handleTouchStart, { passive: false }); | |
| touchControlsDiv.addEventListener('touchend', handleTouchEnd, { passive: false }); | |
| touchControlsDiv.addEventListener('touchcancel', handleTouchEnd, { passive: false }); | |
| // Also listen for taps on the canvas itself for restarting | |
| canvas.addEventListener('touchstart', (e) => { | |
| if (gameOver || gameWon) { | |
| e.preventDefault(); // Prevent potential double restart with button taps | |
| initGame(); | |
| } | |
| }, { passive: false }); | |
| // --- Start the game --- | |
| initGame(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
β π§π»βπ» GOOD πβπ»