Race the Sun
thinkingcap qwen3.6 27b · full

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 31,882 bytes · SHA-256 a2e6a7a7635e
<!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>
// ============================================================
// === CONSTANTS — tune these for balancing ====================
// ============================================================
const C = {
PLAYER_W: 28, PLAYER_H: 16, PLAYER_DEPTH: 40,
MOVE_SPEED: 14,
EASE_FACTOR: 0.15,
BANK_ANGLE: 0.35,
PITCH_LEAN: 0.2,
BASE_SPEED: 4,
MAX_SPEED: 18,
ACCEL_RATE: 0.003,
SPEED_LINE_THRESHOLD: 7,
LANE_COUNT: 5,
LANE_WIDTH: 60,
ROAD_WIDTH: 400,
CHUNK_LENGTH: 200,
DRAW_DISTANCE: 1800,
SUN_START_Y: 0.35,
SUN_SINK_RATE: 0.00004,
ENERGY_MAX: 100,
ENERGY_DRAIN: 0.012,
ENERGY_SHADOW_MULT: 3.5,
ENERGY_PICKUP: 20,
OBSTACLE_MIN_W: 30,
OBSTACLE_MAX_W: 80,
OBSTACLE_H_RANGE: [40, 120],
GAP_CHANCE: 0.35,
PARTICLE_LIFE: 40,
THRUST_PARTICLES_PER_FRAME: 2,
SHAKE_NEAR_MISS: 4,
SHAKE_CRASH: 16,
SHAKE_DECAY: 0.85,
BOOST_SPEED_MULT: 2.2,
BOOST_DURATION: 30,
BOOST_ENERGY_COST: 15,
BOOST_COOLDOWN: 45,
SKY_TOP: [10, 5, 30],
SKY_MID: [60, 20, 80],
SKY_BOT: [200, 80, 40],
SUN_COLOR: [255, 220, 100],
SUN_GLOW: [255, 160, 50],
GROUND_COLOR: [30, 15, 40],
OBSTACLE_COLORS: [[80, 40, 100], [120, 50, 70], [60, 30, 90]],
PICKUP_ENERGY: [100, 255, 180],
PICKUP_SCORE: [255, 220, 80],
PICKUP_MULTI: [255, 100, 255],
AUDIO_VOLUME: 0.3,
};
// ============================================================
// === SETUP — canvas, state, seed ===========================
// ============================================================
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// Seeded PRNG (mulberry32)
function mulberry32(a) {
return function() {
a |= 0; a = a + 0x6D2B79F5 | 0;
let 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 seed = Date.now() & 0xFFFFFFFF;
let rng;
const STATE = { TITLE: 0, PLAYING: 1, GAMEOVER: 2 };
let gameState = STATE.TITLE;
// ============================================================
// === AUDIO — procedural WebAudio ===========================
// ============================================================
let audioCtx = null;
let masterGain = null;
let droneOsc = null;
let droneGain = null;
function initAudio() {
if (audioCtx) return;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = C.AUDIO_VOLUME;
masterGain.connect(audioCtx.destination);
droneOsc = audioCtx.createOscillator();
droneGain = audioCtx.createGain();
droneOsc.type = 'sawtooth';
droneOsc.frequency.value = 55;
droneGain.gain.value = 0.12;
const lfo = audioCtx.createOscillator();
lfo.frequency.value = 0.3;
const lfoGain = audioCtx.createGain();
lfoGain.gain.value = 8;
lfo.connect(lfoGain);
lfoGain.connect(droneOsc.frequency);
lfo.start();
droneOsc.connect(droneGain);
droneGain.connect(masterGain);
droneOsc.start();
} catch(e) { /* audio not available */ }
}
function playTone(freq, dur, type, vol) {
if (!audioCtx) return;
try {
freq = freq || 440; dur = dur || 0.1; type = type || 'square'; vol = vol || 0.3;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.setValueAtTime(vol, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
o.connect(g);
g.connect(masterGain);
o.start();
o.stop(audioCtx.currentTime + dur);
} catch(e) {}
}
function sfxPickup() { playTone(880, 0.12, 'square', 0.25); setTimeout(() => playTone(1320, 0.1, 'sine', 0.2), 60); }
function sfxMulti() { playTone(660, 0.08); setTimeout(() => playTone(990, 0.08), 50); setTimeout(() => playTone(1320, 0.15), 100); }
function sfxCrash() { playTone(60, 0.4, 'sawtooth', 0.5); playTone(40, 0.5, 'square', 0.3); }
function sfxNearMiss() { playTone(200, 0.15, 'triangle', 0.15); }
function sfxBoost() { playTone(300, 0.2, 'sawtooth', 0.2); setTimeout(() => playTone(600, 0.15), 80); }
// ============================================================
// === INPUT ==================================================
// ============================================================
const keys = {};
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyR' && gameState === STATE.GAMEOVER) startGame();
if ((e.code === 'Enter' || e.code === 'Space') && gameState === STATE.TITLE) startGame();
if (gameState !== STATE.PLAYING) e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
// ============================================================
// === GAME OBJECTS ===========================================
// ============================================================
let player, obstacles, pickups, particles;
let distance, speed, energy, multiplier, score, highScore;
let shakeX, shakeY, shakeMag;
let sunY, frameCount;
let boostTimer, boostCooldown;
let nearMissFlash;
highScore = parseInt(localStorage.getItem('solarRunHigh') || '0');
function resetGame() {
nextSpawnZ = 500;
rng = mulberry32(seed);
player = { x: 0, y: 0, targetX: 0, targetY: 0, bankAngle: 0, pitchLean: 0 };
obstacles = [];
pickups = [];
particles = [];
distance = 0;
speed = C.BASE_SPEED;
energy = C.ENERGY_MAX;
multiplier = 1;
score = 0;
shakeX = 0; shakeY = 0; shakeMag = 0;
sunY = C.SUN_START_Y;
frameCount = 0;
boostTimer = 0;
boostCooldown = 0;
nearMissFlash = 0;
}
function startGame() {
seed = Date.now() & 0xFFFFFFFF;
resetGame();
gameState = STATE.PLAYING;
initAudio();
canvas.onclick = null;
}
// ============================================================
// === WORLD GENERATION =======================================
// ============================================================
let nextSpawnZ = 500;
function spawnObstacleGroup(z) {
const numGaps = Math.random() < C.GAP_CHANCE ? (Math.random() < 0.3 ? 2 : 1) : 0;
const gapLanes = [];
for (let i = 0; i < numGaps; i++) {
let lane;
do { lane = Math.floor(rng() * C.LANE_COUNT); } while (gapLanes.includes(lane));
gapLanes.push(lane);
}
for (let l = 0; l < C.LANE_COUNT; l++) {
if (gapLanes.includes(l)) continue;
const w = C.OBSTACLE_MIN_W + rng() * (C.OBSTACLE_MAX_W - C.OBSTACLE_MIN_W);
const hMin = C.OBSTACLE_H_RANGE[0] + rng() * (C.OBSTACLE_H_RANGE[1] - C.OBSTACLE_H_RANGE[0]);
const colorIdx = Math.floor(rng() * C.OBSTACLE_COLORS.length);
obstacles.push({
x: (l - 2) * C.LANE_WIDTH,
y: 0,
z: z + rng() * 40,
w: w, h: hMin, d: 30 + rng() * 20,
color: C.OBSTACLE_COLORS[colorIdx],
});
}
if (numGaps > 0) {
for (const gl of gapLanes) {
const type = rng();
let pType;
if (type < 0.5) pType = 'energy';
else if (type < 0.8) pType = 'score';
else pType = 'multi';
pickups.push({
x: (gl - 2) * C.LANE_WIDTH,
y: 30 + rng() * 40,
z: z + 50 + rng() * 60,
type: pType,
collected: false,
bobPhase: rng() * Math.PI * 2,
});
}
}
if (rng() < 0.4) {
const lane = Math.floor(rng() * C.LANE_COUNT);
pickups.push({
x: (lane - 2) * C.LANE_WIDTH,
y: 20 + rng() * 50,
z: z + rng() * 100,
type: 'energy',
collected: false,
bobPhase: rng() * Math.PI * 2,
});
}
}
// ============================================================
// === PARTICLES ==============================================
// ============================================================
function emitParticles(x, y, z, count, color, spread, lifeScale) {
spread = spread || 3; lifeScale = lifeScale || 1;
for (let i = 0; i < count; i++) {
particles.push({
x: x + (rng() - 0.5) * spread,
y: y + (rng() - 0.5) * spread,
z: z,
vx: (rng() - 0.5) * 4,
vy: (rng() - 0.5) * 4,
vz: rng() * 2,
life: C.PARTICLE_LIFE * lifeScale,
maxLife: C.PARTICLE_LIFE * lifeScale,
color: color,
size: 1 + rng() * 3,
});
}
}
// ============================================================
// === PROJECTION =============================================
// ============================================================
const FOV = 500;
const CAM_Y = -80;
const CAM_Z = -200;
function project(wx, wy, wz) {
const relZ = wz + CAM_Z;
if (relZ <= 1) return null;
const scale = FOV / relZ;
const sx = W / 2 + wx * scale;
const sy = H / 2 - (wy + CAM_Y) * scale;
return { x: sx, y: sy, scale };
}
// ============================================================
// === UPDATE ================================================
// ============================================================
function update() {
if (gameState !== STATE.PLAYING) return;
frameCount++;
speed = Math.min(C.MAX_SPEED, C.BASE_SPEED + distance * C.ACCEL_RATE);
let currentSpeed = speed;
if (boostTimer > 0) {
boostTimer--;
currentSpeed *= C.BOOST_SPEED_MULT;
energy -= 0.15;
} else if (keys['Space'] && boostCooldown <= 0 && energy >= C.BOOST_ENERGY_COST) {
boostTimer = C.BOOST_DURATION;
boostCooldown = C.BOOST_COOLDOWN;
energy -= C.BOOST_ENERGY_COST;
sfxBoost();
}
if (boostCooldown > 0) boostCooldown--;
let moveX = 0, moveY = 0;
if (keys['ArrowLeft'] || keys['KeyA']) moveX -= 1;
if (keys['ArrowRight'] || keys['KeyD']) moveX += 1;
if (keys['ArrowUp'] || keys['KeyW']) moveY -= 1;
if (keys['ArrowDown'] || keys['KeyS']) moveY += 1;
player.targetX += moveX * C.MOVE_SPEED;
player.targetY += moveY * C.MOVE_SPEED;
const halfRoad = C.ROAD_WIDTH / 2 - C.PLAYER_W / 2;
player.targetX = Math.max(-halfRoad, Math.min(halfRoad, player.targetX));
player.targetY = Math.max(-60, Math.min(120, player.targetY));
const dx = player.targetX - player.x;
const dy = player.targetY - player.y;
player.x += dx * C.EASE_FACTOR;
player.y += dy * C.EASE_FACTOR;
const targetBank = -moveX * C.BANK_ANGLE;
player.bankAngle += (targetBank - player.bankAngle) * 0.2;
const targetPitch = moveY * C.PITCH_LEAN;
player.pitchLean += (targetPitch - player.pitchLean) * 0.15;
distance += currentSpeed;
score += Math.floor(currentSpeed * multiplier);
sunY += C.SUN_SINK_RATE;
let shadowed = false;
for (const ob of obstacles) {
if (ob.z > -50 && ob.z < 100 && Math.abs(ob.x - player.x) < (ob.w + C.PLAYER_W) / 2) {
shadowed = true; break;
}
}
const drainMult = shadowed ? C.ENERGY_SHADOW_MULT : 1;
energy -= C.ENERGY_DRAIN * drainMult;
nextSpawnZ += currentSpeed;
while (nextSpawnZ < distance + C.DRAW_DISTANCE) {
spawnObstacleGroup(nextSpawnZ);
nextSpawnZ += C.CHUNK_LENGTH;
}
for (let i = obstacles.length - 1; i >= 0; i--) {
const ob = obstacles[i];
ob.z -= currentSpeed;
if (ob.z < -200) { obstacles.splice(i, 1); continue; }
if (ob.z > -C.PLAYER_DEPTH && ob.z < C.PLAYER_DEPTH / 2) {
const hw = (ob.w + C.PLAYER_W) / 2;
if (Math.abs(ob.x - player.x) < hw && Math.abs(player.y - ob.h / 2) < (ob.h + C.PLAYER_H) / 2) {
emitParticles(player.x, player.y, 0, 60, [255, 100, 50], 8, 1.5);
shakeMag = C.SHAKE_CRASH;
sfxCrash();
gameOver();
return;
}
if (Math.abs(ob.x - player.x) < hw * 1.4 && Math.abs(player.y - ob.h / 2) < (ob.h + C.PLAYER_H) * 0.7) {
nearMissFlash = 8;
shakeMag = C.SHAKE_NEAR_MISS;
score += 50 * multiplier;
sfxNearMiss();
}
}
}
for (let i = pickups.length - 1; i >= 0; i--) {
const pk = pickups[i];
pk.z -= currentSpeed;
if (pk.collected || pk.z < -200) { pickups.splice(i, 1); continue; }
if (pk.z > -30 && pk.z < 40) {
const pdx = Math.abs(pk.x - player.x);
const pdy = Math.abs(pk.y - player.y);
if (pdx < 40 && pdy < 50) {
pk.collected = true;
if (pk.type === 'energy') {
energy = Math.min(C.ENERGY_MAX, energy + C.ENERGY_PICKUP);
emitParticles(pk.x, pk.y, pk.z, 12, C.PICKUP_ENERGY, 4);
} else if (pk.type === 'score') {
score += 100 * multiplier;
emitParticles(pk.x, pk.y, pk.z, 12, C.PICKUP_SCORE, 4);
} else {
multiplier = Math.min(8, multiplier + 1);
emitParticles(pk.x, pk.y, pk.z, 20, C.PICKUP_MULTI, 6);
sfxMulti();
}
sfxPickup();
}
}
}
if (frameCount % 2 === 0) {
emitParticles(player.x - 15, player.y + 5, -10, C.THRUST_PARTICLES_PER_FRAME, [255, 180, 60], 3, 0.6);
}
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.z -= currentSpeed * 0.5 + p.vz;
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
if (shakeMag > 0.5) {
shakeX = (Math.random() - 0.5) * shakeMag;
shakeY = (Math.random() - 0.5) * shakeMag;
shakeMag *= C.SHAKE_DECAY;
} else {
shakeX = 0; shakeY = 0; shakeMag = 0;
}
if (nearMissFlash > 0) nearMissFlash--;
if (energy <= 0 || sunY >= 1.0) gameOver();
if (droneOsc) droneOsc.frequency.value = 55 + currentSpeed * 3;
}
function gameOver() {
gameState = STATE.GAMEOVER;
if (score > highScore) {
highScore = score;
localStorage.setItem('solarRunHigh', String(highScore));
}
}
// ============================================================
// === RENDER ================================================
// ============================================================
function render() {
ctx.save();
ctx.translate(shakeX || 0, shakeY || 0);
const sy = sunY || C.SUN_START_Y;
const fc = frameCount || 0;
// Sky gradient based on sun position
const t1 = Math.min(1, sy * 2);
const t2 = Math.min(1, sy * 1.8);
const skyTop = lerpColor(C.SKY_TOP, [0, 0, 5], t1);
const skyMid = lerpColor(C.SKY_MID, [10, 5, 30], t2);
const skyBot = lerpColor(C.SKY_BOT, [40, 10, 50], t1);
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, `rgb(${skyTop[0]},${skyTop[1]},${skyTop[2]})`);
grad.addColorStop(0.5, `rgb(${skyMid[0]},${skyMid[1]},${skyMid[2]})`);
grad.addColorStop(1, `rgb(${skyBot[0]},${skyBot[1]},${skyBot[2]})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// Sun
const sunScreenY = H * (0.1 + sy * 0.5);
const sunSize = 60 + Math.sin(fc * 0.02) * 3;
const sunGlowGrad = ctx.createRadialGradient(W / 2, sunScreenY, 0, W / 2, sunScreenY, sunSize * 4);
sunGlowGrad.addColorStop(0, `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.6)`);
sunGlowGrad.addColorStop(0.3, `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.15)`);
sunGlowGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = sunGlowGrad;
ctx.fillRect(0, 0, W, H);
const sunDiscGrad = ctx.createRadialGradient(W / 2, sunScreenY, 0, W / 2, sunScreenY, sunSize);
sunDiscGrad.addColorStop(0, `rgb(${C.SUN_COLOR[0]},${C.SUN_COLOR[1]},${C.SUN_COLOR[2]})`);
sunDiscGrad.addColorStop(0.7, `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.8)`);
sunDiscGrad.addColorStop(1, 'rgba(255,160,50,0)');
ctx.fillStyle = sunDiscGrad;
ctx.beginPath();
ctx.arc(W / 2, sunScreenY, sunSize, 0, Math.PI * 2);
ctx.fill();
// Ground plane
const horizonY = H * (0.35 + sy * 0.15);
ctx.fillStyle = `rgb(${C.GROUND_COLOR[0]},${C.GROUND_COLOR[1]},${C.GROUND_COLOR[2]})`;
ctx.fillRect(0, horizonY, W, H - horizonY);
// Road lines (perspective)
const vanishX = W / 2;
for (let i = 0; i < 30; i++) {
const tt = Math.pow((i + ((distance || 0) * (speed || C.BASE_SPEED) * 0.01) % 1) / 30, 1.5);
if (tt <= 0 || tt > 1) continue;
const y = horizonY + (H - horizonY) * tt;
const spread = (y - horizonY) * 2.5;
const alpha = Math.min(1, tt * 3);
ctx.strokeStyle = `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, ${alpha * 0.3})`;
ctx.lineWidth = 1 + tt * 2;
ctx.beginPath();
ctx.moveTo(vanishX - spread, y);
ctx.lineTo(vanishX + spread, y);
ctx.stroke();
}
// Lane markers
for (let l = 0; l <= C.LANE_COUNT; l++) {
const laneX = (l - C.LANE_COUNT / 2) * C.LANE_WIDTH;
const p1 = project(laneX, 0, 50);
const p2 = project(laneX, 0, C.DRAW_DISTANCE);
if (!p1 || !p2) continue;
ctx.strokeStyle = `rgba(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]}, 0.15)`;
ctx.lineWidth = Math.max(1, p1.scale * 0.5);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
// Only render game objects if in playing state
if (gameState === STATE.PLAYING && Array.isArray(obstacles)) {
const sortedObs = obstacles.slice().sort((a, b) => b.z - a.z);
for (const ob of sortedObs) {
if (ob.z < 10 || ob.z > C.DRAW_DISTANCE) continue;
const p = project(ob.x, ob.y + ob.h / 2, ob.z);
if (!p) continue;
const sw = ob.w * p.scale;
const sh = ob.h * p.scale;
const sd = ob.d * p.scale;
const shadowFactor = Math.min(1, sy * 2);
const r = Math.floor(ob.color[0] * (1 - shadowFactor * 0.5));
const g = Math.floor(ob.color[1] * (1 - shadowFactor * 0.5));
const b = Math.floor(ob.color[2] * (1 - shadowFactor * 0.3));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(p.x - sw / 2, p.y - sh / 2, sw, sh);
ctx.fillStyle = `rgb(${Math.min(255, r + 40)},${Math.min(255, g + 30)},${Math.min(255, b + 50)})`;
ctx.beginPath();
ctx.moveTo(p.x - sw / 2, p.y - sh / 2);
ctx.lineTo(p.x + sw / 2, p.y - sh / 2);
ctx.lineTo(p.x + sw / 2 + sd * 0.3, p.y - sh / 2 - sd * 0.5);
ctx.lineTo(p.x - sw / 2 + sd * 0.3, p.y - sh / 2 - sd * 0.5);
ctx.closePath();
ctx.fill();
ctx.fillStyle = `rgb(${Math.floor(r * 0.6)},${Math.floor(g * 0.6)},${Math.floor(b * 0.7)})`;
ctx.beginPath();
ctx.moveTo(p.x + sw / 2, p.y - sh / 2);
ctx.lineTo(p.x + sw / 2, p.y + sh / 2);
ctx.lineTo(p.x + sw / 2 + sd * 0.3, p.y + sh / 2 - sd * 0.5);
ctx.lineTo(p.x + sw / 2 + sd * 0.3, p.y - sh / 2 - sd * 0.5);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = `rgba(255,200,150,${Math.min(0.4, 0.8 / (ob.z * 0.01))})`;
ctx.lineWidth = Math.max(1, p.scale);
ctx.strokeRect(p.x - sw / 2, p.y - sh / 2, sw, sh);
}
// Pickups
if (pickups) {
const sortedPks = [...pickups.filter(pk => !pk.collected)].sort((a, b) => b.z - a.z);
for (const pk of sortedPks) {
if (pk.z < 10 || pk.z > C.DRAW_DISTANCE) continue;
const bob = Math.sin(fc * 0.08 + pk.bobPhase) * 5;
const pp = project(pk.x, pk.y + bob, pk.z);
if (!pp) continue;
const size = 12 * pp.scale;
let color;
if (pk.type === 'energy') color = C.PICKUP_ENERGY;
else if (pk.type === 'score') color = C.PICKUP_SCORE;
else color = C.PICKUP_MULTI;
const glowGrad = ctx.createRadialGradient(pp.x, pp.y, 0, pp.x, pp.y, size * 2);
glowGrad.addColorStop(0, `rgba(${color[0]},${color[1]},${color[2]}, 0.5)`);
glowGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glowGrad;
ctx.beginPath();
ctx.arc(pp.x, pp.y, size * 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
ctx.save();
ctx.translate(pp.x, pp.y);
ctx.rotate(fc * 0.05 + pk.bobPhase);
ctx.beginPath();
ctx.moveTo(0, -size);
ctx.lineTo(size * 0.6, 0);
ctx.lineTo(0, size);
ctx.lineTo(-size * 0.6, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
// Particles
if (particles) {
for (const pt of particles) {
const pp = project(pt.x, pt.y, pt.z);
if (!pp) continue;
const alpha = pt.life / pt.maxLife;
const size = pt.size * pp.scale * alpha;
ctx.fillStyle = `rgba(${pt.color[0]},${pt.color[1]},${pt.color[2]}, ${alpha})`;
ctx.beginPath();
ctx.arc(pp.x, pp.y, Math.max(1, size), 0, Math.PI * 2);
ctx.fill();
}
}
// Player craft
renderCraft(fc);
// Speed lines at high speed
const cs = boostTimer > 0 ? speed * C.BOOST_SPEED_MULT : speed;
if (cs > C.SPEED_LINE_THRESHOLD) {
const intensity = Math.min(1, (cs - C.SPEED_LINE_THRESHOLD) / 8);
ctx.strokeStyle = `rgba(255,200,150,${intensity * 0.3})`;
ctx.lineWidth = 1;
for (let i = 0; i < 20; i++) {
const lx = Math.random() * W;
const ly = H * 0.4 + Math.random() * H * 0.5;
const len = 30 + intensity * 80;
ctx.beginPath();
ctx.moveTo(lx, ly);
ctx.lineTo(lx + (lx - W / 2) * 0.1, ly + len);
ctx.stroke();
}
}
// Near miss flash overlay
if (nearMissFlash > 0) {
ctx.fillStyle = `rgba(255,255,200,${nearMissFlash * 0.03})`;
ctx.fillRect(0, 0, W, H);
}
// Chromatic aberration at very high speed
if (cs > C.MAX_SPEED * 0.7) {
const caIntensity = (cs - C.MAX_SPEED * 0.7) / (C.MAX_SPEED * 0.3);
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = `rgba(255,0,0,${caIntensity * 0.04})`;
ctx.fillRect(2, 0, W, H);
ctx.fillStyle = `rgba(0,0,255,${caIntensity * 0.04})`;
ctx.fillRect(-2, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
}
// HUD
renderHUD();
}
ctx.restore();
if (gameState === STATE.TITLE) renderTitleScreen();
else if (gameState === STATE.GAMEOVER) renderGameOverScreen();
}
// ============================================================
// === RENDER CRAFT ===========================================
// ============================================================
function renderCraft(fc) {
const cx = W / 2 + (shakeX || 0);
const cy = H * 0.65 + (shakeY || 0);
ctx.save();
ctx.translate(cx, cy);
if (player && player.bankAngle) ctx.rotate(player.bankAngle);
// Thrust glow during boost
if (boostTimer > 0) {
const thrustGrad = ctx.createRadialGradient(0, 20, 0, 0, 20, 40);
thrustGrad.addColorStop(0, 'rgba(100,200,255,0.8)');
thrustGrad.addColorStop(1, 'rgba(50,100,255,0)');
ctx.fillStyle = thrustGrad;
ctx.beginPath();
ctx.arc(0, 20, 40 + Math.random() * 10, 0, Math.PI * 2);
ctx.fill();
}
const lean = (player && player.pitchLean) || 0;
// Main hull
ctx.fillStyle = '#e8e0f0';
ctx.beginPath();
ctx.moveTo(0, -C.PLAYER_H * 1.5);
ctx.lineTo(-C.PLAYER_W / 2 + 4, C.PLAYER_H * 0.3);
ctx.lineTo(-C.PLAYER_W / 2, C.PLAYER_H);
ctx.lineTo(C.PLAYER_W / 2, C.PLAYER_H);
ctx.lineTo(C.PLAYER_W / 2 - 4, C.PLAYER_H * 0.3);
ctx.closePath();
ctx.fill();
// Cockpit highlight
ctx.fillStyle = '#c8b8ff';
ctx.beginPath();
ctx.moveTo(0, -C.PLAYER_H * 1.2 + lean * 10);
ctx.lineTo(-6, C.PLAYER_H * 0.1);
ctx.lineTo(6, C.PLAYER_H * 0.1);
ctx.closePath();
ctx.fill();
// Wing accents
ctx.strokeStyle = '#ff8844';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-C.PLAYER_W / 2 + 2, C.PLAYER_H * 0.5);
ctx.lineTo(0, -C.PLAYER_H * 1.3);
ctx.lineTo(C.PLAYER_W / 2 - 2, C.PLAYER_H * 0.5);
ctx.stroke();
// Engine glow
const engineGlow = 0.4 + Math.sin((fc || 0) * 0.2) * 0.15;
ctx.fillStyle = `rgba(255,${boostTimer > 0 ? '200' : '150'},80,${engineGlow})`;
ctx.beginPath();
ctx.arc(-6, C.PLAYER_H + 2, 4, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(6, C.PLAYER_H + 2, 4, 0, Math.PI * 2);
ctx.fill();
// Boost flame
if (boostTimer > 0) {
const flameLen = 15 + Math.random() * 15;
ctx.fillStyle = 'rgba(80,180,255,0.7)';
ctx.beginPath();
ctx.moveTo(-8, C.PLAYER_H);
ctx.lineTo(0, C.PLAYER_H + flameLen);
ctx.lineTo(8, C.PLAYER_H);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// ============================================================
// === HUD ====================================================
// ============================================================
function renderHUD() {
const pad = 20;
const barW = 160;
const barH = 14;
// Distance & Score panel
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(pad - 5, pad - 5, 200, 80, 8);
ctx.fill();
ctx.font = 'bold 14px monospace';
ctx.fillStyle = '#aab';
ctx.fillText('DISTANCE', pad, pad + 12);
ctx.font = 'bold 22px monospace';
ctx.fillStyle = '#fff';
ctx.fillText(`${Math.floor(distance)}m`, pad, pad + 36);
ctx.font = 'bold 14px monospace';
ctx.fillStyle = '#aab';
ctx.fillText('SCORE', pad, pad + 58);
ctx.font = 'bold 20px monospace';
ctx.fillStyle = `rgb(${C.PICKUP_SCORE[0]},${C.PICKUP_SCORE[1]},${C.PICKUP_SCORE[2]})`;
ctx.fillText(`${score.toLocaleString()}`, pad, pad + 76);
if (multiplier > 1) {
ctx.font = 'bold 18px monospace';
ctx.fillStyle = `rgb(${C.PICKUP_MULTI[0]},${C.PICKUP_MULTI[1]},${C.PICKUP_MULTI[2]})`;
ctx.fillText(`x${multiplier}`, pad + barW - 30, pad + 76);
}
// Energy bar
const ey = pad + 95;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(pad - 5, ey - 5, barW + 10, 30, 8);
ctx.fill();
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#aab';
ctx.fillText('ENERGY', pad, ey + 6);
const energyPct = Math.max(0, energy / C.ENERGY_MAX);
const eColor = energyPct > 0.3 ? `rgb(${C.PICKUP_ENERGY[0]},${C.PICKUP_ENERGY[1]},${C.PICKUP_ENERGY[2]})` : '#ff4444';
ctx.fillStyle = 'rgba(255,255,255,0.1)';
roundRect(pad, ey + 10, barW, barH, 4);
ctx.fill();
if (energyPct > 0) {
ctx.fillStyle = eColor;
roundRect(pad, ey + 10, barW * energyPct, barH, 4);
ctx.fill();
}
// Speed indicator
const speedPct = Math.max(0, (speed - C.BASE_SPEED) / (C.MAX_SPEED - C.BASE_SPEED));
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(pad - 5, ey + 35, barW + 10, 28, 8);
ctx.fill();
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#aab';
ctx.fillText('SPEED', pad, ey + 48);
ctx.fillStyle = 'rgba(255,255,255,0.1)';
roundRect(pad, ey + 52, barW, barH - 4, 3);
ctx.fill();
const speedColor = boostTimer > 0 ? '#66ccff' : `rgb(${C.SUN_GLOW[0]},${C.SUN_GLOW[1]},${C.SUN_GLOW[2]})`;
ctx.fillStyle = speedColor;
roundRect(pad, ey + 52, barW * Math.min(1, speedPct), barH - 4, 3);
ctx.fill();
// Sun warning
if (sunY > 0.6) {
const warnAlpha = Math.sin(frameCount * 0.15) * 0.3 + 0.3;
ctx.font = 'bold 14px monospace';
ctx.fillStyle = `rgba(255,80,50,${warnAlpha})`;
ctx.fillText('SUN SETTING', pad, ey + 90);
}
// High score
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#667';
ctx.textAlign = 'right';
ctx.fillText(`HI ${highScore.toLocaleString()}`, W - pad, pad + 14);
ctx.textAlign = 'left';
}
function roundRect(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();
}
// ============================================================
// === TITLE SCREEN ===========================================
// ============================================================
function renderTitleScreen() {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
const titleSize = Math.min(72, W * 0.08);
ctx.font = `bold ${titleSize}px monospace`;
const titleGrad = ctx.createLinearGradient(W / 2 - 150, 0, W / 2 + 150, 0);
titleGrad.addColorStop(0, '#ffaa44');
titleGrad.addColorStop(0.5, '#ffdd88');
titleGrad.addColorStop(1, '#ff6633');
ctx.fillStyle = titleGrad;
ctx.fillText('SOLAR RUN', W / 2, H * 0.3);
const subSize = Math.min(24, W * 0.03);
ctx.font = `${subSize}px monospace`;
ctx.fillStyle = '#aab';
ctx.fillText('Endless solar flight through a geometric world', W / 2, H * 0.38);
const cy = H * 0.5;
const ctrlSize = Math.min(18, W * 0.025);
ctx.font = `${ctrlSize}px monospace`;
ctx.fillStyle = '#99a';
ctx.fillText('WASD / Arrow Keys — Move', W / 2, cy);
ctx.fillText('Space — Boost (costs energy)', W / 2, cy + 30);
const pulseAlpha = Math.sin(Date.now() * 0.005) * 0.3 + 0.7;
const promptSize = Math.min(28, W * 0.035);
ctx.font = `bold ${promptSize}px monospace`;
ctx.fillStyle = `rgba(255,220,100,${pulseAlpha})`;
ctx.fillText('Press ENTER or SPACE to launch', W / 2, H * 0.7);
if (highScore > 0) {
ctx.font = 'bold 16px monospace';
ctx.fillStyle = '#889';
ctx.fillText(`High Score: ${highScore.toLocaleString()}`, W / 2, H * 0.78);
}
ctx.textAlign = 'left';
}
// ============================================================
// === GAME OVER SCREEN =======================================
// ============================================================
function renderGameOverScreen() {
ctx.fillStyle = 'rgba(10,5,20,0.75)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
const goSize = Math.min(64, W * 0.07);
ctx.font = `bold ${goSize}px monospace`;
ctx.fillStyle = '#ff5544';
ctx.fillText('GAME OVER', W / 2, H * 0.28);
const cy = H * 0.38;
const statSize = Math.min(22, W * 0.03);
ctx.font = `bold ${statSize}px monospace`;
ctx.fillStyle = '#dde';
ctx.fillText(`Distance: ${Math.floor(distance)}m`, W / 2, cy);
ctx.fillText(`Score: ${score.toLocaleString()}`, W / 2, cy + 35);
if (score >= highScore && score > 0) {
const pulseAlpha = Math.sin(Date.now() * 0.006) * 0.4 + 0.6;
ctx.font = `bold ${Math.min(28, W * 0.035)}px monospace`;
ctx.fillStyle = `rgba(${C.PICKUP_SCORE[0]},${C.PICKUP_SCORE[1]},${C.PICKUP_SCORE[2]},${pulseAlpha})`;
ctx.fillText('NEW HIGH SCORE', W / 2, cy + 75);
}
const pulseAlpha = Math.sin(Date.now() * 0.005) * 0.3 + 0.7;
ctx.font = `bold ${Math.min(24, W * 0.03)}px monospace`;
ctx.fillStyle = `rgba(255,220,100,${pulseAlpha})`;
ctx.fillText('Press R or click to restart', W / 2, H * 0.7);
ctx.textAlign = 'left';
canvas.onclick = () => { startGame(); canvas.onclick = null; };
}
// ============================================================
// === UTILITIES ==============================================
// ============================================================
function lerpColor(a, b, t) {
t = Math.max(0, Math.min(1, t));
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
// ============================================================
// === MAIN LOOP ==============================================
// ============================================================
let lastTime = 0;
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
const dt = Math.min(32, timestamp - lastTime);
lastTime = timestamp;
update();
render();
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"c5a7dab7-d44a-459e-bb35-b65489f32632","tokensIn":1487838,"tokensOut":31178,"tokensTotal":1519016,"turns":25,"toolCalls":24,"failedToolCalls":0,"timestamp":"2026-07-18T21:57:21.738Z"} -->