<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elegant Canvas Racer</title>
<style>
/* --- Basic Reset & Body Style --- */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #1a1a1d; /* Dark background */
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* Elegant font */
color: #f0f0f0; /* Light text color */
overflow: hidden; /* Prevent scrollbars */
}
/* --- Game Container --- */
#gameContainer {
position: relative; /* Needed for overlay positioning */
border-radius: 10px; /* Slightly rounded corners */
overflow: hidden; /* Keep overlays inside */
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); /* Premium shadow */
}
/* --- Canvas Style --- */
#gameCanvas {
display: block; /* Remove extra space below canvas */
background-color: #555; /* Default background if track doesn't cover */
}
/* --- UI Elements --- */
#uiLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* Allow clicks to go through to canvas if needed */
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px;
color: #ffffff;
font-size: 1.5em;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7); /* Text readability */
}
#lapInfo {
display: flex;
justify-content: space-between;
width: 100%;
}
#playerLap, #aiLap {
background-color: rgba(0, 0, 0, 0.4);
padding: 5px 10px;
border-radius: 5px;
}
/* --- Overlay for Messages (Start/End) --- */
#messageOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75); /* Dark semi-transparent overlay */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
font-size: 2em;
cursor: pointer; /* Indicate it's clickable */
transition: opacity 0.5s ease-in-out; /* Smooth fade */
opacity: 1; /* Start visible */
z-index: 10; /* Ensure it's on top */
}
#messageOverlay.hidden {
opacity: 0;
pointer-events: none; /* Disable clicks when hidden */
}
#messageOverlay h2 {
margin-bottom: 15px;
color: #e0e0e0;
}
#messageOverlay p {
font-size: 0.6em;
color: #cccccc;
}
#messageOverlay span { /* Winner/Loser message */
display: block;
margin-top: 20px;
font-size: 1.2em;
font-weight: bold;
}
.player-win { color: #4CAF50; } /* Green for player win */
.ai-win { color: #F44336; } /* Red for AI win */
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas" width="1000" height="600"></canvas>
<div id="uiLayer">
<div id="lapInfo">
<div id="playerLap">Player: Lap 0 / 3</div>
<div id="aiLap">AI: Lap 0 / 3</div>
</div>
<!-- Other UI elements like speed could go here -->
</div>
<div id="messageOverlay">
<h2>Elegant Canvas Racer</h2>
<p>Use Arrow Keys to drive.<br>First to 3 laps wins!</p>
<p style="margin-top: 20px;">Click to Start</p>
<span id="winnerMessage"></span>
</div>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const messageOverlay = document.getElementById('messageOverlay');
const winnerMessageElement = document.getElementById('winnerMessage');
const playerLapElement = document.getElementById('playerLap');
const aiLapElement = document.getElementById('aiLap');
// --- Game Settings ---
const TRACK_W = 1000;
const TRACK_H = 600;
const TOTAL_LAPS = 10;
// Car properties
const CAR_WIDTH = 15;
const CAR_HEIGHT = 30;
const PLAYER_COLOR = '#4a90e2'; // Elegant blue
const AI_COLOR = '#d0021b'; // Elegant red
const ACCELERATION = 0.08;
const BRAKING_FORCE = 0.15;
const FRICTION = 0.97; // Closer to 1 = less friction
const TURN_SPEED = 0.05; // Radians per frame
const MAX_SPEED = 5;
const MAX_REVERSE_SPEED = -2;
const OFF_TRACK_FRICTION = 0.85;
const OFF_TRACK_LIMIT = 1.5; // Max speed off-track
// AI properties
const AI_MAX_SPEED_FACTOR = 0.90; // AI slightly slower max speed
const AI_TURN_RATE = 0.06;
const AI_WAYPOINT_THRESHOLD = 50; // How close AI needs to be to waypoint
// --- Game State ---
let keysPressed = {};
let playerCar = {};
let aiCar = {};
let waypoints = [];
let aiTargetWaypointIndex = 0;
let gameStarted = false;
let gameOver = false;
let winner = null; // 'player' or 'ai'
// --- Track Definition ---
const TRACK_INNER_RADIUS_X = 250;
const TRACK_INNER_RADIUS_Y = 150;
const TRACK_OUTER_RADIUS_X = 400;
const TRACK_OUTER_RADIUS_Y = 250;
const TRACK_CENTER_X = TRACK_W / 2;
const TRACK_CENTER_Y = TRACK_H / 2;
const TRACK_COLOR = '#666'; // Dark grey track
const GRASS_COLOR = '#4CAF50'; // Muted green grass
const FINISH_LINE_COLOR = '#FFF';
const FINISH_LINE_WIDTH = 10;
const FINISH_LINE_X = TRACK_CENTER_X;
const FINISH_LINE_Y_START = TRACK_CENTER_Y - TRACK_OUTER_RADIUS_Y;
const FINISH_LINE_Y_END = TRACK_CENTER_Y - TRACK_INNER_RADIUS_Y;
// --- Initialization ---
function resetCar(car, isPlayer) {
car.x = TRACK_CENTER_X + (isPlayer ? 20 : -20); // Stagger start position slightly
car.y = TRACK_CENTER_Y - (TRACK_INNER_RADIUS_Y + TRACK_OUTER_RADIUS_Y) / 2;
car.width = CAR_WIDTH;
car.height = CAR_HEIGHT;
car.speed = 0;
car.angle = Math.PI / 2; // Pointing right initially
car.lap = 0;
car.onTrack = true;
car.justCrossedFinish = false; // To prevent multiple lap counts per cross
car.passedHalfway = false; // Ensure full lap completed
}
function defineWaypoints() {
waypoints = [];
const points = 24; // Number of waypoints
for (let i = 0; i < points; i++) {
const angle = (i / points) * Math.PI * 2;
const radiusX = (TRACK_INNER_RADIUS_X + TRACK_OUTER_RADIUS_X) / 2;
const radiusY = (TRACK_INNER_RADIUS_Y + TRACK_OUTER_RADIUS_Y) / 2;
// Rotate ellipse points to match track orientation if needed
const waypointX = TRACK_CENTER_X + radiusX * Math.cos(angle);
const waypointY = TRACK_CENTER_Y + radiusY * Math.sin(angle);
waypoints.push({ x: waypointX, y: waypointY });
}
// Adjust first waypoint slightly to align better with start
waypoints[0].y = TRACK_CENTER_Y - (TRACK_INNER_RADIUS_Y + TRACK_OUTER_RADIUS_Y) / 2;
waypoints[0].x = TRACK_CENTER_X + (TRACK_INNER_RADIUS_X + TRACK_OUTER_RADIUS_X) / 2; // Start on right side
// Reorder waypoints to start near finish line and go clockwise
const startIndex = waypoints.findIndex(wp => wp.y < TRACK_CENTER_Y && wp.x > TRACK_CENTER_X);
if (startIndex !== -1) {
const part1 = waypoints.slice(startIndex);
const part2 = waypoints.slice(0, startIndex);
waypoints = [...part1, ...part2];
}
}
function initGame() {
resetCar(playerCar, true);
playerCar.color = PLAYER_COLOR;
resetCar(aiCar, false);
aiCar.color = AI_COLOR;
defineWaypoints();
aiTargetWaypointIndex = 0; // Start AI at the first waypoint
gameOver = false;
winner = null;
winnerMessageElement.textContent = '';
winnerMessageElement.className = ''; // Reset winner style
updateLapUI();
messageOverlay.classList.remove('hidden'); // Show start message
messageOverlay.querySelector('h2').textContent = 'Elegant Canvas Racer';
messageOverlay.querySelector('p').style.display = 'block'; // Show instructions
messageOverlay.querySelector('p:last-of-type').textContent = 'Click to Start';
// Don't start game loop until user clicks
// requestAnimationFrame(gameLoop); // Moved to event listener
}
// --- Event Listeners ---
window.addEventListener('keydown', (e) => { keysPressed[e.key] = true; });
window.addEventListener('keyup', (e) => { keysPressed[e.key] = false; });
messageOverlay.addEventListener('click', () => {
if (!gameStarted || gameOver) {
gameStarted = true;
gameOver = false; // Ensure game isn't immediately over if restarting
initGame(); // Reset positions and laps
messageOverlay.classList.add('hidden');
requestAnimationFrame(gameLoop); // Start the game loop
}
});
// --- Game Loop ---
function gameLoop(timestamp) {
if (gameOver) {
displayEndMessage();
return; // Stop the loop if game is over
}
update();
draw();
requestAnimationFrame(gameLoop);
}
// --- Update Logic ---
function update() {
if (!gameStarted) return;
updateCar(playerCar, keysPressed);
updateAI(aiCar);
checkLaps(playerCar);
checkLaps(aiCar);
checkWinCondition();
}
function updateCar(car, input) {
let accelerationInput = 0;
let turnInput = 0;
if (input['ArrowUp'] || input['w']) accelerationInput = 1;
if (input['ArrowDown'] || input['s']) accelerationInput = -1;
if (input['ArrowLeft'] || input['a']) turnInput = -1;
if (input['ArrowRight'] || input['d']) turnInput = 1;
// Apply acceleration/braking
if (accelerationInput > 0) {
car.speed += ACCELERATION;
} else if (accelerationInput < 0) {
car.speed -= BRAKING_FORCE;
}
// Apply friction
car.speed *= FRICTION;
// Clamp speed
car.speed = Math.max(MAX_REVERSE_SPEED, Math.min(MAX_SPEED, car.speed));
// Apply turning only when moving (prevents spinning in place)
const effectiveTurnSpeed = Math.abs(car.speed) > 0.1 ? TURN_SPEED : 0;
if (turnInput !== 0) {
const turnDirection = car.speed >= 0 ? 1 : -1; // Reverse steering when reversing
car.angle += effectiveTurnSpeed * turnInput * turnDirection;
}
// Store previous position for collision response
const prevX = car.x;
const prevY = car.y;
// Update position based on speed and angle
car.x += Math.cos(car.angle) * car.speed;
car.y += Math.sin(car.angle) * car.speed;
// Collision Detection / Off-track check
checkTrackCollision(car, prevX, prevY);
// Limit speed if off-track
if (!car.onTrack) {
car.speed = Math.min(car.speed, OFF_TRACK_LIMIT);
car.speed = Math.max(car.speed, -OFF_TRACK_LIMIT / 2); // Limit reverse off-track too
car.speed *= OFF_TRACK_FRICTION;
}
}
function updateAI(car) {
if (waypoints.length === 0) return;
const targetWaypoint = waypoints[aiTargetWaypointIndex];
const dx = targetWaypoint.x - car.x;
const dy = targetWaypoint.y - car.y;
const distanceToWaypoint = Math.sqrt(dx * dx + dy * dy);
const targetAngle = Math.atan2(dy, dx);
// Check if waypoint reached
if (distanceToWaypoint < AI_WAYPOINT_THRESHOLD) {
aiTargetWaypointIndex = (aiTargetWaypointIndex + 1) % waypoints.length;
}
// Steer towards the target angle
let angleDifference = targetAngle - car.angle;
// Normalize angle difference to [-PI, PI]
while (angleDifference > Math.PI) angleDifference -= 2 * Math.PI;
while (angleDifference < -Math.PI) angleDifference += 2 * Math.PI;
// Turn AI car
const turnDirection = Math.sign(angleDifference);
car.angle += turnDirection * AI_TURN_RATE * (car.onTrack ? 1 : 0.5); // Turn slower off-track
// Normalize car angle after turning
car.angle %= (2 * Math.PI);
// AI Speed Control (simple: try to maintain near max speed, slow slightly on sharp turns)
const turnSharpness = Math.abs(angleDifference);
const speedFactor = Math.max(0.3, 1 - turnSharpness * 0.5); // Slow down for sharper turns
const targetSpeed = MAX_SPEED * AI_MAX_SPEED_FACTOR * speedFactor;
if (car.speed < targetSpeed) {
car.speed += ACCELERATION * 0.8; // AI accelerates slightly slower
}
car.speed = Math.min(car.speed, MAX_SPEED * AI_MAX_SPEED_FACTOR);
// Apply friction
car.speed *= FRICTION;
// Store previous position
const prevX = car.x;
const prevY = car.y;
// Update position
car.x += Math.cos(car.angle) * car.speed;
car.y += Math.sin(car.angle) * car.speed;
// Collision Detection / Off-track check
checkTrackCollision(car, prevX, prevY);
// Limit speed if off-track
if (!car.onTrack) {
car.speed = Math.min(car.speed, OFF_TRACK_LIMIT);
car.speed *= OFF_TRACK_FRICTION;
}
}
function checkTrackCollision(car, prevX, prevY) {
// More robust check: Use car corners instead of just center
const corners = getCarCorners(car);
let onTrackPixels = 0;
const requiredOnTrackPixels = 2; // Need at least 2 corners on track
for (const corner of corners) {
if (isPointOnTrack(corner.x, corner.y)) {
onTrackPixels++;
}
}
car.onTrack = onTrackPixels >= requiredOnTrackPixels;
if (!car.onTrack && isPointOnTrack(car.x, car.y)) {
// If center is on track but corners aren't, still count as on track (allows slight corner cutting)
car.onTrack = true;
}
// Simple collision response: If completely off track, slightly push back or slow dramatically
if (onTrackPixels === 0) {
// Try moving back slightly
car.x = prevX;
car.y = prevY;
car.speed *= 0.5; // Drastic slowdown
car.onTrack = false; // Ensure it's marked as off track
}
}
function isPointOnTrack(x, y) {
// Check based on drawn track color (less precise but simpler for complex shapes)
// This requires drawing the track first, then checking.
// Alternatively, use math for the oval shape.
try {
// Math-based check for the oval track:
const dx = x - TRACK_CENTER_X;
const dy = y - TRACK_CENTER_Y;
// Check if outside the outer ellipse
const outerDistSq = (dx / TRACK_OUTER_RADIUS_X) ** 2 + (dy / TRACK_OUTER_RADIUS_Y) ** 2;
if (outerDistSq > 1.05) return false; // Add a small buffer
// Check if inside the inner ellipse
const innerDistSq = (dx / TRACK_INNER_RADIUS_X) ** 2 + (dy / TRACK_INNER_RADIUS_Y) ** 2;
if (innerDistSq < 0.95) return false; // Add a small buffer
return true; // Must be within the track bounds
} catch (e) {
// getImageData can throw errors if coords are out of bounds
// console.warn("Point out of canvas bounds for collision check:", x, y);
return false;
}
}
function getCarCorners(car) {
const halfW = car.width / 2;
const halfH = car.height / 2;
const cosA = Math.cos(car.angle);
const sinA = Math.sin(car.angle);
// Calculate corners relative to car center, then rotate and translate
const corners = [
{ x: halfH, y: -halfW }, // Front-Left
{ x: halfH, y: halfW }, // Front-Right
{ x: -halfH, y: halfW }, // Rear-Right
{ x: -halfH, y: -halfW } // Rear-Left
];
x: car.x + (corner.x * cosA - corner.y * sinA),
y: car.y + (corner.x * sinA + corner.y * cosA)
}));
}
function checkLaps(car) {
const finishLineRect = {
x: FINISH_LINE_X,
y: FINISH_LINE_Y_START,
width: FINISH_LINE_WIDTH,
height: FINISH_LINE_Y_END - FINISH_LINE_Y_START
};
const carRect = {
x: car.x - car.width / 2, // Use center for collision check simplicity here
y: car.y - car.height / 2,
width: car.width,
height: car.height
};
// Basic collision check between car center and finish line bounding box
const crossedFinishLine = car.x > finishLineRect.x &&
car.x < finishLineRect.x + finishLineRect.width &&
car.y > finishLineRect.y &&
car.y < finishLineRect.y + finishLineRect.height;
// Check for passing the halfway point (approximate - left side of track)
if (car.x < TRACK_CENTER_X - TRACK_INNER_RADIUS_X) {
car.passedHalfway = true;
}
if (crossedFinishLine && !car.justCrossedFinish && car.passedHalfway) {
car.lap++;
car.justCrossedFinish = true; // Set flag to prevent immediate re-count
car.passedHalfway = false; // Reset halfway flag for next lap
updateLapUI();
}
// Reset the 'justCrossedFinish' flag once the car moves away from the finish line
if (!crossedFinishLine && car.justCrossedFinish) {
car.justCrossedFinish = false;
}
}
function updateLapUI() {
playerLapElement.textContent = `Player: Lap ${Math.min(playerCar.lap, TOTAL_LAPS)} / ${TOTAL_LAPS}`;
aiLapElement.textContent = `AI: Lap ${Math.min(aiCar.lap, TOTAL_LAPS)} / ${TOTAL_LAPS}`;
}
function checkWinCondition() {
if (playerCar.lap >= TOTAL_LAPS) {
gameOver = true;
winner = 'player';
} else if (aiCar.lap >= TOTAL_LAPS) {
gameOver = true;
winner = 'ai';
}
}
function displayEndMessage() {
messageOverlay.classList.remove('hidden');
messageOverlay.querySelector('p').style.display = 'none'; // Hide instructions
messageOverlay.querySelector('p:last-of-type').textContent = 'Click to Play Again';
if (winner === 'player') {
messageOverlay.querySelector('h2').textContent = 'You Win!';
winnerMessageElement.textContent = `Congratulations!`;
winnerMessageElement.className = 'player-win';
} else if (winner === 'ai') {
messageOverlay.querySelector('h2').textContent = 'AI Wins!';
winnerMessageElement.textContent = `Better luck next time!`;
winnerMessageElement.className = 'ai-win';
}
}
// --- Drawing Logic ---
function draw() {
// Clear canvas (or draw background)
ctx.fillStyle = GRASS_COLOR; // Background color outside track
ctx.fillRect(0, 0, TRACK_W, TRACK_H);
// Draw Track
drawTrack();
// Draw Cars
drawCar(playerCar);
drawCar(aiCar);
// Draw Waypoints (for debugging AI)
// drawWaypoints();
// UI is drawn via HTML/CSS overlay
}
function drawTrack() {
ctx.fillStyle = TRACK_COLOR;
ctx.beginPath();
// Outer ellipse
ctx.ellipse(TRACK_CENTER_X, TRACK_CENTER_Y, TRACK_OUTER_RADIUS_X, TRACK_OUTER_RADIUS_Y, 0, 0, Math.PI * 2);
// Inner ellipse (drawn counter-clockwise to create a hole)
ctx.ellipse(TRACK_CENTER_X, TRACK_CENTER_Y, TRACK_INNER_RADIUS_X, TRACK_INNER_RADIUS_Y, 0, 0, Math.PI * 2, true);
ctx.fill();
// Draw Finish Line
ctx.fillStyle = FINISH_LINE_COLOR;
ctx.fillRect(FINISH_LINE_X, FINISH_LINE_Y_START, FINISH_LINE_WIDTH, FINISH_LINE_Y_END - FINISH_LINE_Y_START);
// Draw some subtle kerbs (optional detail)
drawKerbs();
}
function drawKerbs() {
const kerbWidth = 6;
const kerbColor1 = '#f44336'; // Red
const kerbColor2 = '#ffffff'; // White
const segments = 60; // More segments for smoother kerbs
// Draw inner kerb
drawAlternatingEllipseBorder(TRACK_CENTER_X, TRACK_CENTER_Y, TRACK_INNER_RADIUS_X - kerbWidth/2, TRACK_INNER_RADIUS_Y - kerbWidth/2, kerbWidth, segments, kerbColor1, kerbColor2);
// Draw outer kerb
drawAlternatingEllipseBorder(TRACK_CENTER_X, TRACK_CENTER_Y, TRACK_OUTER_RADIUS_X + kerbWidth/2, TRACK_OUTER_RADIUS_Y + kerbWidth/2, kerbWidth, segments, kerbColor1, kerbColor2);
}
function drawAlternatingEllipseBorder(cx, cy, rx, ry, lineWidth, segments, color1, color2) {
ctx.lineWidth = lineWidth;
const angleStep = (Math.PI * 2) / segments;
for (let i = 0; i < segments; i++) {
ctx.beginPath();
ctx.strokeStyle = (i % 2 === 0) ? color1 : color2;
const startAngle = i * angleStep;
const endAngle = (i + 1) * angleStep;
ctx.ellipse(cx, cy, rx, ry, 0, startAngle, endAngle);
ctx.stroke();
}
ctx.lineWidth = 1; // Reset line width
}
function drawCar(car) {
ctx.save(); // Save current canvas state ctx.translate(car.x, car.y); // Move origin to car's center
ctx.rotate(car.angle); // Rotate canvas to car's angle
// Draw car body
ctx.fillStyle = car.color;
ctx.fillRect(-car.height / 2, -car.width / 2, car.height, car.width); // Draw lengthwise
// Add a subtle windshield/cockpit indication
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; // Darker shade
ctx.fillRect(car.height * 0.1, -car.width * 0.3, car.height * 0.4, car.width * 0.6);
// Add simple wheels (optional detail)
const wheelWidth = car.width * 0.3;
const wheelLength = car.height * 0.2;
ctx.fillStyle = '#333'; // Dark grey wheels
// Front wheels
ctx.fillRect(car.height * 0.25, -car.width/2 - wheelWidth * 0.2, wheelLength, wheelWidth); // Left
ctx.fillRect(car.height * 0.25, car.width/2 - wheelWidth * 0.8, wheelLength, wheelWidth); // Right
// Rear wheels
ctx.fillRect(-car.height * 0.35, -car.width/2 - wheelWidth * 0.2, wheelLength, wheelWidth); // Left
ctx.fillRect(-car.height * 0.35, car.width/2 - wheelWidth * 0.8, wheelLength, wheelWidth); // Right
ctx.restore(); // Restore canvas state
}
function drawWaypoints() {
ctx.fillStyle = 'yellow';
waypoints.forEach((wp, index) => {
ctx.beginPath();
ctx.arc(wp.x, wp.y, 5, 0, Math.PI * 2);
ctx.fill();
// Draw index number
ctx.fillStyle = "white";
ctx.fillText(index, wp.x + 5, wp.y - 5);
});
// Highlight current AI target
if (waypoints.length > 0) {
ctx.fillStyle = 'lime';
const target = waypoints[aiTargetWaypointIndex];
ctx.beginPath();
ctx.arc(target.x, target.y, 7, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Start the game ---
// We wait for the user to click the overlay now
initGame(); // Set up initial state and show start message
</script>
</body>
</html>