Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Loki.AI - Access Premium AI Models Free</title> | |
<!-- Favicon (Optional but recommended) --> | |
<link rel="icon" href="favicon.ico" type="image/x-icon"> | |
<!-- Google Fonts: DM Sans (Bold 700, Regular 400) --> | |
<link rel="preconnect" href="https://fonts.googleapis.com"> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet"> | |
<style> | |
:root { | |
--bg-color: #050505; | |
--text-color: #e0e0e0; | |
--text-color-muted: #777; | |
--accent-color: #00ffff; /* Cyan */ | |
--glow-color: rgba(0, 255, 255, 0.15); | |
--hover-bg: rgba(0, 255, 255, 0.1); | |
--border-color: rgba(255, 255, 255, 0.1); | |
--font-main: 'DM Sans', sans-serif; | |
--transition-speed: 0.3s; | |
--transition-cubic: cubic-bezier(0.25, 0.46, 0.45, 0.94); | |
} | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
html { | |
font-size: 16px; | |
scroll-behavior: smooth; /* Optional: for smooth scrolling if page grows */ | |
} | |
body { | |
background-color: var(--bg-color); | |
color: var(--text-color); | |
font-family: var(--font-main); | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
min-height: 100vh; | |
text-align: center; | |
position: relative; | |
overflow: hidden; | |
cursor: none; /* Hide default cursor */ | |
perspective: 1000px; | |
} | |
/* Subtle Animated Background Gradient */ | |
body::before { | |
content: ''; | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background: radial-gradient(circle at 30% 70%, rgba(0, 60, 60, 0.3), transparent 55%), | |
radial-gradient(circle at 70% 30%, rgba(60, 0, 60, 0.25), transparent 55%); | |
opacity: 0; | |
animation: fadeInBg 3s ease-out forwards, pulseBg 15s infinite ease-in-out alternate; | |
z-index: 0; | |
} | |
@keyframes fadeInBg { | |
to { opacity: 0.8; } /* Slightly more visible */ | |
} | |
@keyframes pulseBg { | |
0% { transform: scale(1); opacity: 0.5; } | |
100% { transform: scale(1.05); opacity: 0.8; } /* Slightly less scale */ | |
} | |
.container { | |
position: relative; | |
z-index: 2; | |
padding: 2rem; | |
max-width: 900px; | |
width: 90%; | |
animation: slideInUp 1s var(--transition-cubic) 0.5s forwards; | |
opacity: 0; | |
transform: translateY(30px); | |
} | |
@keyframes slideInUp { | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
h1 { | |
font-size: clamp(3rem, 10vw, 6rem); | |
font-weight: 700; | |
letter-spacing: -0.05em; | |
margin-bottom: 0.2rem; | |
color: var(--text-color); | |
text-shadow: 0 0 15px rgba(255, 255, 255, 0.1), 0 0 25px var(--glow-color); /* Added subtle glow */ | |
transition: color var(--transition-speed) ease, text-shadow var(--transition-speed) ease; | |
} | |
/* --- Enhancement: H1 Hover --- */ | |
h1:hover { | |
color: var(--accent-color); | |
text-shadow: 0 0 10px rgba(255, 255, 255, 0.2), 0 0 30px var(--glow-color), 0 0 50px rgba(0, 255, 255, 0.3); | |
} | |
.subtitle { | |
font-size: clamp(0.9rem, 2.5vw, 1.2rem); | |
font-weight: 400; | |
color: var(--text-color-muted); | |
margin-bottom: 2.5rem; | |
letter-spacing: 0.05em; | |
transition: color var(--transition-speed) ease; | |
} | |
.models-section { | |
margin-bottom: 3rem; | |
opacity: 0; | |
animation: fadeIn 1s ease 1s forwards; | |
} | |
.models-section h2 { | |
font-size: clamp(1rem, 3vw, 1.3rem); | |
font-weight: 700; | |
color: var(--accent-color); | |
margin-bottom: 1.5rem; | |
text-transform: uppercase; | |
letter-spacing: 0.1em; | |
text-shadow: 0 0 10px var(--glow-color); | |
} | |
.models-grid { | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: center; | |
gap: 0.8rem; | |
} | |
.model-badge { | |
display: inline-block; | |
background-color: rgba(255, 255, 255, 0.05); | |
color: var(--text-color-muted); | |
font-size: clamp(0.75rem, 2vw, 0.9rem); | |
font-weight: 400; | |
padding: 0.5em 1em; | |
border-radius: 15px; | |
border: 1px solid var(--border-color); | |
transition: all var(--transition-speed) var(--transition-cubic); | |
transform: scale(1); | |
} | |
.model-badge:hover { | |
background-color: var(--hover-bg); | |
color: var(--accent-color); | |
border-color: var(--accent-color); | |
transform: scale(1.05) translateY(-2px); | |
box-shadow: 0 4px 15px rgba(0, 255, 255, 0.2); | |
} | |
.playgrounds-section { | |
opacity: 0; | |
animation: fadeIn 1s ease 1.2s forwards; | |
display: flex; | |
flex-direction: column; /* Stack links vertically on mobile */ | |
align-items: center; | |
gap: 1rem; | |
} | |
@media (min-width: 600px) { | |
.playgrounds-section { | |
flex-direction: row; /* Side-by-side on larger screens */ | |
justify-content: center; | |
gap: 1.5rem; | |
} | |
} | |
.playground-link { | |
display: inline-block; | |
color: var(--text-color); | |
text-decoration: none; | |
font-size: clamp(0.9rem, 2.5vw, 1.1rem); | |
font-weight: 700; | |
padding: 0.8em 1.8em; | |
border: 2px solid var(--border-color); | |
border-radius: 5px; | |
position: relative; | |
overflow: hidden; | |
transition: all var(--transition-speed) ease; | |
z-index: 1; | |
will-change: transform; /* Hint browser for smoother animation */ | |
} | |
.playground-link::before { | |
content: ''; | |
position: absolute; | |
top: 0; | |
left: -100%; | |
width: 100%; | |
height: 100%; | |
background: var(--accent-color); | |
transition: left var(--transition-speed) ease; | |
z-index: -1; | |
} | |
.playground-link:hover { | |
color: var(--bg-color); | |
border-color: var(--accent-color); | |
transform: translateY(-3px); | |
box-shadow: 0 5px 20px rgba(0, 255, 255, 0.3); | |
} | |
.playground-link:hover::before { | |
left: 0; | |
} | |
.playground-link span { | |
display: inline-block; | |
transition: transform 0.2s ease; | |
will-change: transform; | |
} | |
.playground-link:hover span { | |
transform: translateX(3px); | |
} | |
/* --- Mouse Effects --- */ | |
.glow { | |
position: fixed; | |
left: 0; | |
top: 0; | |
width: 800px; | |
height: 800px; | |
background: radial-gradient(circle at center, var(--glow-color) 0%, rgba(0, 0, 0, 0) 60%); | |
border-radius: 50%; | |
pointer-events: none; | |
opacity: 0; | |
transition: opacity 0.4s ease; /* Only transition opacity */ | |
/* transform: translate(-50%, -50%); JS handles transform */ | |
z-index: 1; | |
filter: blur(10px); | |
will-change: transform, opacity; /* Optimize animation */ | |
} | |
body:hover .glow { | |
opacity: 1; | |
} | |
.cursor { | |
position: fixed; | |
left: 0; | |
top: 0; | |
width: 25px; | |
height: 25px; | |
border: 2px solid var(--accent-color); | |
border-radius: 50%; | |
pointer-events: none; | |
transition: background-color 0.2s ease, border-color 0.2s ease, border-width 0.2s ease, width 0.2s ease, height 0.2s ease; /* Smooth style changes, transform handled by JS loop */ | |
/* transform: translate(-50%, -50%); JS handles transform */ | |
z-index: 9999; | |
mix-blend-mode: difference; /* Keeping this cool effect */ | |
background-color: transparent; | |
will-change: transform; /* Optimize animation */ | |
} | |
/* Cursor interaction states (applied via JS) */ | |
.cursor.hover-link { | |
/* Scale handled by JS */ | |
background-color: var(--accent-color); | |
border-color: transparent; | |
} | |
.cursor.hover-text { | |
/* Scale handled by JS */ | |
border-width: 4px; | |
border-color: rgba(255,255,255, 0.5); | |
} | |
.cursor.clicking { | |
/* Scale handled by JS */ | |
background-color: rgba(255,255,255,0.3); | |
} | |
@keyframes fadeIn { | |
to { opacity: 1; } | |
} | |
/* Responsive adjustments */ | |
@media (max-width: 768px) { | |
h1 { | |
letter-spacing: -0.03em; | |
} | |
.container { | |
padding: 1.5rem; | |
} | |
.models-grid { | |
gap: 0.6rem; | |
} | |
.playgrounds-section { | |
gap: 0.8rem; | |
} | |
.playground-link { | |
padding: 0.7em 1.5em; | |
} | |
/* Reduce glow size on smaller screens */ | |
.glow { | |
width: 500px; | |
height: 500px; | |
} | |
/* Reduce glass ball size */ | |
.glass-ball { | |
width: 100px; | |
height: 100px; | |
top: 20px; | |
right: 20px; | |
} | |
} | |
/* 3D Glass Ball Style */ | |
.glass-ball { | |
position: absolute; | |
top: 40px; | |
right: 40px; | |
width: 150px; | |
height: 150px; | |
border-radius: 50%; | |
background: radial-gradient(circle at 30% 30%, | |
rgba(255, 255, 255, 0.15) 0%, /* Slightly less opaque highlight */ | |
rgba(0, 255, 255, 0.1) 40%, | |
rgba(0, 0, 0, 0.1) 100%); | |
box-shadow: | |
inset 0 0 20px rgba(0, 255, 255, 0.25), /* Slightly stronger inset */ | |
0 0 30px rgba(0, 255, 255, 0.15); /* Reduced outer glow */ | |
animation: float 7s infinite ease-in-out; /* Slower float */ | |
transform-style: preserve-3d; | |
perspective: 1000px; | |
z-index: 3; | |
transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, right 0.3s ease; /* For responsive */ | |
} | |
.glass-ball::before { /* Highlight */ | |
content: ''; | |
position: absolute; | |
top: 15%; | |
left: 15%; | |
width: 20%; | |
height: 20%; | |
border-radius: 50%; | |
background: rgba(255, 255, 255, 0.5); /* Slightly dimmer */ | |
filter: blur(3px); /* Slightly more blur */ | |
} | |
.glass-ball::after { /* Inner glow */ | |
content: ''; | |
position: absolute; | |
top: 45%; | |
left: 45%; | |
width: 70%; | |
height: 70%; | |
border-radius: 50%; | |
background: radial-gradient(circle at center, | |
rgba(0, 255, 255, 0.08) 0%, /* More subtle inner glow */ | |
rgba(0, 0, 0, 0) 70%); | |
animation: pulse 5s infinite alternate; /* Slower pulse */ | |
} | |
@keyframes float { | |
0%, 100% { transform: translateY(0px) rotate(0deg); } | |
50% { transform: translateY(-15px) rotate(8deg); } /* Less movement */ | |
} | |
@keyframes pulse { | |
0% { opacity: 0.3; transform: scale(0.85); } | |
100% { opacity: 0.5; transform: scale(1.05); } | |
} | |
/* Loading Animation */ | |
.loading-container { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: var(--bg-color); | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
z-index: 10000; | |
perspective: 1000px; | |
transition: opacity 0.6s ease-out, visibility 0s linear 0.6s; /* Added visibility transition */ | |
opacity: 1; | |
visibility: visible; | |
} | |
/* Class to hide loader */ | |
.loading-container.hidden { | |
opacity: 0; | |
visibility: hidden; | |
} | |
.loading-cube { | |
width: 100px; | |
height: 100px; | |
position: relative; | |
transform-style: preserve-3d; | |
animation: spin 4s infinite linear; | |
} | |
@keyframes spin { | |
0% { transform: rotateX(0deg) rotateY(0deg); } | |
100% { transform: rotateX(360deg) rotateY(360deg); } | |
} | |
.cube-face { | |
position: absolute; | |
width: 100px; | |
height: 100px; | |
background: rgba(0, 255, 255, 0.1); | |
border: 2px solid var(--accent-color); | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
font-weight: bold; | |
color: var(--accent-color); | |
text-shadow: 0 0 10px var(--accent-color); | |
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3); | |
backface-visibility: visible; | |
/* Slightly transparent text for depth */ | |
color: rgba(0, 255, 255, 0.9); | |
} | |
.front { transform: translateZ(50px); } | |
.back { transform: translateZ(-50px) rotateY(180deg); } | |
.right { transform: translateX(50px) rotateY(90deg); } | |
.left { transform: translateX(-50px) rotateY(-90deg); } | |
.top { transform: translateY(-50px) rotateX(90deg); } | |
.bottom { transform: translateY(50px) rotateX(-90deg); } | |
/* Game Related Styles */ | |
.game-toggle { | |
position: fixed; | |
bottom: 20px; | |
right: 20px; | |
background-color: var(--accent-color); | |
color: var(--bg-color); | |
border: none; | |
border-radius: 50%; | |
width: 60px; | |
height: 60px; | |
font-size: 24px; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
cursor: pointer; /* Re-enable pointer for the button itself */ | |
z-index: 1000; | |
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5); | |
transition: all 0.3s ease; | |
} | |
/* Ensure default cursor shows on the button */ | |
.game-toggle:hover { | |
transform: scale(1.1) rotate(10deg); /* Added slight rotation */ | |
box-shadow: 0 0 25px rgba(0, 255, 255, 0.7); | |
} | |
#game-container { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(0, 0, 0, 0.95); /* Slightly darker overlay */ | |
z-index: 5000; | |
display: none; /* Initially hidden */ | |
opacity: 0; /* Start faded out */ | |
visibility: hidden; /* Start hidden */ | |
transition: opacity 0.4s ease, visibility 0s linear 0.4s; /* Fade transition */ | |
overflow: hidden; | |
} | |
/* Class to show game */ | |
#game-container.active { | |
display: block; /* Need display:block before animating opacity */ | |
opacity: 1; | |
visibility: visible; | |
transition: opacity 0.4s ease, visibility 0s linear 0s; | |
} | |
#game-canvas { | |
width: 100%; | |
height: 100%; | |
display: block; | |
} | |
.controls-info { | |
position: absolute; | |
bottom: 20px; | |
left: 50%; /* Center align */ | |
transform: translateX(-50%); | |
color: var(--text-color-muted); /* Muted color */ | |
background-color: rgba(0, 0, 0, 0.6); /* Slightly less opaque */ | |
padding: 8px 15px; | |
border-radius: 5px; | |
font-size: 13px; /* Slightly smaller */ | |
z-index: 5001; | |
pointer-events: none; /* Don't interfere with game clicks */ | |
white-space: nowrap; /* Prevent wrapping */ | |
} | |
.score-display { | |
position: absolute; | |
top: 20px; | |
left: 20px; | |
color: var(--accent-color); | |
font-size: clamp(18px, 3vw, 24px); /* Responsive font size */ | |
font-weight: bold; | |
z-index: 5001; | |
text-shadow: 0 0 10px rgba(0, 255, 255, 0.5); | |
pointer-events: none; | |
} | |
.game-message { | |
position: absolute; | |
top: 40%; /* Slightly higher */ | |
left: 50%; | |
transform: translate(-50%, -50%); | |
color: var(--accent-color); | |
font-size: clamp(30px, 6vw, 45px); /* Responsive */ | |
font-weight: bold; | |
text-align: center; | |
z-index: 5002; | |
text-shadow: 0 0 20px rgba(0, 255, 255, 0.7), 0 0 30px rgba(0, 255, 255, 0.4); | |
display: none; /* Managed by JS */ | |
opacity: 0; /* For fade in/out */ | |
transition: opacity 0.3s ease; | |
pointer-events: none; | |
} | |
.game-message.visible { | |
display: block; /* Needed to apply opacity */ | |
opacity: 1; | |
} | |
</style> | |
</head> | |
<body> | |
<!-- Loading Animation --> | |
<div class="loading-container" id="loading-screen"> | |
<div class="loading-cube"> | |
<div class="cube-face front">LOKI.AI</div> | |
<div class="cube-face back">LOADING</div> | |
<div class="cube-face right">AI</div> | |
<div class="cube-face left">MODELS</div> | |
<div class="cube-face top">PREMIUM</div> | |
<div class="cube-face bottom">FREE</div> | |
</div> | |
</div> | |
<!-- 3D Glass Ball --> | |
<div class="glass-ball"></div> | |
<!-- Mouse Effect Elements --> | |
<div class="cursor"></div> | |
<div class="glow"></div> | |
<!-- Main Content --> | |
<div class="container"> | |
<h1>Loki.AI</h1> | |
<p class="subtitle">By Parth Sadaria</p> | |
<div class="models-section"> | |
<h2>Free Access To Premium Models</h2> | |
<div class="models-grid"> | |
<!-- Model list kept the same --> | |
<span class="model-badge">GPT-4o</span> | |
<span class="model-badge">GPT-4o-mini</span> | |
<span class="model-badge">OpenAI o1</span> | |
<span class="model-badge">OpenAI o3-mini</span> | |
<span class="model-badge">GPT-4.5</span> | |
<span class="model-badge">Claude 3.7 Sonnet</span> | |
<span class="model-badge">Gemini 1.5 Pro</span> | |
<span class="model-badge">Gemini 1.5 Flash</span> | |
<span class="model-badge">And More Top Models...</span> | |
</div> | |
</div> | |
<div class="playgrounds-section"> | |
<a href="https://parthsadaria-lokiai.hf.space/playground" class="playground-link" target="_blank" rel="noopener noreferrer"> | |
<span>AI Playground</span> | |
</a> | |
<a href="https://parthsadaria-lokiai.hf.space/image-playground" class="playground-link" target="_blank" rel="noopener noreferrer"> | |
<span>Image Playground</span> | |
</a> | |
</div> | |
</div> | |
<!-- Car Game Toggle Button --> | |
<!-- Enhancement: Added aria-label --> | |
<button class="game-toggle" id="game-toggle" aria-label="Toggle Car Game">🏎️</button> | |
<!-- Game Container --> | |
<div id="game-container"> | |
<canvas id="game-canvas"></canvas> | |
<div class="controls-info"> | |
WASD/Arrows: Drive | SPACE: Drift | ESC: Exit | |
</div> | |
<div class="score-display" id="score-display">Score: 0</div> | |
<div class="game-message" id="game-message"></div> | |
<!-- Added a simple pause overlay --> | |
<div id="pause-overlay" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); display: none; justify-content: center; align-items: center; z-index: 5003; font-size: 3em; color: var(--accent-color); text-shadow: 0 0 15px var(--accent-color);">PAUSED</div> | |
</div> | |
<script> | |
// --- [ Mouse Cursor Effects Code - Unchanged - Keep As Is ] --- | |
const cursor = document.querySelector('.cursor'); | |
const glow = document.querySelector('.glow'); | |
const hoverables = document.querySelectorAll('a, .model-badge, .game-toggle, h1'); // Added h1 | |
const textElements = document.querySelectorAll('p, h2, .model-badge'); // Excluded h1 here | |
let mouseX = 0; | |
let mouseY = 0; | |
let cursorX = 0; | |
let cursorY = 0; | |
let glowX = 0; | |
let glowY = 0; | |
const cursorSpeed = 0.15; // Fine-tune if needed | |
const glowSpeed = 0.1; | |
let currentCursorScale = 1; | |
const baseCursorScale = 1; | |
const hoverLinkCursorScale = 1.5; // For buttons, links | |
const hoverTextCursorScale = 0.7; // For body text, badges | |
const hoverTitleCursorScale = 1.1; // Special scale for H1 | |
const clickCursorScale = 0.6; | |
let cursorHalfWidth = cursor.offsetWidth / 2; | |
let cursorHalfHeight = cursor.offsetHeight / 2; | |
let glowHalfWidth = glow.offsetWidth / 2; | |
let glowHalfHeight = glow.offsetHeight / 2; | |
let isGameFocused = false; // Track if game has focus (for cursor style) | |
window.addEventListener('resize', () => { | |
cursorHalfWidth = cursor.offsetWidth / 2; | |
cursorHalfHeight = cursor.offsetHeight / 2; | |
glowHalfWidth = glow.offsetWidth / 2; | |
glowHalfHeight = glow.offsetHeight / 2; | |
}); | |
function animateCursor() { | |
cursorX += (mouseX - cursorX) * cursorSpeed; | |
cursorY += (mouseY - cursorY) * cursorSpeed; | |
glowX += (mouseX - glowX) * glowSpeed; | |
glowY += (mouseY - glowY) * glowSpeed; | |
const cursorTranslateX = cursorX - cursorHalfWidth; | |
const cursorTranslateY = cursorY - cursorHalfHeight; | |
const glowTranslateX = glowX - glowHalfWidth; | |
const glowTranslateY = glowY - glowHalfHeight; | |
// Apply transform only if cursor is not hidden for game | |
if (!isGameFocused || document.body.style.cursor !== 'none') { | |
cursor.style.transform = `translate(${cursorTranslateX}px, ${cursorTranslateY}px) scale(${currentCursorScale})`; | |
} else { | |
cursor.style.transform = `translate(${cursorTranslateX}px, ${cursorTranslateY}px) scale(0)`; // Hide if game focused & using default cursor | |
} | |
glow.style.transform = `translate(${glowTranslateX}px, ${glowTranslateY}px)`; | |
requestAnimationFrame(animateCursor); | |
} | |
document.addEventListener('mousemove', (e) => { | |
mouseX = e.clientX; | |
mouseY = e.clientY; | |
if (!isGameFocused) { // Only update hover states if game isn't active/focused | |
updateCursorState(); | |
} | |
}); | |
animateCursor(); // Start animation loop | |
function updateCursorState() { | |
let targetScale = baseCursorScale; | |
let isHoveringLink = false; | |
let isHoveringText = false; | |
let isHoveringTitle = false; | |
// Direct check based on current mouse position | |
const elementUnderMouse = document.elementFromPoint(mouseX, mouseY); | |
if (elementUnderMouse) { | |
if (elementUnderMouse.closest('a, .playground-link, .game-toggle')) { // More specific link types | |
isHoveringLink = true; | |
} else if (elementUnderMouse.closest('h1')) { | |
isHoveringTitle = true; | |
} else if (elementUnderMouse.closest('p, h2, .model-badge')) { // Specific text elements | |
isHoveringText = true; | |
} | |
} | |
cursor.classList.toggle('hover-link', isHoveringLink); | |
cursor.classList.toggle('hover-text', isHoveringText && !isHoveringLink && !isHoveringTitle); | |
cursor.classList.toggle('hover-title', isHoveringTitle); // New class for title hover | |
if (isHoveringLink) { | |
targetScale = hoverLinkCursorScale; | |
} else if (isHoveringTitle) { | |
targetScale = hoverTitleCursorScale; | |
} else if (isHoveringText) { | |
targetScale = hoverTextCursorScale; | |
} | |
if (cursor.classList.contains('clicking')) { | |
targetScale = clickCursorScale; | |
} | |
currentCursorScale = targetScale; | |
} | |
document.addEventListener('mousedown', () => { | |
if (!isGameFocused) { | |
cursor.classList.add('clicking'); | |
updateCursorState(); | |
} | |
}); | |
document.addEventListener('mouseup', () => { | |
if (!isGameFocused) { | |
cursor.classList.remove('clicking'); | |
updateCursorState(); | |
} | |
}); | |
// Initial state | |
updateCursorState(); | |
// --- [ End of Mouse Cursor Effects Code ] --- | |
// Loading screen animation | |
window.addEventListener('load', () => { | |
const loadingScreen = document.getElementById('loading-screen'); | |
if (loadingScreen) { | |
setTimeout(() => { | |
loadingScreen.classList.add('hidden'); // Use class to trigger transition | |
}, 1500); // Reduced duration slightly | |
} else { | |
console.error("Loading screen element not found!"); | |
} | |
// Recalculate cursor/glow size after load/potential style changes | |
cursorHalfWidth = cursor.offsetWidth / 2; | |
cursorHalfHeight = cursor.offsetHeight / 2; | |
glowHalfWidth = glow.offsetWidth / 2; | |
glowHalfHeight = glow.offsetHeight / 2; | |
}); | |
// --- [ Game Logic - START - Debugging Version ] --- | |
console.log("Game Script Initializing..."); // DEBUG | |
const gameToggle = document.getElementById('game-toggle'); | |
const gameContainer = document.getElementById('game-container'); | |
const canvas = document.getElementById('game-canvas'); | |
const scoreDisplay = document.getElementById('score-display'); | |
const gameMessage = document.getElementById('game-message'); | |
const pauseOverlay = document.getElementById('pause-overlay'); | |
let ctx = null; | |
if (canvas) { | |
try { | |
ctx = canvas.getContext('2d'); | |
if (!ctx) { | |
console.error("Failed to get 2D context from canvas."); | |
} else { | |
console.log("Canvas context obtained."); // DEBUG | |
} | |
} catch (e) { | |
console.error("Error getting canvas context:", e); | |
} | |
} else { | |
console.error("Game canvas element not found!"); | |
} | |
// Game state | |
let gameActive = false; | |
let gameVisible = false; | |
let isPaused = false; | |
let animationFrameId = null; | |
let score = 0; | |
let lastTimestamp = 0; | |
let deltaTime = 0; | |
let timeScale = 1.0; | |
// --- Physics Constants (Unchanged) --- | |
const FRICTION = 0.98; | |
const ANGULAR_FRICTION = 0.95; | |
const CAR_ENGINE_FORCE = 13000; | |
const CAR_BRAKE_FORCE = 18000; | |
const CAR_HANDBRAKE_FORCE = 25000; | |
const CAR_TURN_TORQUE = 100000; | |
const CAR_MASS = 1000; | |
const CAR_INERTIA = CAR_MASS * 500; | |
const OBSTACLE_DENSITY = 5; | |
const COLLISION_RESTITUTION = 0.3; | |
const SCREEN_BOUND_RESTITUTION = 0.4; | |
const TIRE_LATERAL_GRIP = 6.0; | |
const TIRE_HANDBRAKE_GRIP_FACTOR = 0.2; | |
// --- Car properties (Add safety for invMass/invInertia) --- | |
const car = { | |
x: 0, y: 0, vx: 0, vy: 0, angle: 0, angularVelocity: 0, | |
width: 25, height: 45, | |
physicsWidth: 2.0, physicsHeight: 4.0, | |
mass: CAR_MASS, | |
invMass: CAR_MASS > 0 ? 1 / CAR_MASS : 0, // SAFETY CHECK | |
inertia: CAR_INERTIA, | |
invInertia: CAR_INERTIA > 0 ? 1 / CAR_INERTIA : 0, // SAFETY CHECK | |
color: '#00ffff', shadowColor: 'rgba(0, 255, 255, 0.3)', | |
drifting: false, tireMarks: [], vertices: [], axes: [], // Add vertices/axes here too | |
turboMode: false, turboTimer: 0, turboMaxTime: 3, | |
turboCooldown: 0, turboCooldownMax: 5, turboForceBoost: 10000, | |
}; | |
// Obstacles & Powerups Arrays | |
const obstacles = []; | |
const powerUps = []; | |
const obstacleTypes = [ /* Same as before */ | |
{ text: "GPT-4o", width: 100, height: 40, color: "#ff5a5a" }, | |
{ text: "Claude 3.7", width: 120, height: 40, color: "#5a92ff" }, | |
{ text: "Gemini", width: 90, height: 40, color: "#5aff7f" }, | |
{ text: "Loki.AI", width: 120, height: 50, color: "#ffaa5a" }, | |
{ text: "AI", width: 60, height: 40, color: "#aa5aff" }, | |
{ text: "Premium", width: 110, height: 40, color: "#ff5aaa" } | |
]; | |
const maxObstacles = 20; | |
const maxPowerUps = 3; | |
// --- Canvas sizing --- | |
function resizeCanvas() { | |
console.log("resizeCanvas called"); // DEBUG | |
if (!canvas) { | |
console.error("resizeCanvas: Canvas not found"); return; | |
} | |
canvas.width = window.innerWidth; | |
canvas.height = window.innerHeight; | |
console.log(`Canvas resized to ${canvas.width}x${canvas.height}`); // DEBUG | |
if(gameActive && ctx) { | |
drawBackground(ctx); | |
} | |
} | |
window.addEventListener('resize', resizeCanvas); | |
// Initial size set needs ctx to be ready | |
if (canvas && ctx) { | |
resizeCanvas(); | |
} else if (canvas && !ctx) { | |
console.warn("resizeCanvas: ctx not ready during initial call."); | |
} | |
// --- Helper Functions (Unchanged) --- | |
function worldToScreen(x, y) { return { x: x, y: y }; } | |
function screenToWorld(x, y) { return { x: x, y: y }; } | |
function vecAdd(v1, v2) { return { x: v1.x + v2.x, y: v1.y + v2.y }; } | |
function vecSub(v1, v2) { return { x: v1.x - v2.x, y: v1.y - v2.y }; } | |
function vecScale(v, s) { return { x: v.x * s, y: v.y * s }; } | |
function vecLength(v) { return Math.sqrt(v.x * v.x + v.y * v.y); } | |
function vecNormalize(v) { const l = vecLength(v); return l > 0.0001 ? { x: v.x / l, y: v.y / l } : { x: 0, y: 0 }; } // Added tolerance | |
function vecDot(v1, v2) { return v1.x * v2.x + v1.y * v2.y; } | |
function vecRotate(v, angle) { const c=Math.cos(angle), s=Math.sin(angle); return { x: v.x*c - v.y*s, y: v.x*s + v.y*c }; } | |
function vecCross2D(r, F) { return r.x * F.y - r.y * F.x; } | |
// --- Game Toggle and State Management --- | |
if (gameToggle && gameContainer) { | |
console.log("Attaching listener to game toggle button."); // DEBUG | |
gameToggle.addEventListener('click', () => { | |
console.log("Game toggle clicked. gameVisible:", gameVisible); // DEBUG | |
if (!gameVisible) { | |
startGame(); | |
} else { | |
stopGame(); | |
} | |
}); | |
} else { | |
console.error("Game toggle button or container not found! Cannot attach listener."); | |
} | |
function startGame() { | |
console.log("startGame called"); // DEBUG | |
if (!gameContainer) { console.error("startGame: gameContainer not found."); return; } | |
if (!ctx) { console.error("startGame: Canvas context (ctx) is not available."); return; } | |
gameVisible = true; | |
gameActive = true; | |
isPaused = false; | |
isGameFocused = true; | |
gameContainer.classList.add('active'); | |
if(pauseOverlay) pauseOverlay.style.display = 'none'; | |
document.body.style.cursor = 'none'; | |
if(cursor) cursor.style.display = 'none'; | |
if(glow) glow.style.display = 'none'; | |
try { | |
resizeCanvas(); // Ensure canvas is sized correctly | |
resetGame(); // Reset state and create objects | |
} catch(e) { | |
console.error("Error during resize/reset in startGame:", e); | |
stopGame(); // Attempt to gracefully stop if init fails | |
return; | |
} | |
lastTimestamp = performance.now(); | |
if (animationFrameId) cancelAnimationFrame(animationFrameId); | |
console.log("Requesting first game loop frame..."); // DEBUG | |
animationFrameId = requestAnimationFrame(gameLoop); | |
} | |
function stopGame() { | |
console.log("stopGame called"); // DEBUG | |
if (!gameContainer) return; // Should not happen if called from toggle, but safety check | |
gameVisible = false; | |
gameActive = false; | |
isPaused = false; | |
isGameFocused = false; | |
gameContainer.classList.remove('active'); | |
document.body.style.cursor = 'none'; // Keep custom cursor style | |
if(cursor) cursor.style.display = ''; // Show custom cursor | |
if(glow) glow.style.display = ''; // Show glow | |
updateCursorState(); // Recalculate hover state | |
if (animationFrameId) { | |
cancelAnimationFrame(animationFrameId); | |
console.log("Cancelled animation frame:", animationFrameId); // DEBUG | |
animationFrameId = null; | |
} | |
lastTimestamp = 0; | |
} | |
function pauseGame() { | |
console.log("pauseGame called"); // DEBUG | |
if (!gameActive) return; | |
isPaused = true; | |
gameActive = false; | |
if(pauseOverlay) pauseOverlay.style.display = 'flex'; | |
} | |
function resumeGame() { | |
console.log("resumeGame called"); // DEBUG | |
if (!gameVisible || !isPaused) return; | |
isPaused = false; | |
gameActive = true; | |
if(pauseOverlay) pauseOverlay.style.display = 'none'; | |
lastTimestamp = performance.now(); | |
if (!animationFrameId && gameVisible) { // Restart loop only if it somehow stopped AND game should be visible | |
console.log("Restarting game loop from resumeGame..."); //DEBUG | |
animationFrameId = requestAnimationFrame(gameLoop); | |
} | |
} | |
// Key handling (Unchanged, assumed correct) | |
const keys = { up: false, down: false, left: false, right: false, handbrake: false }; | |
window.addEventListener('keydown', (e) => { | |
if (!gameVisible) return; | |
if ([' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 'a', 's', 'd', 'W', 'A', 'S', 'D'].includes(e.key)) { | |
e.preventDefault(); | |
} | |
if (e.key === 'Escape') { | |
if (isPaused) { stopGame(); } else if (gameActive) { pauseGame(); } | |
return; | |
} | |
if (isPaused) return; | |
switch(e.key.toLowerCase()) { | |
case 'arrowup': case 'w': keys.up = true; break; | |
case 'arrowdown': case 's': keys.down = true; break; | |
case 'arrowleft': case 'a': keys.left = true; break; | |
case 'arrowright': case 'd': keys.right = true; break; | |
case ' ': keys.handbrake = true; break; | |
} | |
}); | |
window.addEventListener('keyup', (e) => { | |
switch(e.key.toLowerCase()) { | |
case 'arrowup': case 'w': keys.up = false; break; | |
case 'arrowdown': case 's': keys.down = false; break; | |
case 'arrowleft': case 'a': keys.left = false; break; | |
case 'arrowright': case 'd': keys.right = false; break; | |
case ' ': keys.handbrake = false; break; | |
} | |
}); | |
// --- Object Creation --- | |
function createObstacles() { | |
console.log("Creating obstacles..."); // DEBUG | |
if (!canvas) { console.error("createObstacles: Canvas not found."); return; } | |
obstacles.length = 0; | |
const margin = 50; | |
const canvasW = canvas.width; | |
const canvasH = canvas.height; | |
for (let i = 0; i < maxObstacles; i++) { | |
try { // Wrap individual obstacle creation | |
const type = obstacleTypes[Math.floor(Math.random() * obstacleTypes.length)]; | |
const width = type.width; | |
const height = type.height; | |
const mass = width * height * OBSTACLE_DENSITY; | |
const inertia = mass * (width*width + height*height) / 12; | |
// SAFETY CHECKS for inverse mass/inertia | |
const invMass = mass > 0.0001 ? 1 / mass : 0; | |
const invInertia = inertia > 0.0001 ? 1 / inertia : 0; | |
// Placement logic (simplified check for debug) | |
let x = Math.random() * (canvasW - width - 2 * margin) + margin + width / 2; | |
let y = Math.random() * (canvasH - height - 2 * margin) + margin + height / 2; | |
// Basic check to avoid center spawn | |
if (Math.hypot(x - canvasW/2, y - canvasH/2) < 200) { | |
x = margin + width / 2 + Math.random() * 100; // Move near edge if too close to center | |
} | |
const newObstacle = { | |
x: x, y: y, vx: 0, vy: 0, | |
angle: Math.random() * Math.PI * 2, | |
angularVelocity: (Math.random() - 0.5) * 0.3, | |
width: width, height: height, | |
mass: mass, invMass: invMass, | |
inertia: inertia, invInertia: invInertia, | |
text: type.text, color: type.color, | |
vertices: [], axes: [] // Initialize empty | |
}; | |
obstacles.push(newObstacle); | |
// console.log(`Created obstacle ${i}: mass=${mass.toFixed(1)}, invMass=${invMass.toExponential(2)}`); // DEBUG detail | |
} catch (e) { | |
console.error(`Error creating obstacle ${i}:`, e); | |
} | |
} | |
// IMPORTANT: Update vertices AFTER all obstacles are in the array | |
obstacles.forEach(obs => { | |
try { | |
updateVerticesAndAxes(obs); | |
} catch(e) { | |
console.error("Error updating vertices for obstacle:", obs, e); | |
} | |
}); | |
console.log(`Finished creating ${obstacles.length} obstacles.`); // DEBUG | |
} | |
function createPowerUps() { | |
console.log("Creating powerups..."); // DEBUG | |
if (!canvas) { console.error("createPowerUps: Canvas not found."); return; } | |
powerUps.length = 0; | |
// Placement logic (simplified for debug, less strict overlap check) | |
const margin = 60; | |
for (let i = 0; i < maxPowerUps; i++) { | |
try { | |
const type = Math.random() < 0.5 ? 'turbo' : 'score'; | |
const x = Math.random() * (canvas.width - 2 * margin) + margin; | |
const y = Math.random() * (canvas.height - 2 * margin) + margin; | |
const radius = 18; | |
// Basic check against car start | |
if (Math.hypot(x - canvas.width/2, y - canvas.height/2) < 150) continue; // Skip if too close | |
powerUps.push({ | |
x: x, y: y, radius: radius, | |
type: type, color: type === 'turbo' ? '#ff00ff' : '#ffff00', | |
active: true, pulseOffset: Math.random() * Math.PI * 2 | |
}); | |
} catch (e) { | |
console.error(`Error creating powerup ${i}:`, e); | |
} | |
} | |
console.log(`Finished creating ${powerUps.length} powerups.`); // DEBUG | |
} | |
function showGameMessage(text, color, duration = 1500) { | |
// Assume this works, no changes needed unless message itself breaks | |
if (!gameMessage) return; | |
gameMessage.textContent = text; | |
gameMessage.style.color = color; | |
gameMessage.classList.add('visible'); | |
setTimeout(() => { | |
gameMessage.classList.remove('visible'); | |
}, duration); | |
} | |
function activateTurbo() { /* Unchanged */ | |
if (car.turboCooldown <= 0) { | |
car.turboMode = true; | |
car.turboTimer = car.turboMaxTime; | |
showGameMessage("TURBO!", '#ff00ff', 1500); | |
} | |
} | |
function addScore(amount = 100, position = null) { /* Unchanged */ | |
score += amount; | |
updateScore(); | |
if (amount >= 200) { | |
showGameMessage(`+${amount} PTS!`, '#ffff00', 1000); | |
} | |
} | |
function resetGame() { | |
console.log("resetGame called"); // DEBUG | |
if (!canvas) { console.error("resetGame: Canvas not found."); return; } | |
// Reset car state | |
car.x = canvas.width / 2; | |
car.y = canvas.height / 2; | |
car.vx = 0; car.vy = 0; | |
car.angle = -Math.PI / 2; | |
car.angularVelocity = 0; | |
car.tireMarks = []; | |
car.turboMode = false; | |
car.turboTimer = 0; | |
car.turboCooldown = 0; | |
// Ensure car vertices are reset too (or updated immediately after position change) | |
updateVerticesAndAxes(car); // Update car geometry based on reset state | |
score = 0; | |
updateScore(); | |
// Recreate objects | |
createObstacles(); | |
createPowerUps(); | |
console.log("Game reset finished."); // DEBUG | |
} | |
function updateScore() { | |
if (scoreDisplay) { scoreDisplay.textContent = `Score: ${score}`; } | |
} | |
// --- Drawing Functions (Assumed correct, no changes unless errors point here) --- | |
function drawCar(ctx) { /* Unchanged */ | |
if (!ctx) return; | |
const { x: screenX, y: screenY } = worldToScreen(car.x, car.y); | |
ctx.save(); | |
ctx.translate(screenX, screenY); | |
ctx.rotate(car.angle); | |
ctx.shadowColor = car.shadowColor; | |
ctx.shadowBlur = car.drifting ? 25 : 15; | |
ctx.shadowOffsetX = 5; ctx.shadowOffsetY = 5; | |
const drawWidth = car.width; const drawHeight = car.height; | |
ctx.fillStyle = car.turboMode ? '#ff00ff' : car.color; | |
ctx.fillRect(-drawWidth/2, -drawHeight/2, drawWidth, drawHeight); | |
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; | |
ctx.fillStyle = "#223344"; | |
ctx.fillRect(-drawWidth/2 * 0.8, -drawHeight/2 * 0.7, drawWidth * 0.8, drawHeight * 0.3); | |
ctx.fillRect(-drawWidth/2 * 0.7, drawHeight/2 * 0.4, drawWidth * 0.7, drawHeight * 0.2); | |
ctx.fillStyle = "#ffffaa"; | |
ctx.fillRect(-drawWidth/2 * 0.4, -drawHeight/2 - 2, drawWidth * 0.2, 4); | |
ctx.fillRect( drawWidth/2 * 0.2, -drawHeight/2 - 2, drawWidth * 0.2, 4); | |
ctx.fillStyle = "#ffaaaa"; | |
ctx.fillRect(-drawWidth/2 * 0.4, drawHeight/2 - 2, drawWidth * 0.2, 4); | |
ctx.fillRect( drawWidth/2 * 0.2, drawHeight/2 - 2, drawWidth * 0.2, 4); | |
if (car.turboMode) { /* flames */ } | |
if (car.drifting && Math.hypot(car.vx, car.vy) > 5) { /* smoke */ } | |
ctx.restore(); | |
} | |
function drawTireMarks(ctx) { /* Unchanged */ | |
if (!ctx || car.tireMarks.length === 0) return; | |
ctx.lineCap = "round"; | |
const maxMarksToDraw = 100; | |
const startIdx = Math.max(0, car.tireMarks.length - maxMarksToDraw); | |
for (let i = startIdx; i < car.tireMarks.length; i++) { | |
const mark = car.tireMarks[i]; | |
mark.life -= deltaTime * timeScale; | |
if (mark.life <= 0) { continue; } | |
const alpha = Math.max(0, Math.min(1, mark.life / mark.maxLife)); | |
ctx.strokeStyle = `rgba(40, 40, 40, ${alpha * 0.6})`; | |
ctx.lineWidth = mark.width * alpha; | |
ctx.beginPath(); ctx.moveTo(mark.lx1, mark.ly1); ctx.lineTo(mark.lx2, mark.ly2); ctx.stroke(); | |
ctx.beginPath(); ctx.moveTo(mark.rx1, mark.ry1); ctx.lineTo(mark.rx2, mark.ry2); ctx.stroke(); | |
} | |
if (Math.random() < 0.05) { car.tireMarks = car.tireMarks.filter(mark => mark.life > 0); } | |
if (car.tireMarks.length > 250) { car.tireMarks.splice(0, car.tireMarks.length - 200); } | |
} | |
function addTireMarkSegment() { /* Unchanged */ | |
const wheelOffset = car.width * 0.4; const axleOffset = car.height * 0.35; | |
const cosA = Math.cos(car.angle); const sinA = Math.sin(car.angle); | |
const { x: screenX, y: screenY } = worldToScreen(car.x, car.y); | |
const rlX = screenX - sinA*wheelOffset - cosA*axleOffset; const rlY = screenY + cosA*wheelOffset - sinA*axleOffset; | |
const rrX = screenX + sinA*wheelOffset - cosA*axleOffset; const rrY = screenY - cosA*wheelOffset - sinA*axleOffset; | |
const maxLife = 1.8; const markWidth = 4; | |
const lastMark = car.tireMarks.length > 0 ? car.tireMarks[car.tireMarks.length - 1] : null; | |
if (lastMark && lastMark.life > 0) { lastMark.lx2=rlX; lastMark.ly2=rlY; lastMark.rx2=rrX; lastMark.ry2=rrY; } | |
car.tireMarks.push({ lx1:rlX, ly1:rlY, lx2:rlX, ly2:rlY, rx1:rrX, ry1:rrY, rx2:rrX, ry2:rrY, life:maxLife, maxLife:maxLife, width:markWidth }); | |
} | |
function drawObstacles(ctx) { /* Unchanged */ | |
if (!ctx) return; | |
for (const obs of obstacles) { | |
const { x: screenX, y: screenY } = worldToScreen(obs.x, obs.y); | |
ctx.save(); ctx.translate(screenX, screenY); ctx.rotate(obs.angle); | |
ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(-obs.width/2 + 3, -obs.height/2 + 3, obs.width, obs.height); | |
ctx.fillStyle = obs.color; ctx.fillRect(-obs.width/2, -obs.height/2, obs.width, obs.height); | |
ctx.font = 'bold 13px "DM Sans", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
ctx.fillStyle = '#ffffff'; ctx.fillText(obs.text, 0, 1); | |
ctx.restore(); | |
} | |
} | |
function drawPowerUps(ctx) { /* Unchanged */ | |
if (!ctx) return; | |
for (const powerUp of powerUps) { /* ... drawing logic ... */ } | |
} | |
function drawBackground(ctx) { /* Unchanged */ | |
if (!ctx || !canvas) return; | |
ctx.fillStyle = '#08080A'; ctx.fillRect(0, 0, canvas.width, canvas.height); | |
ctx.strokeStyle='rgba(0, 255, 255, 0.06)'; ctx.lineWidth=1; const gridSize=50; | |
for (let x=0; x<canvas.width; x+=gridSize) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,canvas.height); ctx.stroke(); } | |
for (let y=0; y<canvas.height; y+=gridSize) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(canvas.width,y); ctx.stroke(); } | |
// Vignette... | |
} | |
function drawGameInfo(ctx) { /* Unchanged */ | |
if (!ctx || !canvas) return; /* ... HUD drawing logic ... */ | |
} | |
// --- Physics Update Functions --- | |
function applyForces(obj, deltaTime) { // Generalized slightly | |
// Only apply player input to car | |
let isPlayerCar = (obj === car); | |
let totalForce = { x: 0, y: 0 }; | |
let totalTorque = 0; | |
const forwardVec = { x: Math.sin(obj.angle), y: -Math.cos(obj.angle) }; | |
const rightVec = { x: -forwardVec.y, y: forwardVec.x }; | |
const currentSpeed = vecLength({x: obj.vx, y: obj.vy}); | |
if (isPlayerCar) { | |
let engineForceMag = 0; | |
if (keys.up) { engineForceMag += CAR_ENGINE_FORCE; if (obj.turboMode) engineForceMag += obj.turboForceBoost; } | |
if (keys.down) { const movingForward = vecDot({x: obj.vx, y: obj.vy}, forwardVec)>0; if (currentSpeed > 0.5 && movingForward) { engineForceMag -= CAR_BRAKE_FORCE; } else { engineForceMag -= CAR_ENGINE_FORCE*0.6; } } | |
totalForce = vecAdd(totalForce, vecScale(forwardVec, engineForceMag)); | |
const steeringEffectiveness = Math.max(0.2, 1.0 - (currentSpeed / 40)); | |
if (keys.left) { totalTorque -= CAR_TURN_TORQUE * steeringEffectiveness; } | |
if (keys.right) { totalTorque += CAR_TURN_TORQUE * steeringEffectiveness; } | |
obj.drifting = keys.handbrake && currentSpeed > 5; | |
if (obj.drifting) { const brakeDir = currentSpeed > 0.1 ? vecScale(vecNormalize({x: obj.vx, y: obj.vy}),-1) : {x:0, y:0}; totalForce = vecAdd(totalForce, vecScale(brakeDir, CAR_HANDBRAKE_FORCE*0.5)); addTireMarkSegment(); } | |
} | |
// Friction/Drag (Apply to all objects) | |
totalForce = vecAdd(totalForce, vecScale({x: obj.vx, y: obj.vy}, -FRICTION * 30)); | |
// Lateral Tire Friction (Apply more strongly to car, less to obstacles?) | |
// For simplicity, apply same logic but maybe scaled down for obstacles? Let's apply only to car for now. | |
let lateralFrictionMag = 0; | |
if(isPlayerCar) { | |
const lateralVelocity = vecDot({x: obj.vx, y: obj.vy}, rightVec); | |
let gripFactor = (isPlayerCar && keys.handbrake) ? TIRE_HANDBRAKE_GRIP_FACTOR : 1.0; | |
lateralFrictionMag = -lateralVelocity * TIRE_LATERAL_GRIP * gripFactor * obj.mass; | |
totalForce = vecAdd(totalForce, vecScale(rightVec, lateralFrictionMag)); | |
} | |
// --- Apply forces --- | |
// SAFETY CHECK for invMass/invInertia before applying | |
if (obj.invMass > 0) { | |
const linearAccel = vecScale(totalForce, obj.invMass); | |
obj.vx += linearAccel.x * deltaTime * timeScale; | |
obj.vy += linearAccel.y * deltaTime * timeScale; | |
} | |
if (obj.invInertia > 0) { | |
const angularAccel = totalTorque * obj.invInertia; | |
obj.angularVelocity += angularAccel * deltaTime * timeScale; | |
} | |
// Angular damping | |
obj.angularVelocity *= (1 - (1 - ANGULAR_FRICTION) * deltaTime * 60); | |
// --- Integration --- | |
obj.x += obj.vx * deltaTime * timeScale; | |
obj.y += obj.vy * deltaTime * timeScale; | |
obj.angle += obj.angularVelocity * deltaTime * timeScale; | |
// Update Turbo Timer (Only for car) | |
if (isPlayerCar) { | |
if (obj.turboMode) { obj.turboTimer -= deltaTime*timeScale; if (obj.turboTimer <= 0) { obj.turboMode=false; obj.turboTimer=0; obj.turboCooldown=obj.turboCooldownMax; } } | |
else if (obj.turboCooldown > 0) { obj.turboCooldown -= deltaTime*timeScale; if (obj.turboCooldown < 0) { obj.turboCooldown = 0; } } | |
} | |
} | |
function updateObstaclesPhysics(deltaTime) { | |
if (!canvas) return; | |
const dt = deltaTime * timeScale; | |
obstacles.forEach(obs => { | |
try { | |
// --- Apply basic forces (damping) --- | |
// Only damping applied here, more complex forces in applyForces if needed later | |
obs.vx *= (1 - (1 - FRICTION) * dt * 30); | |
obs.vy *= (1 - (1 - FRICTION) * dt * 30); | |
obs.angularVelocity *= (1 - (1 - ANGULAR_FRICTION) * dt * 60); | |
// --- Integration --- | |
obs.x += obs.vx * dt; | |
obs.y += obs.vy * dt; | |
obs.angle += obs.angularVelocity * dt; | |
// Update geometry and check screen bounds | |
updateVerticesAndAxes(obs); | |
handleScreenCollision(obs, canvas.width, canvas.height); | |
} catch (e) { | |
console.error("Error updating physics for obstacle:", obs, e); | |
} | |
}); | |
} | |
// --- Collision Detection & Resolution (SAT - Assumed mostly correct, added logging) --- | |
function updateVerticesAndAxes(obj) { | |
// SAFETY check: Ensure obj and its properties are valid | |
if (!obj || typeof obj.x !== 'number' || typeof obj.y !== 'number' || typeof obj.angle !== 'number' || typeof obj.width !== 'number' || typeof obj.height !== 'number') { | |
console.error("Invalid object passed to updateVerticesAndAxes:", obj); | |
obj.vertices = []; obj.axes = []; // Prevent further errors using these | |
return; | |
} | |
const w = obj.width / 2; | |
const h = obj.height / 2; | |
const cosA = Math.cos(obj.angle); | |
const sinA = Math.sin(obj.angle); | |
const x = obj.x; | |
const y = obj.y; | |
obj.vertices = [ // Ensure this calculation is correct | |
{ x: x + (-w*cosA - -h*sinA), y: y + (-w*sinA + -h*cosA) }, | |
{ x: x + ( w*cosA - -h*sinA), y: y + ( w*sinA + -h*cosA) }, | |
{ x: x + ( w*cosA - h*sinA), y: y + ( w*sinA + h*cosA) }, | |
{ x: x + (-w*cosA - h*sinA), y: y + (-w*sinA + h*cosA) } | |
]; | |
obj.axes = []; | |
for (let i = 0; i < 4; i++) { | |
const p1 = obj.vertices[i]; | |
const p2 = obj.vertices[(i + 1) % 4]; | |
// SAFETY Check: Ensure vertices are valid before calculating edge | |
if (typeof p1.x !== 'number' || typeof p2.x !== 'number') { | |
console.error("Invalid vertices found in updateVerticesAndAxes for obj:", obj); | |
continue; // Skip this axis | |
} | |
const edge = vecSub(p2, p1); | |
const normal = vecNormalize({ x: -edge.y, y: edge.x }); | |
obj.axes.push(normal); | |
} | |
} | |
function projectPolygon(vertices, axis) { | |
// SAFETY check | |
if (!vertices || vertices.length === 0 || !axis || typeof axis.x !== 'number') { | |
console.error("Invalid input to projectPolygon", vertices, axis); | |
return { min: 0, max: 0}; | |
} | |
let min = vecDot(vertices[0], axis); let max = min; | |
for (let i = 1; i < vertices.length; i++) { | |
// SAFETY check vertex | |
if (typeof vertices[i]?.x !== 'number') { | |
console.error("Invalid vertex in projectPolygon", vertices[i]); continue; | |
} | |
const p = vecDot(vertices[i], axis); | |
if (p < min) { min = p; } else if (p > max) { max = p; } | |
} | |
return { min: min, max: max }; | |
} | |
function checkSATCollision(objA, objB) { | |
// SAFETY Check inputs | |
if (!objA || !objB || !objA.axes || !objB.axes || !objA.vertices || !objB.vertices) { | |
console.error("Invalid objects for SAT check:", objA, objB); return null; | |
} | |
// Ensure vertices are current | |
updateVerticesAndAxes(objA); // Update A (e.g., car) | |
// B (obstacle) should be updated in its own physics loop, but update again just in case? Maybe not needed. | |
const axes = [...objA.axes, ...objB.axes]; | |
let minOverlap = Infinity; | |
let collisionNormal = null; | |
for (const axis of axes) { | |
// SAFETY check axis | |
if (typeof axis?.x !== 'number') { console.error("Invalid axis in SAT:", axis); continue; } | |
const projA = projectPolygon(objA.vertices, axis); | |
const projB = projectPolygon(objB.vertices, axis); | |
const overlap = Math.min(projA.max, projB.max) - Math.max(projA.min, projB.min); | |
if (overlap <= 0.0001) { return null; } // Use tolerance | |
if (overlap < minOverlap) { minOverlap = overlap; collisionNormal = axis; } | |
} | |
if (!collisionNormal) return null; // Should not happen if overlap > 0, but safety | |
const direction = vecSub({x:objA.x,y:objA.y}, {x:objB.x,y:objB.y}); | |
if (vecDot(direction, collisionNormal) < 0) { collisionNormal = vecScale(collisionNormal, -1); } | |
// console.log("SAT Collision Detected:", { overlap: minOverlap, normal: collisionNormal }); // DEBUG (can be noisy) | |
return { overlap: minOverlap, normal: collisionNormal }; | |
} | |
function resolveCollision(objA, objB, collisionInfo) { | |
// SAFETY check inputs | |
if (!objA || !objB || !collisionInfo || !collisionInfo.normal || typeof collisionInfo.overlap !== 'number') { | |
console.error("Invalid input to resolveCollision", objA, objB, collisionInfo); return; | |
} | |
const { overlap, normal } = collisionInfo; | |
// SAFETY check normal validity | |
if(typeof normal.x !== 'number' || typeof normal.y !== 'number' || Math.abs(normal.x*normal.x + normal.y*normal.y - 1.0) > 0.01) { | |
console.error("Invalid collision normal:", normal); | |
return; // Avoid resolving with bad normal | |
} | |
// 1. Positional Correction | |
const totalInvMass = objA.invMass + objB.invMass; | |
if (totalInvMass > 0.00001) { // Use tolerance | |
const separationAmount = overlap / totalInvMass; | |
const correctionScale = 0.8; // Penetration resolution percentage (prevent jitter) | |
objA.x += normal.x * separationAmount * objA.invMass * correctionScale; | |
objA.y += normal.y * separationAmount * objA.invMass * correctionScale; | |
objB.x -= normal.x * separationAmount * objB.invMass * correctionScale; | |
objB.y -= normal.y * separationAmount * objB.invMass * correctionScale; | |
// Re-update vertices after position change is important if checking collisions multiple times per frame | |
updateVerticesAndAxes(objA); | |
updateVerticesAndAxes(objB); | |
} | |
// 2. Impulse Calculation | |
const collisionPoint = { x: (objA.x + objB.x) / 2, y: (objA.y + objB.y) / 2 }; // Approx center | |
const rA = vecSub(collisionPoint, {x: objA.x, y: objA.y}); | |
const rB = vecSub(collisionPoint, {x: objB.x, y: objB.y}); | |
const vA = { x: objA.vx + (-objA.angularVelocity * rA.y), y: objA.vy + (objA.angularVelocity * rA.x) }; | |
const vB = { x: objB.vx + (-objB.angularVelocity * rB.y), y: objB.vy + (objB.angularVelocity * rB.x) }; | |
const relativeVelocity = vecSub(vA, vB); | |
const velocityAlongNormal = vecDot(relativeVelocity, normal); | |
if (velocityAlongNormal > 0) return; // Moving apart | |
const restitution = COLLISION_RESTITUTION; | |
const rACrossN = vecCross2D(rA, normal); | |
const rBCrossN = vecCross2D(rB, normal); | |
// SAFETY check for invInertia being valid numbers | |
const invInertiaA = (typeof objA.invInertia === 'number' && isFinite(objA.invInertia)) ? objA.invInertia : 0; | |
const invInertiaB = (typeof objB.invInertia === 'number' && isFinite(objB.invInertia)) ? objB.invInertia : 0; | |
const invInertiaSum = (rACrossN * rACrossN * invInertiaA) + (rBCrossN * rBCrossN * invInertiaB); | |
const denominator = invMassSum + invInertiaSum; | |
// SAFETY Check denominator | |
if (denominator < 0.00001) { | |
console.warn("Collision denominator too small, skipping impulse."); return; | |
} | |
let j = -(1 + restitution) * velocityAlongNormal; | |
j /= denominator; | |
// 3. Apply Impulse | |
const impulse = vecScale(normal, j); | |
if(objA.invMass > 0){ objA.vx += impulse.x * objA.invMass; objA.vy += impulse.y * objA.invMass; } | |
if(objB.invMass > 0){ objB.vx -= impulse.x * objB.invMass; objB.vy -= impulse.y * objB.invMass; } | |
if(invInertiaA > 0){ objA.angularVelocity += vecCross2D(rA, impulse) * invInertiaA; } | |
if(invInertiaB > 0){ objB.angularVelocity -= vecCross2D(rB, impulse) * invInertiaB; } | |
// Scoring & Feedback | |
if (objA === car || objB === car) { | |
const impactMagnitude = Math.abs(j); | |
const scoreToAdd = Math.min(60, Math.max(2, Math.floor(impactMagnitude / 1000))); | |
addScore(scoreToAdd, collisionPoint); | |
if (impactMagnitude > 5000 && gameContainer) { /* Screen shake */ } | |
} | |
} | |
function handleScreenCollision(obj, width, height) { | |
// SAFETY check obj and properties needed | |
if (!obj || typeof obj.x !== 'number' || typeof obj.width !== 'number' || !obj.vertices || obj.vertices.length !== 4) { | |
// console.warn("Invalid object for screen collision:", obj); // Can be noisy | |
return; | |
} | |
const objRadius = Math.max(obj.width, obj.height) / 2; // Approx | |
obj.vertices.forEach((v, i) => { | |
let collided = false; | |
let normal = {x:0, y:0}; | |
let penetration = 0; | |
if (v.x < 0) { collided = true; normal = {x: 1, y: 0}; penetration = -v.x; } | |
if (v.x > width) { collided = true; normal = {x:-1, y: 0}; penetration = v.x - width; } | |
if (v.y < 0) { collided = true; normal = {x: 0, y: 1}; penetration = -v.y; } | |
if (v.y > height) { collided = true; normal = {x: 0, y:-1}; penetration = v.y - height; } | |
if (collided) { | |
// 1. Positional Correction (Move object back slightly) - Simplified | |
// Only apply correction if penetration is significant | |
if(penetration > 0.1 && obj.invMass > 0) { // Don't move static objects | |
obj.x += normal.x * penetration * 0.8; // Correct based on penetration depth | |
obj.y += normal.y * penetration * 0.8; | |
// Need to update vertices after positional correction for accurate impulse | |
updateVerticesAndAxes(obj); | |
} | |
// 2. Impulse Response | |
const velocity = { x: obj.vx, y: obj.vy }; | |
const dot = vecDot(velocity, normal); | |
// Only apply impulse if moving *into* the wall | |
if (dot < 0) { | |
const impulseMag = -(1 + SCREEN_BOUND_RESTITUTION) * dot; | |
const impulse = vecScale(normal, impulseMag); | |
// Apply only if object can move | |
if(obj.invMass > 0){ | |
obj.vx += impulse.x * obj.invMass; | |
obj.vy += impulse.y * obj.invMass; | |
} | |
// Apply angular impulse? Less critical for wall hits unless specific effect desired. | |
// if(obj.invInertia > 0) obj.angularVelocity += (Math.random() - 0.5) * 0.1 * impulseMag * obj.invInertia; | |
} | |
} | |
}); | |
} | |
function checkAllCollisions() { | |
if (!canvas) return; | |
try { // Wrap collision checks | |
// --- Car vs Obstacles --- | |
for (const obstacle of obstacles) { | |
const collisionInfo = checkSATCollision(car, obstacle); | |
if (collisionInfo) { | |
resolveCollision(car, obstacle, collisionInfo); | |
} | |
} | |
// --- Obstacle vs Obstacles --- | |
for (let i = 0; i < obstacles.length; i++) { | |
for (let j = i + 1; j < obstacles.length; j++) { | |
const obsA = obstacles[i]; | |
const obsB = obstacles[j]; | |
const collisionInfo = checkSATCollision(obsA, obsB); | |
if (collisionInfo) { | |
resolveCollision(obsA, obsB, collisionInfo); | |
} | |
} | |
} | |
// --- Car vs Power-Ups --- (Simple Circle Collision) | |
const carRadius = car.width / 2; | |
for (let i = powerUps.length - 1; i >= 0; i--) { | |
const powerUp = powerUps[i]; | |
if (!powerUp.active) continue; | |
const dist = Math.hypot(car.x - powerUp.x, car.y - powerUp.y); | |
if (dist < carRadius + powerUp.radius) { | |
powerUp.active = false; | |
if (powerUp.type === 'turbo') activateTurbo(); else if (powerUp.type === 'score') addScore(250); | |
setTimeout(() => { const cIdx = powerUps.findIndex(p=>p===powerUp); if(cIdx!==-1) powerUps.splice(cIdx, 1); createPowerUps(); }, 2000+Math.random()*2000); | |
} | |
} | |
} catch (e) { | |
console.error("Error during collision checking/resolution:", e); | |
} | |
} | |
// --- Main Game Loop --- | |
let frameCount = 0; // DEBUG | |
function gameLoop(timestamp) { | |
// console.log(`gameLoop start. Visible: ${gameVisible}`); // DEBUG (Very noisy) | |
if (!gameVisible) { animationFrameId = null; return; } | |
const now = performance.now(); | |
// Robust deltaTime calculation | |
deltaTime = (now - (lastTimestamp || now)) / 1000; // Handle first frame case | |
lastTimestamp = now; | |
deltaTime = Math.min(deltaTime, 1 / 20); // Cap delta time | |
// console.log(`Frame: ${frameCount++}, DeltaTime: ${deltaTime.toFixed(4)}, Active: ${gameActive}, Paused: ${isPaused}`); // DEBUG | |
if (gameActive && !isPaused) { | |
try { // Wrap physics updates | |
// --- UPDATE --- | |
applyForces(car, deltaTime); | |
updateObstaclesPhysics(deltaTime); // Updates positions, geometry, screen bounds | |
checkAllCollisions(); | |
} catch (e) { | |
console.error("Error during game update logic:", e); | |
// Consider pausing game on error? | |
// pauseGame(); | |
} | |
} else { | |
// Keep timestamp updated even when paused/inactive to prevent jump on resume | |
// lastTimestamp = performance.now(); // Reconsider this - might cause jump if paused long | |
} | |
// --- DRAW --- | |
if (ctx && canvas) { | |
try { // Wrap drawing | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
drawBackground(ctx); | |
drawTireMarks(ctx); | |
drawObstacles(ctx); | |
drawPowerUps(ctx); | |
drawCar(ctx); | |
drawGameInfo(ctx); | |
} catch (e) { | |
console.error("Error during drawing:", e); | |
// Might indicate issues with object properties being drawn | |
} | |
} else if (!ctx) { | |
// console.warn("gameLoop: ctx is null, skipping draw."); // DEBUG | |
} | |
// Request next frame | |
animationFrameId = requestAnimationFrame(gameLoop); | |
} | |
// --- [ Game Logic - END ] --- | |
// Final check after everything is defined | |
console.log("Game script initialized. Waiting for load event and user interaction."); // DEBUG | |
</script> | |
</body> | |
</html> |