Race the Sun
Qwen 3.6 27b MTP · typescript

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 66,663 bytes · SHA-256 87fca0cd26cb
<!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, sans-serif; }
#game-container { position: relative; width: 100vw; height: 100vh; }
#game-canvas { display: block; width: 100%; height: 100%; }
.hidden { display: none !important; }
/* HUD */
#hud { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; }
#hud-top-left, #hud-top-right { position: absolute; top: 16px; color: #fff; font-size: 14px; text-shadow: 0 0 8px rgba(255,140,0,.6); letter-spacing: .05em; }
#hud-top-left { left: 20px; }
#hud-top-right { right: 20px; text-align: right; }
#score-display { font-size: 22px; font-weight: 700; color: #ffd68a; }
#distance-display { margin-top: 4px; opacity: .75; }
#multiplier-display { font-size: 28px; font-weight: 900; color: #ffaa33; text-shadow: 0 0 12px rgba(255,170,51,.8); }
#speed-display { margin-top: 4px; opacity: .75; }
/* Energy bar */
#energy-bar-container { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: 320px; height: 18px; border-radius: 9px; overflow: hidden; background: rgba(0,0,0,.5); border: 1px solid rgba(255,140,0,.3); }
#energy-bar-bg { position: absolute; inset: 0; background: linear-gradient(90deg, #ff3300, #ff8800, #ffcc00); opacity: .25; border-radius: 9px; }
#energy-bar-fill { height: 100%; width: 100%; background: linear-gradient(90deg, #ff4400, #ffaa00, #ffee44); border-radius: 9px; transition: width .15s ease-out; }
#energy-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 10px; font-weight: 700; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.8); letter-spacing: .1em; }
/* Title screen */
#title-screen { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(ellipse at 50% 60%, rgba(255,100,0,.15) 0%, transparent 70%), #0a0a12; z-index: 10; }
#title-screen h1 { font-size: clamp(36px, 8vw, 80px); font-weight: 900; color: #ffcc44; text-shadow: 0 0 40px rgba(255,140,0,.5), 0 0 80px rgba(255,80,0,.3); letter-spacing: .15em; margin-bottom: 8px; }
.subtitle { color: #cc9966; font-size: clamp(12px, 2vw, 18px); margin-bottom: 40px; opacity: .8; }
.controls-info { display: flex; gap: 32px; margin-bottom: 40px; color: #aa8855; font-size: 13px; letter-spacing: .05em; }
#start-btn { pointer-events: all; padding: 14px 48px; font-size: 18px; font-weight: 700; color: #fff; background: linear-gradient(135deg, #ff6600, #cc3300); border: none; border-radius: 6px; cursor: pointer; letter-spacing: .1em; text-transform: uppercase; box-shadow: 0 4px 20px rgba(255,80,0,.4); transition: transform .1s, box-shadow .1s; }
#start-btn:hover { transform: scale(1.05); box-shadow: 0 6px 30px rgba(255,80,0,.6); }
/* Game over */
#gameover-screen { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(ellipse at 50% 40%, rgba(255,30,0,.1) 0%, transparent 60%), rgba(10,10,18,.92); z-index: 10; }
#gameover-screen h1 { font-size: clamp(32px, 7vw, 72px); font-weight: 900; color: #ff4422; text-shadow: 0 0 40px rgba(255,40,20,.5); letter-spacing: .1em; margin-bottom: 32px; }
#final-score { font-size: 28px; color: #ffd68a; font-weight: 700; }
#final-distance { font-size: 16px; color: #cc9966; margin-top: 4px; }
#high-score-display { font-size: 14px; color: #aa8855; margin-top: 12px; }
.restart-hint { margin-top: 36px; color: #776644; font-size: 14px; letter-spacing: .05em; animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{ opacity:.5; } 50%{ opacity:1; } }
/* ── css: hud-extras ── */
#sun-warning {
position: absolute;
top: 80px;
left: 20px;
color: #ff6633;
font-size: 14px;
font-weight: 700;
text-shadow: 0 0 10px rgba(255,80,30,.6);
letter-spacing: .08em;
animation: sunPulse 1s ease-in-out infinite;
}
@keyframes sunPulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* Low energy pulse on bar */
#energy-bar-container.low-energy {
animation: energyPulse 0.8s ease-in-out infinite;
border-color: rgba(255,40,0,.6);
}
@keyframes energyPulse {
0%, 100% { box-shadow: 0 0 5px rgba(255,40,0,.3); }
50% { box-shadow: 0 0 15px rgba(255,40,0,.6); }
}
/* Boost active indicator */
#energy-bar-container.boosting #energy-label::after {
content: ' BOOST';
color: #ffaa33;
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game-canvas"></canvas>
<div id="hud" class="hidden">
<div id="hud-top-left">
<div id="score-display">SCORE: 0</div>
<div id="distance-display">DISTANCE: 0m</div>
</div><div id="sun-warning" class="hidden">⚠ SUN SETTING — FIND ENERGY!</div>
<div id="hud-top-right">
<div id="multiplier-display">×1.0</div>
<div id="speed-display">SPEED: 0</div>
</div>
<div id="energy-bar-container">
<div id="energy-bar-bg"></div>
<div id="energy-bar-fill"></div>
<span id="energy-label">⚡ ENERGY</span>
</div>
</div>
<div id="title-screen">
<h1>RACE THE SUN</h1>
<p class="subtitle">Pilot through the geometric void. Stay in the light.</p>
<div class="controls-info">
<span>WASD / Arrows — Move</span>
<span>Space — Boost</span>
<span>R — Restart</span>
</div>
<button id="start-btn">LAUNCH</button>
</div>
<div id="gameover-screen" class="hidden">
<h1>SIGNAL LOST</h1>
<div id="final-score"></div>
<div id="final-distance"></div>
<div id="high-score-display"></div>
<p class="restart-hint">Press R or click to restart</p>
</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./input":"src/input.ts","./player":"src/player.ts","./world":"src/world.ts","./particles":"src/particles.ts","./audio":"src/audio.ts","./render":"src/render.ts","./constants":"src/constants.ts"},"src/state.ts":{"./constants":"src/constants.ts"},"src/player.ts":{"./state":"src/state.ts","./input":"src/input.ts","./constants":"src/constants.ts"},"src/world.ts":{"./state":"src/state.ts","./player":"src/player.ts","./constants":"src/constants.ts"},"src/particles.ts":{"./state":"src/state.ts","./constants":"src/constants.ts"},"src/audio.ts":{"./state":"src/state.ts","./constants":"src/constants.ts"},"src/render.ts":{"./state":"src/state.ts","./constants":"src/constants.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// === MAIN — GAME LOOP & ENTRY POINT ===
Object.defineProperty(exports, "__esModule", { value: true });
const state_1 = require("./state");
const input_1 = require("./input");
const player_1 = require("./player");
const world_1 = require("./world");
const particles_1 = require("./particles");
const audio_1 = require("./audio");
const render_1 = require("./render");
const constants_1 = require("./constants");
// === DOM REFERENCES ===
const titleScreen = document.getElementById('title-screen');
const gameoverScreen = document.getElementById('gameover-screen');
const hudEl = document.getElementById('hud');
const startBtn = document.getElementById('start-btn');
const finalScoreEl = document.getElementById('final-score');
const finalDistanceEl = document.getElementById('final-distance');
const highScoreDisplay = document.getElementById('high-score-display');
// === GAME STATE ===
let g = (0, state_1.resetGame)();
let lastBoostState = false;
let crashParticlesSpawned = false;
let collectedPickupIds = new Set();
let prevNearMissFlash = 0; // Track near-miss flash for audio feedback
// === INITIALIZATION ===
function init() {
(0, render_1.initCanvas)();
(0, input_1.initInput)();
(0, audio_1.initAudio)();
startBtn.addEventListener('click', () => {
(0, audio_1.resumeAudio)();
launchGame();
});
gameoverScreen.addEventListener('click', () => {
if (g.state === 'gameover') {
(0, audio_1.resumeAudio)();
resetAndPlay();
}
});
showTitleScreen();
requestAnimationFrame(gameLoop);
}
function launchGame() {
g = (0, state_1.resetGame)();
g.state = 'playing';
(0, world_1.initWorld)(g.runSeed);
collectedPickupIds.clear();
crashParticlesSpawned = false;
lastBoostState = false;
prevNearMissFlash = 0;
titleScreen.classList.add('hidden');
gameoverScreen.classList.add('hidden');
hudEl.classList.remove('hidden');
(0, audio_1.startAmbient)();
}
function resetAndPlay() {
launchGame();
}
// === SCREEN MANAGEMENT ===
function showTitleScreen() {
titleScreen.classList.remove('hidden');
gameoverScreen.classList.add('hidden');
hudEl.classList.add('hidden');
}
function showGameOver() {
const hs = (0, player_1.getHighScore)();
finalScoreEl.textContent = `SCORE: ${Math.floor(g.score).toLocaleString()}`;
finalDistanceEl.textContent = `DISTANCE: ${Math.floor(g.distance / 10)}m`;
highScoreDisplay.textContent = `HIGH SCORE: ${hs.toLocaleString()}`;
gameoverScreen.classList.remove('hidden');
hudEl.classList.add('hidden');
}
// === GAME LOOP ===
let lastTime = 0;
function gameLoop(timestamp) {
const dt = Math.min(0.05, (timestamp - lastTime) / 1000);
lastTime = timestamp;
if (g.state === 'playing') {
update(dt);
}
else if (g.state === 'gameover') {
// Still render particles and effects after game over
(0, particles_1.updateParticles)(g, dt);
(0, world_1.updateEffects)(g, dt);
}
(0, render_1.render)(g);
requestAnimationFrame(gameLoop);
}
// === UPDATE ===
function update(dt) {
const input = (0, input_1.readInput)();
// Update player physics & movement
(0, player_1.updatePlayer)(g, input, dt);
// Sun sinks over time
g.sunY += constants_1.SUN_SINK_RATE * dt;
if (g.sunY >= constants_1.SUN_SET_THRESHOLD) {
(0, player_1.triggerGameOver)(g);
}
// Update world objects
(0, world_1.updateWorld)(g, dt);
// Check collisions
(0, world_1.checkCollisions)(g);
// Spawn thrust particles
(0, particles_1.spawnThrustParticles)(g);
// Ambient atmospheric particles
(0, particles_1.spawnAmbientParticles)(g);
// Detect newly collected pickups for particle effects
for (const p of g.pickups) {
if (p.collected && !collectedPickupIds.has(p.z + p.x * 1000)) {
collectedPickupIds.add(p.z + p.x * 1000);
const color = p.type === 'energy' ? constants_1.COLORS.energyCell :
p.type === 'score' ? constants_1.COLORS.scoreToken : constants_1.COLORS.chainOrb;
(0, particles_1.spawnPickupParticles)(g, p.x, p.y, p.z, color);
(0, audio_1.playPickupSound)(p.type);
}
}
// Boost state change detection
if (g.isBoosting !== lastBoostState) {
(0, audio_1.playBoostSound)(g.isBoosting);
lastBoostState = g.isBoosting;
}
// Update particles
(0, particles_1.updateParticles)(g, dt);
// Near-miss audio feedback (detect when nearMissFlash goes up)
if (g.nearMissFlash > prevNearMissFlash && g.nearMissFlash > 0.1) {
(0, audio_1.playNearMissSound)();
}
prevNearMissFlash = g.nearMissFlash;
// Update effects (shake, flash)
(0, world_1.updateEffects)(g, dt);
// Update ambient audio
(0, audio_1.updateAmbient)(g);
// Handle game over transition
if (g.state === 'gameover' && !crashParticlesSpawned) {
crashParticlesSpawned = true;
(0, particles_1.spawnCrashParticles)(g);
(0, audio_1.playCrashSound)();
(0, world_1.addShake)(g, constants_1.COLLISION_SHAKE);
showGameOver();
}
// Restart from game over / title
if ((g.state === 'gameover' || g.state === 'title') && (0, input_1.consumeRestart)()) {
resetAndPlay();
}
}
// === START ===
init();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// === TUNABLE CONSTANTS ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.NEAR_MISS_BONUS = exports.DISTANCE_SCORE_MULT = exports.COLORS = exports.AUDIO_ENABLED = exports.CRASH_PARTICLES = exports.PICKUP_PARTICLES = exports.THRUST_PARTICLES = exports.NEARMISS_SHAKE = exports.COLLISION_SHAKE = exports.NEAR_MISS_SCORE = exports.NEAR_MISS_DIST = exports.SUN_SET_THRESHOLD = exports.SUN_SINK_RATE = exports.MAX_ENERGY = exports.BOOST_DRAIN_MULT = exports.SHADOW_DRAIN_MULT = exports.SUN_ENERGY_DRAIN = exports.LANE_SPACING = exports.LANE_COUNT = exports.SPAWN_INTERVAL_MIN = exports.SPAWN_INTERVAL_BASE = exports.WORLD_DEPTH = exports.BOOST_MULTIPLIER = exports.SPEED_RAMP = exports.MAX_SPEED = exports.BASE_SPEED = exports.PITCH_LEAN = exports.BANK_ANGLE_MAX = exports.INERTIA_TAIL = exports.EASING_RATE = exports.MOVE_SPEED = exports.PLAYER_HEIGHT = exports.PLAYER_WIDTH = exports.CANVAS_H = exports.CANVAS_W = void 0;
exports.CANVAS_W = 1280;
exports.CANVAS_H = 720;
// Player movement
exports.PLAYER_WIDTH = 36;
exports.PLAYER_HEIGHT = 24;
exports.MOVE_SPEED = 520; // lateral/vertical speed (px/s)
exports.EASING_RATE = 12; // input easing (higher = snappier, ~80ms response)
exports.INERTIA_TAIL = 0.92; // inertia carryover per frame
exports.BANK_ANGLE_MAX = 0.35; // max roll angle in radians when strafing
exports.PITCH_LEAN = 0.15; // forward lean on pitch
// Speed & difficulty
exports.BASE_SPEED = 300; // starting forward speed (world units/s)
exports.MAX_SPEED = 900; // max forward speed
exports.SPEED_RAMP = 8; // speed increase per second
exports.BOOST_MULTIPLIER = 1.6; // boost speed multiplier
// World generation
exports.WORLD_DEPTH = 2400; // how far ahead we generate
exports.SPAWN_INTERVAL_BASE = 1.2; // seconds between obstacle waves at start
exports.SPAWN_INTERVAL_MIN = 0.35; // minimum spawn interval
exports.LANE_COUNT = 5; // number of lateral lanes
exports.LANE_SPACING = 140; // px between lane centers
// Sun & energy
exports.SUN_ENERGY_DRAIN = 2.8; // base drain per second
exports.SHADOW_DRAIN_MULT = 3.5; // multiplier when in shadow
exports.BOOST_DRAIN_MULT = 2.0; // extra drain while boosting
exports.MAX_ENERGY = 100;
exports.SUN_SINK_RATE = 0.012; // sun position drops per second (0=top, 1=bottom) — ~33s to set from start
exports.SUN_SET_THRESHOLD = 0.75; // game over when sun below this
// Collision
exports.NEAR_MISS_DIST = 60; // distance for near-miss bonus
exports.NEAR_MISS_SCORE = 50; // score bonus per near miss
exports.COLLISION_SHAKE = 12; // screen shake magnitude on collision
exports.NEARMISS_SHAKE = 4; // screen shake on near miss
// Particles
exports.THRUST_PARTICLES = 3; // particles per frame from thrust
exports.PICKUP_PARTICLES = 15; // particles on pickup
exports.CRASH_PARTICLES = 60; // particles on crash
// Audio
exports.AUDIO_ENABLED = true;
// Colors (sunset palette)
exports.COLORS = {
skyTop: '#0a0a2e',
skyMid: '#1a0a3e',
skyBot: '#4a1a2e',
sunCore: '#fff8e0',
sunGlow: '#ffaa33',
sunOuter: '#ff6600',
groundFar: '#1a0a20',
groundNear: '#2a1530',
obstacleLight: '#cc4400',
obstacleShadow: '#882200',
obstacleEdge: '#ffaa44',
playerBody: '#ddeeff',
playerAccent: '#44aaff',
playerGlow: '#66ccff',
energyCell: '#ffee44',
scoreToken: '#88ddff',
chainOrb: '#ff88cc',
speedLine: 'rgba(255,200,100,',
};
// Scoring
exports.DISTANCE_SCORE_MULT = 1;
exports.NEAR_MISS_BONUS = 50;
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// === GAME STATE ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInitialState = createInitialState;
exports.resetGame = resetGame;
const constants_1 = require("./constants");
function createInitialState() {
return {
state: 'title',
playerX: 0,
playerY: 80,
targetPlayerX: 0,
targetPlayerY: 80,
velocityX: 0,
velocityY: 0,
bankAngle: 0,
pitchLean: 0,
distance: 0,
speed: constants_1.BASE_SPEED,
isBoosting: false,
boostEnergy: constants_1.MAX_ENERGY,
sunY: 0.35,
score: 0,
multiplier: 1,
chainCount: 0,
chainTimer: 0,
shakeX: 0,
shakeY: 0,
shakeDecay: 0,
flashAlpha: 0,
nearMissFlash: 0,
obstacles: [],
pickups: [],
particles: [],
spawnTimer: 2,
lastTime: 0,
runSeed: Math.floor(Math.random() * 999999),
};
}
function resetGame() {
return createInitialState();
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// === INPUT HANDLING ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.initInput = initInput;
exports.readInput = readInput;
exports.consumeRestart = consumeRestart;
const keys = new Set();
let lastRestart = false;
function initInput() {
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (!keys.has(k)) {
if (k === 'r')
lastRestart = true;
}
keys.add(k);
// Prevent scrolling for game keys
if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(e.key)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
const k = e.key.toLowerCase();
keys.delete(k);
});
}
function readInput() {
return {
left: keys.has('a') || keys.has('arrowleft'),
right: keys.has('d') || keys.has('arrowright'),
up: keys.has('w') || keys.has('arrowup'),
down: keys.has('s') || keys.has('arrowdown'),
boost: keys.has(' ') || keys.has('space'),
restart: lastRestart,
};
}
function consumeRestart() {
const r = lastRestart;
lastRestart = false;
return r;
}
};
// ── module: src/player.ts ──
__mods["src/player.ts"] = function (exports, require, module) {
"use strict";
// === PLAYER PHYSICS & MOVEMENT ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.updatePlayer = updatePlayer;
exports.triggerGameOver = triggerGameOver;
exports.startNewGame = startNewGame;
exports.getHighScore = getHighScore;
const input_1 = require("./input");
const constants_1 = require("./constants");
const HALF_WIDTH = (constants_1.LANE_COUNT * constants_1.LANE_SPACING) / 2;
const MIN_Y = 0;
const MAX_Y = 350;
function updatePlayer(g, input, dt) {
// --- Forward speed ramp ---
const effectiveSpeed = g.isBoosting ? g.speed * constants_1.BOOST_MULTIPLIER : g.speed;
if (g.state === 'playing') {
g.speed = Math.min(constants_1.MAX_SPEED, g.speed + constants_1.SPEED_RAMP * dt);
}
// --- Lateral movement with snappy easing + inertia tail ---
let targetVX = 0;
if (input.left)
targetVX -= constants_1.MOVE_SPEED;
if (input.right)
targetVX += constants_1.MOVE_SPEED;
// Smooth acceleration toward target velocity
const accel = constants_1.EASING_RATE * dt;
g.velocityX += (targetVX - g.velocityX) * Math.min(1, accel);
// Apply inertia tail when no input
if (!input.left && !input.right) {
g.velocityX *= constants_1.INERTIA_TAIL;
}
// --- Vertical movement ---
let targetVY = 0;
if (input.up)
targetVY -= constants_1.MOVE_SPEED * 0.85;
if (input.down)
targetVY += constants_1.MOVE_SPEED * 0.85;
g.velocityY += (targetVY - g.velocityY) * Math.min(1, accel);
if (!input.up && !input.down) {
g.velocityY *= constants_1.INERTIA_TAIL;
}
// --- Apply velocity to position ---
g.playerX += g.velocityX * dt;
g.playerY += g.velocityY * dt;
// Clamp lateral bounds
g.playerX = Math.max(-HALF_WIDTH, Math.min(HALF_WIDTH, g.playerX));
// Clamp vertical bounds with soft bounce
if (g.playerY < MIN_Y) {
g.playerY = MIN_Y;
g.velocityY = Math.abs(g.velocityY) * 0.3;
}
if (g.playerY > MAX_Y) {
g.playerY = MAX_Y;
g.velocityY = -Math.abs(g.velocityY) * 0.3;
}
// --- Bank angle (roll when strafing) ---
const targetBank = Math.sign(targetVX || g.velocityX) * constants_1.BANK_ANGLE_MAX * Math.min(1, Math.abs(g.velocityX) / constants_1.MOVE_SPEED);
g.bankAngle += (targetBank - g.bankAngle) * Math.min(1, accel * 1.5);
// --- Pitch lean on vertical movement ---
const targetPitch = Math.sign(targetVY || g.velocityY) * constants_1.PITCH_LEAN * Math.min(1, Math.abs(g.velocityY) / constants_1.MOVE_SPEED);
g.pitchLean += (targetPitch - g.pitchLean) * Math.min(1, accel * 1.5);
// --- Distance & score ---
g.distance += effectiveSpeed * dt;
if (g.state === 'playing') {
g.score += effectiveSpeed * dt * g.multiplier * 0.01;
}
// --- Boost energy ---
const drainRate = constants_1.SUN_ENERGY_DRAIN + (constants_1.BOOST_DRAIN_MULT * Number(g.isBoosting));
g.boostEnergy -= drainRate * dt;
if (g.boostEnergy <= 0) {
g.boostEnergy = 0;
g.state = 'gameover';
triggerGameOver(g);
}
// --- Boost toggle ---
const wasBoosting = g.isBoosting;
g.isBoosting = input.boost && g.boostEnergy > 5;
if (g.isBoosting !== wasBoosting) {
// Toggle feedback handled in audio/render
}
// --- Chain timer decay ---
if (g.chainTimer > 0) {
g.chainTimer -= dt;
if (g.chainTimer <= 0) {
g.chainCount = 0;
g.multiplier = Math.max(1, g.multiplier - g.chainCount * 0.5);
}
}
// --- Restart check ---
if ((g.state === 'gameover' || g.state === 'title') && (0, input_1.consumeRestart)()) {
startNewGame(g);
}
}
function triggerGameOver(g) {
g.flashAlpha = 0.8;
// Save high score
const prev = getHighScore();
if (g.score > prev) {
localStorage.setItem('rts-highscore', String(Math.floor(g.score)));
}
}
function startNewGame(g) {
g.state = 'playing';
g.playerX = 0;
g.playerY = 80;
g.velocityX = 0;
g.velocityY = 0;
g.bankAngle = 0;
g.pitchLean = 0;
g.distance = 0;
g.speed = constants_1.BASE_SPEED;
g.isBoosting = false;
g.boostEnergy = constants_1.MAX_ENERGY;
g.sunY = 0.35;
g.score = 0;
g.multiplier = 1;
g.chainCount = 0;
g.chainTimer = 0;
g.shakeX = 0;
g.shakeY = 0;
g.shakeDecay = 0;
g.flashAlpha = 0;
g.nearMissFlash = 0;
g.obstacles = [];
g.pickups = [];
g.particles = [];
g.spawnTimer = 1.5;
g.runSeed = Math.floor(Math.random() * 999999);
}
function getHighScore() {
try {
return Number(localStorage.getItem('rts-highscore')) || 0;
}
catch {
return 0;
}
}
};
// ── module: src/world.ts ──
__mods["src/world.ts"] = function (exports, require, module) {
"use strict";
// === PROCEDURAL WORLD GENERATION ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.initWorld = initWorld;
exports.spawnObstacles = spawnObstacles;
exports.updateWorld = updateWorld;
exports.checkCollisions = checkCollisions;
exports.addShake = addShake;
exports.updateEffects = updateEffects;
const player_1 = require("./player");
const constants_1 = require("./constants");
// Simple seeded PRNG (mulberry32)
function mulberry32(seed) {
return function () {
seed |= 0;
seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
let rng = null;
function initWorld(seed) {
rng = mulberry32(seed);
}
function rand() { return rng ? rng() : Math.random(); }
// === SPAWN OBSTACLES ===
function spawnObstacles(g, dt) {
if (!rng)
initWorld(g.runSeed);
g.spawnTimer -= dt;
if (g.spawnTimer > 0)
return;
// Spawn interval decreases with speed
const progress = Math.min(1, (g.speed - 300) / 600);
const interval = constants_1.SPAWN_INTERVAL_BASE - progress * (constants_1.SPAWN_INTERVAL_BASE - constants_1.SPAWN_INTERVAL_MIN);
g.spawnTimer = interval * (0.7 + rand() * 0.6);
// Spawn at fixed distance ahead of player (relative z)
const spawnZ = constants_1.WORLD_DEPTH;
const halfWidth = (constants_1.LANE_COUNT * constants_1.LANE_SPACING) / 2;
const patternRoll = rand();
if (patternRoll < 0.4) {
// Single pillar in a random lane
const lane = Math.floor(rand() * constants_1.LANE_COUNT);
const x = -halfWidth + lane * constants_1.LANE_SPACING;
g.obstacles.push({
x, z: spawnZ, w: 50 + rand() * 30, h: 80 + rand() * 120,
type: 'pillar', passed: false, nearMissed: false,
});
}
else if (patternRoll < 0.7) {
// Wall with gap — block some lanes, leave others open
const gapLane = Math.floor(rand() * constants_1.LANE_COUNT);
for (let i = 0; i < constants_1.LANE_COUNT; i++) {
if (i === gapLane || i === gapLane + 1)
continue;
const x = -halfWidth + i * constants_1.LANE_SPACING;
g.obstacles.push({
x, z: spawnZ, w: constants_1.LANE_SPACING * 0.85, h: 60 + rand() * 80,
type: 'wall', passed: false, nearMissed: false,
});
}
}
else {
// Arch — tall obstacles on sides with space in middle (or vice versa)
const midGap = rand() > 0.5;
for (let i = 0; i < constants_1.LANE_COUNT; i++) {
if (midGap && i >= 1 && i <= 3)
continue;
if (!midGap && (i === 0 || i === constants_1.LANE_COUNT - 1))
continue;
const x = -halfWidth + i * constants_1.LANE_SPACING;
g.obstacles.push({
x, z: spawnZ, w: constants_1.LANE_SPACING * 0.85, h: 40 + rand() * 60,
type: 'arch', passed: false, nearMissed: false,
});
}
}
// Spawn pickups alongside obstacles (sometimes)
if (rand() < 0.7) {
spawnPickup(g, spawnZ + rand() * 200);
}
}
function spawnPickup(g, z) {
const halfWidth = (constants_1.LANE_COUNT * constants_1.LANE_SPACING) / 2;
const lane = Math.floor(rand() * constants_1.LANE_COUNT);
const x = -halfWidth + lane * constants_1.LANE_SPACING + (rand() - 0.5) * 40;
const roll = rand();
let type;
if (roll < 0.35)
type = 'energy';
else if (roll < 0.7)
type = 'score';
else
type = 'chain';
g.pickups.push({
x, z, y: 40 + rand() * 120,
type, collected: false, bobPhase: rand() * Math.PI * 2,
});
}
// === UPDATE WORLD OBJECTS (move toward player using RELATIVE Z) ===
function updateWorld(g, dt) {
const effectiveSpeed = g.isBoosting ? g.speed * constants_1.BOOST_MULTIPLIER : g.speed;
const moveAmount = effectiveSpeed * dt;
// Move obstacles toward player (relative z decreases)
for (const obs of g.obstacles) {
obs.z -= moveAmount;
}
// Move pickups toward player
for (const p of g.pickups) {
p.z -= moveAmount;
p.bobPhase += dt * 3;
}
// Remove objects that passed behind the camera
g.obstacles = g.obstacles.filter(o => o.z > -200);
g.pickups = g.pickups.filter(p => p.z > -100 && !p.collected);
// Spawn new obstacles
spawnObstacles(g, dt);
}
// === COLLISION DETECTION (using relative Z) ===
function checkCollisions(g) {
const pw = 28; // player collision half-width
const ph = 16; // player collision half-height
for (const obs of g.obstacles) {
if (obs.passed || obs.nearMissed && obs.z < -30)
continue;
// Relative Z distance from player (z=0 means at player position)
const relZ = obs.z;
// Only check obstacles near the player plane
if (relZ > 60 || relZ < -40)
continue;
// AABB overlap in X-Z plane
const dx = Math.abs(g.playerX - obs.x);
const halfObsW = obs.w / 2 + pw;
const halfObsD = 30 + pw;
if (dx < halfObsW && Math.abs(relZ) < halfObsD) {
// Check vertical overlap
const playerBottom = g.playerY - ph;
const playerTop = g.playerY + ph;
const obsBottom = 0;
const obsTop = obs.h;
if (playerBottom < obsTop && playerTop > obsBottom) {
// COLLISION!
(0, player_1.triggerGameOver)(g);
return;
}
}
// Near-miss detection
if (!obs.nearMissed && Math.abs(relZ) < constants_1.NEAR_MISS_DIST * 0.5) {
const nearDx = Math.abs(g.playerX - obs.x);
if (nearDx > pw * 1.2 && nearDx < halfObsW + 20) {
obs.nearMissed = true;
g.score += constants_1.NEAR_MISS_SCORE * g.multiplier;
g.nearMissFlash = 0.4;
addShake(g, constants_1.NEARMISS_SHAKE);
}
}
// Mark as passed when behind player
if (relZ < -50 && !obs.passed) {
obs.passed = true;
}
}
// Pickup collection
for (const p of g.pickups) {
if (p.collected)
continue;
const dx = Math.abs(g.playerX - p.x);
const dy = Math.abs(g.playerY - p.y);
const dz = Math.abs(p.z);
if (dx < 40 && dy < 50 && dz < 40) {
p.collected = true;
collectPickup(g, p);
}
}
}
function collectPickup(g, p) {
switch (p.type) {
case 'energy':
g.boostEnergy = Math.min(constants_1.MAX_ENERGY, g.boostEnergy + 20);
break;
case 'score':
g.score += 100 * g.multiplier;
break;
case 'chain':
g.chainCount++;
g.chainTimer = 4;
g.multiplier = Math.min(8, 1 + g.chainCount * 0.5);
break;
}
}
// === SCREEN SHAKE ===
function addShake(g, amount) {
g.shakeDecay = Math.max(g.shakeDecay, amount);
}
function updateEffects(g, dt) {
// Screen shake decay
if (g.shakeDecay > 0.1) {
const angle = Math.random() * Math.PI * 2;
g.shakeX = Math.cos(angle) * g.shakeDecay;
g.shakeY = Math.sin(angle) * g.shakeDecay;
g.shakeDecay *= 0.85;
}
else {
g.shakeX = 0;
g.shakeY = 0;
g.shakeDecay = 0;
}
// Flash decay
if (g.flashAlpha > 0)
g.flashAlpha *= 0.92;
if (g.nearMissFlash > 0)
g.nearMissFlash -= dt * 3;
}
};
// ── module: src/particles.ts ──
__mods["src/particles.ts"] = function (exports, require, module) {
"use strict";
// === PARTICLE SYSTEM ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.spawnThrustParticles = spawnThrustParticles;
exports.spawnAmbientParticles = spawnAmbientParticles;
exports.spawnPickupParticles = spawnPickupParticles;
exports.spawnCrashParticles = spawnCrashParticles;
exports.updateParticles = updateParticles;
const constants_1 = require("./constants");
function spawnThrustParticles(g) {
const count = g.isBoosting ? constants_1.THRUST_PARTICLES * 3 : constants_1.THRUST_PARTICLES;
for (let i = 0; i < count; i++) {
// Particles use relative Z (positive = ahead of player)
g.particles.push({
x: g.playerX + (Math.random() - 0.5) * 12,
y: g.playerY - 8 + Math.random() * 4,
z: 15 + Math.random() * 10, // Behind player in relative coords
vx: (Math.random() - 0.5) * 60,
vy: -30 - Math.random() * 40,
vz: -(g.speed * 0.3 + Math.random() * 80),
life: 0.4 + Math.random() * 0.3,
maxLife: 0.7,
size: g.isBoosting ? 4 + Math.random() * 4 : 2 + Math.random() * 3,
color: g.isBoosting ? constants_1.COLORS.sunGlow : constants_1.COLORS.playerAccent,
});
}
}
// Ambient floating particles for atmosphere
function spawnAmbientParticles(g) {
if (g.state !== 'playing')
return;
const halfWidth = ((constants_1.LANE_COUNT || 5) * (constants_1.LANE_SPACING || 140)) / 2;
// Spawn a few ambient particles per frame
for (let i = 0; i < 2; i++) {
g.particles.push({
x: (Math.random() - 0.5) * halfWidth * 2,
y: Math.random() * 300 + 20,
z: constants_1.WORLD_DEPTH || 2400,
vx: (Math.random() - 0.5) * 10,
vy: (Math.random() - 0.5) * 5,
vz: 0,
life: 3 + Math.random() * 2,
maxLife: 5,
size: 1 + Math.random() * 2,
color: `rgba(255,${140 + Math.floor(Math.random() * 60)},${80 + Math.floor(Math.random() * 40)},0.3)`,
});
}
}
function spawnPickupParticles(g, x, y, z, color) {
for (let i = 0; i < constants_1.PICKUP_PARTICLES; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 80 + Math.random() * 160;
g.particles.push({
x, y, z: Math.max(5, z), // Ensure positive relative Z
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 40,
vz: (Math.random() - 0.5) * 60,
life: 0.3 + Math.random() * 0.4,
maxLife: 0.7,
size: 2 + Math.random() * 4,
color,
});
}
}
function spawnCrashParticles(g) {
for (let i = 0; i < constants_1.CRASH_PARTICLES; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 100 + Math.random() * 350;
g.particles.push({
x: g.playerX, y: g.playerY, z: 20 + Math.random() * 40,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
vz: (Math.random() - 0.5) * 120,
life: 0.5 + Math.random() * 1.0,
maxLife: 1.5,
size: 3 + Math.random() * 8,
color: ['#ff4400', '#ffaa00', '#ffee44', '#ffffff'][Math.floor(Math.random() * 4)],
});
}
}
function updateParticles(g, dt) {
const effectiveSpeed = g.isBoosting ? g.speed * constants_1.BOOST_MULTIPLIER : g.speed;
for (const p of g.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
// Particles move with world flow + their own velocity
p.z -= effectiveSpeed * dt + p.vz * dt;
p.life -= dt;
}
g.particles = g.particles.filter(p => p.life > 0);
}
};
// ── module: src/audio.ts ──
__mods["src/audio.ts"] = function (exports, require, module) {
"use strict";
// === PROCEDURAL AUDIO (WebAudio) ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.initAudio = initAudio;
exports.resumeAudio = resumeAudio;
exports.startAmbient = startAmbient;
exports.updateAmbient = updateAmbient;
exports.playPickupSound = playPickupSound;
exports.playCrashSound = playCrashSound;
exports.playNearMissSound = playNearMissSound;
exports.playBoostSound = playBoostSound;
const constants_1 = require("./constants");
let audioCtx = null;
let masterGain = null;
let droneOsc = null;
let droneGain = null;
let windNoise = null;
let windGain = null;
function initAudio() {
if (!constants_1.AUDIO_ENABLED)
return;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.35;
masterGain.connect(audioCtx.destination);
}
catch { /* Audio not available */ }
}
function resumeAudio() {
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
// Start ambient drone + wind
function startAmbient() {
if (!audioCtx || !masterGain)
return;
// Low drone oscillator
droneOsc = audioCtx.createOscillator();
droneGain = audioCtx.createGain();
droneOsc.type = 'sawtooth';
droneOsc.frequency.value = 55;
droneGain.gain.value = 0.06;
// Low-pass filter for warmth
const lp = audioCtx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 200;
lp.Q.value = 2;
droneOsc.connect(lp);
lp.connect(droneGain);
droneGain.connect(masterGain);
droneOsc.start();
// Wind noise (filtered white noise)
const bufferSize = audioCtx.sampleRate * 2;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
windNoise = audioCtx.createBufferSource();
windGain = audioCtx.createGain();
const windFilter = audioCtx.createBiquadFilter();
windFilter.type = 'bandpass';
windFilter.frequency.value = 400;
windFilter.Q.value = 0.5;
windNoise.buffer = buffer;
windNoise.loop = true;
windGain.gain.value = 0.03;
windNoise.connect(windFilter);
windFilter.connect(windGain);
windGain.connect(masterGain);
windNoise.start();
}
function updateAmbient(g) {
if (!droneOsc || !windGain)
return;
// Modulate drone frequency with speed
const freq = 50 + (g.speed / 900) * 30;
droneOsc.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.1);
// Wind volume increases with speed
windGain.gain.setTargetAtTime(0.02 + (g.speed / 900) * 0.06, audioCtx.currentTime, 0.1);
}
// Short blip for pickup
function playPickupSound(type) {
if (!audioCtx || !masterGain)
return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
switch (type) {
case 'energy':
osc.type = 'sine';
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(900, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
break;
case 'score':
osc.type = 'triangle';
osc.frequency.setValueAtTime(800, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1200, audioCtx.currentTime + 0.08);
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
break;
case 'chain':
osc.type = 'sine';
osc.frequency.setValueAtTime(500, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1400, audioCtx.currentTime + 0.15);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
break;
}
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
osc.connect(gain);
gain.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + 0.25);
}
// Low thud for crash
function playCrashSound() {
if (!audioCtx || !masterGain)
return;
// Noise burst
const bufferSize = audioCtx.sampleRate * 0.3;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (audioCtx.sampleRate * 0.05));
}
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = 0.4;
const lp = audioCtx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 300;
noise.connect(lp);
lp.connect(gain);
gain.connect(masterGain);
noise.start();
// Sub bass thud
const osc = audioCtx.createOscillator();
const oscGain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(80, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + 0.3);
oscGain.gain.setValueAtTime(0.5, audioCtx.currentTime);
oscGain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.4);
osc.connect(oscGain);
oscGain.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + 0.5);
}
// Near-miss whoosh
function playNearMissSound() {
if (!audioCtx || !masterGain)
return;
const bufferSize = audioCtx.sampleRate * 0.15;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (audioCtx.sampleRate * 0.03));
}
const noise = audioCtx.createBufferSource();
noise.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = 0.12;
const bp = audioCtx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 800;
bp.Q.value = 3;
noise.connect(bp);
bp.connect(gain);
gain.connect(masterGain);
noise.start();
}
// Boost engage/disengage sound
function playBoostSound(engaging) {
if (!audioCtx || !masterGain)
return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
if (engaging) {
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(600, audioCtx.currentTime + 0.15);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2);
}
else {
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(200, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
}
osc.connect(gain);
gain.connect(masterGain);
osc.start();
osc.stop(audioCtx.currentTime + 0.25);
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// === RENDERING ===
Object.defineProperty(exports, "__esModule", { value: true });
exports.initCanvas = initCanvas;
exports.render = render;
const constants_1 = require("./constants");
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
// HUD elements
const scoreDisplay = document.getElementById('score-display');
const distanceDisplay = document.getElementById('distance-display');
const multiplierDisplay = document.getElementById('multiplier-display');
const speedDisplay = document.getElementById('speed-display');
const energyBarFill = document.getElementById('energy-bar-fill');
const energyBarContainer = document.getElementById('energy-bar-container');
const sunWarning = document.getElementById('sun-warning');
// Camera constants
const CAM_HEIGHT = 180;
const FOV = 600;
function initCanvas() {
canvas.width = constants_1.CANVAS_W;
canvas.height = constants_1.CANVAS_H;
}
// Project a relative point (x, y, z) to screen coordinates
// z is RELATIVE distance from player (positive = ahead)
function project(wx, wy, wz) {
if (wz <= 10)
return null; // Behind camera
const scale = FOV / wz;
const sx = constants_1.CANVAS_W / 2 + wx * scale;
const sy = constants_1.CANVAS_H / 2 - (wy - CAM_HEIGHT) * scale;
return [sx, sy, scale];
}
// === DRAW SKY ===
function drawSky(g) {
const sunProgress = g.sunY;
const grad = ctx.createLinearGradient(0, 0, 0, constants_1.CANVAS_H);
if (sunProgress < 0.5) {
const t = sunProgress * 2;
grad.addColorStop(0, lerpColor('#0a0a3e', '#1a0820', t));
grad.addColorStop(0.4, lerpColor('#2a1050', '#3a1030', t));
grad.addColorStop(0.7, lerpColor('#6a2040', '#8a3020', t));
grad.addColorStop(1, lerpColor('#cc5520', '#aa4410', t));
}
else {
const t = Math.min(1, (sunProgress - 0.5) * 2);
grad.addColorStop(0, lerpColor('#1a0820', '#050510', t));
grad.addColorStop(0.4, lerpColor('#3a1030', '#0a0a15', t));
grad.addColorStop(0.7, lerpColor('#8a3020', '#15101a', t));
grad.addColorStop(1, lerpColor('#aa4410', '#1a1020', t));
}
ctx.fillStyle = grad;
ctx.fillRect(0, 0, constants_1.CANVAS_W, constants_1.CANVAS_H);
}
// === DRAW SUN ===
function drawSun(g) {
const sunScreenY = g.sunY * constants_1.CANVAS_H * 0.8 + constants_1.CANVAS_H * 0.1;
const sunX = constants_1.CANVAS_W / 2;
const sunRadius = 60 + (1 - g.sunY) * 30;
// Glow layers
for (let i = 4; i >= 0; i--) {
const r = sunRadius + i * 40;
const alpha = Math.max(0, 0.15 - i * 0.025);
ctx.beginPath();
ctx.arc(sunX, sunScreenY, r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,${140 + i * 20},${30 + i * 10},${alpha})`;
ctx.fill();
}
// Sun body
const sunGrad = ctx.createRadialGradient(sunX, sunScreenY, 0, sunX, sunScreenY, sunRadius);
sunGrad.addColorStop(0, constants_1.COLORS.sunCore);
sunGrad.addColorStop(0.5, constants_1.COLORS.sunGlow);
sunGrad.addColorStop(1, constants_1.COLORS.sunOuter);
ctx.beginPath();
ctx.arc(sunX, sunScreenY, sunRadius, 0, Math.PI * 2);
ctx.fillStyle = sunGrad;
ctx.fill();
// Horizon glow line
const horizonAlpha = Math.max(0, 1 - g.sunY * 2) * 0.3;
if (horizonAlpha > 0.01) {
const hGrad = ctx.createLinearGradient(0, sunScreenY, 0, constants_1.CANVAS_H);
hGrad.addColorStop(0, `rgba(255,140,50,${horizonAlpha})`);
hGrad.addColorStop(1, 'transparent');
ctx.fillStyle = hGrad;
ctx.fillRect(0, sunScreenY - 20, constants_1.CANVAS_W, constants_1.CANVAS_H - sunScreenY + 20);
}
}
// === DRAW GROUND / TRACK ===
function drawGround(g) {
const halfWidth = (constants_1.LANE_COUNT * constants_1.LANE_SPACING) / 2;
const time = performance.now() / 1000;
const effectiveSpeed = g.isBoosting ? g.speed * constants_1.BOOST_MULTIPLIER : g.speed;
ctx.save();
// Animated ground plane — horizontal lines that move toward player
const lineSpacing = 50;
const offset = (g.distance % lineSpacing);
for (let relZ = 20 + offset; relZ < constants_1.WORLD_DEPTH; relZ += lineSpacing) {
const scale = FOV / relZ;
const y = constants_1.CANVAS_H / 2 - (-CAM_HEIGHT + 50) * scale;
// Fade with distance
const alpha = Math.max(0, 1 - relZ / constants_1.WORLD_DEPTH);
const lineAlpha = alpha * (g.isBoosting ? 0.4 : 0.25);
ctx.strokeStyle = `rgba(255,140,80,${lineAlpha})`;
ctx.lineWidth = Math.max(0.5, scale * 1.5);
ctx.beginPath();
ctx.moveTo(constants_1.CANVAS_W / 2 + (-halfWidth - 80) * scale, y);
ctx.lineTo(constants_1.CANVAS_W / 2 + (halfWidth + 80) * scale, y);
ctx.stroke();
// Lane dividers — vertical perspective lines
for (let i = 0; i <= constants_1.LANE_COUNT; i++) {
const lx = -halfWidth + i * constants_1.LANE_SPACING;
const sx = constants_1.CANVAS_W / 2 + lx * scale;
const nextScale = FOV / (relZ + lineSpacing);
const nextY = constants_1.CANVAS_H / 2 - (-CAM_HEIGHT + 50) * nextScale;
ctx.strokeStyle = `rgba(255,140,80,${lineAlpha * 0.6})`;
ctx.lineWidth = Math.max(0.5, scale);
ctx.beginPath();
ctx.moveTo(sx, y);
ctx.lineTo(constants_1.CANVAS_W / 2 + lx * nextScale, nextY);
ctx.stroke();
}
}
// Ground fill below horizon with gradient
const groundGrad = ctx.createLinearGradient(0, constants_1.CANVAS_H * 0.5, 0, constants_1.CANVAS_H);
groundGrad.addColorStop(0, 'rgba(26,10,32,0)');
groundGrad.addColorStop(0.4, 'rgba(26,10,32,0.5)');
groundGrad.addColorStop(1, constants_1.COLORS.groundNear);
ctx.fillStyle = groundGrad;
ctx.fillRect(0, constants_1.CANVAS_H * 0.5, constants_1.CANVAS_W, constants_1.CANVAS_H * 0.5);
// Track edge glow lines
const edgeAlpha = Math.max(0, 1 - g.sunY) * 0.3;
if (edgeAlpha > 0.02) {
for (const side of [-1, 1]) {
ctx.strokeStyle = `rgba(255,140,80,${edgeAlpha})`;
ctx.lineWidth = Math.max(1, FOV / constants_1.WORLD_DEPTH * 3);
ctx.beginPath();
const edgeX = side * (halfWidth + 60);
for (let relZ = 20; relZ < constants_1.WORLD_DEPTH; relZ += 5) {
const scale = FOV / relZ;
const y = constants_1.CANVAS_H / 2 - (-CAM_HEIGHT + 50) * scale;
const sx = constants_1.CANVAS_W / 2 + edgeX * scale;
if (relZ === 20)
ctx.moveTo(sx, y);
else
ctx.lineTo(sx, y);
}
ctx.stroke();
}
}
ctx.restore();
}
// === DRAW OBSTACLES (relative Z) ===
function drawObstacles(g) {
// Sort by Z (far to near) for proper depth ordering
const sorted = [...g.obstacles].sort((a, b) => b.z - a.z);
for (const obs of sorted) {
if (obs.z < -50 || obs.z > constants_1.WORLD_DEPTH)
continue;
const relZ = obs.z; // Already relative to player
if (relZ <= 10)
continue;
const scale = FOV / relZ;
const sx = constants_1.CANVAS_W / 2 + obs.x * scale;
const syBase = constants_1.CANVAS_H / 2 - (-CAM_HEIGHT) * scale; // Ground level
const sw = obs.w * scale;
const sh = obs.h * scale;
if (sx < -sw || sx > constants_1.CANVAS_W + sw)
continue;
// Shadow side based on sun position
const shadowFactor = Math.max(0, 1 - g.sunY * 2);
ctx.save();
// Different visual styles per obstacle type
switch (obs.type) {
case 'pillar':
drawPillar(sx, syBase, sw, sh, scale, shadowFactor, relZ);
break;
case 'wall':
drawWall(sx, syBase, sw, sh, scale, shadowFactor, relZ);
break;
case 'arch':
drawArch(sx, syBase, sw, sh, scale, shadowFactor, relZ);
break;
}
ctx.restore();
}
}
function drawPillar(x, yBase, w, h, scale, shadow, relZ) {
const bodyColor = lerpColor(constants_1.COLORS.obstacleShadow, constants_1.COLORS.obstacleLight, shadow);
// Main pillar body with slight taper
ctx.fillStyle = bodyColor;
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase);
ctx.lineTo(x + w / 2, yBase);
ctx.lineTo(x + w * 0.4, yBase - h);
ctx.lineTo(x - w * 0.4, yBase - h);
ctx.closePath();
ctx.fill();
// Top cap
const topColor = lerpColor('#aa3300', '#ff8844', shadow);
ctx.fillStyle = topColor;
ctx.fillRect(x - w * 0.5, yBase - h - scale * 4, w, scale * 4);
// Edge highlights
ctx.strokeStyle = `rgba(255,170,68,${shadow * 0.5})`;
ctx.lineWidth = Math.max(1, scale * 2);
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase);
ctx.lineTo(x - w * 0.4, yBase - h);
ctx.stroke();
// Warning glow for nearby obstacles
if (relZ < 400) {
const proximity = 1 - relZ / 400;
ctx.shadowColor = '#ff6600';
ctx.shadowBlur = 25 * proximity;
ctx.strokeStyle = `rgba(255,100,0,${proximity * 0.5})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase);
ctx.lineTo(x + w / 2, yBase);
ctx.lineTo(x + w * 0.4, yBase - h);
ctx.lineTo(x - w * 0.4, yBase - h);
ctx.closePath();
ctx.stroke();
}
}
function drawWall(x, yBase, w, h, scale, shadow, relZ) {
const bodyColor = lerpColor(constants_1.COLORS.obstacleShadow, '#cc5522', shadow);
// Solid wall block
ctx.fillStyle = bodyColor;
ctx.fillRect(x - w / 2, yBase - h, w, h);
// Horizontal lines for texture
const lineCount = Math.max(1, Math.floor(h / (scale * 15)));
ctx.strokeStyle = `rgba(0,0,0,${0.15 + shadow * 0.1})`;
ctx.lineWidth = Math.max(0.5, scale);
for (let i = 1; i < lineCount; i++) {
const ly = yBase - h + (h / lineCount) * i;
ctx.beginPath();
ctx.moveTo(x - w / 2, ly);
ctx.lineTo(x + w / 2, ly);
ctx.stroke();
}
// Top edge highlight
ctx.strokeStyle = `rgba(255,170,68,${shadow * 0.7})`;
ctx.lineWidth = Math.max(1, scale * 3);
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase - h);
ctx.lineTo(x + w / 2, yBase - h);
ctx.stroke();
// Warning glow for nearby obstacles
if (relZ < 400) {
const proximity = 1 - relZ / 400;
ctx.shadowColor = '#ff6600';
ctx.shadowBlur = 25 * proximity;
ctx.strokeStyle = `rgba(255,100,0,${proximity * 0.5})`;
ctx.lineWidth = 2;
ctx.strokeRect(x - w / 2, yBase - h, w, h);
}
}
function drawArch(x, yBase, w, h, scale, shadow, relZ) {
const bodyColor = lerpColor(constants_1.COLORS.obstacleShadow, '#bb4411', shadow);
// Arch shape — two pillars with a curved top
const pillarW = w * 0.25;
// Left pillar
ctx.fillStyle = bodyColor;
ctx.fillRect(x - w / 2, yBase - h, pillarW, h);
// Right pillar
ctx.fillRect(x + w / 2 - pillarW, yBase - h, pillarW, h);
// Arch top (curved)
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase - h);
ctx.quadraticCurveTo(x, yBase - h - scale * 15, x + w / 2, yBase - h);
ctx.lineWidth = Math.max(3, pillarW * 0.6);
ctx.strokeStyle = bodyColor;
ctx.stroke();
// Edge highlights
ctx.strokeStyle = `rgba(255,170,68,${shadow * 0.4})`;
ctx.lineWidth = Math.max(1, scale * 2);
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase);
ctx.lineTo(x - w / 2, yBase - h);
ctx.stroke();
// Warning glow for nearby obstacles
if (relZ < 400) {
const proximity = 1 - relZ / 400;
ctx.shadowColor = '#ff6600';
ctx.shadowBlur = 25 * proximity;
ctx.strokeStyle = `rgba(255,100,0,${proximity * 0.5})`;
ctx.lineWidth = 2;
// Draw glow around the arch shape
ctx.beginPath();
ctx.moveTo(x - w / 2, yBase);
ctx.lineTo(x + w / 2, yBase);
ctx.lineTo(x + w / 2, yBase - h);
ctx.quadraticCurveTo(x, yBase - h - scale * 15, x - w / 2, yBase - h);
ctx.closePath();
ctx.stroke();
}
}
// === DRAW PICKUPS (relative Z) ===
function drawPickups(g) {
const time = performance.now() / 1000;
for (const p of g.pickups) {
if (p.collected || p.z < -50 || p.z > constants_1.WORLD_DEPTH)
continue;
const relZ = p.z; // Already relative to player
if (relZ <= 10)
continue;
const scale = FOV / relZ;
const bobY = Math.sin(p.bobPhase + time * 3) * 8 * scale;
const sx = constants_1.CANVAS_W / 2 + p.x * scale;
const sy = constants_1.CANVAS_H / 2 - (p.y - CAM_HEIGHT) * scale + bobY;
const size = Math.max(4, 16 * scale);
if (sx < -size || sx > constants_1.CANVAS_W + size)
continue;
ctx.save();
let color;
switch (p.type) {
case 'energy':
color = constants_1.COLORS.energyCell;
break;
case 'score':
color = constants_1.COLORS.scoreToken;
break;
case 'chain':
color = constants_1.COLORS.chainOrb;
break;
}
// Glow
ctx.shadowColor = color;
ctx.shadowBlur = 15 * scale;
// Diamond shape for pickups
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(sx, sy - size);
ctx.lineTo(sx + size * 0.7, sy);
ctx.lineTo(sx, sy + size);
ctx.lineTo(sx - size * 0.7, sy);
ctx.closePath();
ctx.fill();
// Inner highlight
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.moveTo(sx, sy - size * 0.5);
ctx.lineTo(sx + size * 0.35, sy);
ctx.lineTo(sx, sy + size * 0.5);
ctx.lineTo(sx - size * 0.35, sy);
ctx.closePath();
ctx.fill();
// Proximity pulse when close
if (relZ < 200) {
const pulse = Math.sin(time * 8) * 0.5 + 0.5;
ctx.strokeStyle = `rgba(255,255,255,${pulse * 0.6})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy, size * 1.5, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
}
// === DRAW PLAYER CRAFT ===
function drawPlayer(g) {
const time = performance.now() / 1000;
// Player is drawn at a fixed screen position with lateral/vertical offset
const cx = constants_1.CANVAS_W / 2 + g.playerX * (FOV / 300);
const cy = constants_1.CANVAS_H / 2 - (g.playerY - CAM_HEIGHT) * (FOV / 300);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(g.bankAngle); // Bank/roll
// Pitch lean effect — slight vertical stretch
const pitchScale = 1 + g.pitchLean * 0.3;
ctx.scale(1, pitchScale);
const w = constants_1.PLAYER_WIDTH;
const h = constants_1.PLAYER_HEIGHT;
// Engine glow
const enginePulse = Math.sin(time * 12) * 0.2 + 0.8;
const engineColor = g.isBoosting ? constants_1.COLORS.sunGlow : constants_1.COLORS.playerAccent;
ctx.shadowColor = engineColor;
ctx.shadowBlur = 20 * enginePulse;
// Craft body — sleek triangle shape
ctx.fillStyle = constants_1.COLORS.playerBody;
ctx.beginPath();
ctx.moveTo(0, -h); // Nose
ctx.lineTo(-w / 2, h * 0.6); // Left wing tip
ctx.lineTo(-w * 0.15, h * 0.3); // Left inner
ctx.lineTo(0, h * 0.5); // Tail center
ctx.lineTo(w * 0.15, h * 0.3); // Right inner
ctx.lineTo(w / 2, h * 0.6); // Right wing tip
ctx.closePath();
ctx.fill();
// Accent line along body
ctx.strokeStyle = constants_1.COLORS.playerAccent;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -h + 4);
ctx.lineTo(0, h * 0.5);
ctx.stroke();
// Wing accents
ctx.strokeStyle = constants_1.COLORS.playerGlow;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-w / 2, h * 0.6);
ctx.lineTo(0, -h + 4);
ctx.lineTo(w / 2, h * 0.6);
ctx.stroke();
// Engine exhaust
const exhaustLen = g.isBoosting ? 35 : 18;
const exGrad = ctx.createLinearGradient(0, h * 0.5, 0, h * 0.5 + exhaustLen);
exGrad.addColorStop(0, engineColor);
exGrad.addColorStop(1, 'transparent');
ctx.fillStyle = exGrad;
ctx.beginPath();
ctx.moveTo(-6, h * 0.5);
ctx.lineTo(6, h * 0.5);
ctx.lineTo(2 + Math.sin(time * 20) * 2, h * 0.5 + exhaustLen * enginePulse);
ctx.lineTo(-2 - Math.sin(time * 20) * 2, h * 0.5 + exhaustLen * enginePulse);
ctx.closePath();
ctx.fill();
// Boost aura
if (g.isBoosting) {
ctx.shadowColor = constants_1.COLORS.sunGlow;
ctx.shadowBlur = 30;
ctx.strokeStyle = `rgba(255,170,50,${enginePulse * 0.4})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, -h);
ctx.lineTo(-w / 2, h * 0.6);
ctx.lineTo(w / 2, h * 0.6);
ctx.closePath();
ctx.stroke();
}
ctx.restore();
}
// === DRAW PARTICLES (relative Z) ===
function drawParticles(g) {
for (const p of g.particles) {
const relZ = p.z; // Already relative to player
if (relZ <= 10 || relZ > constants_1.WORLD_DEPTH)
continue;
const scale = FOV / relZ;
const sx = constants_1.CANVAS_W / 2 + p.x * scale;
const sy = constants_1.CANVAS_H / 2 - (p.y - CAM_HEIGHT) * scale;
const size = Math.max(1, p.size * scale);
const alpha = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.fillRect(sx - size / 2, sy - size / 2, size, size);
}
ctx.globalAlpha = 1;
}
// === DRAW SPEED LINES ===
function drawSpeedLines(g) {
const effectiveSpeed = g.isBoosting ? g.speed * constants_1.BOOST_MULTIPLIER : g.speed;
if (effectiveSpeed < 500)
return;
const intensity = Math.min(1, (effectiveSpeed - 500) / 400);
ctx.save();
for (let i = 0; i < 20 * intensity; i++) {
const x = Math.random() * constants_1.CANVAS_W;
const y = Math.random() * constants_1.CANVAS_H;
const len = 30 + Math.random() * 60;
ctx.strokeStyle = constants_1.COLORS.speedLine + (0.1 + Math.random() * 0.2 * intensity) + ')';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
const angle = Math.atan2(y - constants_1.CANVAS_H / 2, x - constants_1.CANVAS_W / 2);
ctx.lineTo(x + Math.cos(angle) * len, y + Math.sin(angle) * len);
ctx.stroke();
}
// Chromatic aberration at very high speed
if (effectiveSpeed > 700) {
const caIntensity = (effectiveSpeed - 700) / 200;
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = `rgba(255,0,0,${caIntensity * 0.03})`;
ctx.fillRect(2, 0, constants_1.CANVAS_W, constants_1.CANVAS_H);
ctx.fillStyle = `rgba(0,0,255,${caIntensity * 0.03})`;
ctx.fillRect(-2, 0, constants_1.CANVAS_W, constants_1.CANVAS_H);
ctx.globalCompositeOperation = 'source-over';
}
ctx.restore();
}
// === DRAW NEAR-MISS FLASH ===
function drawNearMissFlash(g) {
if (g.nearMissFlash <= 0)
return;
const alpha = g.nearMissFlash * 0.3;
ctx.fillStyle = `rgba(255,200,100,${alpha})`;
ctx.fillRect(0, 0, constants_1.CANVAS_W, constants_1.CANVAS_H);
if (g.nearMissFlash > 0.1) {
ctx.save();
ctx.font = 'bold 24px system-ui';
ctx.fillStyle = `rgba(255,200,100,${alpha * 3})`;
ctx.textAlign = 'center';
ctx.fillText('CLOSE!', constants_1.CANVAS_W / 2, constants_1.CANVAS_H / 2 - 60);
ctx.restore();
}
}
// === DRAW DAMAGE FLASH ===
function drawDamageFlash(g) {
if (g.flashAlpha <= 0.01)
return;
ctx.fillStyle = `rgba(255,40,20,${g.flashAlpha})`;
ctx.fillRect(0, 0, constants_1.CANVAS_W, constants_1.CANVAS_H);
}
// === DRAW VIGNETTE ===
function drawVignette() {
const grad = ctx.createRadialGradient(constants_1.CANVAS_W / 2, constants_1.CANVAS_H / 2, constants_1.CANVAS_W * 0.3, constants_1.CANVAS_W / 2, constants_1.CANVAS_H / 2, constants_1.CANVAS_W * 0.8);
grad.addColorStop(0, 'transparent');
grad.addColorStop(1, 'rgba(0,0,0,0.4)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, constants_1.CANVAS_W, constants_1.CANVAS_H);
}
// === DRAW TITLE BACKGROUND ANIMATION ===
function drawTitleBackground() {
const time = performance.now() / 1000;
// Floating geometric shapes in background
ctx.save();
for (let i = 0; i < 8; i++) {
const x = constants_1.CANVAS_W * 0.2 + Math.sin(time * 0.3 + i * 1.2) * constants_1.CANVAS_W * 0.3;
const y = constants_1.CANVAS_H * 0.3 + Math.cos(time * 0.4 + i * 0.8) * constants_1.CANVAS_H * 0.25;
const size = 20 + Math.sin(time + i) * 10;
const alpha = 0.05 + Math.sin(time * 0.5 + i) * 0.03;
ctx.strokeStyle = `rgba(255,140,80,${alpha})`;
ctx.lineWidth = 1;
ctx.beginPath();
// Draw diamond shapes
ctx.moveTo(x, y - size);
ctx.lineTo(x + size * 0.7, y);
ctx.lineTo(x, y + size);
ctx.lineTo(x - size * 0.7, y);
ctx.closePath();
ctx.stroke();
}
// Subtle scan lines
for (let y = 0; y < constants_1.CANVAS_H; y += 4) {
ctx.fillStyle = `rgba(0,0,0,${0.02 + Math.sin(y * 0.1 + time) * 0.01})`;
ctx.fillRect(0, y, constants_1.CANVAS_W, 1);
}
ctx.restore();
}
// === MAIN RENDER ===
function render(g) {
// Apply screen shake
ctx.save();
ctx.translate(g.shakeX, g.shakeY);
drawSky(g);
drawSun(g);
drawGround(g);
drawObstacles(g);
drawPickups(g);
drawParticles(g);
drawPlayer(g);
drawSpeedLines(g);
drawNearMissFlash(g);
drawDamageFlash(g);
drawVignette();
ctx.restore();
// Title screen background animation
if (g.state === 'title') {
drawTitleBackground();
}
// Update HUD DOM elements
updateHUD(g);
}
// === UPDATE HUD ===
function updateHUD(g) {
if (g.state !== 'playing')
return;
const effectiveSpeed = g.isBoosting ? g.speed * constants_1.BOOST_MULTIPLIER : g.speed;
scoreDisplay.textContent = `SCORE: ${Math.floor(g.score).toLocaleString()}`;
distanceDisplay.textContent = `DISTANCE: ${Math.floor(g.distance / 10)}m`;
multiplierDisplay.textContent = `×${g.multiplier.toFixed(1)}`;
speedDisplay.textContent = `SPEED: ${Math.floor(effectiveSpeed)}`;
const energyPct = (g.boostEnergy / 100) * 100;
energyBarFill.style.width = `${energyPct}%`;
// Low energy visual feedback
if (g.boostEnergy < 25) {
energyBarFill.style.background = `linear-gradient(90deg, #ff2200, #ff4400)`;
energyBarContainer.classList.add('low-energy');
}
else {
energyBarFill.style.background = '';
energyBarContainer.classList.remove('low-energy');
}
// Boost indicator
if (g.isBoosting) {
energyBarContainer.classList.add('boosting');
}
else {
energyBarContainer.classList.remove('boosting');
}
// Sun warning when sun is getting low
if (g.sunY > 0.55) {
sunWarning.classList.remove('hidden');
}
else {
sunWarning.classList.add('hidden');
}
// Pulsing multiplier when chaining
if (g.chainTimer > 0) {
const pulse = Math.sin(performance.now() / 150) * 0.3 + 0.7;
multiplierDisplay.style.opacity = String(pulse);
}
else {
multiplierDisplay.style.opacity = '1';
}
}
// === COLOR UTILITIES ===
function lerpColor(a, b, t) {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
if (!ca || !cb)
return a;
const r = Math.round(ca.r + (cb.r - ca.r) * t);
const g2 = Math.round(ca.g + (cb.g - ca.g) * t);
const b2 = Math.round(ca.b + (cb.b - ca.b) * t);
return `rgb(${r},${g2},${b2})`;
}
function hexToRgb(hex) {
if (!hex.startsWith('#'))
return null;
const v = parseInt(hex.slice(1), 16);
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>