Race the Sun
Qwen 3.6 35b a3b · composer

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 47,796 bytes · SHA-256 bb1b1e0129b3
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Race The Sun</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#gameCanvas { display: block; width: 100%; height: 100%; }
/* HUD */
.hud {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10; font-family: 'Courier New', monospace;
}
#hudTop {
position: absolute; top: 20px; left: 20px; right: 20px;
display: flex; justify-content: space-between; align-items: flex-start;
}
#hudScore {
font-size: 36px; font-weight: bold; color: #fff;
text-shadow: 0 0 10px rgba(255,200,50,0.8), 0 0 30px rgba(255,150,0,0.4);
}
#hudMultiplier {
font-size: 28px; color: #ffcc00;
text-shadow: 0 0 8px rgba(255,200,0,0.6);
transition: transform 0.1s;
}
#hudMultiplier.bump { transform: scale(1.3); }
#hudBottom {
position: absolute; bottom: 20px; left: 20px; right: 20px;
display: flex; justify-content: space-between; align-items: flex-end;
}
#hudEnergy {
width: 200px; height: 12px; background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.3); border-radius: 6px; overflow: hidden;
}
#hudEnergyBar {
height: 100%; width: 100%; background: linear-gradient(90deg, #ff6600, #ffcc00, #ffff00);
border-radius: 6px; transition: width 0.15s;
}
#hudSpeed {
font-size: 20px; color: #aaa; text-align: right;
}
#hudDistance {
font-size: 20px; color: #aaa; text-align: right;
}
/* Overlays */
.overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
z-index: 20; pointer-events: auto;
background: rgba(0,0,0,0.7);
transition: opacity 0.3s;
}
.overlay.hidden { opacity: 0; pointer-events: none; }
.title-content { text-align: center; }
.title-text {
font-size: 64px; font-weight: bold; color: #ff8800;
text-shadow: 0 0 20px rgba(255,136,0,0.8), 0 0 60px rgba(255,100,0,0.4);
margin-bottom: 10px; letter-spacing: 8px;
}
.subtitle {
font-size: 20px; color: #ffcc88; margin-bottom: 30px;
text-shadow: 0 0 10px rgba(255,200,136,0.5);
}
.controls-info {
font-size: 16px; color: #aaa; margin-bottom: 40px; line-height: 2;
}
.action-btn {
font-family: 'Courier New', monospace; font-size: 24px; font-weight: bold;
padding: 14px 48px; border: 2px solid #ff8800; background: rgba(255,136,0,0.15);
color: #ff8800; cursor: pointer; letter-spacing: 4px;
text-shadow: 0 0 8px rgba(255,136,0,0.5);
box-shadow: 0 0 20px rgba(255,136,0,0.2);
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(255,136,0,0.3);
box-shadow: 0 0 40px rgba(255,136,0,0.4);
transform: scale(1.05);
}
.go-stats {
font-size: 22px; color: #ddd; margin-bottom: 30px; line-height: 2;
}
.go-stats span { color: #ffcc00; font-weight: bold; }
.go-reason {
font-size: 28px; color: #ff6644; margin-bottom: 20px;
text-shadow: 0 0 10px rgba(255,100,50,0.5);
}
.go-best {
font-size: 24px; color: #ffcc00; margin-top: 15px;
text-shadow: 0 0 15px rgba(255,200,0,0.6);
}
.go-hint {
font-size: 16px; color: #888; margin-top: 15px;
}
</style>
</head>
<body>
<div id="app"></div>
<canvas id="gameCanvas"></canvas>
<div id="hud" class="hud">
<div id="hudTop">
<div id="hudScore">0</div>
<div id="hudMultiplier">×1</div>
</div>
<div id="hudBottom">
<div id="hudEnergy">
<div id="hudEnergyBar"></div>
</div>
<div id="hudSpeed">0 km/s</div>
<div id="hudDistance">0 m</div>
</div>
</div>
<div id="titleScreen" class="overlay">
<div class="title-content">
<h1 class="title-text">RACE THE SUN</h1>
<p class="subtitle">Stay in the light. Survive.</p>
<div class="controls-info">
<p>WASD / Arrow Keys — Move</p>
<p>SPACE — Boost (uses energy)</p>
<p>R — Restart</p>
</div>
<button id="startBtn" class="action-btn">START</button>
</div>
</div>
<div id="gameOverScreen" class="overlay hidden">
<div class="title-content">
<h1 class="title-text">GAME OVER</h1>
<div id="goStats" class="go-stats"></div>
<button id="restartBtn" class="action-btn">RESTART</button>
</div>
</div>
<script>
'use strict';
// ── const: C ──
// === TUNABLE CONSTANTS ===
const C = {
// World
SUN_Y: 0.15,
SUN_SIZE: 120,
GRID_SIZE: 2000,
GRID_SEGMENTS: 80,
GRID_DRAW_DIST: 1800,
// Player
PLAYER_SPEED: 3.5,
PLAYER_MAX_SPEED: 8,
PLAYER_ACCEL: 0.08,
PLAYER_DECCEL: 0.04,
PLAYER_LERP: 0.12,
PLAYER_BOUNDS: 22,
PLAYER_HEIGHT: 0.6,
PLAYER_ROLL_MAX: 0.35,
PLAYER_PITCH_MAX: 0.2,
// Energy
ENERGY_MAX: 100,
ENERGY_DRAIN: 0.02,
ENERGY_DRAIN_SHADOW: 0.08,
ENERGY_BOOST_COST: 0.6,
ENERGY_PICKUP: 18,
// Boost
BOOST_MULTIPLIER: 1.8,
BOOST_DRAIN: 0.005,
// Obstacles
OBSTACLE_MIN_Z: 150,
OBSTACLE_MAX_Z: 1800,
OBSTACLE_SPAWN_INTERVAL: 12,
OBSTACLE_WIDTH: 8,
OBSTACLE_HEIGHT: 12,
OBSTACLE_DEPTH: 4,
// Pickups
PICKUP_SPAWN_INTERVAL: 20,
PICKUP_SIZE: 1.5,
// Scoring
SCORE_BASE: 0.5,
SCORE_NEAR_MISS: 50,
SCORE_MULTIPLIER_INTERVAL: 300,
// Camera
CAM_DIST: 18,
CAM_HEIGHT: 10,
CAM_LERP: 0.08,
CAM_SHAKE_NEAR: 0.3,
CAM_SHAKE_NEAR_DIST: 5,
CAM_SHAKE_CRASH: 1.5,
// Particles
PARTICLE_MAX: 500,
PARTICLE_LIFE: 1.5,
// Speed lines
SPEED_LINES_THRESHOLD: 5,
// Colors
COLORS: {
skyTop: [40, 10, 60],
skyMid: [180, 60, 40],
skyBot: [255, 160, 50],
sun: [255, 220, 80],
sunGlow: [255, 150, 30],
gridLit: [255, 200, 100],
gridShadow: [80, 40, 30],
gridLine: [255, 180, 60],
obstacleLit: [120, 80, 60],
obstacleShadow: [40, 25, 20],
obstacleEdge: [255, 160, 60],
playerBody: [200, 220, 255],
playerAccent: [255, 200, 60],
playerGlow: [255, 220, 100],
pickupEnergy: [255, 220, 50],
pickupScore: [100, 255, 100],
pickupMulti: [255, 100, 255],
particleThrust: [255, 180, 50],
particlePickup: [255, 255, 100],
particleCrash: [255, 100, 50],
speedLine: [255, 255, 200],
shadowOverlay: [30, 10, 20],
},
// Game duration
SUN_SET_DURATION: 90,
SUN_SET_THRESHOLD: 0.8,
};
// ── function: vec3 ──
// === 3D MATH UTILITIES ===
function vec3(x, y, z) { return [x, y, z]; }
// ── function: v3Add ──
function v3Add(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
// ── function: v3Sub ──
function v3Sub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
// ── function: v3Scale ──
function v3Scale(v, s) { return [v[0]*s, v[1]*s, v[2]*s]; }
// ── function: v3Len ──
function v3Len(v) { return Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); }
// ── function: v3Norm ──
function v3Norm(v) { const l = v3Len(v) || 1; return [v[0]/l, v[1]/l, v[2]/l]; }
// ── function: v3Dot ──
function v3Dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
// ── function: v3Cross ──
function v3Cross(a, b) { return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
// ── function: rotateY ──
function rotateY(v, a) {
const c = Math.cos(a), s = Math.sin(a);
return [v[0]*c + v[2]*s, v[1], -v[0]*s + v[2]*c];
}
// ── function: rotateX ──
function rotateX(v, a) {
const c = Math.cos(a), s = Math.sin(a);
return [v[0], v[1]*c - v[2]*s, v[1]*s + v[2]*c];
}
// ── function: rotateZ ──
function rotateZ(v, a) {
const c = Math.cos(a), s = Math.sin(a);
return [v[0]*c - v[1]*s, v[0]*s + v[1]*c, v[2]];
}
// ── function: project ──
function project(p, w, h, fov, camPos) {
const rel = v3Sub(p, camPos);
if (rel[2] <= 0.1) return null;
const scale = fov / rel[2];
return [w/2 + rel[0] * scale, h/2 - rel[1] * scale, rel[2]];
}
// ── function: lerp ──
function lerp(a, b, t) { return a + (b - a) * t; }
// ── function: clamp ──
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// ── function: smoothstep ──
function smoothstep(t) { return t * t * (3 - 2 * t); }
// ── function: rand ──
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }
// ── function: randInt ──
function randInt(lo, hi) { return Math.floor(rand(lo, hi + 1)); }
// ── function: createPlayer ──
// === PLAYER ===
function createPlayer() {
return {
pos: [0, 0.5, 0],
vel: [0, 0, 0],
targetX: 0,
targetY: 0.5,
speed: C.PLAYER_SPEED,
baseSpeed: C.PLAYER_SPEED,
pitch: 0,
targetPitch: 0,
roll: 0,
targetRoll: 0,
energy: C.ENERGY_MAX,
boosting: false,
boostTimer: 0,
score: 0,
distance: 0,
multiplier: 1,
nearMissTimer: 0,
flashTimer: 0,
alive: true,
};
}
// ── function: createCamera ──
// === CAMERA ===
function createCamera() {
return {
pos: [0, 5, -10],
targetPos: [0, 5, -10],
shake: [0, 0, 0],
shakeDecay: 0.9,
nearMissShake: 0,
flashIntensity: 0,
};
}
// ── function: applyCameraShake ──
function applyCameraShake(cam, intensity) {
cam.nearMissShake = Math.max(cam.nearMissShake, intensity);
}
// ── function: updateCamera ──
function updateCamera(cam, player, dt) {
const camTargetX = player.pos[0] * 0.7;
const camTargetY = player.pos[1] + C.CAM_HEIGHT;
const camTargetZ = player.pos[2] - C.CAM_DIST;
cam.targetPos = v3Add(
v3Scale(v3Norm(v3Sub([0, 0, player.pos[2]], [0, 0, player.pos[2]])), 0),
[camTargetX, camTargetY, camTargetZ]
);
cam.pos[0] = lerp(cam.pos[0], camTargetX, C.CAM_LERP);
cam.pos[1] = lerp(cam.pos[1], camTargetY, C.CAM_LERP);
cam.pos[2] = lerp(cam.pos[2], camTargetZ, C.CAM_LERP);
// Shake
cam.shake[0] = (Math.random() - 0.5) * cam.nearMissShake * 2;
cam.shake[1] = (Math.random() - 0.5) * cam.nearMissShake * 2;
cam.nearMissShake *= cam.shakeDecay;
if (cam.nearMissShake < 0.01) cam.nearMissShake = 0;
cam.flashIntensity *= 0.9;
if (cam.flashIntensity < 0.01) cam.flashIntensity = 0;
}
// ── function: createWorld ──
// === WORLD GENERATION ===
function createWorld() {
return {
obstacles: [],
pickups: [],
particles: [],
speedLines: [],
seed: Math.floor(Math.random() * 999999),
spawnTimer: 0,
pickupTimer: 0,
time: 0,
sunAngle: 0,
gridOffset: 0,
lastSpawnZ: 0,
};
}
// ── function: seededRandom ──
// Seeded random for procedural generation
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
// ── function: spawnObstacle ──
function spawnObstacle(world, rng) {
const z = world.lastSpawnZ + rand(120, 250);
const x = (rng() - 0.5) * 40;
const type = rng();
let w, h, d;
if (type < 0.4) {
w = rand(4, 10); h = rand(8, 20); d = rand(2, 6);
} else if (type < 0.7) {
w = rand(10, 20); h = rand(3, 8); d = rand(2, 4);
} else {
w = rand(3, 6); h = rand(15, 30); d = rand(2, 4);
}
const obs = {
pos: [x, h/2, z],
size: [w, h, d],
type: type < 0.4 ? 'tower' : type < 0.7 ? 'wall' : 'pillar',
scored: false,
nearMissed: false,
hit: false,
};
world.obstacles.push(obs);
world.lastSpawnZ = z;
return obs;
}
// ── function: spawnPickup ──
function spawnPickup(world, rng) {
const z = world.lastSpawnZ + rand(80, 160);
const x = (rng() - 0.5) * 30;
const type = rng();
let pType;
if (type < 0.5) pType = 'energy';
else if (type < 0.8) pType = 'score';
else pType = 'multiplier';
return {
pos: [x, 2, z],
size: C.PICKUP_SIZE,
type: pType,
collected: false,
bobPhase: rng() * Math.PI * 2,
glowPhase: rng() * Math.PI * 2,
};
}
// ── function: createParticle ──
// === PARTICLE SYSTEM ===
function createParticle(particlePool) {
if (particlePool.length < C.PARTICLE_MAX) {
particlePool.push({
pos: [0, 0, 0],
vel: [0, 0, 0],
life: 0,
maxLife: C.PARTICLE_LIFE,
color: [255, 255, 255],
size: 1,
active: true,
});
return particlePool[particlePool.length - 1];
}
return null;
}
// ── function: emitThrust ──
function emitThrust(particlePool, playerPos) {
for (let i = 0; i < 3; i++) {
const p = createParticle(particlePool);
if (!p) continue;
p.pos = [playerPos[0] + rand(-0.3, 0.3), playerPos[1] + rand(-0.1, 0.1), playerPos[2] + 0.5];
p.vel = [rand(-0.2, 0.2), rand(-0.1, 0.1), rand(0.3, 0.8)];
p.life = rand(0.3, 0.8);
p.maxLife = p.life;
p.color = [...C.COLORS.particleThrust];
p.size = rand(0.3, 0.8);
}
}
// ── function: emitPickup ──
function emitPickup(particlePool, pos, color) {
for (let i = 0; i < 15; i++) {
const p = createParticle(particlePool);
if (!p) continue;
const angle = rand(0, Math.PI * 2);
const speed = rand(1, 4);
p.pos = [pos[0], pos[1], pos[2]];
p.vel = [Math.cos(angle) * speed, rand(0.5, 3), Math.sin(angle) * speed];
p.life = rand(0.5, 1.2);
p.maxLife = p.life;
p.color = [...color];
p.size = rand(0.3, 0.7);
}
}
// ── function: emitCrash ──
function emitCrash(particlePool, pos) {
for (let i = 0; i < 40; i++) {
const p = createParticle(particlePool);
if (!p) continue;
const angle = rand(0, Math.PI * 2);
const elev = rand(0, Math.PI);
const speed = rand(2, 8);
p.pos = [pos[0], pos[1], pos[2]];
p.vel = [Math.cos(angle) * Math.cos(elev) * speed, Math.sin(elev) * speed, Math.sin(angle) * Math.cos(elev) * speed];
p.life = rand(0.5, 2);
p.maxLife = p.life;
p.color = [...C.COLORS.particleCrash];
p.size = rand(0.5, 1.5);
}
}
// ── function: updateParticles ──
function updateParticles(particlePool, dt) {
for (let i = particlePool.length - 1; i >= 0; i--) {
const p = particlePool[i];
if (!p.active) continue;
p.pos = v3Add(p.pos, v3Scale(p.vel, dt));
p.vel[1] -= dt * 2; // gravity
p.life -= dt;
if (p.life <= 0) {
p.active = false;
particlePool.splice(i, 1);
}
}
}
// ── let: audioCtx ──
// === AUDIO SYSTEM ===
let audioCtx = null;
// ── let: masterGain ──
let masterGain = null;
// ── let: windNode ──
let windNode = null;
// ── let: windGain ──
let windGain = null;
// ── function: initAudio ──
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 loop
const bufferSize = audioCtx.sampleRate * 2;
const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * 0.5;
windNode = audioCtx.createBufferSource();
windNode.buffer = noiseBuffer;
windNode.loop = true;
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 200;
filter.Q.value = 0.5;
windGain = audioCtx.createGain();
windGain.gain.value = 0.15;
windNode.connect(filter);
filter.connect(windGain);
windGain.connect(masterGain);
windNode.start();
}
// ── function: playPickupSound ──
function playPickupSound(type) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(masterGain);
if (type === 'energy') {
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.15);
} else if (type === 'score') {
osc.type = 'triangle';
osc.frequency.value = 660;
gain.gain.setValueAtTime(0.12, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
osc.frequency.exponentialRampToValueAtTime(1320, audioCtx.currentTime + 0.2);
} else {
osc.type = 'sine';
osc.frequency.value = 550;
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.frequency.exponentialRampToValueAtTime(1100, audioCtx.currentTime + 0.25);
}
osc.start();
osc.stop(audioCtx.currentTime + 0.4);
}
// ── function: playNearMissSound ──
function playNearMissSound() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 200;
osc.connect(gain);
gain.connect(masterGain);
gain.gain.setValueAtTime(0.06, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + 0.15);
osc.start();
osc.stop(audioCtx.currentTime + 0.2);
}
// ── function: playCrashSound ──
function playCrashSound() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 100;
osc.connect(gain);
gain.connect(masterGain);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + 0.5);
osc.start();
osc.stop(audioCtx.currentTime + 0.6);
}
// ── function: updateWind ──
function updateWind(speed) {
if (!windGain || !audioCtx) return;
const t = audioCtx.currentTime;
const vol = 0.05 + (speed / C.PLAYER_MAX_SPEED) * 0.2;
windGain.gain.setTargetAtTime(vol, t, 0.1);
}
// ── function: drawSky ──
// === RENDERING HELPERS ===
function drawSky(ctx, w, h, sunY) {
const grad = ctx.createLinearGradient(0, 0, 0, h);
const top = C.COLORS.skyTop;
const mid = C.COLORS.skyMid;
const bot = C.COLORS.skyBot;
const t = clamp(sunY, 0, 1);
const r1 = Math.round(lerp(top[0], mid[0], t));
const g1 = Math.round(lerp(top[1], mid[1], t));
const b1 = Math.round(lerp(top[2], mid[2], t));
const r2 = Math.round(lerp(mid[0], bot[0], t));
const g2 = Math.round(lerp(mid[1], bot[1], t));
const b2 = Math.round(lerp(mid[2], bot[2], t));
grad.addColorStop(0, `rgb(${r1},${g1},${b1})`);
grad.addColorStop(0.6, `rgb(${r2},${g2},${b2})`);
grad.addColorStop(1, `rgb(${bot[0]},${bot[1]},${bot[2]})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
}
// ── function: drawSun ──
function drawSun(ctx, w, h, sunY, sunAngle) {
const sunX = w / 2 + Math.sin(sunAngle) * w * 0.3;
const sunScreenY = sunY * h;
const sunSize = C.SUN_SIZE;
// Outer glow
const glow = ctx.createRadialGradient(sunX, sunScreenY, sunSize * 0.5, sunX, sunScreenY, sunSize * 4);
glow.addColorStop(0, 'rgba(255,150,30,0.3)');
glow.addColorStop(0.3, 'rgba(255,100,20,0.1)');
glow.addColorStop(1, 'rgba(255,50,0,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
// Sun disc
const disc = ctx.createRadialGradient(sunX, sunScreenY, 0, sunX, sunScreenY, sunSize);
disc.addColorStop(0, 'rgba(255,255,200,1)');
disc.addColorStop(0.4, 'rgba(255,220,80,1)');
disc.addColorStop(0.8, 'rgba(255,150,30,0.8)');
disc.addColorStop(1, 'rgba(255,80,0,0)');
ctx.fillStyle = disc;
ctx.beginPath();
ctx.arc(sunX, sunScreenY, sunSize, 0, Math.PI * 2);
ctx.fill();
}
// ── function: drawGrid ──
// === DRAW GRID (improved) ===
function drawGrid(ctx, w, h, camPos, playerZ, speed) {
const segLen = C.GRID_SIZE / C.GRID_SEGMENTS;
const totalWidth = C.GRID_SIZE / 2;
const offset = (playerZ % segLen + segLen) % segLen;
// Draw longitudinal lines (along Z)
for (let i = -C.GRID_SEGMENTS; i <= C.GRID_SEGMENTS; i++) {
const x = i * (totalWidth / C.GRID_SEGMENTS);
const nearZ = playerZ + 1;
const farZ = playerZ + C.GRID_DRAW_DIST;
const near = project([x, 0, nearZ], w, h, 600, camPos);
const far = project([x, 0, farZ], w, h, 600, camPos);
if (!near || !far) continue;
if (near[2] <= 0.1 || far[2] <= 0.1) continue;
const dist = far[2];
const alpha = clamp(0.6 - dist / C.GRID_DRAW_DIST * 0.55, 0.02, 0.5);
// Brighter near the center (sunlit path)
const centerDist = Math.abs(x) / totalWidth;
const brightness = clamp(1 - centerDist * 1.5, 0.3, 1);
const r = Math.round(lerp(180, 255, brightness));
const g = Math.round(lerp(100, 200, brightness));
const b = Math.round(lerp(40, 80, brightness));
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`;
ctx.lineWidth = Math.max(0.5, 2 - dist / 1000);
ctx.beginPath();
ctx.moveTo(near[0], near[1]);
ctx.lineTo(far[0], far[1]);
ctx.stroke();
}
// Draw transverse lines (along X)
const startIdx = Math.floor(playerZ / segLen);
for (let i = startIdx - 3; i < startIdx + C.GRID_SEGMENTS; i++) {
const z = i * segLen;
if (z < playerZ + 2) continue;
if (z > playerZ + C.GRID_DRAW_DIST) break;
const near = project([-totalWidth, 0, z], w, h, 600, camPos);
const far = project([totalWidth, 0, z], w, h, 600, camPos);
if (!near || !far) continue;
if (near[2] <= 0.1) continue;
const dist = near[2];
const alpha = clamp(0.5 - dist / C.GRID_DRAW_DIST * 0.45, 0.02, 0.4);
ctx.strokeStyle = `rgba(255,180,60,${alpha})`;
ctx.lineWidth = Math.max(0.5, 1.5 - dist / 1200);
ctx.beginPath();
ctx.moveTo(near[0], near[1]);
ctx.lineTo(far[0], far[1]);
ctx.stroke();
}
}
// ── function: drawObstacle ──
// === DRAW OBSTACLES ===
function drawObstacle(ctx, w, h, camPos, obs) {
const corners = getBoxCorners(obs.pos, obs.size);
const projected = corners.map(c => project(c, w, h, 600, camPos));
const visible = projected.filter(p => p && p[2] > 0.1 && p[2] < 2000);
if (visible.length < 3) return;
// Determine if lit (near sun direction)
const sunDir = v3Norm([0, 1, -1]);
const toObs = v3Norm(v3Sub(obs.pos, camPos));
const lightFactor = clamp(v3Dot(toObs, sunDir) * 0.5 + 0.5, 0, 1);
const lit = C.COLORS.obstacleLit;
const shad = C.COLORS.obstacleShadow;
const edge = C.COLORS.obstacleEdge;
const rLit = Math.round(lerp(shad[0], lit[0], lightFactor));
const gLit = Math.round(lerp(shad[1], lit[1], lightFactor));
const bLit = Math.round(lerp(shad[2], lit[2], lightFactor));
// Draw back faces first, then front (simple z-sort by drawing order)
const faces = [
{ verts: [projected[0], projected[1], projected[3], projected[2]], z: (projected[0][2]+projected[1][2]+projected[3][2]+projected[2][2])/4 },
{ verts: [projected[4], projected[5], projected[7], projected[6]], z: (projected[4][2]+projected[5][2]+projected[7][2]+projected[6][2])/4 },
{ verts: [projected[0], projected[4], projected[5], projected[1]], z: (projected[0][2]+projected[4][2]+projected[5][2]+projected[1][2])/4 },
{ verts: [projected[2], projected[3], projected[7], projected[6]], z: (projected[2][2]+projected[3][2]+projected[7][2]+projected[6][2])/4 },
{ verts: [projected[1], projected[5], projected[7], projected[3]], z: (projected[1][2]+projected[5][2]+projected[7][2]+projected[3][2])/4 },
{ verts: [projected[0], projected[2], projected[6], projected[4]], z: (projected[0][2]+projected[2][2]+projected[6][2]+projected[4][2])/4 },
];
faces.sort((a, b) => b.z - a.z);
for (const face of faces) {
const alpha = clamp(1 - face.z / 1800, 0.05, 0.85);
ctx.fillStyle = `rgba(${rLit},${gLit},${bLit},${alpha})`;
ctx.strokeStyle = `rgba(${edge[0]},${edge[1]},${edge[2]},${alpha * 0.7})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(face.verts[0][0], face.verts[0][1]);
for (let i = 1; i < face.verts.length; i++) {
ctx.lineTo(face.verts[i][0], face.verts[i][1]);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
// ── function: getBoxCorners ──
function getBoxCorners(pos, size) {
const [x, y, z] = pos;
const [hx, hy, hz] = v3Scale(size, 0.5);
return [
[x-hx, y-hy, z-hz], [x+hx, y-hy, z-hz], [x-hx, y+hy, z-hz], [x+hx, y+hy, z-hz],
[x-hx, y-hy, z+hz], [x+hx, y-hy, z+hz], [x-hx, y+hy, z+hz], [x+hx, y+hy, z+hz],
];
}
// ── function: drawPlayer ──
// === DRAW PLAYER CRAFT (improved) ===
function drawPlayer(ctx, w, h, camPos, player) {
if (!player.alive) return;
const craftPos = [player.pos[0], player.pos[1], player.pos[2] + 3];
const p = project(craftPos, w, h, 600, camPos);
if (!p || p[2] < 0.1) return;
const scale = 600 / p[2];
const roll = player.roll;
const pitch = player.pitch;
ctx.save();
ctx.translate(p[0], p[1]);
ctx.rotate(-roll);
// Engine glow behind craft
const engineGlow = ctx.createRadialGradient(0, 10 * scale, 0, 0, 10 * scale, 50 * scale);
engineGlow.addColorStop(0, 'rgba(255,200,60,0.4)');
engineGlow.addColorStop(0.5, 'rgba(255,150,30,0.15)');
engineGlow.addColorStop(1, 'rgba(255,80,0,0)');
ctx.fillStyle = engineGlow;
ctx.fillRect(-60 * scale, -60 * scale, 120 * scale, 120 * scale);
// Craft body - sleek triangle
const bodyLen = 14 * scale;
const bodyW = 6 * scale;
// Shadow underneath
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.beginPath();
ctx.ellipse(0, bodyLen * 0.8, bodyW * 1.2, bodyW * 0.4, 0, 0, Math.PI * 2);
ctx.fill();
// Main body
ctx.fillStyle = '#d0d8e8';
ctx.strokeStyle = '#ffcc44';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -bodyLen);
ctx.lineTo(-bodyW, bodyLen * 0.3);
ctx.lineTo(-bodyW * 0.6, bodyLen * 0.55);
ctx.lineTo(-bodyW * 0.3, bodyLen * 0.45);
ctx.lineTo(0, bodyLen * 0.35);
ctx.lineTo(bodyW * 0.3, bodyLen * 0.45);
ctx.lineTo(bodyW * 0.6, bodyLen * 0.55);
ctx.lineTo(bodyW, bodyLen * 0.3);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Cockpit
const cockpitGrad = ctx.createLinearGradient(0, -bodyLen * 0.6, 0, -bodyLen * 0.2);
cockpitGrad.addColorStop(0, '#ffee88');
cockpitGrad.addColorStop(1, '#ffaa22');
ctx.fillStyle = cockpitGrad;
ctx.beginPath();
ctx.ellipse(0, -bodyLen * 0.45, bodyW * 0.3, bodyLen * 0.18, 0, 0, Math.PI * 2);
ctx.fill();
// Wing accents
ctx.strokeStyle = '#ff8800';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-bodyW, bodyLen * 0.15);
ctx.lineTo(-bodyW * 1.8, bodyLen * 0.5);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(bodyW, bodyLen * 0.15);
ctx.lineTo(bodyW * 1.8, bodyLen * 0.5);
ctx.stroke();
// Wing tip lights
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(-bodyW * 1.8, bodyLen * 0.5, 2 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#44ff44';
ctx.beginPath();
ctx.arc(bodyW * 1.8, bodyLen * 0.5, 2 * scale, 0, Math.PI * 2);
ctx.fill();
// Engine thrust
const thrustLen = (8 + (player.boosting ? 15 : 0)) * scale;
const thrustGrad = ctx.createLinearGradient(0, bodyLen * 0.35, 0, bodyLen * 0.35 + thrustLen);
thrustGrad.addColorStop(0, 'rgba(255,220,80,0.9)');
thrustGrad.addColorStop(0.3, 'rgba(255,150,30,0.6)');
thrustGrad.addColorStop(0.7, 'rgba(255,80,0,0.3)');
thrustGrad.addColorStop(1, 'rgba(255,50,0,0)');
ctx.fillStyle = thrustGrad;
ctx.beginPath();
ctx.moveTo(-bodyW * 0.3, bodyLen * 0.35);
ctx.lineTo(0, bodyLen * 0.35 + thrustLen);
ctx.lineTo(bodyW * 0.3, bodyLen * 0.35);
ctx.closePath();
ctx.fill();
// Boost flame (extra)
if (player.boosting) {
const boostGrad = ctx.createRadialGradient(0, bodyLen * 0.35 + thrustLen * 0.5, 0, 0, bodyLen * 0.35 + thrustLen * 0.5, 20 * scale);
boostGrad.addColorStop(0, 'rgba(255,255,200,0.5)');
boostGrad.addColorStop(1, 'rgba(255,200,100,0)');
ctx.fillStyle = boostGrad;
ctx.beginPath();
ctx.arc(0, bodyLen * 0.35 + thrustLen * 0.5, 20 * scale, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ── function: drawPickup ──
// === DRAW PICKUPS ===
function drawPickup(ctx, w, h, camPos, pickup, time) {
const bob = Math.sin(time * 3 + pickup.bobPhase) * 0.5;
const glow = Math.sin(time * 5 + pickup.glowPhase) * 0.3 + 0.7;
const pos3D = [pickup.pos[0], pickup.pos[1] + bob, pickup.pos[2]];
const p = project(pos3D, w, h, 600, camPos);
if (!p || p[2] < 0.1 || p[2] > 2000) return;
const scale = 600 / p[2];
const size = pickup.size * scale * 15;
let color;
if (pickup.type === 'energy') color = C.COLORS.pickupEnergy;
else if (pickup.type === 'score') color = C.COLORS.pickupScore;
else color = C.COLORS.pickupMulti;
const r = color[0], g = color[1], b = color[2];
// Glow
const glowGrad = ctx.createRadialGradient(p[0], p[1], 0, p[0], p[1], size * 2);
glowGrad.addColorStop(0, `rgba(${r},${g},${b},${0.4 * glow})`);
glowGrad.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = glowGrad;
ctx.beginPath();
ctx.arc(p[0], p[1], size * 2, 0, Math.PI * 2);
ctx.fill();
// Core shape
ctx.fillStyle = `rgba(${r},${g},${b},${0.8 * glow})`;
ctx.strokeStyle = `rgba(255,255,255,${0.6 * glow})`;
ctx.lineWidth = 1;
if (pickup.type === 'energy') {
// Lightning bolt shape
ctx.beginPath();
ctx.moveTo(p[0], p[1] - size);
ctx.lineTo(p[0] + size * 0.3, p[1] - size * 0.2);
ctx.lineTo(p[0] - size * 0.1, p[1] - size * 0.1);
ctx.lineTo(p[0] + size * 0.1, p[1] + size);
ctx.lineTo(p[0] - size * 0.3, p[1] + size * 0.2);
ctx.lineTo(p[0] + size * 0.1, p[1] + size * 0.1);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else if (pickup.type === 'score') {
// Diamond
ctx.beginPath();
ctx.moveTo(p[0], p[1] - size);
ctx.lineTo(p[0] + size, p[1]);
ctx.lineTo(p[0], p[1] + size);
ctx.lineTo(p[0] - size, p[1]);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else {
// Circle (multiplier)
ctx.beginPath();
ctx.arc(p[0], p[1], size, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.arc(p[0], p[1], size * 0.5, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r},${g},${b},${0.5 * glow})`;
ctx.stroke();
}
}
// ── function: drawParticle ──
// === DRAW PARTICLES ===
function drawParticle(ctx, w, h, camPos, p) {
const proj = project(p.pos, w, h, 600, camPos);
if (!proj || proj[2] < 0.1) return;
const scale = 600 / proj[2];
const alpha = clamp(p.life / p.maxLife, 0, 1);
const size = p.size * scale * 8;
const r = Math.round(lerp(p.color[0], 255, 1 - alpha));
const g = Math.round(lerp(p.color[1], 255, 1 - alpha));
const b = Math.round(lerp(p.color[2], 255, 1 - alpha));
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
ctx.beginPath();
ctx.arc(proj[0], proj[1], size, 0, Math.PI * 2);
ctx.fill();
}
// ── function: drawSpeedLines ──
// === DRAW SPEED LINES (improved) ===
function drawSpeedLines(ctx, w, h, speed) {
if (speed < C.SPEED_LINES_THRESHOLD) return;
const intensity = clamp((speed - C.SPEED_LINES_THRESHOLD) / (C.PLAYER_MAX_SPEED - C.SPEED_LINES_THRESHOLD), 0, 1);
const count = Math.floor(intensity * 40);
for (let i = 0; i < count; i++) {
const x = Math.random() * w;
const y = Math.random() * h;
const len = 30 + Math.random() * 80 * intensity;
const alpha = intensity * 0.2;
// Chromatic aberration effect - offset R and B channels
ctx.strokeStyle = `rgba(255,255,200,${alpha * 0.5})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (x - w/2) * 0.05, y + len);
ctx.stroke();
ctx.strokeStyle = `rgba(255,100,100,${alpha * 0.3})`;
ctx.beginPath();
ctx.moveTo(x - 2, y);
ctx.lineTo(x + (x - w/2) * 0.05 - 3, y + len);
ctx.stroke();
ctx.strokeStyle = `rgba(100,100,255,${alpha * 0.3})`;
ctx.beginPath();
ctx.moveTo(x + 2, y);
ctx.lineTo(x + (x - w/2) * 0.05 + 3, y + len);
ctx.stroke();
}
}
// ── function: drawShadowOverlay ──
// === DRAW SHADOW OVERLAY ===
function drawShadowOverlay(ctx, w, h, player, world) {
let shadowAmount = 0;
// Check if player is near a large obstacle (shadow effect)
for (const obs of world.obstacles) {
const dist = v3Len(v3Sub(player.pos, obs.pos));
if (dist < 15) {
const overlap = 1 - dist / 15;
shadowAmount = Math.max(shadowAmount, overlap * 0.3);
}
}
if (shadowAmount > 0) {
ctx.fillStyle = `rgba(20,10,30,${shadowAmount})`;
ctx.fillRect(0, 0, w, h);
}
}
// ── function: updatePlayer ──
function updatePlayer(player, input, dt) {
if (!player.alive) return;
// Input handling with lerp for smooth movement
let targetX = 0, targetY = 0;
if (input.left) targetX -= 1;
if (input.right) targetX += 1;
if (input.up) targetY += 1;
if (input.down) targetY -= 1;
player.targetX = lerp(player.targetX, targetX, 0.3);
player.targetY = lerp(player.targetY, targetY, 0.3);
// Strafe
const strafeSpeed = 12;
player.pos[0] += player.targetX * strafeSpeed * dt;
player.pos[0] = clamp(player.pos[0], -C.PLAYER_BOUNDS, C.PLAYER_BOUNDS);
// Pitch (vertical)
player.pos[1] = lerp(player.pos[1], 0.5 + player.targetY * 3, 0.15);
player.pos[1] = clamp(player.pos[1], -1, 8);
// Roll and pitch visual
player.targetRoll = player.targetX * C.PLAYER_ROLL_MAX;
player.targetPitch = player.targetY * C.PLAYER_PITCH_MAX;
player.roll = lerp(player.roll, player.targetRoll, C.PLAYER_LERP * 3);
player.pitch = lerp(player.pitch, player.targetPitch, C.PLAYER_LERP * 3);
// Speed management
if (player.boosting && player.energy > 0) {
player.speed = lerp(player.speed, C.PLAYER_SPEED * C.BOOST_MULTIPLIER, 0.1);
player.energy -= C.BOOST_DRAIN;
} else {
player.speed = lerp(player.speed, player.baseSpeed, 0.05);
}
// Move forward
player.pos[2] += player.speed * dt;
player.distance += player.speed * dt;
player.score += player.speed * dt * C.SCORE_BASE * player.multiplier;
// Energy drain
let drain = C.ENERGY_DRAIN;
for (const obs of world.obstacles) {
const dist = v3Len(v3Sub(player.pos, obs.pos));
if (dist < 10) {
drain = C.ENERGY_DRAIN_SHADOW;
break;
}
}
player.energy -= drain;
player.energy = clamp(player.energy, 0, C.ENERGY_MAX);
// Check for sun setting (game over)
const sunFraction = world.time / C.SUN_SET_DURATION;
if (sunFraction >= C.SUN_SET_THRESHOLD) {
player.alive = false;
}
// Near-miss detection
for (const obs of world.obstacles) {
if (obs.nearMissed) continue;
const dist = v3Len(v3Sub([player.pos[0], player.pos[1]], [obs.pos[0], obs.pos[1]]));
const zDist = Math.abs(player.pos[2] - obs.pos[2]);
if (zDist < 5 && dist > obs.size[0] * 0.5 + 2 && dist < obs.size[0] * 0.5 + 8) {
obs.nearMissed = true;
player.score += C.SCORE_NEAR_MISS * player.multiplier;
player.nearMissTimer = 0.5;
applyCameraShake(camera, C.CAM_SHAKE_NEAR);
playNearMissSound();
const canvas = document.getElementById('gameCanvas');
const p = project([player.pos[0], player.pos[1] + 2, player.pos[2] + 3], canvas.width, canvas.height, 600, camera.pos);
if (p) {
addScorePopup(p[0], p[1], `+${Math.floor(C.SCORE_NEAR_MISS * player.multiplier)}`, 'rgba(255,255,100,1)');
}
}
}
// Boost energy check
if (player.boosting && player.energy <= 0) {
player.boosting = false;
}
player.nearMissTimer = Math.max(0, player.nearMissTimer - dt);
player.flashTimer = Math.max(0, player.flashTimer - dt);
}
// ── function: checkCollisions ──
function checkCollisions(player, world) {
if (!player.alive) return;
for (const obs of world.obstacles) {
if (obs.hit) continue;
const dx = Math.abs(player.pos[0] - obs.pos[0]);
const dy = Math.abs(player.pos[1] - obs.pos[1]);
const dz = Math.abs(player.pos[2] - obs.pos[2]);
if (dx < obs.size[0] * 0.5 + 0.5 &&
dy < obs.size[1] * 0.5 + 0.3 &&
dz < obs.size[2] * 0.5 + 0.5) {
obs.hit = true;
player.alive = false;
player.flashTimer = 0.3;
applyCameraShake(camera, C.CAM_SHAKE_CRASH);
emitCrash(particlePool, player.pos);
playCrashSound();
return;
}
}
for (const pickup of world.pickups) {
if (pickup.collected) continue;
const dx = player.pos[0] - pickup.pos[0];
const dy = player.pos[1] - pickup.pos[1];
const dz = player.pos[2] - pickup.pos[2];
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < pickup.size + 1.5) {
pickup.collected = true;
const pColor = pickup.type === 'energy' ? C.COLORS.pickupEnergy :
pickup.type === 'score' ? C.COLORS.pickupScore : C.COLORS.pickupMulti;
emitPickup(particlePool, pickup.pos, pColor);
if (pickup.type === 'energy') {
player.energy = Math.min(C.ENERGY_MAX, player.energy + C.ENERGY_PICKUP);
} else if (pickup.type === 'score') {
player.score += 100 * player.multiplier;
} else {
player.multiplier += 1;
}
playPickupSound(pickup.type);
}
}
}
// ── function: updateWorld ──
function updateWorld(world, dt) {
world.time += dt;
// Sun sinks linearly over SUN_SET_DURATION seconds
world.sunAngle = (world.time / C.SUN_SET_DURATION) * Math.PI;
// Spawn obstacles
world.spawnTimer += dt;
const spawnInterval = Math.max(4, C.OBSTACLE_SPAWN_INTERVAL - world.time * 0.01);
if (world.spawnTimer >= spawnInterval) {
world.spawnTimer = 0;
const rng = seededRandom(Math.floor(world.time * 100) + world.seed);
const count = randInt(1, 3);
for (let i = 0; i < count; i++) {
spawnObstacle(world, rng);
}
}
// Spawn pickups
world.pickupTimer += dt;
if (world.pickupTimer >= C.PICKUP_SPAWN_INTERVAL) {
world.pickupTimer = 0;
const rng = seededRandom(Math.floor(world.time * 200) + world.seed + 999);
spawnPickup(world, rng);
}
// Clean up old objects
const cleanupZ = player.pos[2] - 50;
world.obstacles = world.obstacles.filter(o => o.pos[2] > cleanupZ);
world.pickups = world.pickups.filter(p => p.pos[2] > cleanupZ);
}
// ── let: gameRunning ──
// === GAME STATE ===
let gameRunning = false;
// ── let: gameState ──
let gameState = 'title';
// ── let: player ──
let player;
// ── let: camera ──
let camera;
// ── let: world ──
let world;
// ── let: particlePool ──
let particlePool;
// ── let: input ──
let input = { left: false, right: false, up: false, down: false, boost: false };
// ── let: lastTime ──
let lastTime = 0;
// ── let: multiplierTimer ──
let multiplierTimer = 0;
// ── let: highScore ──
let highScore = 0;
// ── let: gameTime ──
let gameTime = 0;
// ── function: initGame ──
function initGame() {
player = createPlayer();
camera = createCamera();
world = createWorld();
particlePool = [];
input = { left: false, right: false, up: false, down: false, boost: false };
multiplierTimer = 0;
gameTime = 0;
gameRunning = true;
}
// ── function: updateHUD ──
function updateHUD() {
const hudScore = document.getElementById('hudScore');
const hudMult = document.getElementById('hudMultiplier');
const hudEnergy = document.getElementById('hudEnergyBar');
const hudSpeed = document.getElementById('hudSpeed');
const hudDist = document.getElementById('hudDistance');
if (hudScore) hudScore.textContent = Math.floor(player.score).toLocaleString();
if (hudMult) {
hudMult.textContent = `×${player.multiplier}`;
if (player.nearMissTimer > 0) {
hudMult.classList.add('bump');
} else {
hudMult.classList.remove('bump');
}
}
if (hudEnergy) hudEnergy.style.width = `${(player.energy / C.ENERGY_MAX) * 100}%`;
if (hudSpeed) hudSpeed.textContent = `${Math.floor(player.speed * 100)} km/s`;
if (hudDist) hudDist.textContent = `${Math.floor(player.distance)} m`;
}
// ── function: showGameOver ──
function showGameOver() {
gameState = 'gameover';
gameRunning = false;
const goScreen = document.getElementById('gameOverScreen');
const goStats = document.getElementById('goStats');
if (player.score > highScore) {
highScore = player.score;
localStorage.setItem('raceTheSun_highScore', highScore);
}
let reason = '';
if (world.sunAngle > Math.PI * 0.8) {
reason = '<p class="go-reason">The sun has set.</p>';
} else {
reason = '<p class="go-reason">Craft destroyed.</p>';
}
const dist = Math.floor(player.distance);
const score = Math.floor(player.score);
const speed = Math.floor(player.speed * 100);
const time = Math.floor(gameTime);
const isBest = score >= highScore;
goStats.innerHTML = `
${reason}
<p>Distance: <span>${dist} m</span></p>
<p>Score: <span>${score.toLocaleString()}</span></p>
<p>Top Speed: <span>${speed} km/s</span></p>
<p>Time: <span>${time}s</span></p>
<p>Multiplier: <span>×${player.multiplier}</span></p>
${isBest ? '<p class="go-best">★ NEW HIGH SCORE ★</p>' : '<p>High Score: <span>' + Math.floor(highScore).toLocaleString() + '</span></p>'}
<p class="go-hint">Press R or click to restart</p>
`;
goScreen.classList.remove('hidden');
}
// ── function: startGame ──
function startGame() {
document.getElementById('titleScreen').classList.add('hidden');
document.getElementById('gameOverScreen').classList.add('hidden');
initAudio();
initGame();
gameState = 'playing';
}
// ── function: restartGame ──
function restartGame() {
document.getElementById('gameOverScreen').classList.add('hidden');
initAudio();
initGame();
gameState = 'playing';
}
// ── function: render ──
function render(timestamp) {
requestAnimationFrame(render);
if (!lastTime) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width = window.innerWidth;
const h = canvas.height = window.innerHeight;
if (gameState === 'playing') {
gameTime += dt;
player.baseSpeed = clamp(C.PLAYER_SPEED + gameTime * 0.03, C.PLAYER_SPEED, C.PLAYER_MAX_SPEED);
updatePlayer(player, input, dt);
updateCamera(camera, player, dt);
updateWorld(world, dt);
checkCollisions(player, world);
updateParticles(particlePool, dt);
updateWind(player.speed);
updateScorePopups(dt);
emitThrust(particlePool, player.pos);
const sunScreenY = C.SUN_Y * h + world.sunAngle * h * 0.3;
drawSky(ctx, w, h, clamp(sunScreenY / h, 0, 1));
drawSun(ctx, w, h, sunScreenY / h, world.sunAngle);
drawGrid(ctx, w, h, camera.pos, player.pos[2], player.speed);
const sortedObs = [...world.obstacles].filter(o => {
const p = project(o.pos, w, h, 600, camera.pos);
return p && p[2] > 0.1;
}).sort((a, b) => {
const pa = project(a.pos, w, h, 600, camera.pos);
const pb = project(b.pos, w, h, 600, camera.pos);
return (pb[2] || 0) - (pa[2] || 0);
});
for (const obs of sortedObs) drawObstacle(ctx, w, h, camera.pos, obs);
for (const pickup of world.pickups) drawPickup(ctx, w, h, camera.pos, pickup, world.time);
for (const p of particlePool) drawParticle(ctx, w, h, camera.pos, p);
drawPlayer(ctx, w, h, camera.pos, player);
drawShadowOverlay(ctx, w, h, player, world);
if (player.speed > C.SPEED_LINES_THRESHOLD) drawSpeedLines(ctx, w, h, player.speed);
drawScorePopups(ctx);
drawLowEnergyWarning(ctx, w, h, player);
if (player.flashTimer > 0) {
ctx.fillStyle = `rgba(255,100,50,${player.flashTimer * 0.5})`;
ctx.fillRect(0, 0, w, h);
}
if (player.nearMissTimer > 0) {
ctx.fillStyle = `rgba(255,255,200,${player.nearMissTimer * 0.3})`;
ctx.fillRect(0, 0, w, h);
}
updateHUD();
if (!player.alive) showGameOver();
}
if (gameState === 'title') {
world.time += dt;
drawSky(ctx, w, h, 0.5 + Math.sin(world.time * 0.5) * 0.1);
drawSun(ctx, w, h, 0.45, world.time * 0.1);
drawGrid(ctx, w, h, [0, 5, -10], 0, 2);
}
if (gameState === 'gameover') {
drawSky(ctx, w, h, 0.6);
drawSun(ctx, w, h, 0.5, world.sunAngle);
drawGrid(ctx, w, h, camera.pos, player.pos[2], 0);
updateHUD();
}
}
// ── let: scorePopups ──
// === SCORE POPUPS ===
let scorePopups = [];
// ── function: addScorePopup ──
function addScorePopup(x, y, text, color) {
scorePopups.push({
x: x, y: y, text: text, color: color,
life: 1.5, maxLife: 1.5,
vy: -50,
});
}
// ── function: updateScorePopups ──
function updateScorePopups(dt) {
for (let i = scorePopups.length - 1; i >= 0; i--) {
const p = scorePopups[i];
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) scorePopups.splice(i, 1);
}
}
// ── function: drawScorePopups ──
function drawScorePopups(ctx) {
for (const p of scorePopups) {
const alpha = clamp(p.life / p.maxLife, 0, 1);
ctx.font = `bold ${16 + (1 - alpha) * 8}px 'Courier New', monospace`;
ctx.textAlign = 'center';
ctx.fillStyle = p.color.replace('1)', `${alpha})`);
ctx.fillText(p.text, p.x, p.y);
}
}
// ── function: drawLowEnergyWarning ──
function drawLowEnergyWarning(ctx, w, h, player) {
if (player.energy > 20) return;
const intensity = (1 - player.energy / 20) * 0.3;
const pulse = Math.sin(gameTime * 8) * 0.5 + 0.5;
// Edge glow
const grad = ctx.createRadialGradient(w/2, h/2, Math.min(w,h) * 0.3, w/2, h/2, Math.max(w,h) * 0.7);
grad.addColorStop(0, 'rgba(255,0,0,0)');
grad.addColorStop(1, `rgba(255,0,0,${intensity * pulse})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
}
// ── main ──
// === MAIN BLOCK ===
// Load high score
const saved = localStorage.getItem('raceTheSun_highScore');
if (saved) highScore = parseFloat(saved);
// Initialize world for title screen
world = createWorld();
// Canvas setup
const canvas = document.getElementById('gameCanvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Input handling
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.left = true;
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.right = true;
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') input.up = true;
if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') input.down = true;
if (e.key === ' ') { input.boost = true; e.preventDefault(); }
if (e.key === 'r' || e.key === 'R') {
if (gameState === 'playing' || gameState === 'gameover') restartGame();
}
if (e.key === 'Enter' && gameState === 'title') startGame();
});
document.addEventListener('keyup', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.left = false;
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.right = false;
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') input.up = false;
if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') input.down = false;
if (e.key === ' ') input.boost = false;
});
// Button handlers
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', restartGame);
// Resize handling
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
// Start render loop
requestAnimationFrame(render);
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"1669f200-1789-4698-9c77-f06752b52618","tokensIn":4148031,"tokensOut":46433,"tokensTotal":4194464,"turns":98,"toolCalls":97,"failedToolCalls":5,"timestamp":"2026-07-20T23:56:28.456Z"} -->