← back to Warp Starfield
index.html
359 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Warp Starfield Tunnel</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
cursor: crosshair;
-webkit-tap-highlight-color: transparent;
}
canvas { display: block; }
#hud {
position: fixed;
left: 14px;
bottom: 12px;
font: 11px/1.6 "SF Mono", Menlo, Consolas, monospace;
color: rgba(160, 200, 255, 0.55);
pointer-events: none;
user-select: none;
letter-spacing: 0.5px;
text-shadow: 0 0 6px rgba(80, 140, 255, 0.4);
white-space: nowrap;
}
#hud b { color: rgba(200, 225, 255, 0.9); font-weight: 600; }
#stats {
position: fixed;
right: 14px;
bottom: 12px;
font: 11px/1.6 "SF Mono", Menlo, Consolas, monospace;
color: rgba(160, 200, 255, 0.45);
pointer-events: none;
user-select: none;
text-align: right;
text-shadow: 0 0 6px rgba(80, 140, 255, 0.35);
}
#stats .val { color: rgba(200, 225, 255, 0.85); }
#speedbar {
position: fixed;
left: 14px;
bottom: 46px;
width: 160px;
height: 3px;
background: rgba(120, 160, 255, 0.15);
border-radius: 2px;
overflow: hidden;
pointer-events: none;
}
#speedfill {
height: 100%;
width: 20%;
background: linear-gradient(90deg, #3af, #aef);
border-radius: 2px;
box-shadow: 0 0 8px rgba(90, 180, 255, 0.8);
transition: background 0.25s;
}
#speedfill.boost { background: linear-gradient(90deg, #f6a, #fdb); box-shadow: 0 0 10px rgba(255, 140, 180, 0.9); }
/* Fade the chrome out when idle so the field is unobstructed */
#hud, #stats, #speedbar { transition: opacity 0.6s; }
body.idle #hud, body.idle #stats, body.idle #speedbar { opacity: 0.25; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="speedbar"><div id="speedfill"></div></div>
<div id="hud">WARP DRIVE — move mouse to steer · scroll to change speed · hold <b>space</b>/click for hyperspace</div>
<div id="stats"><span class="val" id="fps">--</span> fps · <span class="val" id="spd">--</span> ly/s</div>
<script>
(function () {
"use strict";
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d", { alpha: false });
var speedfill = document.getElementById("speedfill");
var fpsEl = document.getElementById("fps");
var spdEl = document.getElementById("spd");
var body = document.body;
var prefersReduced = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var W = 0, H = 0, CX = 0, CY = 0, DPR = 1;
// ---- config ----
var DEPTH = 1600; // z range (near..far)
var FOV = 320; // projection focal length
var MIN_SPEED = 2;
var MAX_SPEED = 90;
var BOOST_SPEED = 150; // hyperspace target
var STAR_DENSITY = 1800; // one star per this many screen px^2
var stars = [];
var NUM_STARS = 0;
function targetStarCount() {
// scale with screen area, clamped to a sane range
return Math.min(1400, Math.max(400, Math.floor((W * H) / STAR_DENSITY)));
}
function spawn(s, randomZ) {
// spread wide so steering reveals new stars at the edges
s.x = (Math.random() * 2 - 1) * DEPTH * 1.2;
s.y = (Math.random() * 2 - 1) * DEPTH * 1.2;
s.z = randomZ ? Math.random() * DEPTH : DEPTH;
s.px = null;
s.py = null;
s.hue = 200 + Math.random() * 60 - 30; // blue-white core palette
if (Math.random() < 0.08) s.hue = 30 + Math.random() * 30; // occasional gold star
s.mag = 0.5 + Math.random() * 1.0; // brightness / size multiplier
s.tw = Math.random() * 6.283; // twinkle phase
return s;
}
function buildStars() {
NUM_STARS = targetStarCount();
if (stars.length > NUM_STARS) {
stars.length = NUM_STARS;
} else {
while (stars.length < NUM_STARS) stars.push(spawn({}, true));
}
}
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
CX = W / 2;
CY = H / 2;
buildStars();
// hard reset so we don't smear stale pixels after a resize
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, W, H);
}
window.addEventListener("resize", resize);
resize();
// ---- state ----
var speed = 16; // scroll-controlled target speed
var curSpeed = 16; // smoothed speed actually rendered
var mouseX = 0, mouseY = 0; // -1..1 steer target
var steerX = 0, steerY = 0; // smoothed steer
var boosting = false;
var boostAmt = 0; // 0..1 eased boost
var idleTimer = null;
function wake() {
body.classList.remove("idle");
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(function () { body.classList.add("idle"); }, 2600);
}
wake();
// ---- input: steering ----
window.addEventListener("mousemove", function (e) {
mouseX = (e.clientX / W) * 2 - 1;
mouseY = (e.clientY / H) * 2 - 1;
wake();
});
// ---- input: speed via scroll ----
window.addEventListener("wheel", function (e) {
e.preventDefault();
speed += (e.deltaY < 0 ? 1 : -1) * (2 + speed * 0.12);
speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));
wake();
}, { passive: false });
// ---- input: hyperspace boost (hold) ----
function boostOn() { boosting = true; wake(); }
function boostOff() { boosting = false; }
window.addEventListener("mousedown", boostOn);
window.addEventListener("mouseup", boostOff);
window.addEventListener("keydown", function (e) {
if (e.code === "Space") { e.preventDefault(); boostOn(); }
else if (e.key === "ArrowUp") { speed = Math.min(MAX_SPEED, speed + 6); }
else if (e.key === "ArrowDown") { speed = Math.max(MIN_SPEED, speed - 6); }
});
window.addEventListener("keyup", function (e) {
if (e.code === "Space") boostOff();
});
// ---- input: touch (drag steers, two-finger vertical changes speed) ----
var lastTouchY = null;
window.addEventListener("touchstart", function (e) {
if (e.touches.length === 1) boostOn();
}, { passive: true });
window.addEventListener("touchmove", function (e) {
var t = e.touches[0];
mouseX = (t.clientX / W) * 2 - 1;
mouseY = (t.clientY / H) * 2 - 1;
if (e.touches.length === 2) {
boostOff();
if (lastTouchY !== null) {
speed += (lastTouchY - e.touches[1].clientY) * 0.3;
speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));
}
lastTouchY = e.touches[1].clientY;
}
wake();
e.preventDefault();
}, { passive: false });
window.addEventListener("touchend", function () { lastTouchY = null; boostOff(); });
window.addEventListener("contextmenu", function (e) { e.preventDefault(); });
// ---- pause when tab is hidden (and avoid a huge dt on return) ----
var running = true;
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
running = false;
} else if (!running) {
running = true;
lastT = performance.now();
requestAnimationFrame(frame);
}
});
// ---- fps sampling ----
var fpsAccum = 0, fpsFrames = 0, fpsLast = performance.now(), fpsShown = 0;
var lastT = performance.now();
function frame(now) {
if (!running) return;
var dt = Math.min((now - lastT) / 16.6667, 3); // normalize to 60fps units, clamp spikes
lastT = now;
// fps meter (updated ~4x/sec)
fpsFrames++;
if (now - fpsLast >= 250) {
fpsShown = Math.round((fpsFrames * 1000) / (now - fpsLast));
fpsFrames = 0;
fpsLast = now;
fpsEl.textContent = fpsShown;
}
// smooth boost + resulting speed target
boostAmt += ((boosting ? 1 : 0) - boostAmt) * 0.12 * dt;
var speedTarget = speed + (BOOST_SPEED - speed) * boostAmt;
curSpeed += (speedTarget - curSpeed) * 0.08 * dt;
var speedFrac = (curSpeed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED);
var displayFrac = Math.min(1.4, speedFrac); // may exceed 1 during boost
// smooth steering
steerX += (mouseX - steerX) * 0.05 * dt;
steerY += (mouseY - steerY) * 0.05 * dt;
// motion blur: translucent black wash — faster = less wash = longer streaks
var fade = 0.34 - Math.min(1, speedFrac + boostAmt * 0.6) * 0.26; // 0.34 slow .. 0.08 fast
ctx.fillStyle = "rgba(0,0,0," + fade.toFixed(3) + ")";
ctx.fillRect(0, 0, W, H);
// steering: shift the vanishing point toward the mouse for a "banking" feel,
// plus a gentle world-space cross-drift (kept subtle so it doesn't lurch)
var vpx = CX - steerX * W * 0.28;
var vpy = CY - steerY * H * 0.28;
var camVX = steerX * curSpeed * 6;
var camVY = steerY * curSpeed * 6;
ctx.lineCap = "round";
for (var i = 0; i < NUM_STARS; i++) {
var s = stars[i];
s.z -= curSpeed * dt;
s.x -= camVX * dt;
s.y -= camVY * dt;
if (s.z <= 1) { spawn(s, false); continue; }
var k = FOV / s.z;
var sx = vpx + s.x * k;
var sy = vpy + s.y * k;
// recycle stars that leave the viewport (with margin)
if (sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
spawn(s, false);
continue;
}
if (s.px !== null) {
var depthFrac = 1 - s.z / DEPTH; // 0 far .. 1 near
var size = (0.3 + depthFrac * 2.2) * s.mag;
// twinkle only matters for slow, distant stars
var twinkle = 1 - 0.25 * (1 - depthFrac) * (0.5 + 0.5 * Math.sin(s.tw += 0.15 * dt));
var alpha = Math.min(1, (0.14 + depthFrac * 0.95) * (0.6 + 0.4 * s.mag)) * twinkle;
// hotter / whiter at speed
var sat = Math.max(0, 72 - (speedFrac + boostAmt) * 50);
var light = 60 + depthFrac * 30 + Math.min(1, speedFrac + boostAmt) * 10;
ctx.strokeStyle = "hsla(" + s.hue + "," + sat + "%," + light + "%," + alpha.toFixed(3) + ")";
ctx.lineWidth = size;
ctx.beginPath();
ctx.moveTo(s.px, s.py);
ctx.lineTo(sx, sy);
ctx.stroke();
// bright core dot on the nearest stars
if (depthFrac > 0.72) {
ctx.fillStyle = "hsla(" + s.hue + ",30%,96%," + (alpha * 0.9).toFixed(3) + ")";
ctx.beginPath();
ctx.arc(sx, sy, size * 0.55, 0, 6.2832);
ctx.fill();
}
}
s.px = sx;
s.py = sy;
}
// central glow that intensifies with speed / boost
var glow = Math.min(1, speedFrac + boostAmt * 0.8);
if (glow > 0.05) {
var glowR = Math.max(60, Math.min(W, H) * (0.1 + glow * 0.4));
var g = ctx.createRadialGradient(vpx, vpy, 0, vpx, vpy, glowR);
var tint = boostAmt > 0.3 ? "255,170,200" : "150,190,255";
g.addColorStop(0, "rgba(" + tint + "," + (0.11 * glow).toFixed(3) + ")");
g.addColorStop(1, "rgba(" + tint + ",0)");
ctx.fillStyle = g;
ctx.fillRect(vpx - glowR, vpy - glowR, glowR * 2, glowR * 2);
}
// HUD readouts
speedfill.style.width = (Math.min(1, displayFrac) * 100).toFixed(1) + "%";
if (boostAmt > 0.15) speedfill.classList.add("boost");
else speedfill.classList.remove("boost");
spdEl.textContent = (curSpeed / 3).toFixed(1);
requestAnimationFrame(frame);
}
// If the user prefers reduced motion, start slow and don't auto-race.
if (prefersReduced) { speed = 6; curSpeed = 6; }
requestAnimationFrame(frame);
})();
</script>
</body>
</html>