🎮ArcadeLab

Void Voyager - 3D Space Explorer

by LunarGalaxy87
764 lines31.0 KB🛠️ Three.js (3D graphics)
▶ Play
<!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 &nbsp;|&nbsp; 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.