Complete deployment of unified Light Trinity system: 🔴 RedLight: Template & brand system (18 HTML templates) 💚 GreenLight: Project & collaboration (14 layers, 103 templates) 💛 YellowLight: Infrastructure & deployment 🌈 Trinity: Unified compliance & testing Includes: - 12 documentation files - 8 shell scripts - 18 HTML brand templates - Trinity compliance workflow Built by: Cece + Alexa Date: December 23, 2025 Source: blackroad-os/blackroad-os-infra 🌸✨
801 lines
35 KiB
HTML
801 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>BlackRoad OS — Living Earth</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
overflow: hidden;
|
|
background: #000;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
|
|
}
|
|
#canvas-container { position: fixed; inset: 0; }
|
|
|
|
.logo {
|
|
position: fixed;
|
|
top: 20px;
|
|
left: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: rgba(0,0,0,0.75);
|
|
padding: 10px 18px;
|
|
border-radius: 50px;
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
z-index: 100;
|
|
}
|
|
.logo-icon {
|
|
width: 28px; height: 28px;
|
|
background: linear-gradient(135deg, #FF1D6C, #F5A623);
|
|
border-radius: 50%;
|
|
position: relative;
|
|
}
|
|
.logo-icon::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%; left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 10px; height: 10px;
|
|
background: #000;
|
|
border-radius: 50%;
|
|
}
|
|
.logo-text { color: #fff; font-weight: 600; font-size: 14px; }
|
|
|
|
.stats-bar {
|
|
position: fixed;
|
|
top: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
gap: 20px;
|
|
background: rgba(0,0,0,0.75);
|
|
padding: 10px 24px;
|
|
border-radius: 50px;
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
z-index: 100;
|
|
}
|
|
.stat { display: flex; align-items: center; gap: 6px; }
|
|
.stat-icon { font-size: 15px; }
|
|
.stat-value { font-size: 13px; font-weight: 600; color: #fff; }
|
|
|
|
.biome-panel {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: rgba(0,0,0,0.75);
|
|
padding: 14px 18px;
|
|
border-radius: 16px;
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
z-index: 100;
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
}
|
|
.biome-title {
|
|
font-size: 9px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
color: rgba(255,255,255,0.4);
|
|
margin-bottom: 10px;
|
|
}
|
|
.biome-list { display: flex; flex-direction: column; gap: 4px; }
|
|
.biome-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 11px;
|
|
color: rgba(255,255,255,0.8);
|
|
}
|
|
.biome-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
.biome-count { margin-left: auto; color: rgba(255,255,255,0.5); font-size: 10px; }
|
|
|
|
.info-panel {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
left: 20px;
|
|
background: rgba(0,0,0,0.75);
|
|
padding: 16px 20px;
|
|
border-radius: 16px;
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
z-index: 100;
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
transition: all 0.3s ease;
|
|
max-width: 300px;
|
|
}
|
|
.info-panel.visible { opacity: 1; transform: translateY(0); }
|
|
.info-biome { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 4px; }
|
|
.info-name { font-size: 18px; font-weight: 600; color: #fff; margin-bottom: 8px; }
|
|
.info-desc { font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.4; }
|
|
.info-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
.info-stat { text-align: center; }
|
|
.info-stat-icon { font-size: 14px; }
|
|
.info-stat-value { font-size: 13px; font-weight: 600; color: #fff; }
|
|
.info-stat-label { font-size: 8px; color: rgba(255,255,255,0.4); text-transform: uppercase; }
|
|
|
|
.controls {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
z-index: 100;
|
|
}
|
|
.ctrl-btn {
|
|
width: 42px; height: 42px;
|
|
border: none; border-radius: 50%;
|
|
background: rgba(0,0,0,0.75);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
color: #fff; font-size: 16px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.ctrl-btn:hover { background: rgba(255,29,108,0.4); transform: scale(1.1); }
|
|
.ctrl-btn.active { background: #FF1D6C; border-color: #FF1D6C; }
|
|
|
|
.fps {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(0,0,0,0.6);
|
|
padding: 6px 14px;
|
|
border-radius: 20px;
|
|
font-size: 11px;
|
|
color: rgba(255,255,255,0.6);
|
|
z-index: 100;
|
|
}
|
|
|
|
.loading {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: #000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
transition: opacity 0.6s ease;
|
|
}
|
|
.loading.hidden { opacity: 0; pointer-events: none; }
|
|
.loading-spinner {
|
|
width: 60px; height: 60px;
|
|
border: 3px solid rgba(255,255,255,0.1);
|
|
border-top-color: #FF1D6C;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loading-text { margin-top: 20px; color: rgba(255,255,255,0.7); font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="loading" id="loading">
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text">Loading biomes...</div>
|
|
</div>
|
|
|
|
<div id="canvas-container"></div>
|
|
|
|
<div class="logo">
|
|
<div class="logo-icon"></div>
|
|
<span class="logo-text">BlackRoad Earth</span>
|
|
</div>
|
|
|
|
<div class="stats-bar">
|
|
<div class="stat"><span class="stat-icon">🌳</span><span class="stat-value" id="treeCount">0</span></div>
|
|
<div class="stat"><span class="stat-icon">🏠</span><span class="stat-value" id="houseCount">0</span></div>
|
|
<div class="stat"><span class="stat-icon">🐾</span><span class="stat-value" id="animalCount">0</span></div>
|
|
<div class="stat"><span class="stat-icon">🤖</span><span class="stat-value" id="agentCount">0</span></div>
|
|
</div>
|
|
|
|
<div class="biome-panel">
|
|
<div class="biome-title">Biomes</div>
|
|
<div class="biome-list" id="biomeList"></div>
|
|
</div>
|
|
|
|
<div class="info-panel" id="infoPanel">
|
|
<div class="info-biome" id="infoBiome">Tropical Rainforest</div>
|
|
<div class="info-name" id="infoName">Amazon Basin</div>
|
|
<div class="info-desc" id="infoDesc">Description...</div>
|
|
<div class="info-stats">
|
|
<div class="info-stat">
|
|
<div class="info-stat-icon">🌳</div>
|
|
<div class="info-stat-value" id="infoTrees">0</div>
|
|
<div class="info-stat-label">Trees</div>
|
|
</div>
|
|
<div class="info-stat">
|
|
<div class="info-stat-icon">🐾</div>
|
|
<div class="info-stat-value" id="infoAnimals">0</div>
|
|
<div class="info-stat-label">Animals</div>
|
|
</div>
|
|
<div class="info-stat">
|
|
<div class="info-stat-icon">🌡️</div>
|
|
<div class="info-stat-value" id="infoTemp">0°</div>
|
|
<div class="info-stat-label">Temp</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button class="ctrl-btn active" id="btnRotate" title="Auto Rotate">🔄</button>
|
|
<button class="ctrl-btn active" id="btnClouds" title="Clouds">☁️</button>
|
|
<button class="ctrl-btn active" id="btnLife" title="Life">🌿</button>
|
|
<button class="ctrl-btn" id="btnNight" title="Night">🌙</button>
|
|
</div>
|
|
|
|
<div class="fps" id="fps">60 FPS</div>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<script>
|
|
// ============ CONFIG ============
|
|
const EARTH_RADIUS = 100;
|
|
const QUALITY = {
|
|
treesPerRegion: 15, // Reduced from 40
|
|
animalsPerRegion: 5, // Reduced from 15
|
|
agentsPerRegion: 2, // Reduced from 3
|
|
housesPerRegion: 3, // Reduced from 8
|
|
animateEveryNthFrame: 2 // Only animate every 2nd frame
|
|
};
|
|
|
|
const TEXTURES = {
|
|
earth: 'https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg',
|
|
bump: 'https://unpkg.com/three-globe/example/img/earth-topology.png',
|
|
clouds: 'https://unpkg.com/three-globe/example/img/earth-clouds.png',
|
|
night: 'https://unpkg.com/three-globe/example/img/earth-night.jpg'
|
|
};
|
|
|
|
// Simplified biomes
|
|
const BIOMES = {
|
|
TROPICAL_RAINFOREST: { name: "Tropical Rainforest", color: 0x1a5a1a, icon: "🌴", temp: "27°C", treeColor: 0x0d5c0d },
|
|
TEMPERATE_FOREST: { name: "Temperate Forest", color: 0x2d5a27, icon: "🌲", temp: "12°C", treeColor: 0x228b22 },
|
|
BOREAL_TAIGA: { name: "Boreal Taiga", color: 0x1a4a2a, icon: "🌲", temp: "-5°C", treeColor: 0x0a3a0a },
|
|
TUNDRA: { name: "Tundra", color: 0x8fbc8f, icon: "❄️", temp: "-20°C", treeColor: 0x556b2f },
|
|
DESERT_HOT: { name: "Hot Desert", color: 0xc2b280, icon: "🏜️", temp: "38°C", treeColor: 0x228b22 },
|
|
SAVANNA: { name: "Savanna", color: 0xbdb76b, icon: "🦁", temp: "25°C", treeColor: 0x6b8e23 },
|
|
GRASSLAND: { name: "Grassland", color: 0x9acd32, icon: "🌾", temp: "15°C", treeColor: 0x7cba3d },
|
|
MOUNTAIN: { name: "Mountain", color: 0x696969, icon: "⛰️", temp: "-8°C", treeColor: 0x1a4a1a },
|
|
ICE_SHEET: { name: "Polar Ice", color: 0xf0f8ff, icon: "🧊", temp: "-50°C", treeColor: 0xffffff },
|
|
WETLAND: { name: "Wetland", color: 0x2f4f4f, icon: "🐊", temp: "22°C", treeColor: 0x2f4f2f }
|
|
};
|
|
|
|
// Reduced regions - key locations only
|
|
const BIOME_REGIONS = [
|
|
// Rainforests
|
|
{ biome: 'TROPICAL_RAINFOREST', name: "Amazon Basin", lat: -3, lng: -60, radius: 18, desc: "World's largest rainforest" },
|
|
{ biome: 'TROPICAL_RAINFOREST', name: "Congo Rainforest", lat: 0, lng: 22, radius: 12, desc: "Africa's largest rainforest" },
|
|
{ biome: 'TROPICAL_RAINFOREST', name: "Southeast Asia", lat: 2, lng: 110, radius: 10, desc: "Borneo and Sumatra forests" },
|
|
|
|
// Temperate
|
|
{ biome: 'TEMPERATE_FOREST', name: "Eastern USA", lat: 38, lng: -80, radius: 12, desc: "Appalachian forests" },
|
|
{ biome: 'TEMPERATE_FOREST', name: "Western Europe", lat: 48, lng: 8, radius: 10, desc: "European woodlands" },
|
|
{ biome: 'TEMPERATE_FOREST', name: "East Asia", lat: 36, lng: 138, radius: 8, desc: "Japanese and Korean forests" },
|
|
|
|
// Boreal
|
|
{ biome: 'BOREAL_TAIGA', name: "Canadian Boreal", lat: 58, lng: -100, radius: 22, desc: "World's largest intact forest" },
|
|
{ biome: 'BOREAL_TAIGA', name: "Siberian Taiga", lat: 62, lng: 100, radius: 25, desc: "Largest terrestrial biome" },
|
|
|
|
// Tundra
|
|
{ biome: 'TUNDRA', name: "Arctic Tundra", lat: 72, lng: -100, radius: 18, desc: "Frozen treeless plains" },
|
|
{ biome: 'TUNDRA', name: "Siberian Tundra", lat: 72, lng: 130, radius: 16, desc: "Northern Russia permafrost" },
|
|
|
|
// Deserts
|
|
{ biome: 'DESERT_HOT', name: "Sahara Desert", lat: 24, lng: 10, radius: 25, desc: "World's largest hot desert" },
|
|
{ biome: 'DESERT_HOT', name: "Arabian Desert", lat: 23, lng: 50, radius: 12, desc: "Arabian Peninsula" },
|
|
{ biome: 'DESERT_HOT', name: "Australian Outback", lat: -24, lng: 135, radius: 18, desc: "Red heart of Australia" },
|
|
|
|
// Savanna
|
|
{ biome: 'SAVANNA', name: "Serengeti", lat: -3, lng: 35, radius: 10, desc: "Great migration lands" },
|
|
{ biome: 'SAVANNA', name: "Brazilian Cerrado", lat: -15, lng: -48, radius: 12, desc: "South American savanna" },
|
|
|
|
// Grassland
|
|
{ biome: 'GRASSLAND', name: "Great Plains", lat: 42, lng: -100, radius: 15, desc: "North American prairie" },
|
|
{ biome: 'GRASSLAND', name: "Eurasian Steppe", lat: 48, lng: 65, radius: 20, desc: "Vast grasslands" },
|
|
|
|
// Mountains
|
|
{ biome: 'MOUNTAIN', name: "Himalayas", lat: 28, lng: 85, radius: 12, desc: "World's highest peaks" },
|
|
{ biome: 'MOUNTAIN', name: "Rocky Mountains", lat: 45, lng: -110, radius: 10, desc: "North American spine" },
|
|
{ biome: 'MOUNTAIN', name: "Andes", lat: -15, lng: -72, radius: 10, desc: "Longest mountain range" },
|
|
{ biome: 'MOUNTAIN', name: "European Alps", lat: 46, lng: 10, radius: 6, desc: "Central European peaks" },
|
|
|
|
// Ice
|
|
{ biome: 'ICE_SHEET', name: "Antarctica", lat: -82, lng: 0, radius: 28, desc: "Frozen continent" },
|
|
{ biome: 'ICE_SHEET', name: "Greenland", lat: 75, lng: -42, radius: 15, desc: "Arctic ice sheet" },
|
|
|
|
// Wetlands
|
|
{ biome: 'WETLAND', name: "Pantanal", lat: -18, lng: -57, radius: 8, desc: "World's largest wetland" },
|
|
{ biome: 'WETLAND', name: "Everglades", lat: 26, lng: -81, radius: 5, desc: "River of grass" }
|
|
];
|
|
|
|
// ============ GLOBALS ============
|
|
let scene, camera, renderer;
|
|
let earth, clouds, nightLights;
|
|
let lifeGroup;
|
|
let regionData = [];
|
|
let counts = { trees: 0, houses: 0, animals: 0, agents: 0 };
|
|
|
|
let frameCount = 0;
|
|
let lastTime = performance.now();
|
|
let fps = 60;
|
|
|
|
let autoRotate = true;
|
|
let showClouds = true;
|
|
let showLife = true;
|
|
let isNight = false;
|
|
|
|
let isDragging = false;
|
|
let prevMouse = { x: 0, y: 0 };
|
|
let spherical = { theta: 0, phi: Math.PI / 3, radius: 250 };
|
|
|
|
// ============ INIT ============
|
|
function init() {
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x000008);
|
|
|
|
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 2000);
|
|
updateCamera();
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); // Cap pixel ratio
|
|
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
|
|
|
// Load and build
|
|
loadTextures(() => {
|
|
createScene();
|
|
populateBiomes();
|
|
buildUI();
|
|
document.getElementById('loading').classList.add('hidden');
|
|
animate();
|
|
});
|
|
|
|
// Events
|
|
window.addEventListener('resize', onResize);
|
|
renderer.domElement.addEventListener('mousedown', e => { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
|
|
renderer.domElement.addEventListener('mousemove', onDrag);
|
|
renderer.domElement.addEventListener('mouseup', () => isDragging = false);
|
|
renderer.domElement.addEventListener('mouseleave', () => isDragging = false);
|
|
renderer.domElement.addEventListener('wheel', e => {
|
|
spherical.radius = Math.max(140, Math.min(400, spherical.radius + e.deltaY * 0.15));
|
|
updateCamera();
|
|
}, { passive: true });
|
|
renderer.domElement.addEventListener('click', onClick);
|
|
|
|
// Touch
|
|
renderer.domElement.addEventListener('touchstart', e => {
|
|
if (e.touches.length === 1) { isDragging = true; prevMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }
|
|
}, { passive: true });
|
|
renderer.domElement.addEventListener('touchmove', e => {
|
|
if (!isDragging || e.touches.length !== 1) return;
|
|
const dx = e.touches[0].clientX - prevMouse.x;
|
|
const dy = e.touches[0].clientY - prevMouse.y;
|
|
spherical.theta -= dx * 0.005;
|
|
spherical.phi = Math.max(0.3, Math.min(Math.PI - 0.3, spherical.phi + dy * 0.005));
|
|
updateCamera();
|
|
prevMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
}, { passive: true });
|
|
renderer.domElement.addEventListener('touchend', () => isDragging = false);
|
|
|
|
// Buttons
|
|
document.getElementById('btnRotate').onclick = () => { autoRotate = !autoRotate; document.getElementById('btnRotate').classList.toggle('active', autoRotate); };
|
|
document.getElementById('btnClouds').onclick = () => { showClouds = !showClouds; clouds.visible = showClouds; document.getElementById('btnClouds').classList.toggle('active', showClouds); };
|
|
document.getElementById('btnLife').onclick = () => { showLife = !showLife; lifeGroup.visible = showLife; document.getElementById('btnLife').classList.toggle('active', showLife); };
|
|
document.getElementById('btnNight').onclick = () => {
|
|
isNight = !isNight;
|
|
nightLights.material.opacity = isNight ? 0.8 : 0;
|
|
window.sunLight.intensity = isNight ? 0.15 : 1.2;
|
|
document.getElementById('btnNight').classList.toggle('active', isNight);
|
|
};
|
|
}
|
|
|
|
function loadTextures(callback) {
|
|
const loader = new THREE.TextureLoader();
|
|
let loaded = 0;
|
|
const total = 4;
|
|
|
|
window.tex = {};
|
|
Object.entries(TEXTURES).forEach(([key, url]) => {
|
|
loader.load(url, tex => {
|
|
window.tex[key] = tex;
|
|
loaded++;
|
|
if (loaded === total) callback();
|
|
});
|
|
});
|
|
}
|
|
|
|
function createScene() {
|
|
// Stars - simple points
|
|
const starGeo = new THREE.BufferGeometry();
|
|
const starPos = new Float32Array(3000 * 3);
|
|
for (let i = 0; i < 3000; i++) {
|
|
const r = 600 + Math.random() * 800;
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const phi = Math.acos(Math.random() * 2 - 1);
|
|
starPos[i*3] = r * Math.sin(phi) * Math.cos(theta);
|
|
starPos[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
|
|
starPos[i*3+2] = r * Math.cos(phi);
|
|
}
|
|
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
|
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.8 })));
|
|
|
|
// Lights
|
|
window.sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
window.sunLight.position.set(300, 150, 300);
|
|
scene.add(window.sunLight);
|
|
scene.add(new THREE.AmbientLight(0x404060, 0.35));
|
|
|
|
// Earth
|
|
const earthGeo = new THREE.SphereGeometry(EARTH_RADIUS, 64, 64); // Reduced segments
|
|
earth = new THREE.Mesh(earthGeo, new THREE.MeshPhongMaterial({
|
|
map: window.tex.earth,
|
|
bumpMap: window.tex.bump,
|
|
bumpScale: 0.3,
|
|
shininess: 3
|
|
}));
|
|
scene.add(earth);
|
|
|
|
// Night lights
|
|
nightLights = new THREE.Mesh(earthGeo.clone(), new THREE.MeshBasicMaterial({
|
|
map: window.tex.night,
|
|
blending: THREE.AdditiveBlending,
|
|
transparent: true,
|
|
opacity: 0
|
|
}));
|
|
nightLights.scale.setScalar(1.002);
|
|
scene.add(nightLights);
|
|
|
|
// Clouds
|
|
clouds = new THREE.Mesh(
|
|
new THREE.SphereGeometry(EARTH_RADIUS + 1, 48, 48),
|
|
new THREE.MeshPhongMaterial({ map: window.tex.clouds, transparent: true, opacity: 0.25, depthWrite: false })
|
|
);
|
|
scene.add(clouds);
|
|
|
|
// Atmosphere
|
|
scene.add(new THREE.Mesh(
|
|
new THREE.SphereGeometry(EARTH_RADIUS + 8, 32, 32),
|
|
new THREE.ShaderMaterial({
|
|
vertexShader: `varying vec3 vN; void main() { vN = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
|
fragmentShader: `varying vec3 vN; void main() { float i = pow(0.6 - dot(vN, vec3(0,0,1)), 2.0); gl_FragColor = vec4(0.3, 0.6, 1.0, i * 0.35); }`,
|
|
blending: THREE.AdditiveBlending,
|
|
side: THREE.BackSide,
|
|
transparent: true,
|
|
depthWrite: false
|
|
})
|
|
));
|
|
|
|
// Life container
|
|
lifeGroup = new THREE.Group();
|
|
scene.add(lifeGroup);
|
|
}
|
|
|
|
// ============ POPULATE ============
|
|
function populateBiomes() {
|
|
BIOME_REGIONS.forEach(region => {
|
|
const biome = BIOMES[region.biome];
|
|
const scale = region.radius / 12;
|
|
|
|
const treesCount = Math.max(1, Math.floor(QUALITY.treesPerRegion * scale * (biome.name.includes('Desert') || biome.name.includes('Ice') ? 0.1 : 1)));
|
|
const housesCount = Math.max(0, Math.floor(QUALITY.housesPerRegion * scale * (biome.name.includes('Ice') ? 0.1 : 1)));
|
|
const animalsCount = Math.max(1, Math.floor(QUALITY.animalsPerRegion * scale));
|
|
const agentsCount = Math.floor(QUALITY.agentsPerRegion * scale);
|
|
|
|
const regionCounts = { trees: 0, animals: 0, houses: 0, agents: 0 };
|
|
|
|
// Trees - simple merged geometry approach
|
|
for (let i = 0; i < treesCount; i++) {
|
|
const tree = createSimpleTree(biome);
|
|
placeOnGlobe(tree, region);
|
|
lifeGroup.add(tree);
|
|
regionCounts.trees++;
|
|
counts.trees++;
|
|
}
|
|
|
|
// Houses
|
|
for (let i = 0; i < housesCount; i++) {
|
|
const house = createSimpleHouse(biome);
|
|
placeOnGlobe(house, region);
|
|
lifeGroup.add(house);
|
|
regionCounts.houses++;
|
|
counts.houses++;
|
|
}
|
|
|
|
// Animals - very simple
|
|
for (let i = 0; i < animalsCount; i++) {
|
|
const animal = createSimpleAnimal(biome);
|
|
placeOnGlobe(animal, region);
|
|
animal.userData.region = region;
|
|
lifeGroup.add(animal);
|
|
regionCounts.animals++;
|
|
counts.animals++;
|
|
}
|
|
|
|
// Agents
|
|
for (let i = 0; i < agentsCount; i++) {
|
|
const agent = createSimpleAgent();
|
|
placeOnGlobe(agent, region, 1.5);
|
|
agent.userData.region = region;
|
|
lifeGroup.add(agent);
|
|
regionCounts.agents++;
|
|
counts.agents++;
|
|
}
|
|
|
|
regionData.push({ ...region, biome, counts: regionCounts });
|
|
});
|
|
|
|
// Update UI counts
|
|
document.getElementById('treeCount').textContent = counts.trees;
|
|
document.getElementById('houseCount').textContent = counts.houses;
|
|
document.getElementById('animalCount').textContent = counts.animals;
|
|
document.getElementById('agentCount').textContent = counts.agents;
|
|
}
|
|
|
|
function placeOnGlobe(obj, region, heightOffset = 0) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const dist = Math.random() * region.radius * 0.75;
|
|
const lat = region.lat + Math.cos(angle) * dist * 0.5;
|
|
const lng = region.lng + Math.sin(angle) * dist * 0.7;
|
|
|
|
const phi = (90 - lat) * Math.PI / 180;
|
|
const theta = (lng + 180) * Math.PI / 180;
|
|
const r = EARTH_RADIUS + 0.2 + heightOffset;
|
|
|
|
obj.position.set(
|
|
r * Math.sin(phi) * Math.cos(theta),
|
|
r * Math.cos(phi),
|
|
r * Math.sin(phi) * Math.sin(theta)
|
|
);
|
|
|
|
const normal = obj.position.clone().normalize();
|
|
obj.lookAt(obj.position.clone().add(normal));
|
|
obj.rotateX(Math.PI / 2);
|
|
obj.rotateY(Math.random() * Math.PI * 2);
|
|
|
|
obj.userData.lat = lat;
|
|
obj.userData.lng = lng;
|
|
}
|
|
|
|
// ============ SIMPLE CREATORS ============
|
|
function createSimpleTree(biome) {
|
|
const group = new THREE.Group();
|
|
|
|
if (biome.name.includes('Desert')) {
|
|
// Cactus
|
|
const mat = new THREE.MeshLambertMaterial({ color: 0x228b22 });
|
|
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.8, 5), mat);
|
|
trunk.position.y = 0.4;
|
|
group.add(trunk);
|
|
} else if (biome.name.includes('Ice') || biome.name.includes('Tundra')) {
|
|
// Shrub
|
|
const mat = new THREE.MeshLambertMaterial({ color: 0x556b2f });
|
|
const bush = new THREE.Mesh(new THREE.SphereGeometry(0.2, 4, 4), mat);
|
|
bush.position.y = 0.15;
|
|
group.add(bush);
|
|
} else if (biome.name.includes('Savanna')) {
|
|
// Acacia
|
|
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x4a3728 });
|
|
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.08, 1, 5), trunkMat);
|
|
trunk.position.y = 0.5;
|
|
group.add(trunk);
|
|
const canopy = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.6, 0.15, 6), new THREE.MeshLambertMaterial({ color: biome.treeColor }));
|
|
canopy.position.y = 1.1;
|
|
group.add(canopy);
|
|
} else if (biome.name.includes('Boreal') || biome.name.includes('Mountain')) {
|
|
// Pine
|
|
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x4a3728 });
|
|
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.08, 0.6, 5), trunkMat);
|
|
trunk.position.y = 0.3;
|
|
group.add(trunk);
|
|
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1, 5), new THREE.MeshLambertMaterial({ color: biome.treeColor }));
|
|
cone.position.y = 0.9;
|
|
group.add(cone);
|
|
} else {
|
|
// Regular tree
|
|
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x4a3728 });
|
|
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.1, 0.6, 5), trunkMat);
|
|
trunk.position.y = 0.3;
|
|
group.add(trunk);
|
|
const leaves = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 0), new THREE.MeshLambertMaterial({ color: biome.treeColor }));
|
|
leaves.position.y = 0.8;
|
|
group.add(leaves);
|
|
}
|
|
|
|
group.scale.setScalar(0.4 + Math.random() * 0.2);
|
|
return group;
|
|
}
|
|
|
|
function createSimpleHouse(biome) {
|
|
const group = new THREE.Group();
|
|
|
|
const wallColor = biome.name.includes('Desert') ? 0xf5deb3 : 0xfaf8f5;
|
|
const roofColor = biome.name.includes('Desert') ? 0xdaa520 : 0xcc4444;
|
|
|
|
const walls = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.4, 0.5), new THREE.MeshLambertMaterial({ color: wallColor }));
|
|
walls.position.y = 0.2;
|
|
group.add(walls);
|
|
|
|
const roof = new THREE.Mesh(new THREE.ConeGeometry(0.45, 0.3, 4), new THREE.MeshLambertMaterial({ color: roofColor }));
|
|
roof.position.y = 0.55;
|
|
roof.rotation.y = Math.PI / 4;
|
|
group.add(roof);
|
|
|
|
group.scale.setScalar(0.5);
|
|
return group;
|
|
}
|
|
|
|
function createSimpleAnimal(biome) {
|
|
const group = new THREE.Group();
|
|
const color = biome.name.includes('Ice') ? 0xffffff :
|
|
biome.name.includes('Savanna') ? 0xd4a574 :
|
|
0x8b7355;
|
|
|
|
const mat = new THREE.MeshLambertMaterial({ color });
|
|
const body = new THREE.Mesh(new THREE.SphereGeometry(0.12, 6, 6), mat);
|
|
body.scale.set(1, 0.7, 1.2);
|
|
group.add(body);
|
|
|
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.08, 6, 6), mat);
|
|
head.position.set(0, 0.05, 0.12);
|
|
group.add(head);
|
|
|
|
group.scale.setScalar(0.5);
|
|
group.userData.hop = Math.random() * Math.PI * 2;
|
|
return group;
|
|
}
|
|
|
|
function createSimpleAgent() {
|
|
const group = new THREE.Group();
|
|
const colors = [0xff1d6c, 0x2979ff, 0xf5a623, 0x9c27b0];
|
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
|
|
|
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.25, 6), new THREE.MeshLambertMaterial({ color }));
|
|
body.position.y = 0.12;
|
|
group.add(body);
|
|
|
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 8), new THREE.MeshLambertMaterial({ color: 0xffffff }));
|
|
head.position.y = 0.32;
|
|
group.add(head);
|
|
|
|
group.scale.setScalar(0.6);
|
|
group.userData.hover = Math.random() * Math.PI * 2;
|
|
return group;
|
|
}
|
|
|
|
// ============ UI ============
|
|
function buildUI() {
|
|
const list = document.getElementById('biomeList');
|
|
const biomeCounts = {};
|
|
BIOME_REGIONS.forEach(r => { biomeCounts[r.biome] = (biomeCounts[r.biome] || 0) + 1; });
|
|
|
|
Object.entries(BIOMES).forEach(([key, biome]) => {
|
|
if (!biomeCounts[key]) return;
|
|
const item = document.createElement('div');
|
|
item.className = 'biome-item';
|
|
item.innerHTML = `<div class="biome-dot" style="background:#${biome.color.toString(16).padStart(6,'0')}"></div>
|
|
<span>${biome.icon} ${biome.name}</span>
|
|
<span class="biome-count">${biomeCounts[key]}</span>`;
|
|
list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function showInfo(region) {
|
|
document.getElementById('infoBiome').textContent = region.biome.icon + ' ' + region.biome.name;
|
|
document.getElementById('infoBiome').style.color = '#' + region.biome.color.toString(16).padStart(6, '0');
|
|
document.getElementById('infoName').textContent = region.name;
|
|
document.getElementById('infoDesc').textContent = region.desc;
|
|
document.getElementById('infoTrees').textContent = region.counts.trees;
|
|
document.getElementById('infoAnimals').textContent = region.counts.animals;
|
|
document.getElementById('infoTemp').textContent = region.biome.temp;
|
|
document.getElementById('infoPanel').classList.add('visible');
|
|
clearTimeout(window.infoTimeout);
|
|
window.infoTimeout = setTimeout(() => document.getElementById('infoPanel').classList.remove('visible'), 4000);
|
|
}
|
|
|
|
// ============ EVENTS ============
|
|
function updateCamera() {
|
|
camera.position.x = spherical.radius * Math.sin(spherical.phi) * Math.sin(spherical.theta);
|
|
camera.position.y = spherical.radius * Math.cos(spherical.phi);
|
|
camera.position.z = spherical.radius * Math.sin(spherical.phi) * Math.cos(spherical.theta);
|
|
camera.lookAt(0, 0, 0);
|
|
}
|
|
|
|
function onResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
function onDrag(e) {
|
|
if (!isDragging) return;
|
|
const dx = e.clientX - prevMouse.x;
|
|
const dy = e.clientY - prevMouse.y;
|
|
spherical.theta -= dx * 0.005;
|
|
spherical.phi = Math.max(0.3, Math.min(Math.PI - 0.3, spherical.phi + dy * 0.005));
|
|
updateCamera();
|
|
prevMouse = { x: e.clientX, y: e.clientY };
|
|
}
|
|
|
|
function onClick(e) {
|
|
const mouse = new THREE.Vector2(
|
|
(e.clientX / window.innerWidth) * 2 - 1,
|
|
-(e.clientY / window.innerHeight) * 2 + 1
|
|
);
|
|
const ray = new THREE.Raycaster();
|
|
ray.setFromCamera(mouse, camera);
|
|
|
|
const hits = ray.intersectObject(earth);
|
|
if (hits.length > 0) {
|
|
const p = hits[0].point.normalize().multiplyScalar(EARTH_RADIUS);
|
|
const lat = 90 - Math.acos(p.y / EARTH_RADIUS) * 180 / Math.PI;
|
|
const lng = Math.atan2(p.z, p.x) * 180 / Math.PI - 180;
|
|
|
|
let nearest = null, minDist = Infinity;
|
|
regionData.forEach(r => {
|
|
const d = Math.sqrt((lat - r.lat) ** 2 + (lng - r.lng) ** 2);
|
|
if (d < r.radius && d < minDist) { minDist = d; nearest = r; }
|
|
});
|
|
if (nearest) showInfo(nearest);
|
|
}
|
|
}
|
|
|
|
// ============ ANIMATION ============
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
frameCount++;
|
|
|
|
// FPS counter
|
|
if (frameCount % 30 === 0) {
|
|
const now = performance.now();
|
|
fps = Math.round(30000 / (now - lastTime));
|
|
lastTime = now;
|
|
document.getElementById('fps').textContent = fps + ' FPS';
|
|
}
|
|
|
|
// Rotate earth slowly
|
|
earth.rotation.y += 0.0002;
|
|
nightLights.rotation.y = earth.rotation.y;
|
|
clouds.rotation.y += 0.00025;
|
|
lifeGroup.rotation.y = earth.rotation.y;
|
|
|
|
// Auto rotate camera
|
|
if (autoRotate && !isDragging) {
|
|
spherical.theta += 0.0008;
|
|
updateCamera();
|
|
}
|
|
|
|
// Animate life only every Nth frame
|
|
if (frameCount % QUALITY.animateEveryNthFrame === 0) {
|
|
lifeGroup.children.forEach(obj => {
|
|
if (obj.userData.hop !== undefined) {
|
|
obj.userData.hop += 0.1;
|
|
}
|
|
if (obj.userData.hover !== undefined) {
|
|
obj.userData.hover += 0.05;
|
|
// Slight bobbing
|
|
obj.position.y += Math.sin(obj.userData.hover) * 0.002;
|
|
}
|
|
});
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|