Race the Sun
Qwen 3.6 27b MTP · composer

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 63,489 bytes · SHA-256 345ca629b77c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Race the Sun - Endless Runner</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a12; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; user-select: none; }
#app { position: relative; width: 100%; height: 100%; }
canvas { display: block; width: 100%; height: 100%; }
/* HUD */
#hud { position: absolute; top: 0; left: 0; right: 0; pointer-events: none; z-index: 10; padding: 24px 32px; }
#hud-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
#score-display { font-size: 42px; font-weight: 900; color: #fff; text-shadow: 0 2px 15px rgba(0,0,0,0.6), 0 0 30px rgba(255,140,80,0.2); letter-spacing: 3px; line-height: 1; }
#multiplier { color: #ffcc00; margin-right: 10px; font-size: 26px; text-shadow: 0 0 15px rgba(255,200,0,0.4); }
#distance { font-size: 22px; color: rgba(255,255,255,0.75); text-shadow: 0 2px 8px rgba(0,0,0,0.6); letter-spacing: 1px; }
#energy-bar-container { width: 220px; height: 10px; background: rgba(0,0,0,0.5); border-radius: 5px; overflow: hidden; margin-top: 8px; border: 1px solid rgba(255,255,255,0.1); }
#energy-bar { width: 100%; height: 100%; background: linear-gradient(90deg, #ff4444, #ffaa00, #44ff88); border-radius: 5px; transition: width 0.15s ease-out; }
#speed-display { position: absolute; bottom: 24px; right: 32px; font-size: 18px; color: rgba(255,255,255,0.4); text-shadow: 0 1px 4px rgba(0,0,0,0.6); letter-spacing: 1px; }
/* Title Screen */
#title-screen { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 20; background: radial-gradient(ellipse at 50% 60%, rgba(255,140,50,0.1) 0%, transparent 70%); }
#title-screen h1 { font-size: clamp(36px, 8vw, 80px); font-weight: 900; color: #fff; text-shadow: 0 0 40px rgba(255,160,50,0.7), 0 4px 20px rgba(0,0,0,0.6); letter-spacing: clamp(4px, 1vw, 10px); margin-bottom: 8px; text-align: center; }
.subtitle { font-size: clamp(12px, 2vw, 18px); color: rgba(255,200,150,0.6); letter-spacing: clamp(3px, 0.8vw, 8px); text-transform: uppercase; margin-bottom: 40px; }
#high-score-label { font-size: 16px; color: rgba(255,255,255,0.4); margin-bottom: 30px; letter-spacing: 1px; }
#start-btn { pointer-events: all; padding: 18px 56px; font-size: 20px; font-weight: 700; letter-spacing: 5px; color: #fff; background: linear-gradient(135deg, #ff6b35, #f7c948); border: none; border-radius: 10px; cursor: pointer; text-transform: uppercase; box-shadow: 0 4px 25px rgba(255,107,53,0.4), inset 0 1px 0 rgba(255,255,255,0.2); transition: transform 0.1s ease-out, box-shadow 0.1s; }
#start-btn:hover { transform: scale(1.06); box-shadow: 0 6px 35px rgba(255,107,53,0.6), inset 0 1px 0 rgba(255,255,255,0.2); }
#start-btn:active { transform: scale(0.98); }
.controls-info { margin-top: 45px; text-align: center; color: rgba(255,255,255,0.35); font-size: 14px; line-height: 2.2; letter-spacing: 0.5px; }
/* Game Over */
#gameover-screen { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 20; background: rgba(10,5,20,0.88); backdrop-filter: blur(4px); }
#gameover-screen h1 { font-size: clamp(36px, 7vw, 72px); font-weight: 900; color: #ff4444; text-shadow: 0 0 35px rgba(255,50,50,0.5), 0 4px 15px rgba(0,0,0,0.6); letter-spacing: clamp(4px, 1vw, 10px); margin-bottom: 35px; }
#go-stats { display: flex; flex-direction: column; gap: 14px; margin-bottom: 35px; padding: 20px 30px; background: rgba(255,255,255,0.05); border-radius: 12px; border: 1px solid rgba(255,255,255,0.08); }
.stat-row { display: flex; justify-content: space-between; width: 260px; font-size: 20px; color: rgba(255,255,255,0.7); letter-spacing: 1px; }
.stat-row span:first-child { opacity: 0.5; text-transform: uppercase; font-size: 14px; letter-spacing: 2px; }
#restart-btn { pointer-events: all; padding: 16px 48px; font-size: 18px; font-weight: 700; letter-spacing: 4px; color: #fff; background: linear-gradient(135deg, #ff6b35, #f7c948); border: none; border-radius: 10px; cursor: pointer; text-transform: uppercase; box-shadow: 0 4px 25px rgba(255,107,53,0.4), inset 0 1px 0 rgba(255,255,255,0.2); transition: transform 0.1s ease-out; }
#restart-btn:hover { transform: scale(1.06); box-shadow: 0 6px 35px rgba(255,107,53,0.6), inset 0 1px 0 rgba(255,255,255,0.2); }
#restart-btn:active { transform: scale(0.98); }
/* Animations */
@keyframes pulseGlow { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud" style="display:none;">
<div id="hud-top">
<div id="score-display"><span id="multiplier">×1</span><span id="score">0</span></div>
<div id="distance">0 m</div>
</div>
<div id="energy-bar-container"><div id="energy-bar"></div></div>
<div id="speed-display">0 km/h</div>
</div>
<div id="title-screen">
<h1>RACE THE SUN</h1>
<p class="subtitle">Endless Solar Runner</p>
<div id="high-score-label">High Score: <span id="high-score-title">0</span></div>
<button id="start-btn">LAUNCH</button>
<div class="controls-info">
<p><b>WASD / Arrows</b> — Move</p>
<p><b>Space</b> — Boost</p>
<p><b>R</b> — Restart</p>
</div>
</div>
<div id="gameover-screen" style="display:none;">
<h1>SUNSET</h1>
<div id="go-stats">
<div class="stat-row"><span>Distance:</span><span id="go-distance">0 m</span></div>
<div class="stat-row"><span>Score:</span><span id="go-score">0</span></div>
<div class="stat-row"><span>Best:</span><span id="go-best">0</span></div>
</div>
<button id="restart-btn">RELAUNCH (R)</button>
</div>
<script>
'use strict';
// ── function: mulberry32 ──
// === SEEDED PRNG (mulberry32) ===
function mulberry32(seed) {
return function() {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
var t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// ── function: hashCoord ──
function hashCoord(x, z, seed) {
let h = (seed + Math.floor(x * 3.7) + Math.floor(z * 7.3)) | 0;
h = ((h >> 16) ^ h) * 0x45d9f3b;
h = ((h >> 16) ^ h) * 0x45d9f3b;
return (h ^ (h >> 16)) >>> 0;
}
// ── function: lerp ──
// === UTILITY FUNCTIONS ===
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: randRange ──
function randRange(rng, a, b) { return a + rng() * (b - a); }
// ── const: AudioSys ──
// === AUDIO SYSTEM (WebAudio Procedural) ===
const AudioSys = {
ctx: null,
masterGain: null,
droneOsc: null,
droneGain: null,
windNode: null,
windGain: null,
init() {
if (this.ctx) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.35;
this.masterGain.connect(this.ctx.destination);
},
startDrone() {
if (!this.ctx) return;
// Low drone oscillator
this.droneOsc = this.ctx.createOscillator();
this.droneOsc.type = 'sawtooth';
this.droneOsc.frequency.value = 55;
this.droneGain = this.ctx.createGain();
this.droneGain.gain.value = 0.06;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 200;
this.droneOsc.connect(filter);
filter.connect(this.droneGain);
this.droneGain.connect(this.masterGain);
this.droneOsc.start();
// Wind noise
const bufSize = this.ctx.sampleRate * 2;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
this.windNode = this.ctx.createBufferSource();
this.windNode.buffer = buf;
this.windNode.loop = true;
this.windGain = this.ctx.createGain();
this.windGain.gain.value = 0.03;
const windFilter = this.ctx.createBiquadFilter();
windFilter.type = 'bandpass';
windFilter.frequency.value = 400;
windFilter.Q.value = 0.5;
this.windNode.connect(windFilter);
windFilter.connect(this.windGain);
this.windGain.connect(this.masterGain);
this.windNode.start();
},
updateDrone(speedRatio) {
if (!this.droneOsc || !this.windGain) return;
const freq = lerp(55, 120, speedRatio);
this.droneOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.1);
this.windGain.gain.setTargetAtTime(lerp(0.02, 0.08, speedRatio), this.ctx.currentTime, 0.1);
},
playPickup(type) {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.masterGain);
const now = this.ctx.currentTime;
if (type === 'energy') {
osc.type = 'sine';
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(900, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
osc.start(now);
osc.stop(now + 0.15);
} else if (type === 'score') {
osc.type = 'triangle';
osc.frequency.setValueAtTime(800, now);
osc.frequency.exponentialRampToValueAtTime(1200, now + 0.06);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
} else if (type === 'multiplier') {
osc.type = 'square';
osc.frequency.setValueAtTime(500, now);
osc.frequency.exponentialRampToValueAtTime(1400, now + 0.2);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc.start(now);
osc.stop(now + 0.25);
} else if (type === 'nearmiss') {
osc.type = 'sine';
osc.frequency.setValueAtTime(300, now);
osc.frequency.exponentialRampToValueAtTime(150, now + 0.15);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
} else if (type === 'crash') {
// Noise burst for crash
const bufSize = this.ctx.sampleRate * 0.3;
const buf = this.ctx.createBuffer(1, bufSize, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufSize, 2);
const noise = this.ctx.createBufferSource();
noise.buffer = buf;
const nGain = this.ctx.createGain();
nGain.gain.value = 0.4;
noise.connect(nGain);
nGain.connect(this.masterGain);
noise.start(now);
// Low thud
osc.type = 'sine';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(30, now + 0.3);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
osc.start(now);
osc.stop(now + 0.4);
} else if (type === 'boost') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(600, now + 0.15);
gain.gain.setValueAtTime(0.08, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
}
},
stop() {
if (this.droneOsc) { try { this.droneOsc.stop(); } catch(e){} this.droneOsc = null; }
if (this.windNode) { try { this.windNode.stop(); } catch(e){} this.windNode = null; }
},
destroy() {
this.stop();
if (this.ctx) { this.ctx.close(); this.ctx = null; }
}
};
// ── const: Particles ──
// === PARTICLE SYSTEM ===
const Particles = {
pool: [],
active: [],
emit(x, y, count, config) {
for (let i = 0; i < count; i++) {
let p = this.pool.length > 0 ? this.pool.pop() : {};
const spread = config.spread || Math.PI * 2;
const angle = (config.angle !== undefined ? config.angle : 0) + (Math.random() - 0.5) * spread;
const speed = randRange(Math.random, config.speedMin || 1, config.speedMax || 4);
p.x = x;
p.y = y;
p.vx = Math.cos(angle) * speed;
p.vy = Math.sin(angle) * speed;
p.life = 1.0;
p.decay = randRange(Math.random, config.decayMin || 0.02, config.decayMax || 0.05);
p.size = randRange(Math.random, config.sizeMin || 2, config.sizeMax || 5);
p.color = config.color || '#ffaa44';
p.gravity = config.gravity || 0;
this.active.push(p);
}
},
update() {
for (let i = this.active.length - 1; i >= 0; i--) {
const p = this.active[i];
p.x += p.vx;
p.y += p.vy + p.gravity;
p.life -= p.decay;
if (p.life <= 0) {
this.pool.push(p);
this.active.splice(i, 1);
}
}
},
render(ctx) {
for (const p of this.active) {
ctx.globalAlpha = clamp(p.life, 0, 1);
ctx.fillStyle = p.color;
const s = p.size * p.life;
ctx.fillRect(p.x - s/2, p.y - s/2, s, s);
}
ctx.globalAlpha = 1;
},
clear() {
for (const p of this.active) this.pool.push(p);
this.active.length = 0;
}
};
// ── const: Collision ──
// === COLLISION DETECTION ===
const Collision = {
checkPlayerVsObstacles(player, obstacles) {
const pw = CFG.PLAYER_W / 2;
const ph = CFG.PLAYER_H / 2;
for (const obs of obstacles) {
if (obs.z < -10 || obs.z > 30) continue; // only check nearby obstacles
const ox = obs.x;
const oy = obs.y + obs.h / 2;
const ow = obs.w / 2;
const oh = obs.h / 2;
if (obs.type === 'arch') {
// Arch: two pillars, check each separately
const pillarW = ow * 0.2;
// Left pillar collision
if (this.aabbOverlap(player.x - pw, player.y - ph, player.x + pw, player.y + ph,
ox - ow + pillarW/2 - pillarW/2, oy - oh, ox - ow + pillarW/2 + pillarW/2, oy + oh)) {
return obs;
}
// Right pillar collision
if (this.aabbOverlap(player.x - pw, player.y - ph, player.x + pw, player.y + ph,
ox + ow - pillarW/2 - pillarW/2, oy - oh, ox + ow - pillarW/2 + pillarW/2, oy + oh)) {
return obs;
}
// Top beam collision (only if player is high enough)
const beamTop = oy - oh * 0.85;
if (this.aabbOverlap(player.x - pw, player.y - ph, player.x + pw, player.y + ph,
ox - ow, beamTop - oh*0.15, ox + ow, beamTop + oh*0.15)) {
return obs;
}
} else {
// Standard AABB collision
if (this.aabbOverlap(player.x - pw, player.y - ph, player.x + pw, player.y + ph,
ox - ow, oy - oh, ox + ow, oy + oh)) {
return obs;
}
}
// Near-miss detection
if (!obs.nearMissed && !obs.passed) {
const dx = Math.abs(player.x - ox);
const dy = Math.abs(player.y - oy);
const nearDist = (ow + pw) * 0.8;
if (dx < nearDist && dy < (oh + ph) * 1.2 && obs.z > 5 && obs.z < 25) {
return { ...obs, isNearMiss: true };
}
}
}
// Mark obstacles as passed
for (const obs of obstacles) {
if (!obs.passed && obs.z < -10) {
obs.passed = true;
}
}
return null;
},
aabbOverlap(x1, y1, x2, y2, x3, y3, x4, y4) {
return x1 < x4 && x2 > x3 && y1 < y4 && y2 > y3;
},
checkPlayerVsPickups(player, pickups) {
const pw = CFG.PLAYER_W / 2 + 5;
const ph = CFG.PLAYER_H / 2 + 5;
for (const p of pickups) {
if (p.collected || p.z < -10 || p.z > 30) continue;
const dx = Math.abs(player.x - p.x);
const dy = Math.abs(player.y - (p.y + 20));
if (dx < pw && dy < ph) {
return p;
}
}
return null;
},
};
// ── const: Shake ──
// === SCREEN SHAKE SYSTEM ===
const Shake = {
intensity: 0,
decay: 0.85,
offsetX: 0,
offsetY: 0,
add(amount) {
this.intensity = Math.max(this.intensity, amount);
},
update() {
if (this.intensity > 0.1) {
this.offsetX = (Math.random() - 0.5) * this.intensity;
this.offsetY = (Math.random() - 0.5) * this.intensity;
this.intensity *= this.decay;
} else {
this.intensity = 0;
this.offsetX = 0;
this.offsetY = 0;
}
},
getOffset() {
return { x: this.offsetX, y: this.offsetY };
},
};
// ── function: renderChromaticAberration ──
// === CHROMATIC ABERRATION EFFECT (improved) ===
function renderChromaticAberration(ctx, W, H, speedRatio) {
const threshold = CFG.CHROMATIC_THRESH / CFG.MAX_SPEED;
if (speedRatio < threshold) return;
const intensity = clamp((speedRatio - threshold) * 10, 0, 6);
// Edge-based chromatic aberration using composite operations
ctx.save();
// Red channel offset (left edge)
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = `rgba(255,0,0,${intensity * 0.04})`;
ctx.fillRect(-intensity, -intensity * 0.3, W + intensity, H);
// Blue channel offset (right edge)
ctx.fillStyle = `rgba(0,0,255,${intensity * 0.04})`;
ctx.fillRect(intensity, -intensity * 0.3, W + intensity, H);
ctx.restore();
}
// ── const: DamageFlash ──
// === DAMAGE FLASH ===
const DamageFlash = {
alpha: 0,
trigger() {
this.alpha = 0.6;
},
update() {
if (this.alpha > 0) {
this.alpha *= 0.9;
}
},
render(ctx, W, H) {
if (this.alpha <= 0.01) return;
ctx.fillStyle = `rgba(255,50,30,${this.alpha})`;
ctx.fillRect(0, 0, W, H);
},
};
// ── const: Popups ──
// === NEAR MISS TEXT POPUP ===
const Popups = {
items: [],
add(x, y, text, color) {
this.items.push({ x, y, text, color, life: 1.0 });
},
update() {
for (let i = this.items.length - 1; i >= 0; i--) {
const p = this.items[i];
p.y -= 1.5;
p.life -= 0.02;
if (p.life <= 0) this.items.splice(i, 1);
}
},
render(ctx) {
for (const p of this.items) {
ctx.save();
ctx.globalAlpha = clamp(p.life, 0, 1);
ctx.fillStyle = p.color;
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.shadowColor = p.color;
ctx.shadowBlur = 8;
ctx.fillText(p.text, p.x, p.y);
ctx.restore();
}
},
clear() { this.items.length = 0; },
};
// ── const: Input ──
// === INPUT HANDLING ===
const Input = {
keys: {},
left: false, right: false, up: false, down: false, boost: false,
prevBoost: false,
init() {
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
});
},
update() {
this.left = !!(this.keys['KeyA'] || this.keys['ArrowLeft']);
this.right = !!(this.keys['KeyD'] || this.keys['ArrowRight']);
this.up = !!(this.keys['KeyW'] || this.keys['ArrowUp']);
this.down = !!(this.keys['KeyS'] || this.keys['ArrowDown']);
this.boost = !!this.keys['Space'];
// Detect boost tap (for single-use boost)
this.boostTap = this.boost && !this.prevBoost;
this.prevBoost = this.boost;
},
shouldRestart() {
return !!(this.keys['KeyR']);
}
};
// ── const: CFG ──
// === CONSTANTS ===
const CFG = {
// Player
PLAYER_W: 28,
PLAYER_H: 16,
MOVE_SPEED: 0.15, // easing factor per frame (snappy)
INERTIA_TAIL: 0.92, // velocity retention
MAX_LATERAL_VEL: 14,
MAX_VERTICAL_VEL: 8,
BANK_ANGLE: 0.35, // radians max bank when strafing
PITCH_LEAN: 0.2, // forward lean on pitch
// World
LANE_COUNT: 5,
LANE_WIDTH: 60,
WORLD_HALF_W: (5 * 60) / 2, // = 150
FOG_DIST: 1800,
RENDER_DIST: 2400,
// Speed
BASE_SPEED: 3.5, // world units per frame at start
SPEED_RAMP: 0.0008, // speed increase per frame
MAX_SPEED: 12,
BOOST_MULT: 2.0,
BOOST_DRAIN: 0.6, // energy drain per boost frame
// Energy / Sun
ENERGY_MAX: 100,
ENERGY_DRAIN_RATE: 0.015, // per frame in light
ENERGY_SHADOW_MULT: 3.0, // multiplier when in shadow
SUN_SINK_RATE: 0.0004, // sun y-position decrease per frame
SUN_START_Y: 0.6, // normalized sky position (0=top, 1=bottom)
// Obstacles
OBSTACLE_MIN_GAP: 250, // min distance between obstacle waves
OBSTACLE_MAX_GAP: 500,
WAVE_SIZE_MIN: 1,
WAVE_SIZE_MAX: 4,
SPAWN_Z: 2800,
// Pickups
PICKUP_CHANCE: 0.3,
ENERGY_PICKUP_VAL: 15,
SCORE_TOKEN_VAL: 100,
MULTIPLIER_ORB_VAL: 1,
// Collision
NEAR_MISS_DIST: 40,
COLLISION_SHAKE: 12,
NEAR_MISS_SHAKE: 4,
// Visuals
SPEED_LINE_THRESH: 6, // speed at which speed lines appear
CHROMATIC_THRESH: 8, // speed for chromatic aberration
};
// ── const: Player ──
// === PLAYER CRAFT ===
const Player = {
x: 0,
y: -40,
vx: 0,
vy: 0,
bankAngle: 0,
pitchLean: 0,
thrustPhase: 0,
reset() {
this.x = 0;
this.y = -40;
this.vx = 0;
this.vy = 0;
this.bankAngle = 0;
this.pitchLean = 0;
this.thrustPhase = 0;
},
update(input, dt) {
const targetVx = input.left ? -CFG.MAX_LATERAL_VEL : (input.right ? CFG.MAX_LATERAL_VEL : 0);
const targetVy = input.up ? -CFG.MAX_VERTICAL_VEL : (input.down ? CFG.MAX_VERTICAL_VEL : 0);
this.vx = lerp(this.vx, targetVx, CFG.MOVE_SPEED * dt * 60);
this.vy = lerp(this.vy, targetVy, CFG.MOVE_SPEED * dt * 60);
if (!input.left && !input.right) {
this.vx *= Math.pow(CFG.INERTIA_TAIL, dt * 60);
}
if (!input.up && !input.down) {
this.vy *= Math.pow(CFG.INERTIA_TAIL, dt * 60);
}
this.vx = clamp(this.vx, -CFG.MAX_LATERAL_VEL, CFG.MAX_LATERAL_VEL);
this.vy = clamp(this.vy, -CFG.MAX_VERTICAL_VEL, CFG.MAX_VERTICAL_VEL);
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
this.x = clamp(this.x, -CFG.WORLD_HALF_W - 20, CFG.WORLD_HALF_W + 20);
this.y = clamp(this.y, -180, 40);
const targetBank = (this.vx / CFG.MAX_LATERAL_VEL) * CFG.BANK_ANGLE;
this.bankAngle = lerp(this.bankAngle, targetBank, 0.15 * dt * 60);
const targetPitch = -(this.vy / CFG.MAX_VERTICAL_VEL) * CFG.PITCH_LEAN;
this.pitchLean = lerp(this.pitchLean, targetPitch, 0.12 * dt * 60);
this.thrustPhase += dt * 30;
},
render(ctx, camX, camY) {
const sx = ctx.canvas.width / 2 + (this.x - camX) * 0.5;
const sy = ctx.canvas.height * 0.65 + (this.y - camY) * 0.3;
ctx.save();
ctx.translate(sx, sy);
ctx.rotate(this.bankAngle);
// Engine glow (behind everything)
const flicker = Math.sin(this.thrustPhase * 7) * 0.3 + 1;
const thrustLen = (12 + Math.random() * 6) * flicker;
// Outer engine glow
ctx.shadowColor = '#ff8833';
ctx.shadowBlur = 20;
const outerGlow = ctx.createRadialGradient(0, 10, 0, 0, 10, 15);
outerGlow.addColorStop(0, 'rgba(255,170,50,0.6)');
outerGlow.addColorStop(1, 'rgba(255,80,20,0)');
ctx.fillStyle = outerGlow;
ctx.beginPath();
ctx.arc(0, 10, 15, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Main thrust flame
const grad = ctx.createLinearGradient(0, 8, 0, 8 + thrustLen);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.2, '#ffeecc');
grad.addColorStop(0.5, '#ffaa44');
grad.addColorStop(1, 'rgba(255,60,20,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(-7, 8);
ctx.lineTo(7, 8);
ctx.quadraticCurveTo(3 + Math.random() * 2, 8 + thrustLen * 0.5, Math.random() * 4 - 2, 8 + thrustLen);
ctx.closePath();
ctx.fill();
// Secondary flame (inner)
const grad2 = ctx.createLinearGradient(0, 7, 0, 7 + thrustLen * 0.6);
grad2.addColorStop(0, 'rgba(255,255,255,0.9)');
grad2.addColorStop(1, 'rgba(255,200,100,0)');
ctx.fillStyle = grad2;
ctx.beginPath();
ctx.moveTo(-4, 7);
ctx.lineTo(4, 7);
ctx.quadraticCurveTo(1 + Math.random(), 7 + thrustLen * 0.3, Math.random() * 2 - 1, 7 + thrustLen * 0.6);
ctx.closePath();
ctx.fill();
// Craft body
const lean = this.pitchLean;
// Shadow underneath craft
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.moveTo(-2, -10 + lean * 20);
ctx.lineTo(-16, 10);
ctx.lineTo(16, 10);
ctx.closePath();
ctx.fill();
// Main hull (dark)
ctx.fillStyle = '#2a2a3e';
ctx.strokeStyle = '#4a4a6e';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -16 + lean * 20); // nose tip
ctx.lineTo(-8, -4); // upper left body
ctx.lineTo(-16, 8); // left wing tip
ctx.lineTo(-10, 9); // left wing inner
ctx.lineTo(-5, 7); // left engine mount
ctx.lineTo(0, 8); // center bottom
ctx.lineTo(5, 7); // right engine mount
ctx.lineTo(10, 9); // right wing inner
ctx.lineTo(16, 8); // right wing tip
ctx.lineTo(8, -4); // upper right body
ctx.closePath();
ctx.fill();
ctx.stroke();
// Hull highlight (top surface)
ctx.fillStyle = '#3a3a5e';
ctx.beginPath();
ctx.moveTo(0, -16 + lean * 20);
ctx.lineTo(-8, -4);
ctx.lineTo(0, -2);
ctx.lineTo(8, -4);
ctx.closePath();
ctx.fill();
// Cockpit canopy (glass)
const cockpitGrad = ctx.createLinearGradient(0, -10 + lean * 15, 0, 2);
cockpitGrad.addColorStop(0, 'rgba(180,220,255,0.9)');
cockpitGrad.addColorStop(0.5, 'rgba(100,160,220,0.7)');
cockpitGrad.addColorStop(1, 'rgba(60,100,180,0.4)');
ctx.fillStyle = cockpitGrad;
ctx.beginPath();
ctx.moveTo(0, -12 + lean * 15);
ctx.lineTo(-5, -2);
ctx.quadraticCurveTo(0, 3, 5, -2);
ctx.closePath();
ctx.fill();
// Cockpit reflection
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.moveTo(-1, -10 + lean * 13);
ctx.lineTo(-3, -4);
ctx.lineTo(-1, -3);
ctx.closePath();
ctx.fill();
// Wing accents (orange/red)
const wingGrad = ctx.createLinearGradient(0, 6, 0, 10);
wingGrad.addColorStop(0, '#ff6b35');
wingGrad.addColorStop(1, '#cc4422');
ctx.fillStyle = wingGrad;
// Left wing accent
ctx.beginPath();
ctx.moveTo(-12, 7);
ctx.lineTo(-16, 8);
ctx.lineTo(-10, 9);
ctx.closePath();
ctx.fill();
// Right wing accent
ctx.beginPath();
ctx.moveTo(12, 7);
ctx.lineTo(16, 8);
ctx.lineTo(10, 9);
ctx.closePath();
ctx.fill();
// Nose tip highlight
ctx.fillStyle = '#ffcc44';
ctx.beginPath();
ctx.moveTo(0, -16 + lean * 20);
ctx.lineTo(-2, -13 + lean * 18);
ctx.lineTo(2, -13 + lean * 18);
ctx.closePath();
ctx.fill();
// Body panel lines
ctx.strokeStyle = 'rgba(100,100,140,0.5)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(-6, -2);
ctx.lineTo(-8, 6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(6, -2);
ctx.lineTo(8, 6);
ctx.stroke();
// Engine nozzles
ctx.fillStyle = '#1a1a2e';
ctx.strokeStyle = '#3a3a5e';
ctx.lineWidth = 1;
// Left nozzle
ctx.beginPath();
ctx.moveTo(-7, 6);
ctx.lineTo(-9, 8);
ctx.lineTo(-4, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Right nozzle
ctx.beginPath();
ctx.moveTo(7, 6);
ctx.lineTo(9, 8);
ctx.lineTo(4, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
};
// ── const: World ──
// === WORLD & OBSTACLE GENERATION ===
const World = {
rng: null,
seed: 0,
obstacles: [],
pickups: [],
lastSpawnZ: 0,
groundSegments: [],
clouds: [],
init(seed) {
this.seed = seed;
this.rng = mulberry32(seed);
this.obstacles.length = 0;
this.pickups.length = 0;
this.lastSpawnZ = 400;
this.groundSegments.length = 0;
// Generate clouds
this.clouds.length = 0;
for (let i = 0; i < 12; i++) {
this.clouds.push({
x: randRange(this.rng, -400, 400),
y: randRange(this.rng, -80, -20),
z: randRange(this.rng, 500, 3000),
w: randRange(this.rng, 60, 150),
h: randRange(this.rng, 15, 40),
alpha: randRange(this.rng, 0.1, 0.3),
});
}
// Pre-generate ground segments
for (let z = 0; z < CFG.RENDER_DIST + 200; z += 80) {
const h = Math.sin(z * 0.005 + seed) * 15 + Math.cos(z * 0.013) * 8;
this.groundSegments.push({ z, height: h });
}
// Pre-spawn initial obstacles and pickups
for (let i = 0; i < 20; i++) {
this.spawnWave();
}
},
spawnWave() {
const gap = randRange(this.rng, CFG.OBSTACLE_MIN_GAP, CFG.OBSTACLE_MAX_GAP);
const z = this.lastSpawnZ + gap;
const waveSize = Math.floor(randRange(this.rng, CFG.WAVE_SIZE_MIN, CFG.WAVE_SIZE_MAX));
for (let i = 0; i < waveSize; i++) {
const laneX = randRange(this.rng, -CFG.WORLD_HALF_W + 30, CFG.WORLD_HALF_W - 30);
const typeRoll = this.rng();
let obsType, w, h, d;
if (typeRoll < 0.25) {
obsType = 'pillar';
w = randRange(this.rng, 25, 40);
h = randRange(this.rng, 80, 160);
d = randRange(this.rng, 20, 35);
} else if (typeRoll < 0.5) {
obsType = 'wall';
w = randRange(this.rng, 80, 140);
h = randRange(this.rng, 40, 70);
d = randRange(this.rng, 15, 25);
} else if (typeRoll < 0.7) {
obsType = 'block';
w = randRange(this.rng, 30, 50);
h = randRange(this.rng, 25, 40);
d = randRange(this.rng, 20, 35);
} else if (typeRoll < 0.85) {
obsType = 'arch';
w = randRange(this.rng, 60, 100);
h = randRange(this.rng, 80, 120);
d = randRange(this.rng, 15, 25);
} else {
obsType = 'spire';
w = randRange(this.rng, 15, 25);
h = randRange(this.rng, 100, 180);
d = randRange(this.rng, 15, 25);
}
const yOff = (obsType === 'block' || obsType === 'spire') ? randRange(this.rng, -60, 30) : 0;
this.obstacles.push({
x: laneX, y: yOff, z: z + i * 40,
w, h, d, type: obsType,
passed: false, nearMissed: false,
colorHue: randRange(this.rng, 15, 35), // warm hue range
});
if (this.rng() < CFG.PICKUP_CHANCE) {
const pLaneX = randRange(this.rng, -CFG.WORLD_HALF_W + 20, CFG.WORLD_HALF_W - 20);
const pTypeRoll = this.rng();
let pType;
if (pTypeRoll < 0.4) pType = 'energy';
else if (pTypeRoll < 0.75) pType = 'score';
else pType = 'multiplier';
this.pickups.push({
x: pLaneX, y: randRange(this.rng, -80, 20),
z: z + i * 40 + 30, type: pType,
collected: false, phase: this.rng() * Math.PI * 2,
});
}
}
this.lastSpawnZ = z;
},
update(speed) {
for (const obs of this.obstacles) obs.z -= speed;
for (const p of this.pickups) { p.z -= speed; p.phase += 0.05; }
for (const c of this.clouds) c.z -= speed * 0.3;
this.obstacles = this.obstacles.filter(o => o.z > -200);
this.pickups = this.pickups.filter(p => p.z > -200 && !p.collected);
// Recycle clouds
for (const c of this.clouds) {
if (c.z < -100) {
c.z += 3500;
c.x = randRange(Math.random, -400, 400);
}
}
const furthestZ = this.obstacles.length > 0 ? Math.max(...this.obstacles.map(o => o.z)) : 0;
if (furthestZ < CFG.RENDER_DIST) {
this.spawnWave();
}
for (const seg of this.groundSegments) seg.z -= speed;
while (this.groundSegments.length > 0 && this.groundSegments[0].z < -100) {
const first = this.groundSegments.shift();
const lastZ = this.groundSegments[this.groundSegments.length - 1].z;
first.z = lastZ + 80;
first.height = Math.sin(first.z * 0.005 + this.seed) * 15 + Math.cos(first.z * 0.013) * 8;
this.groundSegments.push(first);
}
},
getShadowFactor(playerX, playerY) {
let shadow = 0;
for (const obs of this.obstacles) {
if (obs.z < 5 || obs.z > 300) continue;
const dx = Math.abs(obs.x - playerX);
if (dx < obs.w / 2 + 10) {
shadow = Math.max(shadow, (obs.h * 0.5) / (obs.z * 0.3));
}
}
return clamp(shadow, 0, 1);
},
projectToScreen(worldX, worldY, worldZ, W, H) {
if (worldZ <= 0) return null;
const perspective = 500 / worldZ;
const screenX = W/2 + worldX * perspective;
const horizonY = H * 0.42;
const groundOffset = (H - horizonY);
const screenY = horizonY + groundOffset * (1 - perspective) - worldY * perspective;
return { x: screenX, y: screenY, scale: perspective };
},
renderSky(ctx, W, H, sunY) {
// Multi-stop sunset gradient
const grad = ctx.createLinearGradient(0, 0, 0, H);
const t = clamp(sunY / 1.2, 0, 1);
const r1 = Math.floor(lerp(35, 8, t));
const g1 = Math.floor(lerp(25, 4, t));
const b1 = Math.floor(lerp(65, 20, t));
const r2 = Math.floor(lerp(120, 30, t));
const g2 = Math.floor(lerp(60, 10, t));
const b2 = Math.floor(lerp(80, 35, t));
const r3 = Math.floor(lerp(200, 50, t));
const g3 = Math.floor(lerp(100, 15, t));
const b3 = Math.floor(lerp(60, 25, t));
const r4 = Math.floor(lerp(255, 80, t));
const g4 = Math.floor(lerp(160, 30, t));
const b4 = Math.floor(lerp(60, 20, t));
grad.addColorStop(0, `rgb(${r1},${g1},${b1})`);
grad.addColorStop(0.35, `rgb(${r2},${g2},${b2})`);
grad.addColorStop(0.7, `rgb(${r3},${g3},${b3})`);
grad.addColorStop(1, `rgb(${r4},${g4},${b4})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// Sun glow layers
const sunScreenY = H * (0.25 + sunY * 0.35);
if (sunScreenY < H) {
// Outer atmospheric glow
const outerGlow = ctx.createRadialGradient(W/2, sunScreenY, 0, W/2, sunScreenY, 350);
const alpha = clamp(1 - t * 0.7, 0.1, 1);
outerGlow.addColorStop(0, `rgba(255,240,200,${alpha * 0.8})`);
outerGlow.addColorStop(0.15, `rgba(255,200,100,${alpha * 0.4})`);
outerGlow.addColorStop(0.4, `rgba(255,140,60,${alpha * 0.15})`);
outerGlow.addColorStop(1, 'rgba(255,80,30,0)');
ctx.fillStyle = outerGlow;
ctx.fillRect(0, Math.max(0, sunScreenY - 350), W, Math.min(H, sunScreenY + 350));
// Inner bright glow
const innerGlow = ctx.createRadialGradient(W/2, sunScreenY, 0, W/2, sunScreenY, 120);
innerGlow.addColorStop(0, `rgba(255,255,240,${alpha})`);
innerGlow.addColorStop(0.3, `rgba(255,220,160,${alpha * 0.6})`);
innerGlow.addColorStop(1, 'rgba(255,180,100,0)');
ctx.fillStyle = innerGlow;
ctx.fillRect(W/2 - 120, sunScreenY - 120, 240, 240);
// Sun disc with corona
const sunR = 30 + (1 - t) * 15;
ctx.fillStyle = `rgba(255,245,220,${alpha})`;
ctx.beginPath();
ctx.arc(W/2, sunScreenY, sunR, 0, Math.PI * 2);
ctx.fill();
// Corona rays (animated)
const now = performance.now() * 0.001;
ctx.save();
ctx.translate(W/2, sunScreenY);
for (let i = 0; i < 16; i++) {
const angle = (i / 16) * Math.PI * 2 + now * 0.2;
const rayLen = sunR * (2.5 + Math.sin(now * 1.5 + i * 0.7) * 0.8);
ctx.strokeStyle = `rgba(255,220,160,${alpha * 0.12})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * sunR * 0.9, Math.sin(angle) * sunR * 0.9);
ctx.lineTo(Math.cos(angle) * rayLen, Math.sin(angle) * rayLen);
ctx.stroke();
}
ctx.restore();
// Horizon haze band
const hazeGrad = ctx.createLinearGradient(0, sunScreenY - 20, 0, sunScreenY + 60);
hazeGrad.addColorStop(0, 'rgba(255,180,100,0)');
hazeGrad.addColorStop(0.3, `rgba(255,160,80,${alpha * 0.2})`);
hazeGrad.addColorStop(0.7, `rgba(255,140,60,${alpha * 0.15})`);
hazeGrad.addColorStop(1, 'rgba(255,120,50,0)');
ctx.fillStyle = hazeGrad;
ctx.fillRect(W/2 - 300, sunScreenY - 20, 600, 80);
}
// Stars (appear as sun sinks)
if (t > 0.3) {
const starAlpha = clamp((t - 0.3) / 0.5, 0, 1);
ctx.fillStyle = `rgba(255,255,255,${starAlpha * 0.7})`;
for (let i = 0; i < 60; i++) {
const sx = ((i * 7919 + this.seed) % W);
const sy = ((i * 6271 + this.seed) % Math.floor(H * 0.45));
const ss = 1 + (i % 3);
const twinkle = 0.5 + Math.sin(performance.now() * 0.003 + i * 1.7) * 0.5;
ctx.globalAlpha = starAlpha * 0.7 * twinkle;
ctx.fillRect(sx, sy, ss, ss);
}
ctx.globalAlpha = 1;
}
// Clouds
this.renderClouds(ctx, W, H, sunScreenY || H * 0.4, t);
},
renderClouds(ctx, W, H, sunY, darkness) {
for (const cloud of this.clouds) {
if (cloud.z < 10 || cloud.z > CFG.RENDER_DIST) continue;
const p = this.projectToScreen(cloud.x, cloud.y + 50, cloud.z, W, H);
if (!p || p.scale <= 0.02) continue;
const fogFactor = clamp(1 - cloud.z / CFG.FOG_DIST, 0.05, 0.4);
const cw = cloud.w * p.scale;
const ch = cloud.h * p.scale;
ctx.save();
ctx.globalAlpha = fogFactor * (cloud.alpha + darkness * 0.1);
// Cloud shape (multiple overlapping ellipses)
const cloudGrad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, cw * 0.5);
cloudGrad.addColorStop(0, 'rgba(255,240,220,0.6)');
cloudGrad.addColorStop(0.5, 'rgba(255,200,180,0.3)');
cloudGrad.addColorStop(1, 'rgba(255,180,160,0)');
ctx.fillStyle = cloudGrad;
// Draw fluffy cloud shape
for (let j = 0; j < 4; j++) {
const ox = Math.sin(j * 2.3 + this.seed) * cw * 0.25;
const oy = Math.cos(j * 1.7 + this.seed) * ch * 0.3;
const r = cw * (0.2 + j * 0.08);
ctx.beginPath();
ctx.arc(p.x + ox, p.y + oy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
},
renderGround(ctx, W, H, camZ, speedRatio) {
const horizonY = H * 0.42;
// Ground gradient (dark purple/black)
const groundGrad = ctx.createLinearGradient(0, horizonY, 0, H);
groundGrad.addColorStop(0, '#1a0830');
groundGrad.addColorStop(0.2, '#120625');
groundGrad.addColorStop(0.5, '#0d0418');
groundGrad.addColorStop(1, '#080210');
ctx.fillStyle = groundGrad;
ctx.fillRect(0, horizonY, W, H - horizonY);
// Perspective grid lines (moving)
const gridAlpha = 0.06 + speedRatio * 0.12;
// Horizontal moving lines
for (let i = 0; i < 35; i++) {
const zBase = ((i * 55 + camZ * 0.5) % 1925);
if (zBase < 15) continue;
const perspective = 400 / zBase;
const screenY = horizonY + (H - horizonY) * (1 - perspective);
if (screenY > H || screenY < horizonY) continue;
ctx.globalAlpha = gridAlpha * clamp(perspective, 0.2, 1);
ctx.strokeStyle = 'rgba(255,140,80,1)';
ctx.lineWidth = Math.max(0.5, perspective * 2);
ctx.beginPath();
ctx.moveTo(0, screenY);
ctx.lineTo(W, screenY);
ctx.stroke();
}
// Vertical lane lines converging at vanishing point
for (let i = -CFG.LANE_COUNT; i <= CFG.LANE_COUNT; i++) {
const worldX = i * CFG.LANE_WIDTH;
const nearX = W/2 + worldX * 0.5;
ctx.globalAlpha = gridAlpha * 0.4;
ctx.strokeStyle = 'rgba(255,140,80,1)';
ctx.lineWidth = Math.max(0.5, 1);
ctx.beginPath();
ctx.moveTo(W/2, horizonY);
ctx.lineTo(nearX, H);
ctx.stroke();
}
// Center lane highlight (brighter path)
ctx.globalAlpha = gridAlpha * 0.8;
ctx.strokeStyle = 'rgba(255,180,120,1)';
ctx.lineWidth = Math.max(1, 2);
ctx.beginPath();
ctx.moveTo(W/2, horizonY);
ctx.lineTo(W/2, H);
ctx.stroke();
ctx.globalAlpha = 1;
// Terrain silhouette (mountains on edges)
this.renderTerrainSilhouette(ctx, W, H, camZ);
},
renderTerrainSilhouette(ctx, W, H, camZ) {
const horizonY = H * 0.42;
// Far mountains (darker, more distant)
ctx.fillStyle = 'rgba(12,6,25,0.8)';
ctx.beginPath();
ctx.moveTo(0, horizonY);
for (let x = 0; x <= W; x += 15) {
const worldX = (x - W/2) * 3;
const n = Math.sin(worldX * 0.008 + camZ * 0.0005) * 40 +
Math.cos(worldX * 0.02 + camZ * 0.001) * 20 +
Math.sin(worldX * 0.003) * 50;
ctx.lineTo(x, horizonY - Math.abs(n));
}
ctx.lineTo(W, horizonY);
ctx.closePath();
ctx.fill();
// Near terrain (slightly lighter)
ctx.fillStyle = 'rgba(18,8,32,0.9)';
ctx.beginPath();
ctx.moveTo(0, horizonY);
for (let x = 0; x <= W; x += 15) {
const worldX = (x - W/2) * 2;
const n = Math.sin(worldX * 0.01 + camZ * 0.001) * 25 +
Math.cos(worldX * 0.03 + camZ * 0.002) * 12 +
Math.sin(worldX * 0.005) * 30;
ctx.lineTo(x, horizonY - Math.abs(n));
}
ctx.lineTo(W, horizonY);
ctx.closePath();
ctx.fill();
},
renderObstacles(ctx, W, H, camZ) {
const sorted = [...this.obstacles]
.filter(o => o.z > 10 && o.z < CFG.RENDER_DIST)
.sort((a,b) => b.z - a.z);
for (const obs of sorted) {
const p = this.projectToScreen(obs.x, obs.y + obs.h/2, obs.z, W, H);
if (!p || p.scale <= 0.01) continue;
const sw = obs.w * p.scale;
const sh = obs.h * p.scale;
const sd = obs.d * p.scale;
const fogFactor = clamp(1 - obs.z / CFG.FOG_DIST, 0.15, 1);
ctx.save();
ctx.globalAlpha = fogFactor;
if (obs.type === 'arch') {
this.drawArch(ctx, p.x, p.y, sw, sh, sd, obs.colorHue);
} else if (obs.type === 'spire') {
this.drawSpire(ctx, p.x, p.y, sw, sh, sd, obs.colorHue);
} else {
this.drawObsBlock(ctx, p.x, p.y - sh/2, sw, sh, sd, obs.colorHue);
}
ctx.restore();
}
},
drawObsBlock(ctx, x, y, w, h, d, hue) {
const baseColor = `hsl(${hue}, 80%, 45%)`;
const lightColor = `hsl(${hue}, 70%, 60%)`;
const darkColor = `hsl(${hue}, 90%, 30%)`;
// Front face
ctx.fillStyle = baseColor;
ctx.strokeStyle = `hsla(${hue}, 80%, 55%, 0.8)`;
ctx.lineWidth = Math.max(1, d * 0.05);
ctx.fillRect(x - w/2, y, w, h);
ctx.strokeRect(x - w/2, y, w, h);
// Top face (lighter)
ctx.fillStyle = lightColor;
ctx.beginPath();
ctx.moveTo(x - w/2, y);
ctx.lineTo(x + w/2, y);
ctx.lineTo(x + w/2 - d*0.4, y - d*0.3);
ctx.lineTo(x - w/2 + d*0.4, y - d*0.3);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Right face (darker)
ctx.fillStyle = darkColor;
ctx.beginPath();
ctx.moveTo(x + w/2, y);
ctx.lineTo(x + w/2, y + h);
ctx.lineTo(x + w/2 - d*0.4, y + h - d*0.3);
ctx.lineTo(x + w/2 - d*0.4, y - d*0.3);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Edge highlight
ctx.strokeStyle = `hsla(${hue}, 90%, 75%, 0.3)`;
ctx.lineWidth = Math.max(0.5, d * 0.02);
ctx.beginPath();
ctx.moveTo(x - w/2, y);
ctx.lineTo(x + w/2, y);
ctx.stroke();
// Warning glow on edges (pulsing)
const pulse = 0.3 + Math.sin(performance.now() * 0.005) * 0.15;
ctx.shadowColor = `hsl(${hue}, 90%, 60%)`;
ctx.shadowBlur = d * 0.3;
ctx.strokeStyle = `hsla(${hue}, 90%, 70%, ${pulse})`;
ctx.lineWidth = Math.max(1, d * 0.08);
ctx.strokeRect(x - w/2, y, w, h);
ctx.shadowBlur = 0;
},
drawArch(ctx, x, y, w, h, d, hue) {
const pillarW = w * 0.18;
// Left pillar
this.drawObsBlock(ctx, x - w/2 + pillarW/2, y, pillarW, h, d, hue);
// Right pillar
this.drawObsBlock(ctx, x + w/2 - pillarW/2, y, pillarW, h, d, hue);
// Top beam
const beamH = h * 0.12;
this.drawObsBlock(ctx, x, y - h * 0.85, w, beamH, d, hue);
// Arch glow (passable indicator)
ctx.strokeStyle = `hsla(${hue + 60}, 80%, 70%, 0.2)`;
ctx.lineWidth = Math.max(1, d * 0.1);
ctx.setLineDash([d * 0.3, d * 0.3]);
ctx.beginPath();
ctx.moveTo(x - w/2 + pillarW, y + h);
ctx.lineTo(x - w/2 + pillarW, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + w/2 - pillarW, y + h);
ctx.lineTo(x + w/2 - pillarW, y);
ctx.stroke();
ctx.setLineDash([]);
},
drawSpire(ctx, x, y, w, h, d, hue) {
// Spire: tall thin triangle shape
const baseColor = `hsl(${hue}, 70%, 40%)`;
const lightColor = `hsl(${hue}, 60%, 55%)`;
const darkColor = `hsl(${hue}, 80%, 25%)`;
// Main body (triangle)
ctx.fillStyle = baseColor;
ctx.strokeStyle = `hsla(${hue}, 70%, 50%, 0.8)`;
ctx.lineWidth = Math.max(1, d * 0.05);
ctx.beginPath();
ctx.moveTo(x, y - h * 0.3); // tip (above center)
ctx.lineTo(x - w/2, y + h); // bottom left
ctx.lineTo(x + w/2, y + h); // bottom right
ctx.closePath();
ctx.fill();
ctx.stroke();
// Left face highlight
ctx.fillStyle = lightColor;
ctx.beginPath();
ctx.moveTo(x, y - h * 0.3);
ctx.lineTo(x - w/2, y + h);
ctx.lineTo(x, y + h);
ctx.closePath();
ctx.fill();
// Right face shadow
ctx.fillStyle = darkColor;
ctx.beginPath();
ctx.moveTo(x, y - h * 0.3);
ctx.lineTo(x + w/2, y + h);
ctx.lineTo(x, y + h);
ctx.closePath();
ctx.fill();
// Tip glow
const pulse = 0.4 + Math.sin(performance.now() * 0.008) * 0.2;
ctx.shadowColor = `hsl(${hue}, 90%, 70%)`;
ctx.shadowBlur = d * 0.5;
ctx.fillStyle = `hsla(${hue}, 90%, 80%, ${pulse})`;
ctx.beginPath();
ctx.arc(x, y - h * 0.3, Math.max(2, d * 0.15), 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
},
renderPickups(ctx, W, H, camZ, time) {
for (const p of this.pickups) {
if (p.collected || p.z < 10 || p.z > CFG.RENDER_DIST) continue;
const sp = this.projectToScreen(p.x, p.y + 25, p.z, W, H);
if (!sp || sp.scale <= 0.01) continue;
const fogFactor = clamp(1 - p.z / CFG.FOG_DIST, 0.3, 1);
const bobY = Math.sin(time * 3 + p.phase) * 6 * sp.scale;
const size = (p.type === 'multiplier' ? 14 : 10) * sp.scale;
ctx.save();
ctx.globalAlpha = fogFactor;
ctx.translate(sp.x, sp.y - bobY);
let glowColor, fillColor;
if (p.type === 'energy') {
glowColor = '#44ffaa';
fillColor = '#22dd88';
} else if (p.type === 'score') {
glowColor = '#ffcc00';
fillColor = '#eebb00';
} else {
glowColor = '#ff66ff';
fillColor = '#ee44ee';
}
// Outer glow
ctx.shadowColor = glowColor;
ctx.shadowBlur = 20 * sp.scale;
// Rotating diamond shape
const rot = time * 1.5 + p.phase;
ctx.rotate(rot);
ctx.fillStyle = fillColor;
ctx.beginPath();
ctx.moveTo(0, -size);
ctx.lineTo(size * 0.7, 0);
ctx.lineTo(0, size);
ctx.lineTo(-size * 0.7, 0);
ctx.closePath();
ctx.fill();
// Inner highlight
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath();
ctx.moveTo(0, -size * 0.6);
ctx.lineTo(size * 0.35, 0);
ctx.lineTo(0, size * 0.6);
ctx.lineTo(-size * 0.35, 0);
ctx.closePath();
ctx.fill();
// Border
ctx.strokeStyle = 'rgba(255,255,255,0.7)';
ctx.lineWidth = Math.max(1, sp.scale * 2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.rotate(-rot);
// Symbol
ctx.fillStyle = '#fff';
ctx.font = `bold ${Math.max(8, size * 1.2)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (p.type === 'energy') ctx.fillText('\u26a1', 0, 1);
else if (p.type === 'score') ctx.fillText('\u2726', 0, 1);
else ctx.fillText('\u00d7' + '', 0, 1);
ctx.restore();
}
},
};
// ── const: SpeedLines ──
// === SPEED LINES EFFECT ===
const SpeedLines = {
lines: [],
init() {
this.lines.length = 0;
for (let i = 0; i < 40; i++) {
this.lines.push({
angle: Math.random() * Math.PI * 2,
speed: randRange(Math.random, 8, 20),
length: randRange(Math.random, 30, 100),
offset: Math.random(),
alpha: randRange(Math.random, 0.15, 0.4),
width: randRange(Math.random, 0.5, 2),
});
}
},
render(ctx, W, H, speedRatio) {
if (speedRatio < CFG.SPEED_LINE_THRESH / CFG.MAX_SPEED) return;
const intensity = clamp((speedRatio - CFG.SPEED_LINE_THRESH / CFG.MAX_SPEED) * 3, 0, 1);
const cx = W / 2;
const cy = H * 0.45;
ctx.save();
for (const line of this.lines) {
const dist = line.offset * Math.max(W, H) * 0.8;
const x1 = cx + Math.cos(line.angle) * dist;
const y1 = cy + Math.sin(line.angle) * dist;
const x2 = x1 - Math.cos(line.angle) * line.length * intensity;
const y2 = y1 - Math.sin(line.angle) * line.length * intensity;
ctx.strokeStyle = `rgba(255,200,150,${line.alpha * intensity})`;
ctx.lineWidth = line.width * intensity;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
ctx.restore();
},
update() {
for (const line of this.lines) {
line.offset -= line.speed * 0.008;
if (line.offset < -0.5) line.offset += 1.5;
}
},
};
// ── function: renderVignette ──
// === VIGNETTE EFFECT ===
function renderVignette(ctx, W, H, intensity) {
const grad = ctx.createRadialGradient(W/2, H/2, W * 0.3, W/2, H/2, W * 0.75);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, `rgba(0,0,0,${intensity})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
}
// ── const: Game ──
// === GAME STATE ===
const Game = {
state: 'title',
canvas: null,
ctx: null,
W: 0, H: 0,
runSeed: 0,
distance: 0,
score: 0,
multiplier: 1,
energy: 100,
speed: 3.5,
sunY: 0.6,
camZ: 0,
lastTime: 0,
frameCount: 0,
init() {
this.canvas = document.getElementById('game');
this.ctx = this.canvas.getContext('2d');
this.resize();
window.addEventListener('resize', () => this.resize());
Input.init();
SpeedLines.init();
this.loadHighScore();
this.updateTitleScreen();
document.getElementById('start-btn').addEventListener('click', () => this.startRun());
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
requestAnimationFrame((t) => this.loop(t));
},
resize() {
const dpr = window.devicePixelRatio || 1;
this.W = window.innerWidth;
this.H = window.innerHeight;
this.canvas.width = this.W * dpr;
this.canvas.height = this.H * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
},
loadHighScore() {
try { return parseInt(localStorage.getItem('rts_highscore') || '0'); } catch(e) { return 0; }
},
saveHighScore(score) {
const current = this.loadHighScore();
if (score > current) {
try { localStorage.setItem('rts_highscore', score.toString()); } catch(e){}
}
return Math.max(score, current);
},
updateTitleScreen() {
document.getElementById('high-score-title').textContent = this.loadHighScore().toLocaleString();
},
startRun() {
AudioSys.init();
if (AudioSys.ctx && AudioSys.ctx.state === 'suspended') AudioSys.ctx.resume();
this.runSeed = Math.floor(Math.random() * 999999);
this.distance = 0;
this.score = 0;
this.multiplier = 1;
this.energy = CFG.ENERGY_MAX;
this.speed = CFG.BASE_SPEED;
this.sunY = CFG.SUN_START_Y;
this.camZ = 0;
this.frameCount = 0;
Player.reset();
World.init(this.runSeed);
Particles.clear();
Popups.clear();
Shake.intensity = 0;
DamageFlash.alpha = 0;
AudioSys.startDrone();
this.state = 'playing';
document.getElementById('title-screen').style.display = 'none';
document.getElementById('gameover-screen').style.display = 'none';
document.getElementById('hud').style.display = 'block';
},
gameOver() {
if (this.state !== 'playing') return;
this.state = 'gameover';
AudioSys.playPickup('crash');
Shake.add(CFG.COLLISION_SHAKE);
DamageFlash.trigger();
const px = this.W / 2;
const py = this.H * 0.65;
Particles.emit(px, py, 40, {
angle: Math.random() * Math.PI * 2, speedMin: 3, speedMax: 12,
decayMin: 0.01, decayMax: 0.03, sizeMin: 3, sizeMax: 8, color: '#ff4422',
});
Particles.emit(px, py, 25, {
angle: Math.random() * Math.PI * 2, speedMin: 1, speedMax: 6,
decayMin: 0.005, decayMax: 0.02, sizeMin: 2, sizeMax: 4, color: '#ffcc44',
});
const highScore = this.saveHighScore(this.score);
setTimeout(() => {
document.getElementById('go-distance').textContent = Math.floor(this.distance) + ' m';
document.getElementById('go-score').textContent = this.score.toLocaleString();
document.getElementById('go-best').textContent = highScore.toLocaleString();
document.getElementById('gameover-screen').style.display = 'flex';
}, 500);
},
restart() {
AudioSys.stop();
this.startRun();
},
updateHUD() {
document.getElementById('score').textContent = this.score.toLocaleString();
document.getElementById('multiplier').textContent = '\u00d7' + this.multiplier;
document.getElementById('distance').textContent = Math.floor(this.distance) + ' m';
document.getElementById('speed-display').textContent = Math.floor(this.speed * 30) + ' km/h';
const energyPct = (this.energy / CFG.ENERGY_MAX) * 100;
const bar = document.getElementById('energy-bar');
bar.style.width = energyPct + '%';
if (energyPct < 25) {
bar.style.background = '#ff4444';
} else if (energyPct < 50) {
bar.style.background = 'linear-gradient(90deg, #ff4444, #ffaa00)';
} else {
bar.style.background = 'linear-gradient(90deg, #ff4444, #ffaa00, #44ff88)';
}
if (energyPct < 20 && Math.floor(this.frameCount / 10) % 2 === 0) {
bar.style.boxShadow = '0 0 10px rgba(255,0,0,0.8)';
} else {
bar.style.boxShadow = 'none';
}
},
loop(timestamp) {
const dt = Math.min((timestamp - this.lastTime) / 16.67, 3);
this.lastTime = timestamp;
this.frameCount++;
Input.update();
if (this.state === 'playing') {
this.update(dt);
} else if (this.state === 'gameover') {
Particles.update();
Shake.update();
DamageFlash.update();
Popups.update();
SpeedLines.update();
if (Input.shouldRestart()) this.restart();
}
this.render();
requestAnimationFrame((t) => this.loop(t));
},
update(dt) {
// Speed ramps up over time with slight curve
const elapsed = this.frameCount / 60; // seconds
this.speed = Math.min(CFG.MAX_SPEED, CFG.BASE_SPEED + elapsed * CFG.SPEED_RAMP * 60);
let currentSpeed = this.speed;
if (Input.boost && this.energy > 5) {
currentSpeed *= CFG.BOOST_MULT;
this.energy -= CFG.BOOST_DRAIN * dt;
if (this.frameCount % 8 === 0) AudioSys.playPickup('boost');
const px = this.W / 2 + (Math.random() - 0.5) * 10;
Particles.emit(px, this.H * 0.65 + 10, 3, {
angle: Math.PI / 2, spread: 0.8, speedMin: 4, speedMax: 8,
decayMin: 0.03, decayMax: 0.06, sizeMin: 2, sizeMax: 5, color: '#ffaa33',
});
}
Player.update(Input, dt);
World.update(currentSpeed);
this.distance += currentSpeed * 0.1;
this.score += Math.floor(currentSpeed * this.multiplier * 0.5);
// Sun sinks over time (slower for longer runs)
this.sunY += CFG.SUN_SINK_RATE * dt;
// Energy drain with shadow factor
const shadowFactor = World.getShadowFactor(Player.x, Player.y);
const drainMult = lerp(1, CFG.ENERGY_SHADOW_MULT, shadowFactor);
this.energy -= CFG.ENERGY_DRAIN_RATE * drainMult * dt;
if (this.sunY > 1.0) { this.gameOver(); return; }
if (this.energy <= 0) { this.energy = 0; this.gameOver(); return; }
// Collision detection
const hitResult = Collision.checkPlayerVsObstacles(Player, World.obstacles);
if (hitResult) {
if (hitResult.isNearMiss) {
hitResult.nearMissed = true;
const bonus = 50 * this.multiplier;
this.score += bonus;
Shake.add(CFG.NEAR_MISS_SHAKE);
AudioSys.playPickup('nearmiss');
Popups.add(this.W / 2 + (Math.random() - 0.5) * 100, this.H * 0.48,
'NEAR MISS! +' + bonus, '#ffcc00');
Particles.emit(this.W / 2, this.H * 0.6, 12, {
speedMin: 2, speedMax: 5, decayMin: 0.03, decayMax: 0.06,
sizeMin: 2, sizeMax: 4, color: '#ffcc00',
});
} else {
this.gameOver();
return;
}
}
// Pickup collection
const pickupHit = Collision.checkPlayerVsPickups(Player, World.pickups);
if (pickupHit) {
pickupHit.collected = true;
const px = this.W / 2 + (Math.random() - 0.5) * 60;
const py = this.H * 0.55;
if (pickupHit.type === 'energy') {
this.energy = Math.min(CFG.ENERGY_MAX, this.energy + CFG.ENERGY_PICKUP_VAL);
AudioSys.playPickup('energy');
Popups.add(px, py, '+\u26a1', '#44ffaa');
Particles.emit(this.W / 2, this.H * 0.6, 10, {
speedMin: 1, speedMax: 4, decayMin: 0.02, decayMax: 0.05,
sizeMin: 2, sizeMax: 4, color: '#44ffaa',
});
} else if (pickupHit.type === 'score') {
const val = CFG.SCORE_TOKEN_VAL * this.multiplier;
this.score += val;
AudioSys.playPickup('score');
Popups.add(px, py, '+' + val, '#ffcc00');
Particles.emit(this.W / 2, this.H * 0.6, 8, {
speedMin: 1, speedMax: 4, decayMin: 0.03, decayMax: 0.06,
sizeMin: 2, sizeMax: 5, color: '#ffcc00',
});
} else if (pickupHit.type === 'multiplier') {
this.multiplier += CFG.MULTIPLIER_ORB_VAL;
AudioSys.playPickup('multiplier');
Popups.add(px, py, '\u00d7' + this.multiplier, '#ff66ff');
Particles.emit(this.W / 2, this.H * 0.6, 15, {
speedMin: 2, speedMax: 6, decayMin: 0.01, decayMax: 0.04,
sizeMin: 3, sizeMax: 6, color: '#ff66ff',
});
}
}
// Thrust particles
if (this.frameCount % 2 === 0) {
Particles.emit(this.W / 2 + (Math.random() - 0.5) * 8, this.H * 0.65 + 12, 1, {
angle: Math.PI / 2, spread: 0.4, speedMin: 1, speedMax: 3,
decayMin: 0.04, decayMax: 0.08, sizeMin: 1, sizeMax: 3, color: '#ffaa44',
});
}
Particles.update();
Shake.update();
DamageFlash.update();
Popups.update();
SpeedLines.update();
AudioSys.updateDrone(currentSpeed / CFG.MAX_SPEED);
this.updateHUD();
},
render() {
const ctx = this.ctx;
const W = this.W;
const H = this.H;
const shake = Shake.getOffset();
ctx.save();
ctx.translate(shake.x, shake.y);
ctx.clearRect(-10, -10, W + 20, H + 20);
if (this.state === 'title') {
this.renderTitleBackground(ctx, W, H);
} else {
const speedRatio = this.speed / CFG.MAX_SPEED;
// Sky and sun
World.renderSky(ctx, W, H, this.sunY);
// Ground with perspective grid
World.renderGround(ctx, W, H, this.camZ, speedRatio);
// Obstacles (sorted far to near)
World.renderObstacles(ctx, W, H, this.camZ);
// Pickups
const time = performance.now() * 0.001;
World.renderPickups(ctx, W, H, this.camZ, time);
// Player craft
Player.render(ctx, this.camZ, 0);
// Particles on top
Particles.render(ctx);
// Speed lines at high speed
SpeedLines.render(ctx, W, H, speedRatio);
// Chromatic aberration at very high speed
renderChromaticAberration(ctx, W, H, speedRatio);
// Damage flash overlay
DamageFlash.render(ctx, W, H);
// Score popups
Popups.render(ctx);
// Shadow vignette when behind obstacles
const shadowFactor = World.getShadowFactor(Player.x, Player.y);
if (shadowFactor > 0.1) {
ctx.fillStyle = 'rgba(0,0,20,' + (shadowFactor * 0.35) + ')';
ctx.fillRect(0, 0, W, H);
}
// Energy warning vignette (red pulse when low)
const energyPct = this.energy / CFG.ENERGY_MAX;
if (energyPct < 0.3) {
const vigAlpha = (1 - energyPct / 0.3) * 0.45 * (0.5 + Math.sin(this.frameCount * 0.12) * 0.5);
ctx.fillStyle = 'rgba(255,0,0,' + vigAlpha + ')';
ctx.fillRect(0, 0, W, H);
}
// Sun warning (orange pulse when sun is low)
if (this.sunY > 0.7) {
const warnAlpha = clamp((this.sunY - 0.7) / 0.3, 0, 1) * 0.5;
ctx.fillStyle = 'rgba(255,80,0,' + (warnAlpha * (0.5 + Math.sin(this.frameCount * 0.15) * 0.5)) + ')';
ctx.fillRect(0, 0, W, H);
}
// Always-on vignette for atmosphere
renderVignette(ctx, W, H, 0.3 + speedRatio * 0.2);
}
ctx.restore();
},
renderTitleBackground(ctx, W, H) {
const t = performance.now() * 0.0003;
// Gradient sky
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#1a1040');
grad.addColorStop(0.5, '#2a1535');
grad.addColorStop(1, '#ff6b35');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// Sun on horizon
const sunY = H * 0.4 + Math.sin(t) * 20;
const sunGrad = ctx.createRadialGradient(W/2, sunY, 0, W/2, sunY, 150);
sunGrad.addColorStop(0, 'rgba(255,240,200,0.9)');
sunGrad.addColorStop(0.3, 'rgba(255,160,50,0.4)');
sunGrad.addColorStop(1, 'rgba(255,80,20,0)');
ctx.fillStyle = sunGrad;
ctx.fillRect(W/2 - 150, sunY - 150, 300, 300);
ctx.fillStyle = 'rgba(255,240,200,0.9)';
ctx.beginPath();
ctx.arc(W/2, sunY, 30, 0, Math.PI * 2);
ctx.fill();
// Ground silhouette
const horizonY = H * 0.5;
ctx.fillStyle = '#0a0515';
ctx.fillRect(0, horizonY, W, H - horizonY);
// Grid lines on ground (animated)
ctx.strokeStyle = 'rgba(255,140,80,0.1)';
ctx.lineWidth = 1;
for (let i = 0; i < 20; i++) {
const zBase = ((i * 60 + t * 100) % 1200);
if (zBase < 20) continue;
const perspective = 400 / zBase;
const screenY = horizonY + (H - horizonY) * (1 - perspective);
ctx.globalAlpha = clamp(perspective, 0.1, 0.3);
ctx.beginPath();
ctx.moveTo(0, screenY);
ctx.lineTo(W, screenY);
ctx.stroke();
}
ctx.globalAlpha = 1;
// Floating geometric shapes
for (let i = 0; i < 8; i++) {
const x = W * ((i * 0.13 + t * 0.05) % 1);
const y = horizonY - 40 - Math.sin(i * 2.5 + t * 2) * 60;
ctx.strokeStyle = 'rgba(255,180,120,' + (0.1 + Math.sin(i + t) * 0.05) + ')';
ctx.lineWidth = 1;
const size = 15 + i * 3;
ctx.save();
ctx.translate(x, y);
ctx.rotate(t + i);
ctx.strokeRect(-size/2, -size/2, size, size);
ctx.restore();
}
// Vignette on title screen too
renderVignette(ctx, W, H, 0.4);
},
};
// ── main ──
// === MAIN ENTRY POINT ===
Game.init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"0845489e-10a4-4553-aee4-e4c885780e17","tokensIn":2147482,"tokensOut":52914,"tokensTotal":2200396,"turns":54,"toolCalls":53,"failedToolCalls":2,"timestamp":"2026-07-16T04:31:57.074Z"} -->