-
-
Save shricodev/e5e5951aa3f748caee901d90e4fe8feb to your computer and use it in GitHub Desktop.
Blog - Mario (Gemini 2.5.Pro)
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" /> | |
<title>Simple Mario-Style Platformer</title> | |
<style> | |
body { | |
margin: 0; | |
background-color: #111; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
height: 100vh; | |
font-family: "Courier New", Courier, monospace; | |
} | |
canvas { | |
border: 2px solid #fff; | |
background-color: #70c5ce; /* Sky blue */ | |
display: block; | |
} | |
#controlsInfo { | |
color: #fff; | |
text-align: center; | |
margin-top: 10px; | |
font-size: 14px; | |
} | |
</style> | |
</head> | |
<body> | |
<div> | |
<canvas id="gameCanvas"></canvas> | |
<div id="controlsInfo"> | |
Controls: Arrow Keys or A/D to Move, Space or W to Jump. R to Restart. | |
</div> | |
</div> | |
<script> | |
const canvas = document.getElementById("gameCanvas"); | |
const ctx = canvas.getContext("2d"); | |
// Game constants | |
const CANVAS_WIDTH = 640; | |
const CANVAS_HEIGHT = 360; | |
canvas.width = CANVAS_WIDTH; | |
canvas.height = CANVAS_HEIGHT; | |
const GRAVITY = 0.6; | |
const PLAYER_MAX_FALL_SPEED = 10; | |
const PLAYER_SPEED = 3.5; | |
const PLAYER_JUMP_STRENGTH = 13; | |
const ENEMY_SPEED = 0.8; | |
const PLAYER_WIDTH = 20; | |
const PLAYER_HEIGHT = 30; | |
const ENEMY_WIDTH = 22; | |
const ENEMY_HEIGHT = 22; | |
const TILE_SIZE = 20; // For visual consistency | |
// Game state | |
let gameState = "PLAYING"; // PLAYING, GAME_OVER, LEVEL_COMPLETE | |
let score = 0; | |
let lives = 3; | |
let coins = 0; // Simple coin counter | |
let gameTime = 400; // Timer like in Mario | |
let gameTimeInterval; | |
// Player object | |
let player = { | |
x: 50, | |
y: CANVAS_HEIGHT - 40 - PLAYER_HEIGHT, // Start on ground | |
width: PLAYER_WIDTH, | |
height: PLAYER_HEIGHT, | |
dx: 0, | |
dy: 0, | |
onGround: false, | |
isJumping: false, | |
facingRight: true, | |
isDead: false, | |
deathTimer: 0, // For death animation/delay | |
walkFrameTimer: 0, | |
walkFrame: 0, | |
}; | |
// Level data: Platforms | |
// type: 'ground', 'brick', 'sky-brick' | |
let platforms = [ | |
// Ground | |
{ | |
x: 0, | |
y: CANVAS_HEIGHT - TILE_SIZE * 2, | |
width: CANVAS_WIDTH + 200, | |
height: TILE_SIZE * 2, | |
type: "ground", | |
}, // Extend ground for more space | |
// Floating platforms | |
{ | |
x: 150, | |
y: CANVAS_HEIGHT - TILE_SIZE * 6, | |
width: TILE_SIZE * 5, | |
height: TILE_SIZE, | |
type: "brick", | |
}, | |
{ | |
x: 280, | |
y: CANVAS_HEIGHT - TILE_SIZE * 9, | |
width: TILE_SIZE * 4, | |
height: TILE_SIZE, | |
type: "brick", | |
}, | |
{ | |
x: 400, | |
y: CANVAS_HEIGHT - TILE_SIZE * 6, | |
width: TILE_SIZE * 5, | |
height: TILE_SIZE, | |
type: "brick", | |
}, | |
// Some higher ones | |
{ | |
x: 550, | |
y: CANVAS_HEIGHT - TILE_SIZE * 10, | |
width: TILE_SIZE * 3, | |
height: TILE_SIZE, | |
type: "sky-brick", | |
}, | |
{ | |
x: 50, | |
y: CANVAS_HEIGHT - TILE_SIZE * 9, | |
width: TILE_SIZE * 2, | |
height: TILE_SIZE, | |
type: "sky-brick", | |
}, // Near start | |
// A wall | |
{ | |
x: CANVAS_WIDTH - TILE_SIZE * 3, | |
y: CANVAS_HEIGHT - TILE_SIZE * 7, | |
width: TILE_SIZE, | |
height: TILE_SIZE * 5, | |
type: "brick", | |
}, | |
]; | |
// Goal object (flagpole) | |
const goal = { | |
x: CANVAS_WIDTH + 150, // Place it off initial screen if camera was implemented, for now place it at end of extended ground | |
y: CANVAS_HEIGHT - TILE_SIZE * 2 - TILE_SIZE * 3, // On the ground platform | |
width: TILE_SIZE / 2, | |
height: TILE_SIZE * 3, // Actual collision height | |
poleHeight: TILE_SIZE * 3, // Visual pole height | |
type: "flagpole", | |
}; | |
platforms.push(goal); // Add goal to platforms for drawing, collision handled separately | |
// Enemy data | |
const initialEnemyData = [ | |
{ | |
x: 250, | |
y: CANVAS_HEIGHT - TILE_SIZE * 2 - ENEMY_HEIGHT, | |
type: "koopa", | |
patrolMinX: 200, | |
patrolMaxX: 350, | |
}, | |
{ | |
x: 450, | |
y: CANVAS_HEIGHT - TILE_SIZE * 2 - ENEMY_HEIGHT, | |
type: "koopa", | |
patrolMinX: 400, | |
patrolMaxX: 520, | |
}, | |
{ | |
x: 160, | |
y: CANVAS_HEIGHT - TILE_SIZE * 6 - ENEMY_HEIGHT, | |
type: "koopa", | |
patrolMinX: 150, | |
patrolMaxX: 150 + TILE_SIZE * 5 - ENEMY_WIDTH, | |
}, // On a platform | |
]; | |
let enemies = []; | |
// Input handling | |
const keys = { | |
ArrowLeft: false, | |
ArrowRight: false, | |
Space: false, | |
KeyA: false, | |
KeyD: false, | |
KeyW: false, | |
KeyR: false, | |
}; | |
window.addEventListener("keydown", (e) => { | |
if (keys.hasOwnProperty(e.code)) keys[e.code] = true; | |
if ( | |
["Space", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes( | |
e.code, | |
) | |
) { | |
e.preventDefault(); | |
} | |
}); | |
window.addEventListener("keyup", (e) => { | |
if (keys.hasOwnProperty(e.code)) keys[e.code] = false; | |
}); | |
function handleInput() { | |
if (player.isDead) return; | |
// Horizontal movement | |
if (keys.ArrowLeft || keys.KeyA) { | |
player.dx = -PLAYER_SPEED; | |
player.facingRight = false; | |
} else if (keys.ArrowRight || keys.KeyD) { | |
player.dx = PLAYER_SPEED; | |
player.facingRight = true; | |
} else { | |
player.dx = 0; // No explicit momentum/friction, direct stop | |
} | |
// Jumping | |
if ((keys.Space || keys.KeyW) && player.onGround && !player.isJumping) { | |
player.dy = -PLAYER_JUMP_STRENGTH; | |
player.onGround = false; | |
player.isJumping = true; | |
} | |
// Variable jump height (cut jump short if key released) | |
if ( | |
!(keys.Space || keys.KeyW) && | |
player.isJumping && | |
player.dy < -PLAYER_JUMP_STRENGTH / 2.5 | |
) { | |
player.dy = -PLAYER_JUMP_STRENGTH / 2.5; | |
} | |
} | |
function updatePlayer(deltaTime) { | |
if (player.isDead) { | |
player.deathTimer -= deltaTime; | |
player.y += player.dy; // Death hop | |
player.dy += GRAVITY; // Fall through stage | |
if (player.deathTimer <= 0) { | |
if (lives > 0) { | |
respawnPlayer(); | |
} else { | |
gameState = "GAME_OVER"; | |
} | |
} | |
return; | |
} | |
// Apply gravity | |
if (!player.onGround) { | |
player.dy += GRAVITY; | |
player.dy = Math.min(player.dy, PLAYER_MAX_FALL_SPEED); | |
} | |
let oldX = player.x; | |
let oldY = player.y; | |
let newX = player.x + player.dx; | |
let newY = player.y + player.dy; | |
// Horizontal collision | |
player.x = newX; | |
platforms.forEach((platform) => { | |
if (platform.type === "flagpole") return; // Flagpole doesn't block horizontal movement | |
if (checkCollision(player, platform)) { | |
if (player.dx > 0) { | |
player.x = platform.x - player.width; | |
} else if (player.dx < 0) { | |
player.x = platform.x + platform.width; | |
} | |
} | |
}); | |
// Vertical collision | |
player.y = newY; | |
player.onGround = false; | |
platforms.forEach((platform) => { | |
if (platform.type === "flagpole") return; | |
if (checkCollision(player, platform)) { | |
if (player.dy > 0 && oldY + player.height <= platform.y) { | |
// Landing on top | |
player.y = platform.y - player.height; | |
player.dy = 0; | |
player.onGround = true; | |
player.isJumping = false; | |
} else if (player.dy < 0 && oldY >= platform.y + platform.height) { | |
// Hitting bottom (ceiling) | |
player.y = platform.y + platform.height; | |
player.dy = 0; | |
} | |
} | |
}); | |
// Fall off map | |
if (player.y > CANVAS_HEIGHT + player.height) { | |
playerLosesLife(); | |
} | |
// Animation timer | |
if (player.dx !== 0 && player.onGround) { | |
player.walkFrameTimer += deltaTime; | |
if (player.walkFrameTimer > 80) { | |
// Adjust for walk speed animation | |
player.walkFrame = (player.walkFrame + 1) % 2; | |
player.walkFrameTimer = 0; | |
} | |
} else if (player.dx === 0) { | |
player.walkFrame = 0; // Standing frame | |
} | |
// Check goal collision | |
if (checkCollision(player, goal)) { | |
gameState = "LEVEL_COMPLETE"; | |
// Could add animation of sliding down pole here | |
player.dx = 0; | |
} | |
} | |
function updateEnemies(deltaTime) { | |
enemies.forEach((enemy) => { | |
if (!enemy.isAlive) return; | |
if (enemy.isStomped) { | |
enemy.stompTimer -= deltaTime; | |
if (enemy.stompTimer <= 0) { | |
enemy.isAlive = false; // Remove after stomp animation | |
// No points for koopa shells here, just defeat | |
} | |
return; // No movement if stomped | |
} | |
enemy.x += enemy.speed * enemy.direction; | |
// Patrol logic | |
if ( | |
enemy.direction === 1 && | |
enemy.x + ENEMY_WIDTH > enemy.patrolMaxX | |
) { | |
enemy.x = enemy.patrolMaxX - ENEMY_WIDTH; | |
enemy.direction = -1; | |
} else if (enemy.direction === -1 && enemy.x < enemy.patrolMinX) { | |
enemy.x = enemy.patrolMinX; | |
enemy.direction = 1; | |
} | |
// Basic "turn at edge" if on a platform (simplified: needs platform reference or raycast) | |
// For now, patrolMinX/MaxX must be set correctly for the platform it's on. | |
// Example: if an enemy is on platforms[i], patrolMinX = platforms[i].x, patrolMaxX = platforms[i].x + platforms[i].width - ENEMY_WIDTH | |
}); | |
} | |
function checkCollisions() { | |
if (player.isDead) return; | |
// Player-Enemy collisions | |
enemies.forEach((enemy) => { | |
if (!enemy.isAlive || enemy.isStomped) return; | |
if (checkCollision(player, enemy)) { | |
// Player jumps on enemy | |
if ( | |
player.dy > 0 && | |
player.y + player.height - player.dy <= enemy.y | |
) { | |
// Player was above and is falling | |
enemy.isStomped = true; | |
enemy.stompTimer = 500; // 0.5 seconds | |
enemy.originalHeight = ENEMY_HEIGHT; // Store for stomp visual | |
enemy.height = ENEMY_HEIGHT / 2; | |
enemy.y += ENEMY_HEIGHT / 2; | |
player.dy = -PLAYER_JUMP_STRENGTH / 2; // Bounce | |
player.isJumping = true; // Allow further jump control if key held | |
player.onGround = false; | |
score += 100; | |
} else { | |
// Player hit by enemy from side/below | |
playerLosesLife(); | |
} | |
} | |
}); | |
} | |
function playerLosesLife() { | |
if (player.isDead) return; // Already dying | |
lives--; | |
player.isDead = true; | |
player.deathTimer = 1500; // 1.5 seconds for death animation | |
player.dx = 0; | |
player.dy = -PLAYER_JUMP_STRENGTH / 2; // Small hop animation | |
if (lives <= 0) { | |
// Game over will be set after deathTimer | |
} | |
} | |
function respawnPlayer() { | |
player.x = 50; | |
player.y = CANVAS_HEIGHT - TILE_SIZE * 2 - PLAYER_HEIGHT; | |
player.dx = 0; | |
player.dy = 0; | |
player.onGround = true; // Start on ground | |
player.isJumping = false; | |
player.isDead = false; | |
player.deathTimer = 0; | |
player.facingRight = true; | |
gameTime = 400; // Reset timer on respawn | |
} | |
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 drawPlayer() { | |
if (player.isDead && player.deathTimer > 0) { | |
// Simple death visual: flashing | |
if (Math.floor(player.deathTimer / 100) % 2 === 0) { | |
ctx.fillStyle = "rgba(200, 0, 0, 0.5)"; | |
ctx.fillRect(player.x, player.y, player.width, player.height); | |
} | |
return; | |
} | |
if (player.isDead && player.deathTimer <= 0) return; // Don't draw if fully dead and processed | |
// Body | |
ctx.fillStyle = player.facingRight ? "#E04040" : "#D03030"; // Red for Mario | |
ctx.fillRect(player.x, player.y, player.width, player.height); | |
// Simple eyes (dots) based on direction | |
ctx.fillStyle = "white"; | |
const eyeXOffset = player.facingRight | |
? player.width * 0.6 | |
: player.width * 0.2; | |
ctx.fillRect( | |
player.x + eyeXOffset, | |
player.y + player.height * 0.2, | |
4, | |
4, | |
); | |
ctx.fillStyle = "black"; | |
ctx.fillRect( | |
player.x + eyeXOffset + 1, | |
player.y + player.height * 0.2 + 1, | |
2, | |
2, | |
); | |
// "Legs" part for walking animation | |
if (player.onGround && player.dx !== 0) { | |
// Simple leg movement: shorten body slightly, draw "stepping" legs | |
ctx.fillStyle = "#8B4513"; // Brown for boots | |
const legWidth = player.width / 2.5; | |
if (player.walkFrame === 0) { | |
ctx.fillRect( | |
player.x, | |
player.y + player.height * 0.7, | |
legWidth, | |
player.height * 0.3, | |
); | |
ctx.fillRect( | |
player.x + player.width - legWidth, | |
player.y + player.height * 0.75, | |
legWidth, | |
player.height * 0.25, | |
); | |
} else { | |
ctx.fillRect( | |
player.x, | |
player.y + player.height * 0.75, | |
legWidth, | |
player.height * 0.25, | |
); | |
ctx.fillRect( | |
player.x + player.width - legWidth, | |
player.y + player.height * 0.7, | |
legWidth, | |
player.height * 0.3, | |
); | |
} | |
} else { | |
// Standing or jumping | |
ctx.fillStyle = "#A0522D"; // Brown for boots | |
ctx.fillRect( | |
player.x, | |
player.y + player.height * 0.7, | |
player.width, | |
player.height * 0.3, | |
); | |
} | |
} | |
function drawEnemies() { | |
enemies.forEach((enemy) => { | |
if (!enemy.isAlive) return; | |
ctx.fillStyle = "#2E8B57"; // Green for Koopa | |
if (enemy.isStomped) { | |
ctx.fillRect(enemy.x, enemy.y, ENEMY_WIDTH, ENEMY_HEIGHT / 2); // Squashed | |
} else { | |
ctx.fillRect(enemy.x, enemy.y, ENEMY_WIDTH, ENEMY_HEIGHT); | |
// Simple eyes for enemy | |
ctx.fillStyle = "white"; | |
const eyeX = | |
enemy.direction === 1 | |
? enemy.x + ENEMY_WIDTH * 0.6 | |
: enemy.x + ENEMY_WIDTH * 0.1; | |
ctx.fillRect(eyeX, enemy.y + ENEMY_HEIGHT * 0.2, 5, 5); | |
ctx.fillStyle = "black"; | |
ctx.fillRect(eyeX + 1, enemy.y + ENEMY_HEIGHT * 0.2 + 1, 3, 3); | |
} | |
}); | |
} | |
function drawPlatforms() { | |
platforms.forEach((platform) => { | |
if (platform.type === "ground") { | |
ctx.fillStyle = "#D2B48C"; // Tan | |
} else if (platform.type === "brick") { | |
ctx.fillStyle = "#B22222"; // Firebrick Red | |
} else if (platform.type === "sky-brick") { | |
ctx.fillStyle = "#87CEEB"; // Sky blue brick (lighter) | |
} else if (platform.type === "flagpole") { | |
// Draw flagpole | |
ctx.fillStyle = "silver"; // Pole | |
ctx.fillRect( | |
platform.x, | |
platform.y, | |
platform.width, | |
platform.poleHeight, | |
); | |
// Flag | |
ctx.fillStyle = "green"; | |
ctx.beginPath(); | |
ctx.moveTo(platform.x + platform.width, platform.y + 5); | |
ctx.lineTo( | |
platform.x + platform.width + TILE_SIZE, | |
platform.y + TILE_SIZE / 2 + 5, | |
); | |
ctx.lineTo(platform.x + platform.width, platform.y + TILE_SIZE + 5); | |
ctx.closePath(); | |
ctx.fill(); | |
return; // Skip generic rect draw | |
} else { | |
ctx.fillStyle = "gray"; | |
} | |
ctx.fillRect(platform.x, platform.y, platform.width, platform.height); | |
// Add brick pattern for 'brick' type | |
if (platform.type === "brick" || platform.type === "sky-brick") { | |
ctx.strokeStyle = platform.type === "brick" ? "#800000" : "#4682B4"; // Darker lines | |
ctx.lineWidth = 1; | |
for (let i = 0; i < platform.width; i += TILE_SIZE / 2) { | |
ctx.beginPath(); | |
ctx.moveTo(platform.x + i, platform.y); | |
ctx.lineTo(platform.x + i, platform.y + platform.height); | |
ctx.stroke(); | |
} | |
for (let j = 0; j < platform.height; j += TILE_SIZE / 2) { | |
ctx.beginPath(); | |
ctx.moveTo(platform.x, platform.y + j); | |
ctx.lineTo(platform.x + platform.width, platform.y + j); | |
ctx.stroke(); | |
} | |
} | |
}); | |
} | |
function drawHUD() { | |
ctx.fillStyle = "white"; | |
ctx.font = '18px "Courier New", Courier, monospace'; | |
// Score | |
ctx.textAlign = "left"; | |
ctx.fillText(`SCORE: ${score}`, 10, 25); | |
// Coins (Not fully implemented, just counter) | |
ctx.fillText(`COINS: ${coins}`, 10, 50); | |
// Lives | |
ctx.textAlign = "center"; | |
ctx.fillText(`LIVES: ${lives}`, CANVAS_WIDTH / 2, 25); | |
// Time | |
ctx.textAlign = "right"; | |
ctx.fillText(`TIME: ${gameTime}`, CANVAS_WIDTH - 10, 25); | |
} | |
function drawGameOverScreen() { | |
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; | |
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); | |
ctx.fillStyle = "white"; | |
ctx.font = '40px "Courier New", Courier, monospace'; | |
ctx.textAlign = "center"; | |
ctx.fillText("GAME OVER", CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 20); | |
ctx.font = '20px "Courier New", Courier, monospace'; | |
ctx.fillText( | |
`Final Score: ${score}`, | |
CANVAS_WIDTH / 2, | |
CANVAS_HEIGHT / 2 + 20, | |
); | |
ctx.fillText( | |
"Press R to Restart", | |
CANVAS_WIDTH / 2, | |
CANVAS_HEIGHT / 2 + 60, | |
); | |
} | |
function drawLevelCompleteScreen() { | |
ctx.fillStyle = "rgba(0, 100, 0, 0.7)"; // Greenish tint | |
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); | |
ctx.fillStyle = "white"; | |
ctx.font = '40px "Courier New", Courier, monospace'; | |
ctx.textAlign = "center"; | |
ctx.fillText( | |
"LEVEL COMPLETE!", | |
CANVAS_WIDTH / 2, | |
CANVAS_HEIGHT / 2 - 20, | |
); | |
ctx.font = '20px "Courier New", Courier, monospace'; | |
ctx.fillText( | |
`Final Score: ${score}`, | |
CANVAS_WIDTH / 2, | |
CANVAS_HEIGHT / 2 + 20, | |
); | |
ctx.fillText( | |
`Time Left: ${gameTime}`, | |
CANVAS_WIDTH / 2, | |
CANVAS_HEIGHT / 2 + 50, | |
); | |
ctx.fillText( | |
"Press R to Restart", | |
CANVAS_WIDTH / 2, | |
CANVAS_HEIGHT / 2 + 90, | |
); | |
} | |
function startGameTimer() { | |
if (gameTimeInterval) clearInterval(gameTimeInterval); | |
gameTimeInterval = setInterval(() => { | |
if (gameState === "PLAYING" && !player.isDead) { | |
gameTime--; | |
if (gameTime <= 0) { | |
playerLosesLife(); // Time up | |
gameTime = 0; // Prevent negative time display | |
} | |
} | |
}, 1000); // Approx 1 sec, Mario time is faster | |
} | |
function initGame() { | |
player.x = 50; | |
player.y = CANVAS_HEIGHT - TILE_SIZE * 2 - PLAYER_HEIGHT; | |
player.dx = 0; | |
player.dy = 0; | |
player.onGround = true; | |
player.isJumping = false; | |
player.isDead = false; | |
player.deathTimer = 0; | |
player.facingRight = true; | |
score = 0; | |
lives = 3; | |
coins = 0; | |
gameTime = 400; | |
enemies = initialEnemyData.map((data) => ({ | |
...data, | |
width: ENEMY_WIDTH, | |
height: ENEMY_HEIGHT, | |
speed: ENEMY_SPEED, | |
direction: Math.random() > 0.5 ? 1 : -1, | |
isAlive: true, | |
isStomped: false, | |
stompTimer: 0, | |
})); | |
gameState = "PLAYING"; | |
startGameTimer(); | |
} | |
let lastTime = 0; | |
function gameLoop(timestamp) { | |
const deltaTime = timestamp - lastTime; | |
lastTime = timestamp; | |
// Game state logic | |
if (gameState === "PLAYING") { | |
handleInput(); | |
updatePlayer(deltaTime); | |
updateEnemies(deltaTime); | |
checkCollisions(); | |
} else if ( | |
gameState === "GAME_OVER" || | |
gameState === "LEVEL_COMPLETE" | |
) { | |
if (keys.KeyR) { | |
initGame(); | |
} | |
} else if (gameState === "START_MENU") { | |
// If you add a start menu | |
// Handle start menu logic | |
} | |
// Drawing | |
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // Clear canvas | |
drawPlatforms(); | |
drawEnemies(); | |
drawPlayer(); | |
drawHUD(); | |
if (gameState === "GAME_OVER") { | |
drawGameOverScreen(); | |
} else if (gameState === "LEVEL_COMPLETE") { | |
drawLevelCompleteScreen(); | |
} | |
requestAnimationFrame(gameLoop); | |
} | |
// Start game | |
initGame(); | |
requestAnimationFrame(gameLoop); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment