Void Voyager - 3D Space Explorer
by LunarGalaxy87764 lines31.0 KB🛠️ Three.js (3D graphics)
<!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">
<title>Void Voyager - 3D Space Explorer</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color: transparent; }
html, body { width:100%; height:100%; overflow:hidden; background:#000; font-family:-apple-system,Helvetica,Arial,sans-serif; touch-action:none; }
canvas { display:block; }
#hud {
position:fixed; top:0; left:0; width:100%; pointer-events:none; z-index:10;
padding:14px 16px; display:flex; justify-content:space-between; align-items:flex-start;
}
#stats {
color:#9be7ff; font-size:12px; line-height:1.6; text-shadow:0 0 6px rgba(80,180,255,0.8);
background:rgba(0,10,25,0.35); padding:8px 12px; border-radius:10px; border:1px solid rgba(120,200,255,0.25);
backdrop-filter: blur(4px);
}
#stats b { color:#fff; }
#speedWrap {
color:#9be7ff; font-size:11px; text-align:right;
background:rgba(0,10,25,0.35); padding:8px 12px; border-radius:10px; border:1px solid rgba(120,200,255,0.25);
}
#fuelBarBg { width:110px; height:6px; background:rgba(255,255,255,0.15); border-radius:4px; margin-top:6px; overflow:hidden; }
#fuelBar { height:100%; width:100%; background:linear-gradient(90deg,#00e5ff,#5cffb0); transition:width .2s; }
#crosshair {
position:fixed; top:50%; left:50%; width:16px; height:16px; margin:-8px 0 0 -8px;
border:1.5px solid rgba(150,220,255,0.55); border-radius:50%; pointer-events:none; z-index:9;
}
#crosshair::after {
content:''; position:absolute; top:50%; left:50%; width:2px; height:2px; background:rgba(150,220,255,0.9);
margin:-1px 0 0 -1px; border-radius:50%;
}
#msg {
position:fixed; top:14%; left:50%; transform:translateX(-50%); z-index:11;
color:#e5fbff; font-size:15px; text-shadow:0 0 10px rgba(80,200,255,0.9);
background:rgba(0,15,30,0.5); padding:8px 18px; border-radius:20px; border:1px solid rgba(120,200,255,0.3);
pointer-events:none; opacity:0; transition:opacity .4s; white-space:nowrap;
}
#joystick {
position:fixed; bottom:22px; left:22px; width:150px; height:150px; z-index:10;
border-radius:50%; background:rgba(120,200,255,0.08); border:1.5px solid rgba(120,200,255,0.3);
touch-action:none;
}
#joyStick {
position:absolute; width:64px; height:64px; border-radius:50%; left:43px; top:43px;
background:radial-gradient(circle at 35% 30%, #8fe6ff, #1a7fb0); box-shadow:0 0 14px rgba(80,200,255,0.7);
}
#rightControls {
position:fixed; bottom:26px; right:26px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:10px;
}
.btn {
width:72px; height:72px; border-radius:50%; border:1.5px solid rgba(120,200,255,0.4);
background:rgba(120,200,255,0.1); color:#cdf3ff; font-size:12px; font-weight:600;
display:flex; align-items:center; justify-content:center; text-align:center; user-select:none;
backdrop-filter: blur(3px);
}
.btn:active, .btn.active { background:rgba(120,220,255,0.35); box-shadow:0 0 16px rgba(80,200,255,0.6); }
#boostBtn { width:88px; height:88px; font-size:13px; }
#vertRow { display:flex; gap:12px; }
#upBtn, #downBtn { width:60px; height:60px; font-size:12px; }
#startScreen {
position:fixed; inset:0; z-index:100; background:radial-gradient(ellipse at center, #04121f 0%, #000005 80%);
display:flex; flex-direction:column; align-items:center; justify-content:center; color:#e5fbff; text-align:center; padding:20px;
}
#startScreen h1 {
font-size:32px; letter-spacing:3px; text-shadow:0 0 20px rgba(80,200,255,0.9); margin-bottom:10px;
}
#startScreen p { font-size:13px; color:#8fd4ee; max-width:280px; line-height:1.7; margin-bottom:26px; }
#startBtn {
padding:14px 40px; font-size:16px; letter-spacing:2px; color:#00151f; font-weight:700;
background:linear-gradient(135deg,#5cffb0,#00c3ff); border:none; border-radius:30px;
box-shadow:0 0 30px rgba(0,200,255,0.6);
}
#hint { position:absolute; bottom:24px; font-size:10px; color:#4a7a94; }
</style>
</head>
<body>
<div id="startScreen">
<h1>VOID VOYAGER</h1>
<p>Pilot your ship through deep space. Collect glowing crystals for fuel, dodge asteroids, and discover alien planets and stations scattered across the void.</p>
<button id="startBtn">LAUNCH 🚀</button>
<div id="hint">Left joystick: steer | Right buttons: boost / altitude</div>
</div>
<div id="hud" style="display:none;">
<div id="stats">
Crystals: <b id="crystalCount">0</b><br>
Sector: <b id="sectorName">Kepler Field</b><br>
Nearest: <b id="nearestPOI">—</b>
</div>
<div id="speedWrap">
SPEED<br><span id="speedVal" style="color:#fff;font-size:14px;">0</span><br>
FUEL
<div id="fuelBarBg"><div id="fuelBar"></div></div>
</div>
</div>
<div id="crosshair" style="display:none;"></div>
<div id="msg"></div>
<div id="planetLabel" style="display:none; position:fixed; top:14px; left:50%; transform:translateX(-50%); z-index:11; color:#fff; font-size:13px; letter-spacing:3px; text-shadow:0 0 12px rgba(80,200,255,0.9); background:rgba(0,10,25,0.4); padding:6px 18px; border-radius:16px; border:1px solid rgba(120,200,255,0.3);">PLANET</div>
<div id="exitBtn" class="btn" style="display:none; position:fixed; top:14px; right:14px; width:60px; height:60px; font-size:9px; z-index:12;">EXIT<br>▲</div>
<div id="joystick" style="display:none;"><div id="joyStick"></div></div>
<div id="rightControls" style="display:none;">
<div id="boostBtn" class="btn">BOOST</div>
<div id="vertRow">
<div id="upBtn" class="btn">▲</div>
<div id="downBtn" class="btn">▼</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
let scene, camera, renderer, ship, shipGroup;
let crystals = [], asteroids = [], planets = [], stations = [];
let crystalCount = 0, fuel = 100, speed = 0;
const maxSpeed = 2.2, baseAccel = 0.045;
let boosting = false, moveUp = false, moveDown = false;
let joyVec = {x:0, y:0};
let velocity = new THREE.Vector3();
let shipQuat = new THREE.Quaternion();
let targetPitch = 0, targetYaw = 0, targetRoll = 0;
let stars, starField2;
let running = false;
let clock = new THREE.Clock();
let msgTimeout;
const WORLD_SIZE = 900;
function showMsg(text, dur){
const m = document.getElementById('msg');
m.textContent = text;
m.style.opacity = '1';
clearTimeout(msgTimeout);
msgTimeout = setTimeout(()=>{ m.style.opacity='0'; }, dur||2200);
}
function init(){
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000008, 0.0016);
camera = new THREE.PerspectiveCamera(70, window.innerWidth/window.innerHeight, 0.1, 3000);
renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000005);
document.body.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.AmbientLight(0x334466, 0.9));
const sun = new THREE.DirectionalLight(0xfff2d6, 1.3);
sun.position.set(300, 200, 100);
scene.add(sun);
const rim = new THREE.PointLight(0x3a9fff, 1.2, 800);
rim.position.set(-200,-100,-200);
scene.add(rim);
buildStarfield();
buildShip();
buildWorld();
window.addEventListener('resize', onResize);
}
function buildStarfield(){
function makeLayer(count, spread, size, color){
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count*3);
for(let i=0;i<count;i++){
pos[i*3] = (Math.random()-0.5)*spread;
pos[i*3+1] = (Math.random()-0.5)*spread;
pos[i*3+2] = (Math.random()-0.5)*spread;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
const mat = new THREE.PointsMaterial({ color:color, size:size, transparent:true, opacity:0.9, sizeAttenuation:true });
return new THREE.Points(geo, mat);
}
stars = makeLayer(3500, 2600, 1.6, 0xffffff);
starField2 = makeLayer(1500, 1800, 3.2, 0x9fd8ff);
scene.add(stars, starField2);
}
function buildShip(){
shipGroup = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color:0xd8dfe8, metalness:0.7, roughness:0.3 });
const accentMat = new THREE.MeshStandardMaterial({ color:0x00c3ff, emissive:0x0088cc, emissiveIntensity:0.6, metalness:0.3, roughness:0.4 });
const engineMat = new THREE.MeshStandardMaterial({ color:0x222831, metalness:0.8, roughness:0.35 });
// Hull
const hullGeo = new THREE.ConeGeometry(0.9, 3.2, 8);
const hull = new THREE.Mesh(hullGeo, bodyMat);
hull.rotation.x = Math.PI/2;
shipGroup.add(hull);
// Cockpit
const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.45, 12, 12), accentMat);
cockpit.position.set(0,0.25,0.6);
cockpit.scale.set(1,0.8,1.3);
shipGroup.add(cockpit);
// Wings
const wingGeo = new THREE.BoxGeometry(2.6, 0.08, 1.1);
const wingL = new THREE.Mesh(wingGeo, bodyMat);
wingL.position.set(-1.5, -0.05, -0.3);
wingL.rotation.z = 0.15;
shipGroup.add(wingL);
const wingR = wingL.clone();
wingR.position.x = 1.5;
wingR.rotation.z = -0.15;
shipGroup.add(wingR);
// wing tips accent
[wingL, wingR].forEach((w,i)=>{
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.15,0.12,1.1), accentMat);
tip.position.set(i===0?-1.25:1.25, 0, 0);
w.add(tip);
});
// Engines
const engGeo = new THREE.CylinderGeometry(0.28,0.32,0.9,10);
const engL = new THREE.Mesh(engGeo, engineMat);
engL.rotation.x = Math.PI/2;
engL.position.set(-0.6,-0.1,-1.4);
shipGroup.add(engL);
const engR = engL.clone(); engR.position.x = 0.6;
shipGroup.add(engR);
// Engine glow
const glowGeo = new THREE.SphereGeometry(0.22, 8, 8);
const glowMat = new THREE.MeshBasicMaterial({ color:0x66ffff });
const glowL = new THREE.Mesh(glowGeo, glowMat);
glowL.position.set(-0.6,-0.1,-1.85);
shipGroup.add(glowL);
const glowR = glowL.clone(); glowR.position.x = 0.6;
shipGroup.add(glowR);
shipGroup.userData.engineGlows = [glowL, glowR];
shipGroup.position.set(0, 0, 0);
scene.add(shipGroup);
ship = shipGroup;
}
function randRange(a,b){ return a + Math.random()*(b-a); }
function buildWorld(){
// Crystals (fuel pickups)
const crystalGeo = new THREE.OctahedronGeometry(1.1, 0);
for(let i=0;i<60;i++){
const mat = new THREE.MeshStandardMaterial({
color:0x5cffb0, emissive:0x18ffb0, emissiveIntensity:0.9, metalness:0.2, roughness:0.15,
transparent:true, opacity:0.92
});
const c = new THREE.Mesh(crystalGeo, mat);
c.position.set(randRange(-WORLD_SIZE,WORLD_SIZE), randRange(-200,200), randRange(-WORLD_SIZE,WORLD_SIZE));
c.userData.spin = randRange(0.01,0.03);
c.userData.bobPhase = Math.random()*Math.PI*2;
c.userData.basey = c.position.y;
scene.add(c);
crystals.push(c);
}
// Asteroids
const astGeo = new THREE.IcosahedronGeometry(1, 0);
for(let i=0;i<90;i++){
const mat = new THREE.MeshStandardMaterial({ color:0x8a7f75, roughness:0.9, metalness:0.05, flatShading:true });
const a = new THREE.Mesh(astGeo, mat);
const s = randRange(2,9);
a.scale.set(s, s*randRange(0.7,1.2), s);
a.position.set(randRange(-WORLD_SIZE,WORLD_SIZE), randRange(-150,150), randRange(-WORLD_SIZE,WORLD_SIZE));
a.rotation.set(Math.random()*6,Math.random()*6,Math.random()*6);
a.userData.rotSpeed = new THREE.Vector3(randRange(-0.01,0.01),randRange(-0.01,0.01),randRange(-0.01,0.01));
scene.add(a);
asteroids.push(a);
}
// Planets - the real solar system, compressed distances/sizes for flyable scale
const planetData = [
{ name:'Mercury', color:0x9c8f80, size:22, pos:[260,10,-120], sky:0x0a0a0f, ground:0x8a7f70, weather:'none', desc:'Scorched, airless, cratered.' },
{ name:'Venus', color:0xe0c07a, size:34, pos:[-320,-20,180], sky:0xc9a24a, ground:0xb08a3f, weather:'haze', desc:'Thick toxic clouds, crushing heat.' },
{ name:'Earth', color:0x3f8fe0, size:36, pos:[420,30,340], sky:0x74c6f0, ground:0x4fa85a, weather:'rain', desc:'Home. Blue skies, green land.' },
{ name:'Mars', color:0xc1592f, size:28, pos:[-460,15,-260], sky:0xd99a6c, ground:0xa8532c, weather:'dust', desc:'Rusty deserts, thin cold air.' },
{ name:'Jupiter', color:0xd9b38c, size:95, pos:[600,-40,600], sky:0xcfa876, ground:0xb8895a, weather:'storm', desc:'Gas giant, endless raging storms.' },
{ name:'Saturn', color:0xe3d2a0, size:82, pos:[-680,50,540], sky:0xdcc98f, ground:0xc9b378, weather:'wind', desc:'Ringed giant, golden hazy bands.', ring:true },
{ name:'Uranus', color:0x8fd6e0, size:56, pos:[720,20,-620], sky:0x8fe8ec, ground:0x6bc4cc, weather:'ice', desc:'Tilted ice giant, pale cyan.' },
{ name:'Neptune', color:0x4a6fe0, size:54, pos:[-760,-30,-680], sky:0x3f5fd0, ground:0x3550a8, weather:'wind', desc:'Deep blue, supersonic winds.' },
];
planetData.forEach(pd=>{
const geo = new THREE.SphereGeometry(pd.size, 32, 32);
const mat = new THREE.MeshStandardMaterial({ color:pd.color, roughness:0.85, metalness:0.05 });
const p = new THREE.Mesh(geo, mat);
p.position.set(...pd.pos);
p.userData = pd;
scene.add(p);
// subtle atmosphere glow
const glowGeo = new THREE.SphereGeometry(pd.size*1.06, 24, 24);
const glowMat = new THREE.MeshBasicMaterial({ color:pd.color, transparent:true, opacity:0.15, side:THREE.BackSide });
const glow = new THREE.Mesh(glowGeo, glowMat);
p.add(glow);
if(pd.ring){
const ringGeo = new THREE.RingGeometry(pd.size*1.3, pd.size*2.1, 48);
const ringMat = new THREE.MeshBasicMaterial({ color:0xcbb98a, side:THREE.DoubleSide, transparent:true, opacity:0.55 });
const ringMesh = new THREE.Mesh(ringGeo, ringMat);
ringMesh.rotation.x = Math.PI/2 - 0.35;
p.add(ringMesh);
}
planets.push(p);
});
// Space stations
for(let i=0;i<4;i++){
const st = new THREE.Group();
const coreMat = new THREE.MeshStandardMaterial({ color:0xaab4c2, metalness:0.8, roughness:0.3 });
const accentMat = new THREE.MeshStandardMaterial({ color:0xffaa33, emissive:0xff8800, emissiveIntensity:0.7 });
const core = new THREE.Mesh(new THREE.CylinderGeometry(6,6,4,16), coreMat);
core.rotation.x = Math.PI/2;
st.add(core);
const ring = new THREE.Mesh(new THREE.TorusGeometry(14,1,8,24), accentMat);
st.add(ring);
const ring2 = ring.clone(); ring2.rotation.x = Math.PI/2;
st.add(ring2);
st.position.set(randRange(-700,700), randRange(-100,100), randRange(-700,700));
st.userData.name = 'Station ' + (i+1);
st.userData.rotSpeed = 0.004;
scene.add(st);
stations.push(st);
}
}
// ---------- Planet Landing (surface exploration) ----------
let onSurface = false;
let surfaceGroup = null;
let weatherParticles = null;
let currentPlanet = null;
let spaceReturnPos = new THREE.Vector3();
let terrainMesh = null;
function noise2(x,y){
// cheap pseudo-noise for terrain height
return Math.sin(x*0.08)*Math.cos(y*0.09) + Math.sin(x*0.02+y*0.03)*1.6 + Math.sin(x*0.15+7)*0.4*Math.cos(y*0.13);
}
function buildSurface(pd){
const group = new THREE.Group();
// Terrain
const size = 1000, seg = 90;
const tGeo = new THREE.PlaneGeometry(size, size, seg, seg);
tGeo.rotateX(-Math.PI/2);
const posAttr = tGeo.attributes.position;
for(let i=0;i<posAttr.count;i++){
const x = posAttr.getX(i), z = posAttr.getZ(i);
const h = noise2(x,z) * (pd.weather==='storm' ? 2 : 9);
posAttr.setY(i, h);
}
tGeo.computeVertexNormals();
const tMat = new THREE.MeshStandardMaterial({ color:pd.ground, roughness:0.95, metalness:0.02, flatShading:true });
const terrain = new THREE.Mesh(tGeo, tMat);
group.add(terrain);
terrainMesh = terrain;
// scatter surface rocks
const rockGeo = new THREE.DodecahedronGeometry(1,0);
for(let i=0;i<70;i++){
const rockMat = new THREE.MeshStandardMaterial({ color:pd.ground, roughness:1, flatShading:true });
const r = new THREE.Mesh(rockGeo, rockMat);
const x = randRange(-size/2,size/2), z = randRange(-size/2,size/2);
r.position.set(x, noise2(x,z)*(pd.weather==='storm'?2:9), z);
const s = randRange(0.6,3.5);
r.scale.set(s,s*randRange(0.6,1.3),s);
r.rotation.set(Math.random()*6,Math.random()*6,Math.random()*6);
group.add(r);
}
// sky dome
const skyGeo = new THREE.SphereGeometry(700, 24, 24);
const skyMat = new THREE.MeshBasicMaterial({ color:pd.sky, side:THREE.BackSide });
const sky = new THREE.Mesh(skyGeo, skyMat);
group.add(sky);
// sun-like light + ambient tuned per planet
const amb = new THREE.AmbientLight(0xffffff, pd.weather==='storm' ? 0.55 : 0.85);
group.add(amb);
const sun = new THREE.DirectionalLight(0xfff2d6, pd.weather==='haze' ? 0.6 : 1.1);
sun.position.set(200,300,100);
group.add(sun);
// weather particles
let weatherMesh = null;
if(pd.weather === 'rain' || pd.weather === 'ice'){
const count = 1200;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count*3);
for(let i=0;i<count;i++){
pos[i*3]=randRange(-300,300); pos[i*3+1]=randRange(0,300); pos[i*3+2]=randRange(-300,300);
}
geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
const mat = new THREE.PointsMaterial({ color: pd.weather==='ice'?0xdff5ff:0xaee0ff, size: pd.weather==='ice'?2.2:0.8, transparent:true, opacity:0.75 });
weatherMesh = new THREE.Points(geo, mat);
weatherMesh.userData.fallSpeed = pd.weather==='ice' ? 25 : 70;
group.add(weatherMesh);
} else if(pd.weather === 'dust' || pd.weather === 'storm' || pd.weather === 'wind' || pd.weather === 'haze'){
const count = 900;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count*3);
for(let i=0;i<count;i++){
pos[i*3]=randRange(-300,300); pos[i*3+1]=randRange(2,120); pos[i*3+2]=randRange(-300,300);
}
geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
const col = pd.weather==='dust' ? 0xd9a066 : (pd.weather==='storm' ? 0xf0e0b0 : (pd.weather==='haze' ? 0xe0c07a : 0xffffff));
const mat = new THREE.PointsMaterial({ color: col, size:1.6, transparent:true, opacity:0.55 });
weatherMesh = new THREE.Points(geo, mat);
weatherMesh.userData.driftSpeed = pd.weather==='storm' ? 90 : 40;
group.add(weatherMesh);
}
weatherParticles = weatherMesh;
scene.fog = new THREE.FogExp2(pd.sky, 0.0022);
return group;
}
function enterPlanet(p){
if(onSurface) return;
onSurface = true;
currentPlanet = p;
spaceReturnPos.copy(ship.position);
// hide space-only objects
stars.visible = false; starField2.visible = false;
planets.forEach(pl=>pl.visible=false);
asteroids.forEach(a=>a.visible=false);
crystals.forEach(c=>c.visible=false);
stations.forEach(s=>s.visible=false);
surfaceGroup = buildSurface(p.userData);
scene.add(surfaceGroup);
ship.position.set(0, 60, 0);
ship.rotation.set(0.15, 0, 0);
speed = 0.4;
document.getElementById('exitBtn').style.display = 'flex';
document.getElementById('planetLabel').textContent = p.userData.name.toUpperCase();
document.getElementById('planetLabel').style.display = 'block';
showMsg('Entering ' + p.userData.name + ' — ' + p.userData.desc, 3200);
}
function exitPlanet(){
if(!onSurface) return;
onSurface = false;
scene.remove(surfaceGroup);
surfaceGroup = null;
weatherParticles = null;
terrainMesh = null;
stars.visible = true; starField2.visible = true;
planets.forEach(pl=>pl.visible=true);
asteroids.forEach(a=>a.visible=true);
crystals.forEach(c=>{ if(c.userData.wasVisible!==false) c.visible=true; });
stations.forEach(s=>s.visible=true);
scene.fog = new THREE.FogExp2(0x000008, 0.0016);
// place ship just outside the planet it came from
const dir = spaceReturnPos.clone().sub(currentPlanet.position).normalize();
if(dir.lengthSq() < 0.001) dir.set(0,0,1);
ship.position.copy(currentPlanet.position).addScaledVector(dir, currentPlanet.userData.size + 30);
speed = 0.5;
document.getElementById('exitBtn').style.display = 'none';
document.getElementById('planetLabel').style.display = 'none';
showMsg('Departing ' + currentPlanet.userData.name, 1800);
currentPlanet = null;
}
function terrainHeightAt(x,z, weather){
return noise2(x,z) * (weather==='storm' ? 2 : 9);
}
// ---------- Controls ----------
const joystick = document.getElementById('joystick');
const joyStick = document.getElementById('joyStick');
let joyActive = false, joyTouchId = null, joyCenter = {x:0,y:0};
function joyStart(x,y,id){
joyActive = true; joyTouchId = id;
const rect = joystick.getBoundingClientRect();
joyCenter = { x: rect.left+rect.width/2, y: rect.top+rect.height/2 };
joyMove(x,y);
}
function joyMove(x,y){
if(!joyActive) return;
let dx = x - joyCenter.x, dy = y - joyCenter.y;
const max = 50;
const dist = Math.min(Math.sqrt(dx*dx+dy*dy), max);
const angle = Math.atan2(dy,dx);
const cx = Math.cos(angle)*dist, cy = Math.sin(angle)*dist;
joyStick.style.left = (43+cx) + 'px';
joyStick.style.top = (43+cy) + 'px';
// small deadzone + slightly boosted response curve so light nudges still register
const nx = cx/max, ny = cy/max;
const dz = 0.08;
joyVec.x = Math.abs(nx) < dz ? 0 : Math.sign(nx) * ((Math.abs(nx)-dz)/(1-dz));
joyVec.y = Math.abs(ny) < dz ? 0 : Math.sign(ny) * ((Math.abs(ny)-dz)/(1-dz));
}
function joyEnd(){
joyActive = false; joyTouchId = null;
joyVec.x = 0; joyVec.y = 0;
joyStick.style.left = '43px';
joyStick.style.top = '43px';
}
// widen the actual grab zone so thumb doesn't need to land precisely on the circle
document.addEventListener('touchstart', e=>{
if(joyActive) return;
const t = e.changedTouches[0];
if(t.clientX < 210 && t.clientY > window.innerHeight-260){
e.preventDefault();
joyStart(t.clientX, t.clientY, t.identifier);
}
}, {passive:false});
joystick.addEventListener('touchstart', e=>{
e.preventDefault();
const t = e.changedTouches[0];
joyStart(t.clientX, t.clientY, t.identifier);
}, {passive:false});
joystick.addEventListener('touchmove', e=>{
e.preventDefault();
for(const t of e.changedTouches){ if(t.identifier===joyTouchId) joyMove(t.clientX,t.clientY); }
}, {passive:false});
joystick.addEventListener('touchend', e=>{
for(const t of e.changedTouches){ if(t.identifier===joyTouchId) joyEnd(); }
});
// mouse fallback (desktop testing)
joystick.addEventListener('mousedown', e=>{ joyStart(e.clientX,e.clientY,'m'); });
window.addEventListener('mousemove', e=>{ if(joyActive) joyMove(e.clientX,e.clientY); });
window.addEventListener('mouseup', ()=>{ if(joyActive) joyEnd(); });
function bindBtn(el, onStart, onEnd){
el.addEventListener('touchstart', e=>{ e.preventDefault(); el.classList.add('active'); onStart(); }, {passive:false});
el.addEventListener('touchend', e=>{ e.preventDefault(); el.classList.remove('active'); onEnd(); });
el.addEventListener('mousedown', ()=>{ el.classList.add('active'); onStart(); });
el.addEventListener('mouseup', ()=>{ el.classList.remove('active'); onEnd(); });
el.addEventListener('mouseleave', ()=>{ el.classList.remove('active'); onEnd(); });
}
bindBtn(document.getElementById('boostBtn'), ()=>boosting=true, ()=>boosting=false);
bindBtn(document.getElementById('upBtn'), ()=>moveUp=true, ()=>moveUp=false);
bindBtn(document.getElementById('downBtn'), ()=>moveDown=true, ()=>moveDown=false);
document.getElementById('exitBtn').addEventListener('click', exitPlanet);
document.getElementById('exitBtn').addEventListener('touchstart', e=>{ e.preventDefault(); exitPlanet(); }, {passive:false});
// keyboard fallback for desktop testing
const keys = {};
window.addEventListener('keydown', e=>keys[e.key.toLowerCase()]=true);
window.addEventListener('keyup', e=>keys[e.key.toLowerCase()]=false);
function onResize(){
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// ---------- Game loop ----------
const cameraOffset = new THREE.Vector3(0, 2.2, -7);
const cameraLookOffset = new THREE.Vector3(0, 0.5, 6);
function updateShip(dt){
let jx = joyVec.x, jy = joyVec.y;
if(keys['a']) jx = -1; if(keys['d']) jx = 1;
if(keys['w']) jy = -1; if(keys['s']) jy = 1;
if(keys['shift']) boosting = true;
if(keys[' ']) moveUp = true;
if(keys['control']) moveDown = true;
targetYaw = -jx * 1.6;
targetPitch = jy * 1.0;
targetRoll = -jx * 0.9;
const accel = boosting ? baseAccel*2.1 : baseAccel;
const targetSpeedCap = boosting ? maxSpeed*1.9 : maxSpeed;
if(fuel <= 0 && boosting){ boosting = false; }
// gentle constant auto-forward thrust, so you don't have to fight to build speed
speed += accel*dt*60*0.35;
speed = Math.min(speed, targetSpeedCap);
if(!boosting) speed = Math.min(speed, maxSpeed);
speed *= 0.995;
speed = Math.max(speed, 0.35); // always keep some cruising speed
if(boosting && fuel>0){ fuel -= dt*14; if(fuel<0) fuel=0; }
else { fuel = Math.min(100, fuel + dt*3); }
// snappier, more responsive rotation toward target
ship.rotation.y += ( -jx*0.075 );
ship.rotation.x += ( -jy*0.05 );
ship.rotation.z += ( (-jx*0.9 - ship.rotation.z) * 0.12 );
ship.rotation.x = THREE.MathUtils.clamp(ship.rotation.x, -0.6, 0.6);
// move forward along ship's local -z... using its facing
const forward = new THREE.Vector3(0,0,1).applyEuler(ship.rotation);
ship.position.addScaledVector(forward, speed);
if(moveUp) ship.position.y += 0.6*dt*60;
if(moveDown) ship.position.y -= 0.6*dt*60;
// bounds - wrap around world softly
const B = WORLD_SIZE+50;
['x','z'].forEach(ax=>{
if(ship.position[ax] > B) ship.position[ax] = -B;
if(ship.position[ax] < -B) ship.position[ax] = B;
});
ship.position.y = THREE.MathUtils.clamp(ship.position.y, -220, 220);
// engine glow pulse
const pulse = boosting ? (1.2+Math.sin(Date.now()*0.03)*0.3) : (0.7+Math.sin(Date.now()*0.01)*0.15);
ship.userData.engineGlows.forEach(g=>g.scale.setScalar(pulse));
document.getElementById('speedVal').textContent = (speed*30).toFixed(0);
document.getElementById('fuelBar').style.width = fuel + '%';
}
function updateCamera(){
const offset = cameraOffset.clone().applyEuler(ship.rotation);
const desired = ship.position.clone().add(offset);
camera.position.lerp(desired, 0.12);
const lookAt = ship.position.clone().add(cameraLookOffset.clone().applyEuler(ship.rotation));
camera.lookAt(lookAt);
}
let nearestPOIName = '—';
function updateWorld(dt){
if(onSurface){
// weather particle animation
if(weatherParticles){
const pos = weatherParticles.geometry.attributes.position;
if(weatherParticles.userData.fallSpeed){
for(let i=0;i<pos.count;i++){
let y = pos.getY(i) - weatherParticles.userData.fallSpeed*dt;
if(y < 0) y = 300;
pos.setY(i, y);
}
} else if(weatherParticles.userData.driftSpeed){
for(let i=0;i<pos.count;i++){
let x = pos.getX(i) + weatherParticles.userData.driftSpeed*dt;
if(x > 300) x = -300;
pos.setX(i, x);
}
}
pos.needsUpdate = true;
}
// keep ship above terrain
if(terrainMesh && currentPlanet){
const groundY = terrainHeightAt(ship.position.x, ship.position.z, currentPlanet.userData.weather) + 4;
if(ship.position.y < groundY){ ship.position.y = groundY; speed *= 0.6; }
// soft world bounds on surface
const B = 480;
ship.position.x = THREE.MathUtils.clamp(ship.position.x, -B, B);
ship.position.z = THREE.MathUtils.clamp(ship.position.z, -B, B);
}
document.getElementById('nearestPOI').textContent = currentPlanet ? currentPlanet.userData.desc : '—';
return;
}
crystals.forEach((c,i)=>{
if(!c.visible) return;
c.rotation.y += c.userData.spin;
c.rotation.x += c.userData.spin*0.6;
c.position.y = c.userData.basey + Math.sin(Date.now()*0.001 + c.userData.bobPhase)*1.5;
if(c.position.distanceTo(ship.position) < 5.5){
c.visible = false;
crystalCount++;
fuel = Math.min(100, fuel+18);
document.getElementById('crystalCount').textContent = crystalCount;
showMsg('+ Crystal collected ⛽ Fuel restored', 1400);
setTimeout(()=>{
if(crystals.filter(c=>c.visible).length < 20){
c.position.set(randRange(-WORLD_SIZE,WORLD_SIZE), randRange(-200,200), randRange(-WORLD_SIZE,WORLD_SIZE));
c.userData.basey = c.position.y;
c.visible = true;
}
}, 6000);
}
});
asteroids.forEach(a=>{
a.rotation.x += a.userData.rotSpeed.x;
a.rotation.y += a.userData.rotSpeed.y;
a.rotation.z += a.userData.rotSpeed.z;
const d = a.position.distanceTo(ship.position);
if(d < (a.scale.x*0.9 + 2.2)){
// bump the ship away
const push = ship.position.clone().sub(a.position).normalize().multiplyScalar(2.2);
ship.position.add(push);
speed *= 0.4;
showMsg('⚠ Asteroid collision!', 1000);
}
});
stations.forEach(st=>{ st.rotation.y += st.userData.rotSpeed; });
// nearest POI
let nearestDist = Infinity, nearestName = '—';
[...planets, ...stations].forEach(p=>{
const d = p.position.distanceTo(ship.position);
if(d < nearestDist){ nearestDist = d; nearestName = p.userData.name; }
});
if(nearestDist < 3000){
document.getElementById('nearestPOI').textContent = nearestName + ' (' + nearestDist.toFixed(0) + 'u)';
} else {
document.getElementById('nearestPOI').textContent = '—';
}
// check for planet entry
planets.forEach(p=>{
const d = p.position.distanceTo(ship.position);
if(d < p.userData.size + 6){
enterPlanet(p);
}
});
stars.rotation.y += 0.00006;
starField2.rotation.y -= 0.00003;
}
function animate(){
if(!running) return;
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
updateShip(dt);
updateWorld(dt);
updateCamera();
renderer.render(scene, camera);
}
document.getElementById('startBtn').addEventListener('click', ()=>{
document.getElementById('startScreen').style.display = 'none';
document.getElementById('hud').style.display = 'flex';
document.getElementById('crosshair').style.display = 'block';
document.getElementById('joystick').style.display = 'block';
document.getElementById('rightControls').style.display = 'flex';
running = true;
clock.start();
showMsg('Welcome to the Void, Voyager', 2600);
animate();
});
init();
})();
</script>
</body>
</html>
Game Source: Void Voyager - 3D Space Explorer
Creator: LunarGalaxy87
Libraries: three
Complexity: complex (764 lines, 31.0 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: void-voyager-3d-space-explorer-lunargalaxy87" to link back to the original. Then publish at arcadelab.ai/publish.