Race the Sun
thinkingcap qwen3.6 27b · middle

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 36,172 bytes · SHA-256 94b76dfc771a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solar Run</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; cursor: none; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ============================================================
// SOLAR RUN — Endless Runner (Race the Sun inspired)
// ============================================================
// === TUNABLE CONSTANTS ===
const CFG = {
// Player
PLAYER_W: 18, PLAYER_H: 24, PLAYER_DEPTH: 30,
MOVE_SPEED: 9.0, // lateral/vertical speed (world units per frame)
INERTIA_EASE: 0.15, // how fast input is applied (higher = snappier)
BANK_ANGLE: 0.45, // max roll angle when strafing
PITCH_LEAN: 0.25, // forward lean on pitch
// World
WORLD_WIDTH: 320, // playable width in world units
LANE_COUNT: 5, // logical lanes for obstacle placement
CHUNK_LENGTH: 120, // distance between spawn points
VISIBLE_DISTANCE: 800, // how far we can see
// Speed & Difficulty
BASE_SPEED: 3.5, // starting forward speed
MAX_SPEED: 14.0, // cap
ACCEL_RATE: 0.002, // speed increase per frame
SPEED_LINE_THRESHOLD: 6.0,// speed at which speed lines appear
// Sun & Energy
SUN_START_Y: 0.35, // sun start position (0=top, 1=bottom)
SUN_SET_SPEED: 0.00008, // how fast sun sinks per frame
ENERGY_MAX: 100,
ENERGY_DRAIN_RATE: 0.025, // base drain per frame
ENERGY_SHADOW_MULT: 3.0, // multiplier when in shadow
ENERGY_PICKUP_AMOUNT: 25,
// Boost
BOOST_SPEED_MULT: 2.0,
BOOST_ENERGY_COST: 0.4,
BOOST_COOLDOWN_FRAMES: 60,
// Obstacles
OBSTACLE_MIN_GAP: 80, // min distance between obstacles
OBSTACLE_SPAWN_RATE: 0.7, // probability per spawn point
OBSTACLE_TYPES: ['pillar', 'wall', 'spike'],
// Particles
PARTICLE_LIFETIME: 40,
THRUST_PARTICLES_PER_FRAME: 2,
// Screen Shake
SHAKE_NEAR_MISS: 3,
SHAKE_CRASH: 12,
SHAKE_DECAY: 0.85,
// Colors
SKY_TOP: '#0a0020',
SKY_MID: '#4a1942',
SKY_BOT: '#ff6b35',
SUN_COLOR: '#ffe066',
SUN_GLOW: '#ff8c00',
GROUND_COLOR: '#1a0a2e',
OBSTACLE_COLOR: '#2d1b4e',
OBSTACLE_HIGHLIGHT: '#5c3d7a',
PICKUP_ENERGY: '#00ffaa',
PICKUP_SCORE: '#ffdd00',
PICKUP_MULTIPLIER: '#ff44ff',
};
// === GLOBALS ===
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H;
// Seeded PRNG (mulberry32)
function mulberry32(a) {
return function() {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
let rng;
let runSeed;
// Game state
const STATE = { TITLE: 0, PLAYING: 1, GAMEOVER: 2 };
let gameState = STATE.TITLE;
// Player
let player = {};
// World objects
let obstacles = [];
let pickups = [];
let particles = [];
// Camera
let camera = {};
// Input
let keys = {};
// Timing
let frameCount = 0;
let lastTime = 0;
let dt = 1/60;
// Score
let score = 0;
let distance = 0;
let multiplier = 1;
let highScore = parseInt(localStorage.getItem('solarRunHigh') || '0');
// Audio context
let audioCtx = null;
let masterGain = null;
let droneOsc = null;
let droneGain = null;
// === RESIZE ===
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// === AUDIO SYSTEM ===
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.3;
masterGain.connect(audioCtx.destination);
// Wind drone
droneOsc = audioCtx.createOscillator();
droneGain = audioCtx.createGain();
droneOsc.type = 'sawtooth';
droneOsc.frequency.value = 55;
droneGain.gain.value = 0.08;
// Low-pass filter for wind sound
const lpf = audioCtx.createBiquadFilter();
lpf.type = 'lowpass';
lpf.frequency.value = 200;
lpf.Q.value = 5;
droneOsc.connect(lpf);
lpf.connect(droneGain);
droneGain.connect(masterGain);
droneOsc.start();
}
function playPickup(freq, dur) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq || 800;
g.gain.setValueAtTime(0.2, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + (dur || 0.15));
osc.connect(g);
g.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + dur + 0.05);
}
function playCrash() {
if (!audioCtx) return;
const bufferSize = audioCtx.sampleRate * 0.3;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
}
const src = audioCtx.createBufferSource();
src.buffer = buffer;
const g = audioCtx.createGain();
g.gain.value = 0.5;
src.connect(g);
g.connect(masterGain);
src.start();
// Low thud
const osc = audioCtx.createOscillator();
const g2 = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(100, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(30, audioCtx.currentTime + 0.3);
g2.gain.setValueAtTime(0.4, audioCtx.currentTime);
g2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.connect(g2);
g2.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
}
function playNearMiss() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.value = 1200;
g.gain.setValueAtTime(0.1, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.08);
osc.connect(g);
g.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
}
function playBoost() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(600, audioCtx.currentTime + 0.15);
g.gain.setValueAtTime(0.15, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
osc.connect(g);
g.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + 0.25);
}
function updateDrone(speed) {
if (!droneOsc) return;
droneOsc.frequency.value = 40 + speed * 8;
droneGain.gain.value = Math.min(0.15, 0.03 + speed * 0.01);
}
// === INPUT ===
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyR' && gameState === STATE.GAMEOVER) startGame();
if ((e.code === 'Space' || e.code === 'Enter') && gameState === STATE.TITLE) startGame();
if ((e.code === 'Space' || e.code === 'Enter') && gameState === STATE.GAMEOVER) startGame();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('click', () => {
if (gameState === STATE.TITLE) startGame();
else if (gameState === STATE.GAMEOVER) startGame();
});
// === PROJECTION HELPERS ===
const CAM_HEIGHT = 60;
const CAM_DIST = 80;
const FOV = 500;
function project(x, y, z) {
// Camera is behind player at (player.x, CAM_HEIGHT, player.z - CAM_DIST)
const cx = camera.shakeX || 0;
const cy = camera.shakeY || 0;
const relX = x - player.x + cx;
const relY = y - CAM_HEIGHT + cy;
const relZ = z - player.z + CAM_DIST;
if (relZ <= 1) return null; // behind camera
const scale = FOV / relZ;
return {
sx: W/2 + relX * scale,
sy: H/2 - relY * scale,
scale: scale,
z: relZ
};
}
// === GAME INIT ===
function startGame() {
initAudio();
runSeed = Date.now() ^ (Math.random() * 0xFFFFFFFF);
rng = mulberry32(runSeed);
gameState = STATE.PLAYING;
frameCount = 0;
score = 0;
distance = 0;
multiplier = 1;
player = {
x: 0, y: 0, z: 0,
vx: 0, vy: 0,
speed: CFG.BASE_SPEED,
energy: CFG.ENERGY_MAX,
bankAngle: 0,
pitchLean: 0,
boosting: false,
boostCooldown: 0,
alive: true,
nearMissTimer: 0,
};
camera = { shakeX: 0, shakeY: 0, shakeMag: 0 };
obstacles = [];
pickups = [];
particles = [];
// Pre-populate some obstacles ahead
for (let z = 200; z < CFG.VISIBLE_DISTANCE; z += CFG.CHUNK_LENGTH) {
spawnChunk(z);
}
}
function gameOver() {
gameState = STATE.GAMEOVER;
player.alive = false;
playCrash();
camera.shakeMag = CFG.SHAKE_CRASH;
if (score > highScore) {
highScore = score;
localStorage.setItem('solarRunHigh', String(highScore));
}
// Explosion particles
for (let i = 0; i < 60; i++) {
spawnParticle(player.x, player.y + CFG.PLAYER_H/2, player.z,
(rng()-0.5)*8, (rng()-0.5)*8 - 3, rng()*4+1,
['#ff4400','#ffaa00','#ffffff','#ff6600'][Math.floor(rng()*4)],
30 + Math.floor(rng()*30)));
}
}
// === SPAWNING ===
function spawnChunk(z) {
if (rng() < CFG.OBSTACLE_SPAWN_RATE) {
const type = CFG.OBSTACLE_TYPES[Math.floor(rng() * CFG.OBSTACLE_TYPES.length)];
const laneWidth = CFG.WORLD_WIDTH / CFG.LANE_COUNT;
// Pick 1-3 lanes to block
const numBlocks = Math.min(3, 1 + Math.floor(distance / 500));
const blockedLanes = new Set();
for (let i = 0; i < numBlocks; i++) {
blockedLanes.add(Math.floor(rng() * CFG.LANE_COUNT));
}
for (const lane of blockedLanes) {
const ox = -CFG.WORLD_WIDTH/2 + laneWidth * lane + laneWidth/2;
let ow, oh, od;
if (type === 'pillar') {
ow = 16 + rng()*10; oh = 40 + rng()*50; od = 16 + rng()*10;
} else if (type === 'wall') {
ow = laneWidth * 0.9; oh = 20 + rng()*30; od = 8;
} else { // spike
ow = 20; oh = 25 + rng()*20; od = 20;
}
obstacles.push({
x: ox, y: -oh/2, z: z + (rng()-0.5)*30,
w: ow, h: oh, d: od, type: type,
passed: false, nearMissChecked: false
});
}
// Spawn pickups in open lanes
for (let l = 0; l < CFG.LANE_COUNT; l++) {
if (!blockedLanes.has(l) && rng() < 0.4) {
const px = -CFG.WORLD_WIDTH/2 + laneWidth * l + laneWidth/2;
const types = ['energy', 'score', 'multiplier'];
const weights = [0.5, 0.35, 0.15];
let r = rng(), cum = 0, ptype = 'energy';
for (let i = 0; i < types.length; i++) {
cum += weights[i];
if (r < cum) { ptype = types[i]; break; }
}
pickups.push({
x: px, y: -15 + rng()*20, z: z + (rng()-0.5)*30,
type: ptype, collected: false, bobPhase: rng() * Math.PI*2
});
}
}
}
}
// === PARTICLES ===
function spawnParticle(x, y, z, vx, vy, vz, color, life) {
particles.push({ x, y, z, vx, vy, vz, color, life, maxLife: life || CFG.PARTICLE_LIFETIME });
}
// === UPDATE ===
function update() {
if (gameState !== STATE.PLAYING) return;
frameCount++;
// Speed ramp
player.speed = Math.min(CFG.MAX_SPEED, player.speed + CFG.ACCEL_RATE);
// Input with inertia
let inputX = 0, inputY = 0;
if (keys['ArrowLeft'] || keys['KeyA']) inputX -= 1;
if (keys['ArrowRight'] || keys['KeyD']) inputX += 1;
if (keys['ArrowUp'] || keys['KeyW']) inputY -= 1;
if (keys['ArrowDown'] || keys['KeyS']) inputY += 1;
// Boost
player.boosting = false;
if ((keys['Space']) && player.energy > 5 && player.boostCooldown <= 0) {
player.boosting = true;
player.speed *= CFG.BOOST_SPEED_MULT;
player.energy -= CFG.BOOST_ENERGY_COST;
player.boostCooldown = CFG.BOOST_COOLDOWN_FRAMES;
playBoost();
}
if (player.boostCooldown > 0) player.boostCooldown--;
// Apply movement with easing
const targetVx = inputX * CFG.MOVE_SPEED;
const targetVy = inputY * CFG.MOVE_SPEED;
player.vx += (targetVx - player.vx) * CFG.INERTIA_EASE;
player.vy += (targetVy - player.vy) * CFG.INERTIA_EASE;
// Bank angle from lateral movement
const targetBank = inputX * CFG.BANK_ANGLE;
player.bankAngle += (targetBank - player.bankAngle) * 0.2;
// Pitch lean from vertical movement
const targetPitch = inputY * CFG.PITCH_LEAN;
player.pitchLean += (targetPitch - player.pitchLean) * 0.2;
player.x += player.vx;
player.y += player.vy;
player.z += player.speed;
// Clamp to world bounds
const halfW = CFG.WORLD_WIDTH / 2 - CFG.PLAYER_W/2;
player.x = Math.max(-halfW, Math.min(halfW, player.x));
player.y = Math.max(-80, Math.min(40, player.y));
// Distance & score
distance += player.speed * 0.1;
score += Math.floor(player.speed * multiplier * 0.5);
// Energy drain
let inShadow = false;
for (const obs of obstacles) {
if (!obs.passed && Math.abs(obs.z - player.z) < 30 && Math.abs(obs.x - player.x) < obs.w/2 + CFG.PLAYER_W) {
inShadow = true;
break;
}
}
const drainMult = inShadow ? CFG.ENERGY_SHADOW_MULT : 1;
player.energy -= CFG.ENERGY_DRAIN_RATE * drainMult;
if (player.energy <= 0) {
player.energy = 0;
gameOver();
return;
}
// Sun position
const sunY = CFG.SUN_START_Y - frameCount * CFG.SUN_SET_SPEED;
if (sunY < -0.15) {
gameOver();
return;
}
// Collision detection
for (const obs of obstacles) {
if (obs.passed) continue;
const dx = Math.abs(player.x - obs.x);
const dy = Math.abs((player.y + CFG.PLAYER_H/2) - (obs.y + obs.h/2));
const dz = Math.abs(player.z - obs.z);
// AABB collision
if (dx < (CFG.PLAYER_W + obs.w)/2 &&
dy < (CFG.PLAYER_H + obs.h)/2 &&
dz < (CFG.PLAYER_DEPTH + obs.d)/2) {
gameOver();
return;
}
// Near miss detection
if (!obs.nearMissChecked && player.z > obs.z + obs.d/2 + CFG.PLAYER_DEPTH/2) {
const nearDx = (CFG.PLAYER_W + obs.w)/2 * 1.8;
const nearDz = (CFG.PLAYER_DEPTH + obs.d)/2 * 1.5;
if (dx < nearDx && dz < nearDz) {
score += 50 * multiplier;
camera.shakeMag = CFG.SHAKE_NEAR_MISS;
player.nearMissTimer = 15;
playNearMiss();
// Near miss particles
for (let i = 0; i < 8; i++) {
spawnParticle(player.x + (rng()-0.5)*20, player.y + rng()*20, player.z - 10,
(rng()-0.5)*4, rng()*3+1, rng()*2, '#ffffff', 15);
}
}
obs.nearMissChecked = true;
}
if (player.z > obs.z + obs.d/2 + CFG.PLAYER_DEPTH) {
obs.passed = true;
}
}
// Pickup collection
for (const pk of pickups) {
if (pk.collected) continue;
const dx = Math.abs(player.x - pk.x);
const dy = Math.abs((player.y + CFG.PLAYER_H/2) - pk.y);
const dz = Math.abs(player.z - pk.z);
if (dx < 25 && dy < 25 && dz < 20) {
pk.collected = true;
playPickup(pk.type === 'energy' ? 600 : pk.type === 'score' ? 900 : 1400, 0.12);
if (pk.type === 'energy') player.energy = Math.min(CFG.ENERGY_MAX, player.energy + CFG.ENERGY_PICKUP_AMOUNT);
else if (pk.type === 'score') score += 100 * multiplier;
else if (pk.type === 'multiplier') { multiplier = Math.min(8, multiplier + 1); }
// Pickup particles
const colors = { energy: CFG.PICKUP_ENERGY, score: CFG.PICKUP_SCORE, multiplier: CFG.PICKUP_MULTIPLIER };
for (let i = 0; i < 12; i++) {
spawnParticle(pk.x, pk.y, pk.z,
(rng()-0.5)*5, rng()*4+1, (rng()-0.5)*3,
colors[pk.type], 20);
}
}
}
// Thrust particles
if (player.alive) {
for (let i = 0; i < CFG.THRUST_PARTICLES_PER_FRAME; i++) {
spawnParticle(
player.x + (rng()-0.5)*6,
player.y - 2 + rng()*4,
player.z - CFG.PLAYER_DEPTH/2 - rng()*5,
(rng()-0.5)*1.5, (rng()-0.5)*1.5, -rng()*3-1,
player.boosting ? '#ffaa00' : '#66aaff',
15 + Math.floor(rng()*10)
);
}
}
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx; p.y += p.vy; p.z += p.vz;
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
// Update obstacles - remove far behind
for (let i = obstacles.length - 1; i >= 0; i--) {
if (obstacles[i].z < player.z - 200) obstacles.splice(i, 1);
}
// Remove collected pickups
for (let i = pickups.length - 1; i >= 0; i--) {
if (pickups[i].collected || pickups[i].z < player.z - 200) pickups.splice(i, 1);
}
// Spawn new chunks ahead
const furthestZ = obstacles.reduce((m, o) => Math.max(m, o.z), 0);
if (furthestZ < player.z + CFG.VISIBLE_DISTANCE) {
spawnChunk(furthestZ + CFG.CHUNK_LENGTH);
}
// Screen shake decay
if (camera.shakeMag > 0.1) {
camera.shakeX = (Math.random()-0.5) * camera.shakeMag;
camera.shakeY = (Math.random()-0.5) * camera.shakeMag;
camera.shakeMag *= CFG.SHAKE_DECAY;
} else {
camera.shakeX = 0; camera.shakeY = 0; camera.shakeMag = 0;
}
// Near miss timer
if (player.nearMissTimer > 0) player.nearMissTimer--;
// Update drone sound
updateDrone(player.speed);
}
// === RENDERING ===
function render() {
ctx.clearRect(0, 0, W, H);
const sunY = gameState === STATE.PLAYING ? CFG.SUN_START_Y - frameCount * CFG.SUN_SET_SPEED : 0.3;
// Sky gradient
drawSky(sunY);
if (gameState === STATE.TITLE) {
drawTitleScreen();
return;
}
// Ground plane
drawGround(sunY);
// Collect all renderable objects with depth for sorting
let renderList = [];
// Obstacles
for (const obs of obstacles) {
const relZ = obs.z - player.z + CAM_DIST;
if (relZ > 0 && relZ < CFG.VISIBLE_DISTANCE) {
renderList.push({ type: 'obstacle', data: obs, z: relZ });
}
}
// Pickups
for (const pk of pickups) {
const relZ = pk.z - player.z + CAM_DIST;
if (relZ > 0 && relZ < CFG.VISIBLE_DISTANCE) {
renderList.push({ type: 'pickup', data: pk, z: relZ });
}
}
// Particles
for (const p of particles) {
const relZ = p.z - player.z + CAM_DIST;
if (relZ > 0 && relZ < CFG.VISIBLE_DISTANCE) {
renderList.push({ type: 'particle', data: p, z: relZ });
}
}
// Sort back to front
renderList.sort((a, b) => b.z - a.z);
for (const item of renderList) {
if (item.type === 'obstacle') drawObstacle(item.data, sunY);
else if (item.type === 'pickup') drawPickup(item.data);
else if (item.type === 'particle') drawParticle(item.data);
}
// Player craft
if (player.alive) drawPlayer();
// Speed lines at high speed
if (player.speed > CFG.SPEED_LINE_THRESHOLD && player.alive) {
drawSpeedLines(player.speed);
}
// Chromatic aberration flash on near miss
if (player.nearMissTimer > 0) {
ctx.globalAlpha = player.nearMissTimer / 15 * 0.3;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// HUD
drawHUD(sunY);
if (gameState === STATE.GAMEOVER) {
drawGameOver();
}
}
function drawSky(sunNormY) {
const sunScreenY = H * (0.5 - sunNormY);
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, CFG.SKY_TOP);
grad.addColorStop(0.4, CFG.SKY_MID);
grad.addColorStop(0.75, CFG.SKY_BOT);
grad.addColorStop(1, '#ff8844');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// Sun glow
const sunR = Math.min(W, H) * 0.12;
const glowGrad = ctx.createRadialGradient(W/2, sunScreenY, 0, W/2, sunScreenY, sunR*3);
glowGrad.addColorStop(0, 'rgba(255,224,102,0.8)');
glowGrad.addColorStop(0.3, 'rgba(255,140,0,0.3)');
glowGrad.addColorStop(1, 'rgba(255,100,0,0)');
ctx.fillStyle = glowGrad;
ctx.fillRect(W/2 - sunR*3, sunScreenY - sunR*3, sunR*6, sunR*6);
// Sun disc
const sunDiscGrad = ctx.createRadialGradient(W/2, sunScreenY, 0, W/2, sunScreenY, sunR);
sunDiscGrad.addColorStop(0, '#fff8e0');
sunDiscGrad.addColorStop(0.7, CFG.SUN_COLOR);
sunDiscGrad.addColorStop(1, CFG.SUN_GLOW);
ctx.fillStyle = sunDiscGrad;
ctx.beginPath();
ctx.arc(W/2, sunScreenY, sunR, 0, Math.PI*2);
ctx.fill();
// Horizon line
const horizonY = H * 0.55;
ctx.strokeStyle = 'rgba(255,140,0,0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, horizonY);
ctx.lineTo(W, horizonY);
ctx.stroke();
}
function drawGround(sunNormY) {
const horizonY = H * 0.55;
// Ground gradient
const gGrad = ctx.createLinearGradient(0, horizonY, 0, H);
gGrad.addColorStop(0, '#1a0832');
gGrad.addColorStop(0.5, '#0d0418');
gGrad.addColorStop(1, '#060110');
ctx.fillStyle = gGrad;
ctx.fillRect(0, horizonY, W, H - horizonY);
// Grid lines on ground for depth perception
const gridSpacing = 30;
ctx.strokeStyle = 'rgba(100,50,150,0.2)';
ctx.lineWidth = 1;
// Horizontal lines (depth)
for (let z = 10; z < CFG.VISIBLE_DISTANCE; z += gridSpacing) {
const p1 = project(-CFG.WORLD_WIDTH/2 - 40, 0, player.z + z);
const p2 = project(CFG.WORLD_WIDTH/2 + 40, 0, player.z + z);
if (p1 && p2) {
ctx.globalAlpha = Math.max(0, 1 - z / CFG.VISIBLE_DISTANCE) * 0.3;
ctx.beginPath();
ctx.moveTo(p1.sx, p1.sy);
ctx.lineTo(p2.sx, p2.sy);
ctx.stroke();
}
}
// Vertical lines (lanes)
for (let x = -CFG.WORLD_WIDTH/2; x <= CFG.WORLD_WIDTH/2; x += CFG.WORLD_WIDTH / CFG.LANE_COUNT) {
const p1 = project(x, 0, player.z + 5);
const p2 = project(x, 0, player.z + CFG.VISIBLE_DISTANCE);
if (p1 && p2) {
ctx.globalAlpha = 0.15;
ctx.beginPath();
ctx.moveTo(p1.sx, p1.sy);
ctx.lineTo(p2.sx, p2.sy);
ctx.stroke();
}
}
// World boundary lines (brighter)
for (const side of [-CFG.WORLD_WIDTH/2 - 5, CFG.WORLD_WIDTH/2 + 5]) {
const p1 = project(side, 0, player.z + 5);
const p2 = project(side, 0, player.z + CFG.VISIBLE_DISTANCE);
if (p1 && p2) {
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#aa66ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(p1.sx, p1.sy);
ctx.lineTo(p2.sx, p2.sy);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
}
function drawObstacle(obs, sunNormY) {
const relZ = obs.z - player.z + CAM_DIST;
if (relZ <= 0 || relZ >= CFG.VISIBLE_DISTANCE) return;
// Determine shadow factor based on position relative to sun
const sunDirX = 0; // sun is centered horizontally
const inShadow = Math.abs(obs.x) > CFG.WORLD_WIDTH * 0.3 ? 0.5 : 1.0;
const hw = obs.w/2, hh = obs.h/2, hd = obs.d/2;
// Project all 8 corners of the box
const corners = [
project(obs.x - hw, obs.y + obs.h, obs.z - hd), // front top-left
project(obs.x + hw, obs.y + obs.h, obs.z - hd), // front top-right
project(obs.x + hw, obs.y, obs.z - hd), // front bot-right
project(obs.x - hw, obs.y, obs.z - hd), // front bot-left
project(obs.x - hw, obs.y + obs.h, obs.z + hd), // back top-left
project(obs.x + hw, obs.y + obs.h, obs.z + hd), // back top-right
project(obs.x + hw, obs.y, obs.z + hd), // back bot-right
project(obs.x - hw, obs.y, obs.z + hd), // back bot-left
];
if (corners.some(c => c === null)) return;
const alpha = Math.max(0.15, 1 - relZ / CFG.VISIBLE_DISTANCE);
// Draw faces: top, front, right side
ctx.globalAlpha = alpha;
// Top face
ctx.fillStyle = `rgba(${Math.floor(92*inShadow)},${Math.floor(61*inShadow)},${Math.floor(122*inShadow)},1)`;
drawQuad(corners[0], corners[1], corners[5], corners[4]);
// Front face
ctx.fillStyle = `rgba(${Math.floor(45*inShadow)},${Math.floor(27*inShadow)},${Math.floor(78*inShadow)},1)`;
drawQuad(corners[0], corners[1], corners[2], corners[3]);
// Right face
ctx.fillStyle = `rgba(${Math.floor(65*inShadow)},${Math.floor(40*inShadow)},${Math.floor(100*inShadow)},1)`;
drawQuad(corners[1], corners[5], corners[6], corners[2]);
// Edge highlight
ctx.strokeStyle = `rgba(${Math.floor(140*inShadow)},${Math.floor(90*inShadow)},${Math.floor(200*inShadow)},0.6)`;
ctx.lineWidth = Math.max(1, 3 * (FOV / relZ));
// Spike type gets special rendering
if (obs.type === 'spike') {
const topP = project(obs.x, obs.y + obs.h + 15, obs.z);
if (topP) {
ctx.fillStyle = `rgba(${Math.floor(180*inShadow)},${Math.floor(40*inShadow)},${Math.floor(60*inShadow)},1)`;
drawTriangle(corners[0], corners[1], topP);
}
}
ctx.globalAlpha = 1;
}
function drawQuad(a, b, c, d) {
if (!a || !b || !c || !d) return;
ctx.beginPath();
ctx.moveTo(a.sx, a.sy);
ctx.lineTo(b.sx, b.sy);
ctx.lineTo(c.sx, c.sy);
ctx.lineTo(d.sx, d.sy);
ctx.closePath();
ctx.fill();
}
function drawTriangle(a, b, c) {
if (!a || !b || !c) return;
ctx.beginPath();
ctx.moveTo(a.sx, a.sy);
ctx.lineTo(b.sx, b.sy);
ctx.lineTo(c.sx, c.sy);
ctx.closePath();
ctx.fill();
}
function drawPickup(pk) {
const relZ = pk.z - player.z + CAM_DIST;
if (relZ <= 0 || relZ >= CFG.VISIBLE_DISTANCE) return;
const bob = Math.sin(frameCount * 0.1 + pk.bobPhase) * 5;
const p = project(pk.x, pk.y + bob, pk.z);
if (!p) return;
const size = Math.max(3, 12 * p.scale);
const alpha = Math.max(0.2, 1 - relZ / CFG.VISIBLE_DISTANCE);
ctx.globalAlpha = alpha;
let color;
if (pk.type === 'energy') color = CFG.PICKUP_ENERGY;
else if (pk.type === 'score') color = CFG.PICKUP_SCORE;
else color = CFG.PICKUP_MULTIPLIER;
// Glow
const glowGrad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, size*2);
glowGrad.addColorStop(0, color + '88');
glowGrad.addColorStop(1, color + '00');
ctx.fillStyle = glowGrad;
ctx.fillRect(p.sx - size*2, p.sy - size*2, size*4, size*4);
// Diamond shape
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(p.sx, p.sy - size);
ctx.lineTo(p.sx + size * 0.7, p.sy);
ctx.lineTo(p.sx, p.sy + size);
ctx.lineTo(p.sx - size * 0.7, p.sy);
ctx.closePath();
ctx.fill();
// Inner highlight
ctx.fillStyle = '#ffffff88';
ctx.beginPath();
ctx.moveTo(p.sx, p.sy - size*0.5);
ctx.lineTo(p.sx + size * 0.3, p.sy);
ctx.lineTo(p.sx, p.sy + size*0.2);
ctx.lineTo(p.sx - size * 0.3, p.sy);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
}
function drawParticle(p) {
const relZ = p.z - player.z + CAM_DIST;
if (relZ <= 0 || relZ >= CFG.VISIBLE_DISTANCE) return;
const proj = project(p.x, p.y, p.z);
if (!proj) return;
const size = Math.max(1, 4 * proj.scale * (p.life / p.maxLife));
ctx.globalAlpha = p.life / p.maxLife;
ctx.fillStyle = p.color;
ctx.fillRect(proj.sx - size/2, proj.sy - size/2, size, size);
ctx.globalAlpha = 1;
}
function drawPlayer() {
const px = W/2 + (camera.shakeX || 0);
const py = H * 0.65 + (camera.shakeY || 0);
ctx.save();
ctx.translate(px, py);
ctx.rotate(player.bankAngle);
// Craft body - sleek triangle shape
const cw = CFG.PLAYER_W * 2;
const ch = CFG.PLAYER_H * 1.5;
// Engine glow
if (player.alive) {
const engineGlow = ctx.createRadialGradient(0, ch/2 + 5, 0, 0, ch/2 + 5, 20);
engineGlow.addColorStop(0, player.boosting ? 'rgba(255,170,0,0.8)' : 'rgba(100,170,255,0.6)');
engineGlow.addColorStop(1, 'rgba(100,170,255,0)');
ctx.fillStyle = engineGlow;
ctx.fillRect(-20, ch/2 - 5, 40, 30);
}
// Main body
ctx.fillStyle = '#c8b8ff';
ctx.beginPath();
ctx.moveTo(0, -ch/2); // nose
ctx.lineTo(cw/2, ch/4); // right wing tip
ctx.lineTo(cw/3, ch/2); // right engine
ctx.lineTo(-cw/3, ch/2); // left engine
ctx.lineTo(-cw/2, ch/4); // left wing tip
ctx.closePath();
ctx.fill();
// Cockpit highlight
ctx.fillStyle = '#e8ddff';
ctx.beginPath();
ctx.moveTo(0, -ch/3);
ctx.lineTo(cw/6, ch/6);
ctx.lineTo(-cw/6, ch/6);
ctx.closePath();
ctx.fill();
// Wing accents
ctx.strokeStyle = '#8866cc';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -ch/4);
ctx.lineTo(cw/3, ch/3);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, -ch/4);
ctx.lineTo(-cw/3, ch/3);
ctx.stroke();
// Boost flame
if (player.boosting) {
const flameLen = 15 + Math.random() * 15;
ctx.fillStyle = '#ffaa00';
ctx.beginPath();
ctx.moveTo(-cw/4, ch/2);
ctx.lineTo(0, ch/2 + flameLen);
ctx.lineTo(cw/4, ch/2);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.moveTo(-cw/8, ch/2);
ctx.lineTo(0, ch/2 + flameLen * 0.6);
ctx.lineTo(cw/8, ch/2);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawSpeedLines(speed) {
const intensity = Math.min(1, (speed - CFG.SPEED_LINE_THRESHOLD) / (CFG.MAX_SPEED - CFG.SPEED_LINE_THRESHOLD));
const numLines = Math.floor(intensity * 20);
ctx.globalAlpha = intensity * 0.4;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
for (let i = 0; i < numLines; i++) {
const x = rng() * W;
const y = H * 0.3 + rng() * H * 0.5;
const len = 20 + rng() * 60 * intensity;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (rng()-0.5)*4, y + len);
ctx.stroke();
}
// Chromatic aberration at very high speed
if (speed > CFG.MAX_SPEED * 0.7) {
const caIntensity = (speed - CFG.MAX_SPEED * 0.7) / (CFG.MAX_SPEED * 0.3);
ctx.globalAlpha = caIntensity * 0.15;
// Red channel offset
ctx.globalCompositeOperation = 'screen';
ctx.drawImage(canvas, -caIntensity*8, 0);
ctx.globalCompositeOperation = 'source-over';
}
ctx.globalAlpha = 1;
}
function drawHUD(sunNormY) {
const pad = 20;
const barW = 200;
const barH = 14;
// Semi-transparent HUD background
ctx.fillStyle = 'rgba(0,0,0,0.35)';
roundRect(ctx, pad - 5, pad - 5, barW + 60, 180, 8);
ctx.fill();
ctx.font = 'bold 14px monospace';
ctx.textBaseline = 'top';
// Score
ctx.fillStyle = '#ffffff';
ctx.fillText(`SCORE: ${score.toLocaleString()}`, pad, pad + 5);
// Distance
ctx.fillStyle = '#aabbcc';
ctx.fillText(`DIST: ${Math.floor(distance)}m`, pad, pad + 24);
// Multiplier
ctx.fillStyle = multiplier > 1 ? CFG.PICKUP_MULTIPLIER : '#888888';
ctx.fillText(`MULTI: x${multiplier}`, pad, pad + 43);
// Speed
const speedPct = (player.speed - CFG.BASE_SPEED) / (CFG.MAX_SPEED - CFG.BASE_SPEED);
ctx.fillStyle = speedPct > 0.7 ? '#ff6644' : '#88ccff';
ctx.fillText(`SPD: ${player.speed.toFixed(1)}`, pad, pad + 62);
// Energy bar
const energyPct = player.energy / CFG.ENERGY_MAX;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, pad, pad + 84, barW, barH, 4);
ctx.fill();
const eColor = energyPct > 0.3 ? '#00ddaa' : '#ff4444';
ctx.fillStyle = eColor;
roundRect(ctx, pad, pad + 84, barW * energyPct, barH, 4);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 10px monospace';
ctx.fillText('ENERGY', pad + 5, pad + 86);
ctx.font = 'bold 14px monospace';
// Sun indicator
const sunPct = Math.max(0, (sunNormY + 0.15) / (CFG.SUN_START_Y + 0.15));
ctx.fillStyle = '#ffaa44';
ctx.fillText(`☀ ${Math.floor(sunPct * 100)}%`, pad, pad + 108);
// Sun bar
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(ctx, pad, pad + 126, barW, barH, 4);
ctx.fill();
ctx.fillStyle = '#ffaa44';
roundRect(ctx, pad, pad + 126, barW * sunPct, barH, 4);
ctx.fill();
// High score
ctx.fillStyle = 'rgba(0,0,0,0.35)';
roundRect(ctx, W - pad - 120, pad - 5, 125, 35, 8);
ctx.fill();
ctx.fillStyle = '#ffdd44';
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'center';
ctx.fillText(`HI: ${highScore.toLocaleString()}`, W - pad - 57, pad + 8);
ctx.textAlign = 'left';
// Boost indicator
if (player.boostCooldown > 0) {
ctx.fillStyle = '#ff664488';
ctx.font = 'bold 12px monospace';
ctx.fillText('BOOST COOLDOWN', pad, H - pad - 30);
} else if (player.energy > 5) {
ctx.fillStyle = '#66aaff88';
ctx.font = 'bold 12px monospace';
ctx.fillText('[SPACE] BOOST READY', pad, H - pad - 30);
}
// Controls hint (fades out after 5 seconds)
if (frameCount < 300 && gameState === STATE.PLAYING) {
const fadeAlpha = frameCount > 240 ? (300 - frameCount) / 60 : 1;
ctx.globalAlpha = fadeAlpha * 0.7;
ctx.fillStyle = '#ffffff';
ctx.font = '13px monospace';
ctx.textAlign = 'center';
ctx.fillText('WASD/Arrows: Move | Space: Boost', W/2, H - 40);
ctx.textAlign = 'left';
ctx.globalAlpha = 1;
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawTitleScreen() {
// Animated background particles
for (let i = 0; i < 30; i++) {
const x = ((i * 137.5 + frameCount * 0.5) % W);
const y = ((i * 97.3 + Math.sin(frameCount*0.02+i)*20) % H);
ctx.globalAlpha = 0.15;
ctx.fillStyle = '#aa66ff';
ctx.fillRect(x, y, 3, 3);
}
ctx.globalAlpha = 1;
// Title
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Glow behind title
const titleY = H * 0.35;
const glowGrad = ctx.createRadialGradient(W/2, titleY, 0, W/2, titleY, 200);
glowGrad.addColorStop(0, 'rgba(255,140,0,0.15)');
glowGrad.addColorStop(1, 'rgba(255,140,0,0)');
ctx.fillStyle = glowGrad;
ctx.fillRect(W/2 - 200, titleY - 100, 400, 200);
ctx.font = `bold ${Math.min(72, W*0.08)}px monospace`;
ctx.fillStyle = '#ffe066';
ctx.fillText('SOLAR RUN', W/2, titleY);
ctx.font = `${Math.min(18, W*0.025)}px monospace`;
ctx.fillStyle = '#cc99ff';
ctx.fillText('Endless Runner', W/2, titleY + 40);
// Instructions
const instrY = H * 0.6;
ctx.font = `${Math.min(16, W*0.02)}px monospace`;
ctx.fillStyle = '#aabbcc';
ctx.fillText('WASD / Arrow Keys — Move', W/2, instrY);
ctx.fillText('Space — Boost (costs energy)', W/2, instrY + 25);
// Start prompt
const pulseAlpha = 0.5 + Math.sin(frameCount * 0.08) * 0.4;
ctx.globalAlpha = pulseAlpha;
ctx.font = `bold ${Math.min(22, W*0.03)}px monospace`;
ctx.fillStyle = '#ffffff';
ctx.fillText('[ PRESS SPACE OR CLICK TO START ]', W/2, H * 0.78);
ctx.globalAlpha = 1;
// High score
if (highScore > 0) {
ctx.font = 'bold 16px monospace';
ctx.fillStyle = '#ffdd44';
ctx.fillText(`HIGH SCORE: ${highScore.toLocaleString()}`, W/2, H * 0.88);
}
ctx.textAlign = 'left';
}
function drawGameOver() {
// Dark overlay
ctx.fillStyle = 'rgba(10,0,30,0.75)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const centerY = H * 0.4;
// Title
ctx.font = `bold ${Math.min(60, W*0.07)}px monospace`;
ctx.fillStyle = '#ff4444';
ctx.fillText('GAME OVER', W/2, centerY - 60);
// Stats
ctx.font = `${Math.min(20, W*0.025)}px monospace`;
ctx.fillStyle = '#ffffff';
ctx.fillText(`Score: ${score.toLocaleString()}`, W/2, centerY + 10);
ctx.fillText(`Distance: ${Math.floor(distance)}m`, W/2, centerY + 40);
if (score >= highScore && score > 0) {
ctx.fillStyle = '#ffdd44';
ctx.font = `bold ${Math.min(22, W*0.03)}px monospace`;
ctx.fillText('★ NEW HIGH SCORE ★', W/2, centerY + 80);
}
// Restart prompt
const pulseAlpha = 0.5 + Math.sin(frameCount * 0.08) * 0.4;
ctx.globalAlpha = pulseAlpha;
ctx.font = `bold ${Math.min(18, W*0.025)}px monospace`;
ctx.fillStyle = '#ffffff';
ctx.fillText('[ PRESS R OR CLICK TO RESTART ]', W/2, centerY + 130);
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
// === MAIN LOOP ===
function gameLoop(timestamp) {
if (!lastTime) lastTime = timestamp;
const delta = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Cap dt to prevent spiral of death
dt = Math.min(delta, 1/30);
update();
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"middle","sessionId":"e07755bf-06c6-4e96-93f0-9962fbda5960","tokensIn":17037,"tokensOut":13780,"tokensTotal":30817,"turns":2,"toolCalls":1,"failedToolCalls":0,"timestamp":"2026-07-18T23:47:33.526Z"} -->