Spaces:
Running
Running
Download index.html from DavidVivancos/NeuraxonLife: direct link, hf CLI and curl.
- Browser
- Download file 266 kB
-
https://huggingface.co/spaces/DavidVivancos/NeuraxonLife/resolve/main/index.html
- Command line
-
hf download hf://spaces/DavidVivancos/NeuraxonLife/index.html
-
curl -L -o index.html https://huggingface.co/spaces/DavidVivancos/NeuraxonLife/resolve/main/index.html
266 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Neuraxon Game of Life v5.0 Lite — CHC g-capable Multi-Sphere Brain + Best-g NxEr + Tonal Voices — By David Vivancos & Dr. Jose Sanchez for Qubic Science</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Consolas', 'Courier New', monospace; | |
| background: #000; | |
| color: #eee; | |
| overflow: hidden; | |
| } | |
| #gameCanvas { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| outline: none; | |
| } | |
| .splash-screen { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: #000 url('GameOfLife5.0ByDavidVivancosAndJoseSanchez.jpg') center center no-repeat; | |
| background-size: 100% 100%; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 1000; | |
| animation: fadeIn 0.5s; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .splash-screen h1 { | |
| font-size: 48px; | |
| color: #28b46c; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| text-shadow: 0 0 20px rgba(40, 180, 108, 0.5); | |
| } | |
| .splash-screen p { | |
| font-size: 18px; | |
| color: #aaa; | |
| text-align: center; | |
| margin: 10px 0; | |
| } | |
| .config-screen { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(15, 15, 18, 0.95); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 50px 20px; | |
| overflow-y: auto; | |
| z-index: 999; | |
| } | |
| .config-screen h1 { | |
| font-size: 32px; | |
| color: #ebebf0; | |
| margin-bottom: 40px; | |
| } | |
| .slider-container { | |
| width: 700px; | |
| max-width: 90%; | |
| margin-bottom: 25px; | |
| } | |
| .slider-label { | |
| font-size: 16px; | |
| color: #dcdcdc; | |
| margin-bottom: 8px; | |
| } | |
| .slider-wrapper { | |
| display: flex; | |
| align-items: center; | |
| gap: 15px; | |
| } | |
| .slider { | |
| flex: 1; | |
| height: 6px; | |
| background: #646464; | |
| border-radius: 3px; | |
| outline: none; | |
| -webkit-appearance: none; | |
| } | |
| .slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| background: #c8c8c8; | |
| border: 2px solid #969696; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| .slider-value { | |
| font-size: 16px; | |
| color: #ff0; | |
| min-width: 60px; | |
| text-align: right; | |
| } | |
| .start-button { | |
| margin-top: 30px; | |
| padding: 15px 40px; | |
| font-size: 18px; | |
| background: #23b45c; | |
| color: #fff; | |
| border: 2px solid #3cdc5a; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-family: 'Consolas', monospace; | |
| transition: all 0.3s; | |
| } | |
| .start-button:hover { | |
| background: #2cd068; | |
| transform: scale(1.05); | |
| } | |
| .hud { | |
| position: fixed; | |
| top: 12px; | |
| right: 16px; | |
| background: rgba(0, 0, 0, 0.75); | |
| border: 1px solid #3c3c3c; | |
| border-radius: 8px; | |
| padding: 15px; | |
| min-width: 120px; | |
| max-width: 240px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| z-index: 500; | |
| } | |
| .hud-title { | |
| font-size: 20px; | |
| font-weight: bold; | |
| color: #e6e6f0; | |
| margin-bottom: 15px; | |
| text-align: center; | |
| } | |
| .hud-section { margin-bottom: 10px; } | |
| .hud-section-title { font-size: 12px; color: #b4b4b4; margin-bottom: 6px; font-weight: bold; } | |
| .hud-entry { | |
| font-size: 10px; | |
| color: #e6e6e6; | |
| margin-bottom: 4px; | |
| display: flex; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| padding: 3px 5px; | |
| border-radius: 3px; | |
| transition: background 0.2s; | |
| } | |
| .hud-entry:hover { background: rgba(255, 255, 255, 0.2); } | |
| .hud-dot { | |
| display: inline-block; | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| margin-right: 8px; | |
| border: 1px solid rgba(0,0,0,0.3); | |
| } | |
| .hud-stats { font-size: 12px; color: #dcdcdc; margin-bottom: 0px; } | |
| .button-group { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; } | |
| .button-row { display: flex; gap: 8px; } | |
| .hud-button { | |
| flex: 1; | |
| padding: 8px 12px; | |
| font-size: 13px; | |
| background: #232328; | |
| color: #e6e6e6; | |
| border: 1px solid #5a5a64; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-family: 'Consolas', monospace; | |
| transition: all 0.2s; | |
| } | |
| .hud-button:hover { background: #2d2d35; border-color: #7a7a84; } | |
| .hud-button:active { transform: scale(0.98); } | |
| .detail-panel { | |
| position: fixed; | |
| top: 12px; | |
| right: 360px; | |
| background: rgba(0, 0, 0, 0.82); | |
| border: 1px solid #505050; | |
| border-radius: 8px; | |
| padding: 15px; | |
| min-width: 340px; | |
| max-width: 420px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| z-index: 500; | |
| } | |
| .detail-title { font-size: 18px; font-weight: bold; color: #e6e6f0; margin-bottom: 12px; } | |
| .detail-info { font-size: 13px; color: #dcdcdc; margin-bottom: 3px; } | |
| .detail-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid #3c3c3c; } | |
| .modal { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: #0f0f12; | |
| border: 2px solid #5a5a64; | |
| border-radius: 12px; | |
| padding: 30px; | |
| z-index: 1001; | |
| min-width: 400px; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.8); | |
| } | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.6); | |
| z-index: 1000; | |
| } | |
| .modal-title { font-size: 20px; font-weight: bold; color: #ebebf0; margin-bottom: 10px; text-align: center; } | |
| .modal-subtitle { font-size: 14px; color: #dcdcdc; margin-bottom: 30px; text-align: center; } | |
| .modal-buttons { display: flex; gap: 20px; justify-content: center; } | |
| .modal-button { padding: 12px 40px; font-size: 16px; border-radius: 10px; cursor: pointer; font-family: 'Consolas', monospace; border: 2px solid; transition: all 0.3s; } | |
| .modal-button.yes { background: #232328; color: #ebebf0; border-color: #6e6e82; } | |
| .modal-button.yes:hover { background: #2d2d35; } | |
| .modal-button.no { background: #232328; color: #ebebf0; border-color: #6e6e82; } | |
| .modal-button.no:hover { background: #2d2d35; } | |
| .hidden { display: none ; } | |
| /* v4.0: Detail Panel Tabs */ | |
| .detail-tabs { display: flex; gap: 2px; margin-bottom: 8px; } | |
| .detail-tab { flex: 1; padding: 5px 4px; text-align: center; font-size: 10px; font-weight: bold; color: #888; background: #1a1a22; border: 1px solid #333; border-bottom: 2px solid transparent; cursor: pointer; font-family: 'Consolas', monospace; border-radius: 4px 4px 0 0; } | |
| .detail-tab.active { color: #40d0ff; border-bottom-color: #40d0ff; background: #1a1a2a; } | |
| .detail-tab:hover { color: #ccc; } | |
| .detail-tab-content { display: none; } | |
| .detail-tab-content.active { display: block; } | |
| /* v4.0: Sphere Cards in Detail */ | |
| .sphere-card { background: #12121a; border: 1px solid #2a2a3a; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; font-size: 11px; } | |
| .sphere-card .sphere-header { display: flex; align-items: center; gap: 5px; margin-bottom: 3px; } | |
| .sphere-card .sphere-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } | |
| .sphere-card .sphere-name { font-weight: bold; color: #e0ecff; } | |
| .sphere-card .sphere-type { font-size: 9px; color: #667; font-family: monospace; padding: 1px 4px; background: #0a0a12; border-radius: 3px; } | |
| .sphere-card .sphere-stats { font-size: 10px; color: #999; font-family: monospace; } | |
| /* v4.0: Link Cards */ | |
| .link-card { background: #12121a; border: 1px solid #2a2a3a; border-radius: 4px; padding: 4px 7px; margin-bottom: 3px; font-size: 10px; display: flex; align-items: center; gap: 4px; } | |
| .link-card .link-type { font-family: monospace; font-size: 8px; padding: 1px 4px; border-radius: 3px; color: #000; font-weight: bold; } | |
| .link-type-FF { background: #40d0ff; } | |
| .link-type-FB { background: #ff5090; } | |
| .link-type-LAT { background: #ffc040; } | |
| /* v4.0: Oscillator Bars */ | |
| .osc-bars { display: flex; align-items: flex-end; gap: 2px; height: 24px; margin: 4px 0; } | |
| .osc-bar { flex: 1; border-radius: 2px 2px 0 0; min-height: 2px; transition: height 0.15s; } | |
| .osc-labels { display: flex; justify-content: space-between; font-size: 7px; color: #556; font-family: monospace; } | |
| /* v4.0: Receptor Grid */ | |
| .receptor-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; } | |
| .receptor-cell { padding: 2px 3px; border-radius: 3px; font-size: 8px; text-align: center; background: #0a0a15; border: 1px solid #222; font-family: monospace; } | |
| .receptor-cell .rname { font-weight: bold; color: #e0ecff; } | |
| .receptor-cell .rval { color: #40d0ff; } | |
| /* v4.0: Neuromod Bars */ | |
| .nm-bar-row { display: flex; align-items: center; gap: 4px; margin-bottom: 3px; font-size: 10px; } | |
| .nm-bar-label { width: 16px; text-align: center; } | |
| .nm-bar-track { flex: 1; height: 5px; background: #1a1a2a; border-radius: 3px; overflow: hidden; } | |
| .nm-bar-fill { height: 100%; border-radius: 3px; transition: width 0.2s; } | |
| .nm-bar-val { width: 28px; text-align: right; font-family: monospace; font-size: 9px; color: #40d0ff; } | |
| .controls-hint { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 20px; | |
| background: rgba(0, 0, 0, 0.65); | |
| border: 1px solid #3c3c3c; | |
| border-radius: 8px; | |
| padding: 12px 20px; | |
| font-size: 12px; | |
| color: #aaa; | |
| pointer-events: auto; | |
| z-index: 500; | |
| } | |
| .controls-hint div { margin-bottom: 4px; } | |
| a:visited { color: white; } | |
| /* Loading spinner for 3D init */ | |
| #loader { | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 24px; | |
| z-index: 2000; | |
| display: none; | |
| } | |
| </style> | |
| <!-- Import Map for Three.js --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/", | |
| "postprocessing": "https://unpkg.com/postprocessing@6.33.0/build/postprocessing.esm.js" | |
| } | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <div id="loader">Initializing 3D World...</div> | |
| <!-- Splash Screen --> | |
| <div id="splashScreen" class="splash-screen"> | |
| <p style="margin-top: auto; font-size: 14px; opacity: 0.7; text-align: center; padding-bottom: 20px;">Click anywhere to continue...</p> | |
| </div> | |
| <!-- Configuration Screen --> | |
| <div id="configScreen" class="config-screen hidden"> | |
| <h1>Neuraxon Game Of Life 5.0 Lite — CHC g-capable Multi-Sphere Brain</h1> | |
| <div class="slider-container"> | |
| <div class="slider-label">World Size (3D NxN Grid Sphere)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="worldSize" min="30" max="200" value="150"> | |
| <span class="slider-value" id="worldSizeValue">150</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Sea Percentage</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="seaPct" min="15" max="80" value="60"> | |
| <span class="slider-value" id="seaPctValue">60%</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Rock Percentage</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="rockPct" min="1" max="30" value="10"> | |
| <span class="slider-value" id="rockPctValue">5%</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Starting NxErs (initial population size)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="startingNxErs" min="10" max="400" value="300"> | |
| <span class="slider-value" id="startingNxErsValue">300</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Maximum Possible Number of NxErs</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="maxNxErs" min="50" max="1000" value="500"> | |
| <span class="slider-value" id="maxNxErsValue">500</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Food Sources (Red Pyramids)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="maxFood" min="50" max="1500" value="300"> | |
| <span class="slider-value" id="maxFoodValue">300</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Food Respawn (seconds to reappear)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="foodRespawn" min="200" max="1000" value="300"> | |
| <span class="slider-value" id="foodRespawnValue">300</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Starting Food for each NxEr (Energy)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="startFood" min="20" max="300" value="150"> | |
| <span class="slider-value" id="startFoodValue">150</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Max Neurons per NxEr</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="maxNeurons" min="5" max="35" value="20"> | |
| <span class="slider-value" id="maxNeuronsValue">20</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Global Time Steps (Mental Time Scale)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="globalTimeSteps" min="30" max="120" value="60"> | |
| <span class="slider-value" id="globalTimeStepsValue">60</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Mate Cooldown (seconds between mating again)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="mateCooldown" min="5" max="30" value="10"> | |
| <span class="slider-value" id="mateCooldownValue">10</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Day/Night Cycle Length (seconds per full cycle)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="dayNightCycle" min="60" max="600" value="180"> | |
| <span class="slider-value" id="dayNightCycleValue">180</span> | |
| </div> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label">Base Temperature (environment baseline °C)</div> | |
| <div class="slider-wrapper"> | |
| <input type="range" class="slider" id="baseTemperature" min="15" max="35" value="25"> | |
| <span class="slider-value" id="baseTemperatureValue">25</span> | |
| </div> | |
| </div> | |
| <div class="slider-container" style="margin-top: 20px; background: #1a1a20; padding: 15px; border-radius: 8px; border: 1px solid #333;"> | |
| <label class="slider-label" style="display:flex; align-items:center; cursor: pointer; justify-content: space-between;"> | |
| <span style="color: #4a9eff; font-weight: bold;">🌍 Use Simulated Earth Map (select 150 World Size for best results)</span> | |
| <input type="checkbox" id="useEarth" checked style="width:24px; height:24px; cursor: pointer; accent-color: #4a9eff;"> | |
| </label> | |
| </div> | |
| <div class="slider-container" style="margin-top: 12px; background: #101820; padding: 15px; border-radius: 8px; border: 1px solid #2a4a7a;"> | |
| <div class="slider-label" style="color: #40d0ff; font-weight: bold; margin-bottom: 8px;">🧠 Multi-Sphere Brain Architecture (v5.0 Lite)</div> | |
| <div class="slider-wrapper" style="margin-bottom: 10px; align-items: flex-start;"> | |
| <span style="font-size: 13px; color: #b0c4de; min-width: 180px;">Brain Architecture</span> | |
| <div style="flex:1;"> | |
| <select id="brainArch" style="width:100%; background:#0a0a12; color:#e0ecff; border:1px solid #2a4a7a; border-radius:4px; padding:5px 8px; font-size:13px;"> | |
| <option value="classic">Classic chain (2–5 spheres)</option> | |
| <option value="chc6" selected>CHC g-capable (6-sphere — needed for g)</option> | |
| </select> | |
| <div id="brainArchHint" style="font-size:11px; color:#7799cc; margin-top:5px;"> | |
| CHC g-capable builds visual·auditory·intero → fluid·crystallised → motor with cross-battery (κ) coupling — the structure a general factor (g) needs to emerge. | |
| </div> | |
| </div> | |
| </div> | |
| <div class="slider-wrapper" style="margin-bottom: 10px;" id="brainSpheresRow"> | |
| <span style="font-size: 13px; color: #b0c4de; min-width: 180px;">Brain Spheres per NxEr</span> | |
| <input type="range" class="slider" id="brainSpheres" min="2" max="5" value="3"> | |
| <span class="slider-value" id="brainSpheresValue">3</span> | |
| </div> | |
| <div class="slider-wrapper" style="margin-bottom: 10px;"> | |
| <span style="font-size: 13px; color: #b0c4de; min-width: 180px;">Neurons per Sphere</span> | |
| <input type="range" class="slider" id="neuronsPerSphere" min="5" max="30" value="12"> | |
| <span class="slider-value" id="neuronsPerSphereValue">12</span> | |
| </div> | |
| <div class="slider-wrapper"> | |
| <span style="font-size: 13px; color: #b0c4de; min-width: 180px;">Oscillator Coupling</span> | |
| <input type="range" class="slider" id="oscCoupling" min="1" max="20" value="10"> | |
| <span class="slider-value" id="oscCouplingValue">10</span> | |
| </div> | |
| </div> | |
| <button class="start-button" id="startButton">Start Simulation</button> | |
| </div> | |
| <!-- Game Canvas (WebGL) --> | |
| <canvas id="gameCanvas" class="hidden"></canvas> | |
| <!-- HUD --> | |
| <div id="hud" class="hud hidden"> | |
| <div class="hud-title" id="hudTitle">Metrics: Round #1</div> | |
| <div id="hudContent"></div> | |
| <div id="hudStats"></div> | |
| <div class="button-group" id="buttonGroup"></div> | |
| </div> | |
| <!-- Detail Panel --> | |
| <div id="detailPanel" class="detail-panel hidden"> | |
| <div class="detail-title" id="detailTitle"></div> | |
| <div id="detailContent"></div> | |
| <div class="button-group" id="detailButtons"></div> | |
| </div> | |
| <!-- Controls Hint --> | |
| <div id="controlsHint" class="controls-hint hidden"> | |
| <div style="text-align: center;"><strong><a href="https://www.researchgate.net/publication/400868863" target="_blank">Neuraxon v2.0</a> Game of Life 5.0 Lite — CHC g-capable Brain + Best-g NxEr</strong></div> | |
| <div style="text-align: center;"><strong>By <a href="https://vivancos.com/" target="_blank">David Vivancos</a> & <a href="https://josesanchezgarcia.com/" target="_blank">Jose Sanchez</a></strong></div> | |
| <div style="text-align: center;"><strong>for <a href="https://qubic.org/" target="_blank">Qubic</a> Science</strong></div> | |
| <div>🖱️ Left Click + Drag: Rotate Camera</div> | |
| <div>🖱️ Right Click + Drag: Pan Camera</div> | |
| <div>🖱️ Scroll: Zoom</div> | |
| <div>🖱️ Click Object: Select NxEr</div> | |
| <div>⌨️ Space: Pause/Play</div> | |
| <div>🌓 Day/Night cycle affects behavior & metabolism</div> | |
| <div>🧠 Multi-Sphere Brain: Classic chain or CHC g-capable (6-sphere)</div> | |
| <div>🧬 9 Receptor Subtypes · ChronoPlasticity · AGMP</div> | |
| <div>🎼 Tonal voices · 🎤 Sing on food · 👂 Zoom in to hear</div> | |
| <div>⌨️ H: Toggle HUD</div> | |
| <div id="hideControlsHint" style="cursor: pointer; color: #4a9eff; text-decoration: underline; margin-top: 8px;">Hide Hints</div> | |
| </div> | |
| <!-- v4.5: Audio proximity badge --> | |
| <div id="audioBadge" style="position:fixed;bottom:20px;right:20px;background:rgba(0,0,0,0.7);border:1px solid #3c3c3c;border-radius:20px;padding:8px 14px;font-size:12px;color:#aaa;z-index:500;font-family:'Consolas',monospace;display:none"> | |
| <span id="audioBadgeIcon">🔇</span> | |
| <span id="audioBadgeText">Zoom in to hear NxErs sing</span> | |
| </div> | |
| <!-- Game Over Modal --> | |
| <div id="gameOverOverlay" class="modal-overlay hidden"></div> | |
| <div id="gameOverModal" class="modal hidden"> | |
| <div class="modal-title">Extinction Event</div> | |
| <div class="modal-subtitle">Restart with genetic champions?</div> | |
| <div class="modal-buttons"> | |
| <button class="modal-button yes" id="restartYes">Yes</button> | |
| <button class="modal-button no" id="restartNo">No</button> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; | |
| import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; | |
| import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; | |
| import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; | |
| // ===================================================================== | |
| // LOGIC UTILITIES | |
| // ===================================================================== | |
| function clamp(v, min, max) { | |
| return Math.max(min, Math.min(max, v)); | |
| } | |
| function random(min, max) { | |
| return Math.random() * (max - min) + min; | |
| } | |
| function randomInt(min, max) { | |
| return Math.floor(Math.random() * (max - min + 1)) + min; | |
| } | |
| function randomChoice(arr) { | |
| return arr[Math.floor(Math.random() * arr.length)]; | |
| } | |
| function base26Name(n) { | |
| let letters = []; | |
| let n0 = n; | |
| while (true) { | |
| const div = Math.floor(n0 / 26); | |
| const rem = n0 % 26; | |
| letters.push(String.fromCharCode(65 + rem)); | |
| if (div === 0) break; | |
| n0 = div - 1; | |
| } | |
| return letters.reverse().join(''); | |
| } | |
| function noise(x, y, scale, offset) { | |
| const ox = offset[0]; | |
| const oy = offset[1]; | |
| return (Math.sin((x + ox) * 0.15 * scale) + | |
| Math.cos((y + oy) * 0.13 * scale) + | |
| Math.sin(((x + ox) + (y + oy)) * 0.07 * scale)) * 0.333; | |
| } | |
| function stripLeadingDigits(name) { | |
| let i = 0; | |
| while (i < name.length && /\d/.test(name[i])) { | |
| i++; | |
| } | |
| return name.substring(i); | |
| } | |
| // ===================================================================== | |
| // VOICE & AUDIO SYSTEM (v4.5) — Bio-inspired tonal hearing for NxErs | |
| // ===================================================================== | |
| // Each NxEr has 6 harmonic tones forming its "voice". | |
| // Voices can be learned during mating, inherited by children, | |
| // attract clan members, and become audible only when the camera | |
| // is zoomed in close to the population. | |
| // --------------------------------------------------------------------- | |
| // Musical scales (semitone offsets from a root note) | |
| const MUSICAL_SCALES = { | |
| major: [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19], | |
| minor: [0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19], | |
| pentatonic: [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26], | |
| harmonic: [0, 12, 19, 24, 28, 31, 34, 36, 38, 40, 42, 43] // overtone series | |
| }; | |
| const SCALE_NAMES = Object.keys(MUSICAL_SCALES); | |
| // Consonant intervals (mod 12) — octave, fifth, fourth, major/minor third, major sixth | |
| const CONSONANT_SEMITONES = new Set([0, 3, 4, 5, 7, 8, 9, 12]); | |
| function semitoneToFreq(rootHz, semitones) { | |
| return rootHz * Math.pow(2, semitones / 12); | |
| } | |
| // Generate 6 harmonic tones based on a chosen root and scale | |
| function generateHarmonicVoice(rootHz = null, scaleName = null) { | |
| const root = rootHz || (160 + Math.random() * 280); // E3..F4 range | |
| const sName = scaleName || SCALE_NAMES[Math.floor(Math.random() * SCALE_NAMES.length)]; | |
| const scale = MUSICAL_SCALES[sName]; | |
| const tones = []; | |
| const usedIdx = new Set(); | |
| // Always include the root | |
| tones.push(root); | |
| usedIdx.add(0); | |
| while (tones.length < 6) { | |
| const idx = Math.floor(Math.random() * scale.length); | |
| if (usedIdx.has(idx)) continue; | |
| usedIdx.add(idx); | |
| tones.push(semitoneToFreq(root, scale[idx])); | |
| } | |
| return tones.sort((a, b) => a - b); | |
| } | |
| // Score how musically pleasing the voice is (0 .. 1) | |
| function musicalityScore(tones) { | |
| if (!tones || tones.length < 2) return 0; | |
| const sorted = [...tones].sort((a, b) => a - b); | |
| let score = 0; | |
| let count = 0; | |
| // Pairwise interval analysis | |
| for (let i = 0; i < sorted.length; i++) { | |
| for (let j = i + 1; j < sorted.length; j++) { | |
| const ratio = sorted[j] / sorted[i]; | |
| const semitones = 12 * Math.log2(ratio); | |
| const semiRound = Math.round(semitones); | |
| const closeness = Math.max(0, 1 - Math.abs(semitones - semiRound)); | |
| const consonant = CONSONANT_SEMITONES.has(semiRound % 12) ? 1.0 : 0.25; | |
| score += closeness * consonant; | |
| count++; | |
| } | |
| } | |
| // Range penalty — too wide a range is less singable | |
| const range = sorted[sorted.length - 1] / sorted[0]; | |
| const rangePenalty = range > 8 ? 0.6 : (range > 4 ? 0.85 : 1.0); | |
| return Math.min(1, (score / Math.max(1, count)) * rangePenalty); | |
| } | |
| // Voice similarity: 0..1, used for "same tones tend to be closer" | |
| function voiceSimilarity(voiceA, voiceB) { | |
| if (!voiceA || !voiceB) return 0; | |
| let matched = 0; | |
| for (const a of voiceA) { | |
| for (const b of voiceB) { | |
| // Two tones count as "same" if within ~50 cents (half a semitone) | |
| if (Math.abs(12 * Math.log2(a / b)) < 0.5) { matched++; break; } | |
| } | |
| } | |
| return matched / voiceA.length; | |
| } | |
| // Pick N random elements (without replacement) from an array | |
| function sampleFrom(arr, n) { | |
| const a = [...arr]; | |
| const out = []; | |
| while (out.length < n && a.length) { | |
| out.push(a.splice(Math.floor(Math.random() * a.length), 1)[0]); | |
| } | |
| return out; | |
| } | |
| // ================================================================= | |
| // GENERAL FACTOR (g) APPARATUS — v5.0 Lite | |
| // Pure-JS port of the Python neuraxon/gfactor.py methodology. | |
| // No numpy: correlation matrix + PC1 via power iteration, second | |
| // eigenvalue via Hotelling deflation. | |
| // | |
| // Seven CHC-style ability proxies are derived per living NxEr from | |
| // the quantities the Lite sim already tracks (no new dynamics): | |
| // Gs processing speed food acquired per second lived | |
| // Gv visuospatial world cells explored per second | |
| // Gf fluid reasoning closeness of branching ratio to the | |
| // critical point 1.0 (adaptive dynamics) | |
| // Glr learning/retrieval distinct food sources remembered | |
| // Gc crystallised tones learned + musicality (knowledge) | |
| // Gsm sensorimotor net energy efficiency (motor economy) | |
| // Ga auditory songs sung × musicality (vocal skill) | |
| // | |
| // Population read-outs mirror the paper: PC1 variance fraction, | |
| // positive-manifold %, mean off-diagonal r, and λ1/λ2. Each NxEr's | |
| // own g (its PC1 factor score) is written to nxer.stats.g so it | |
| // flows through the existing rankings / champions unchanged. | |
| // Never throws; needs ≥ MIN_AGENTS_FOR_G living NxErs. | |
| // ================================================================= | |
| const MIN_AGENTS_FOR_G = 8; | |
| function _gProxies(n) { | |
| const s = n.stats || {}; | |
| const tl = Math.max(1e-6, s.timeLived || 0); | |
| const branch = s.temporalSyncScore || 1.0; | |
| const knownFood = (n.knownFoodIds ? n.knownFoodIds.size : 0); | |
| const musical = n.musicality || 0; | |
| return [ | |
| (s.foodFound || 0) / tl, // Gs | |
| (s.explored || 0) / tl, // Gv | |
| 1.0 - Math.min(1.0, Math.abs(branch - 1.0)), // Gf | |
| knownFood, // Glr | |
| (n.tonesLearned || 0) + musical, // Gc | |
| s.energyEfficiency || 0, // Gsm | |
| (n.songsSung || 0) * (0.5 + musical) // Ga | |
| ]; | |
| } | |
| function _corrMatrix(cols) { | |
| // cols: array of P proxy-vectors, each length N (population). | |
| const P = cols.length, N = cols[0].length; | |
| const mean = new Array(P), sd = new Array(P); | |
| for (let p = 0; p < P; p++) { | |
| let m = 0; for (let i = 0; i < N; i++) m += cols[p][i]; | |
| m /= N; mean[p] = m; | |
| let v = 0; for (let i = 0; i < N; i++) { const d = cols[p][i] - m; v += d * d; } | |
| sd[p] = Math.sqrt(v / N); // population std (matches numpy ddof=0) | |
| } | |
| const R = []; | |
| for (let a = 0; a < P; a++) { | |
| R.push(new Array(P).fill(0)); | |
| } | |
| for (let a = 0; a < P; a++) { | |
| for (let b = a; b < P; b++) { | |
| if (sd[a] < 1e-12 || sd[b] < 1e-12) { | |
| const v = (a === b) ? 1.0 : 0.0; | |
| R[a][b] = R[b][a] = v; | |
| continue; | |
| } | |
| let c = 0; | |
| for (let i = 0; i < N; i++) | |
| c += (cols[a][i] - mean[a]) * (cols[b][i] - mean[b]); | |
| c = c / (N * sd[a] * sd[b]); | |
| R[a][b] = R[b][a] = c; | |
| } | |
| } | |
| return R; | |
| } | |
| function _powerIter(M, iters) { | |
| const P = M.length; | |
| let v = new Array(P).fill(1 / Math.sqrt(P)); | |
| let lambda = 0; | |
| for (let it = 0; it < (iters || 100); it++) { | |
| const w = new Array(P).fill(0); | |
| for (let a = 0; a < P; a++) { | |
| let s = 0; | |
| for (let b = 0; b < P; b++) s += M[a][b] * v[b]; | |
| w[a] = s; | |
| } | |
| let norm = 0; for (let a = 0; a < P; a++) norm += w[a] * w[a]; | |
| norm = Math.sqrt(norm); | |
| if (norm < 1e-12) break; | |
| for (let a = 0; a < P; a++) w[a] /= norm; | |
| lambda = norm; | |
| let diff = 0; for (let a = 0; a < P; a++) diff += Math.abs(w[a] - v[a]); | |
| v = w; | |
| if (diff < 1e-10) break; | |
| } | |
| return { vec: v, val: lambda }; | |
| } | |
| // Returns {pc1, posManifold, meanR, lambdaRatio, n} and writes each | |
| // living NxEr's PC1 factor score to nxer.stats.g (0 if too few). | |
| function computePopulationG(aliveNxers) { | |
| try { | |
| const pop = aliveNxers.filter(n => n && n.alive); | |
| const N = pop.length; | |
| if (N < MIN_AGENTS_FOR_G) { | |
| pop.forEach(n => { n.stats.g = 0; }); | |
| return { pc1: 0, posManifold: 0, meanR: 0, lambdaRatio: 1, n: N }; | |
| } | |
| const P = 7; | |
| const raw = pop.map(_gProxies); // N × P | |
| const cols = []; | |
| for (let p = 0; p < P; p++) cols.push(raw.map(r => r[p])); | |
| const R = _corrMatrix(cols); | |
| // Off-diagonal stats (positive manifold + mean r). | |
| let off = 0, pos = 0, sum = 0; | |
| for (let a = 0; a < P; a++) | |
| for (let b = 0; b < P; b++) | |
| if (a !== b) { off++; sum += R[a][b]; if (R[a][b] > 0) pos++; } | |
| const meanR = off ? sum / off : 0; | |
| const posManifold = off ? pos / off : 0; | |
| // λ1 via power iteration; λ2 via deflation. | |
| const e1 = _powerIter(R, 200); | |
| const lambda1 = e1.val; | |
| const v1 = e1.vec; | |
| const D = []; | |
| for (let a = 0; a < P; a++) { | |
| D.push(new Array(P)); | |
| for (let b = 0; b < P; b++) | |
| D[a][b] = R[a][b] - lambda1 * v1[a] * v1[b]; | |
| } | |
| const e2 = _powerIter(D, 200); | |
| const lambda2 = Math.max(1e-9, e2.val); | |
| const traceP = P; // trace of a correlation matrix = P | |
| const pc1 = lambda1 / traceP; | |
| const lambdaRatio = lambda1 / lambda2; | |
| // Per-NxEr g = z-scored proxy vector projected on PC1. | |
| const mean = new Array(P), sd = new Array(P); | |
| for (let p = 0; p < P; p++) { | |
| let m = 0; for (let i = 0; i < N; i++) m += cols[p][i]; | |
| m /= N; mean[p] = m; | |
| let v = 0; for (let i = 0; i < N; i++) { const d = cols[p][i] - m; v += d * d; } | |
| sd[p] = Math.sqrt(v / N) || 1; | |
| } | |
| for (let i = 0; i < N; i++) { | |
| let g = 0; | |
| for (let p = 0; p < P; p++) | |
| g += ((raw[i][p] - mean[p]) / sd[p]) * v1[p]; | |
| pop[i].stats.g = g; | |
| } | |
| return { pc1, posManifold, meanR, lambdaRatio, n: N }; | |
| } catch (e) { | |
| // Never let g measurement break the sim. | |
| try { (aliveNxers || []).forEach(n => { if (n && n.stats && n.stats.g === undefined) n.stats.g = 0; }); } catch (_) {} | |
| return { pc1: 0, posManifold: 0, meanR: 0, lambdaRatio: 1, n: 0 }; | |
| } | |
| } | |
| // ---------- AudioEngine (Web Audio) ---------- | |
| // Plays the NxEr voices as sine-tone melodies. Volume gated by camera zoom. | |
| class AudioEngine { | |
| constructor() { | |
| this.ctx = null; | |
| this.masterGain = null; | |
| this.enabled = false; | |
| this.targetVolume = 0; | |
| this.scheduledUntil = 0; | |
| this.maxConcurrent = 12; // Avoid clipping | |
| this.activeVoices = 0; | |
| } | |
| init() { | |
| if (this.ctx) return; | |
| try { | |
| const Ctx = window.AudioContext || window.webkitAudioContext; | |
| if (!Ctx) return; | |
| this.ctx = new Ctx(); | |
| this.masterGain = this.ctx.createGain(); | |
| this.masterGain.gain.value = 0; | |
| // Soft compressor to keep things gentle | |
| this.compressor = this.ctx.createDynamicsCompressor(); | |
| this.compressor.threshold.value = -20; | |
| this.compressor.knee.value = 12; | |
| this.compressor.ratio.value = 4; | |
| this.compressor.attack.value = 0.01; | |
| this.compressor.release.value = 0.25; | |
| this.masterGain.connect(this.compressor).connect(this.ctx.destination); | |
| this.enabled = true; | |
| } catch (e) { | |
| console.warn('AudioEngine init failed', e); | |
| } | |
| } | |
| resume() { | |
| if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); | |
| } | |
| // Camera proximity gate — silent when zoomed out | |
| setProximity(p) { | |
| this.targetVolume = Math.max(0, Math.min(1, p)); | |
| if (!this.masterGain) return; | |
| const t = this.ctx.currentTime; | |
| this.masterGain.gain.cancelScheduledValues(t); | |
| this.masterGain.gain.linearRampToValueAtTime(this.targetVolume * 0.35, t + 0.2); | |
| } | |
| playNote(freq, duration = 0.4, volume = 0.15, when = 0, type = 'sine') { | |
| if (!this.enabled || !this.ctx) return; | |
| if (this.activeVoices >= this.maxConcurrent) return; | |
| this.activeVoices++; | |
| const start = this.ctx.currentTime + when; | |
| const osc = this.ctx.createOscillator(); | |
| const gain = this.ctx.createGain(); | |
| osc.type = type; | |
| osc.frequency.value = freq; | |
| // Soft attack/decay envelope so notes don't click | |
| gain.gain.setValueAtTime(0.0001, start); | |
| gain.gain.exponentialRampToValueAtTime(volume, start + 0.04); | |
| gain.gain.exponentialRampToValueAtTime(volume * 0.6, start + duration * 0.6); | |
| gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); | |
| osc.connect(gain).connect(this.masterGain); | |
| osc.start(start); | |
| osc.stop(start + duration + 0.05); | |
| osc.onended = () => { this.activeVoices = Math.max(0, this.activeVoices - 1); }; | |
| } | |
| // Play a melodic phrase from a voice (sequence of tones) | |
| playMelody(voice, foodBonus = 0, baseVolume = 0.1) { | |
| if (!this.enabled || !voice || !voice.length) return; | |
| // foodBonus shifts pitch upwards (in semitones, capped) | |
| const semitoneShift = Math.min(6, foodBonus); | |
| const shift = Math.pow(2, semitoneShift / 12); | |
| const noteDur = 0.18; | |
| voice.forEach((f, i) => { | |
| this.playNote(f * shift, noteDur, baseVolume, i * noteDur * 0.85); | |
| }); | |
| } | |
| // Play a sustained chord (used when grouped clans sing together) | |
| playChord(voice, duration = 0.8, baseVolume = 0.06, foodBonus = 0) { | |
| if (!this.enabled || !voice || !voice.length) return; | |
| const shift = Math.pow(2, Math.min(6, foodBonus) / 12); | |
| voice.forEach(f => this.playNote(f * shift, duration, baseVolume, Math.random() * 0.05)); | |
| } | |
| } | |
| // Global audio engine instance | |
| const audioEngine = new AudioEngine(); | |
| // ===================================================================== | |
| // NEURAL NETWORK CLASSES — Neuraxon v2.0 Multi-Sphere Architecture | |
| // Features: ChronoPlasticity, DSN Dynamic Decay, CTSN Complement, | |
| // AGMP Astrocyte-Gated Plasticity, 9 Receptor Subtypes, | |
| // Multi-Band Oscillator Bank with PAC, Inter-Sphere Links | |
| // ===================================================================== | |
| // --- Sphere Type Definitions --- | |
| const SPHERE_TYPES = { | |
| sensory: { hex: '#ddaa44', label: 'Sensory', emoji: '👁', layer: 'L0' }, | |
| association: { hex: '#7799cc', label: 'Association', emoji: '🔄', layer: 'L1' }, | |
| motor: { hex: '#cc66aa', label: 'Motor', emoji: '💪', layer: 'L2' }, | |
| executive: { hex: '#aa77dd', label: 'Executive', emoji: '🧠', layer: 'L3' }, | |
| prefrontal: { hex: '#55cc88', label: 'Prefrontal', emoji: '🧩', layer: 'L2' }, | |
| // v5.0 Lite — CHC g-capable functional spheres (mirrors the | |
| // Python build_chc_multisphere: a broad sensory front-end, two | |
| // association spheres carrying fluid vs crystallised ability, | |
| // and a motor read-out. The lateral assoc_fluid↔assoc_cryst | |
| // link is the cross-battery (κ) coupling that lets a general | |
| // factor emerge across the proxy abilities. | |
| visual: { hex: '#e0b040', label: 'Visual', emoji: '👁', layer: 'L0' }, | |
| auditory: { hex: '#d0a060', label: 'Auditory', emoji: '👂', layer: 'L0' }, | |
| intero: { hex: '#c08050', label: 'Interoceptive',emoji: '🫀', layer: 'L0' }, | |
| assoc_fluid: { hex: '#6fa0d8', label: 'Assoc·Fluid', emoji: '🌊', layer: 'L1' }, | |
| assoc_cryst: { hex: '#8f7fd0', label: 'Assoc·Cryst', emoji: '📚', layer: 'L1' } | |
| }; | |
| // --- Receptor Subtype --- | |
| class ReceptorSubtype { | |
| constructor(name, parentMod, threshold, gain, isTonic) { | |
| this.name = name; | |
| this.parentMod = parentMod; | |
| this.threshold = threshold; | |
| this.gain = gain; | |
| this.isTonic = isTonic; | |
| this.activation = 0; | |
| } | |
| computeActivation(tonic, phasic) { | |
| const conc = this.isTonic ? tonic : (tonic + phasic); | |
| const k = this.isTonic ? 20 : 10; | |
| this.activation = this.gain / (1 + Math.exp(-k * (conc - this.threshold))); | |
| return this.activation; | |
| } | |
| } | |
| // --- Multi-Band Oscillator Bank with PAC --- | |
| class OscillatorBank { | |
| constructor() { | |
| this.bands = { | |
| infraslow: { freq: 0.05, phase: Math.random() * 6.28, amp: 0.3 }, | |
| slow: { freq: 0.5, phase: Math.random() * 6.28, amp: 0.4 }, | |
| theta: { freq: 6, phase: Math.random() * 6.28, amp: 0.5 }, | |
| alpha: { freq: 10, phase: Math.random() * 6.28, amp: 0.4 }, | |
| gamma: { freq: 40, phase: Math.random() * 6.28, amp: 0.6 } | |
| }; | |
| this.coupling = 0.1; | |
| } | |
| update(dt) { | |
| for (const b of Object.values(this.bands)) { | |
| b.phase = (b.phase + 2 * Math.PI * b.freq * dt / 1000) % (2 * Math.PI); | |
| } | |
| } | |
| getDrive(neuronId, N) { | |
| const phi = 2 * Math.PI * neuronId / Math.max(1, N); | |
| const thetaGate = Math.max(0, Math.cos(this.bands.theta.phase + phi)); | |
| const gammaSig = this.bands.gamma.amp * thetaGate * Math.sin(this.bands.gamma.phase + 2 * phi); | |
| const slowSig = this.bands.slow.amp * Math.sin(this.bands.slow.phase + 0.3 * phi); | |
| const infraSig = this.bands.infraslow.amp * Math.sin(this.bands.infraslow.phase); | |
| return this.coupling * (gammaSig + 0.5 * slowSig + 0.3 * infraSig); | |
| } | |
| getAmplitudes() { | |
| const r = {}; | |
| for (const [name, b] of Object.entries(this.bands)) { | |
| r[name] = Math.abs(Math.sin(b.phase)) * b.amp; | |
| } | |
| return r; | |
| } | |
| } | |
| // --- Neuromodulator System with 9 Receptor Subtypes --- | |
| class NeuromodulatorSystem { | |
| constructor(baselines = {}) { | |
| this.tonic = { | |
| DA: baselines.DA || random(0.08, 0.20), | |
| '5HT': baselines['5HT'] || random(0.08, 0.20), | |
| ACh: baselines.ACh || random(0.08, 0.20), | |
| NA: baselines.NA || random(0.08, 0.20) | |
| }; | |
| this.phasic = { DA: 0, '5HT': 0, ACh: 0, NA: 0 }; | |
| this.receptors = { | |
| D1: new ReceptorSubtype('D1', 'DA', 0.35, 1.0, false), | |
| D2: new ReceptorSubtype('D2', 'DA', 0.25, 1.0, true), | |
| '5HT1A': new ReceptorSubtype('5HT1A', '5HT', 0.05, 1.0, true), | |
| '5HT2A': new ReceptorSubtype('5HT2A', '5HT', 0.30, 1.0, false), | |
| '5HT4': new ReceptorSubtype('5HT4', '5HT', 0.20, 1.0, false), | |
| M1: new ReceptorSubtype('M1', 'ACh', 0.30, 1.0, false), | |
| M2: new ReceptorSubtype('M2', 'ACh', 0.10, 1.0, true), | |
| beta1: new ReceptorSubtype('beta1', 'NA', 0.20, 1.0, false), | |
| alpha2:new ReceptorSubtype('alpha2','NA', 0.08, 1.0, true) | |
| }; | |
| this.activations = {}; | |
| } | |
| update(activity, dt) { | |
| const rel = 0.02; | |
| // Tonic: decay toward baseline | |
| for (const k of Object.keys(this.tonic)) { | |
| this.tonic[k] = Math.max(0, Math.min(2, this.tonic[k])); | |
| } | |
| // Phasic: exponential decay + activity-driven release | |
| for (const k of Object.keys(this.phasic)) { | |
| this.phasic[k] *= Math.exp(-0.1 * dt); | |
| } | |
| this.phasic.DA += rel * (activity.stateChangeRate || 0) * dt; | |
| this.phasic['5HT'] += rel * (activity.meanActivity || 0) * dt; | |
| this.phasic.ACh += rel * (activity.excFraction || 0) * dt; | |
| this.phasic.NA += rel * (activity.stateChangeRate || 0) * dt; | |
| // Inter-modulator crosstalk | |
| this.phasic.ACh *= (1 - 0.1 * this.phasic.DA); | |
| this.tonic['5HT'] += 0.02 * (this.tonic.NA + this.phasic.NA) * dt; | |
| // Compute receptor activations | |
| for (const [name, r] of Object.entries(this.receptors)) { | |
| r.computeActivation(this.tonic[r.parentMod], this.phasic[r.parentMod]); | |
| this.activations[name] = r.activation; | |
| } | |
| } | |
| } | |
| class NetworkParameters { | |
| constructor(neuronsPerSphere = 12, numSpheres = 3, brainArch = 'classic') { | |
| this.networkName = "Neuraxon v2.0 Multi-Sphere"; | |
| this.numSpheres = numSpheres; | |
| this.neuronsPerSphere = neuronsPerSphere; | |
| this.brainArch = brainArch; // v5.0 Lite: 'classic' | 'chc6' | |
| this.numInputNeurons = 10; // v4.0: +SphereSync input | |
| this.numOutputNeurons = 6; | |
| this.dt = 1.0; | |
| this.simulationSteps = randomInt(20, 50); | |
| this.connectionProbability = random(0.10, 0.25); | |
| this.smallWorldK = 6; | |
| this.smallWorldRewireProb = 0.35; | |
| this.membraneTimeConstant = random(10.0, 40.0); | |
| this.firingThresholdExcitatory = random(0.4, 1.2); | |
| this.firingThresholdInhibitory = random(-1.2, -0.4); | |
| this.adaptationRate = random(0.01, 0.15); | |
| this.spontaneousFireRate = random(0.01, 0.05); | |
| this.neuronHealthDecay = random(0.0005, 0.005); | |
| this.numDendriticBranches = 3; | |
| this.branchThreshold = 0.6; | |
| this.plateauDecay = 500.0; | |
| this.tauFast = random(3.0, 8.0); | |
| this.tauSlow = random(30.0, 80.0); | |
| this.tauMeta = random(800.0, 2000.0); | |
| this.tauLTP = 15.0; | |
| this.tauLTD = 35.0; | |
| this.wFastInitMin = -1.0; | |
| this.wFastInitMax = 1.0; | |
| this.wSlowInitMin = -0.5; | |
| this.wSlowInitMax = 0.5; | |
| this.wMetaInitMin = -0.3; | |
| this.wMetaInitMax = 0.3; | |
| this.learningRate = random(0.005, 0.03); | |
| this.stdpWindow = random(15.0, 35.0); | |
| this.plasticityThreshold = 0.5; | |
| this.associativityStrength = random(0.05, 0.15); | |
| this.synapseIntegrityThreshold = 0.1; | |
| this.synapseFormationProb = random(0.01, 0.05); | |
| this.synapseDeathProb = random(0.005, 0.02); | |
| this.neuronDeathThreshold = 0.1; | |
| this.dopamineBaseline = random(0.08, 0.20); | |
| this.serotoninBaseline = random(0.08, 0.20); | |
| this.acetylcholineBaseline = random(0.08, 0.20); | |
| this.norepinephrineBaseline = random(0.08, 0.20); | |
| this.neuromodDecayRate = 0.1; | |
| this.energyBaseline = 100.0; | |
| this.firingEnergyCost = random(3.0, 8.0); | |
| this.plasticityEnergyCost = 10.0; | |
| this.metabolicRate = random(0.8, 1.3); | |
| this.recoveryRate = 0.5; | |
| this.targetFiringRate = 0.1; | |
| this.homeostaticPlasticityRate = 0.001; | |
| this.oscillatorCoupling = random(0.05, 0.20); | |
| this.maxAxonalDelay = 10.0; | |
| // v4.0: ChronoPlasticity | |
| this.chronoAlphaFast = 0.95; | |
| this.chronoAlphaSlow = 0.99; | |
| // v4.0: CTSN | |
| this.ctsn_rho = 0.9; | |
| // v4.0: AGMP | |
| this.agmp_lambdaE = 0.95; | |
| this.agmp_lambdaA = 0.999; | |
| this.agmp_eta = 0.005; | |
| // v4.0: Inter-sphere projection plasticity | |
| this.projectionLR = 0.005; | |
| } | |
| clone() { | |
| const p = new NetworkParameters(this.neuronsPerSphere, this.numSpheres, this.brainArch); | |
| Object.assign(p, this); | |
| return p; | |
| } | |
| } | |
| class DendriticBranch { | |
| constructor(branchId, parentNeuronId, params) { | |
| this.branchId = branchId; | |
| this.parentNeuronId = parentNeuronId; | |
| this.params = params; | |
| this.branchPotential = 0.0; | |
| this.plateauPotential = 0.0; | |
| } | |
| integrateInputs(synapticInputs, dt) { | |
| if (!synapticInputs || synapticInputs.length === 0) { | |
| this.plateauPotential += dt / this.params.plateauDecay * (-this.plateauPotential); | |
| this.branchPotential += dt / (this.params.membraneTimeConstant * 0.5) * (-this.branchPotential); | |
| return this.branchPotential + this.plateauPotential; | |
| } | |
| const linearSum = synapticInputs.reduce((sum, val) => sum + val, 0); | |
| const branchSignal = Math.tanh(linearSum); | |
| if (Math.abs(branchSignal) > this.params.branchThreshold) { | |
| this.plateauPotential = branchSignal * 0.8; | |
| } | |
| const tauBranch = Math.max(1.0, this.params.membraneTimeConstant * 0.3); | |
| this.branchPotential += dt / tauBranch * (branchSignal - this.branchPotential); | |
| return this.branchPotential + this.plateauPotential; | |
| } | |
| } | |
| // --- Synapse with ChronoPlasticity --- | |
| class Synapse { | |
| constructor(preId, postId, params) { | |
| this.preId = preId; | |
| this.postId = postId; | |
| this.params = params; | |
| this.wFast = random(params.wFastInitMin, params.wFastInitMax); | |
| this.wSlow = random(params.wSlowInitMin, params.wSlowInitMax); | |
| this.wMeta = random(params.wMetaInitMin, params.wMetaInitMax); | |
| this.isSilent = Math.random() < 0.1; | |
| this.isModulatory = Math.random() < 0.2; | |
| this.integrity = 1.0; | |
| this.axonalDelay = random(0, params.maxAxonalDelay); | |
| this.preTrace = 0.0; | |
| this.postTrace = 0.0; | |
| this.preTraceLTD = 0.0; | |
| this.neighborSynapses = []; | |
| this.potentialDeltaW = 0.0; | |
| // v4.0: ChronoPlasticity traces | |
| this.chronoFast = 0; | |
| this.chronoSlow = 0; | |
| this.chronoOmega = 0.5; | |
| // v4.0: AGMP eligibility trace | |
| this.eligibility = 0; | |
| } | |
| computeInput(preState) { | |
| if (this.isSilent) return 0.0; | |
| const delayFactor = Math.max(0.0, 1.0 - this.axonalDelay / 10.0); | |
| // v4.0: Include chrono traces in input | |
| return (this.wFast + this.wSlow) * preState * delayFactor + | |
| 0.15 * this.wFast * this.chronoFast + 0.08 * this.wSlow * this.chronoSlow; | |
| } | |
| updateChronoPlasticity(preState, dt) { | |
| const spike = Math.abs(preState) === 1 ? 1 : 0; | |
| this.chronoFast = this.params.chronoAlphaFast * this.chronoFast + spike; | |
| const omegaPower = Math.pow(this.params.chronoAlphaSlow, this.chronoOmega); | |
| this.chronoSlow = omegaPower * this.chronoSlow + spike; | |
| // Adaptive omega | |
| const sig = 1 / (1 + Math.exp(-(2 * spike + 1.5 * this.chronoSlow - 1))); | |
| this.chronoOmega = clamp(sig, 0.01, 0.99); | |
| } | |
| calculateDeltaW(preState, postState, receptorAct, dt) { | |
| this.preTrace += (-this.preTrace / this.params.tauLTP + (preState === 1 ? 1 : 0)) * dt; | |
| this.preTraceLTD += (-this.preTraceLTD / this.params.tauLTD + (preState === 1 ? 1 : 0)) * dt; | |
| this.postTrace += (-this.postTrace / this.params.tauLTP + (postState === 1 ? 1 : 0)) * dt; | |
| // v4.0: Differential DA gating — D1 gates LTP, D2 gates LTD | |
| const d1 = receptorAct.D1 || 0.5; | |
| const d2 = receptorAct.D2 || 0.5; | |
| let deltaW = 0; | |
| if (preState === 1 && postState === 1) { | |
| deltaW = this.params.learningRate * d1 * this.preTrace; | |
| } else if (preState === 1 && postState === -1) { | |
| deltaW = -this.params.learningRate * d2 * this.preTraceLTD; | |
| } | |
| // v4.0: AGMP eligibility update | |
| const psi = (preState === 1 && postState === 1) ? 1 : ((preState === 1 && postState === -1) ? -0.5 : 0); | |
| this.eligibility = this.params.agmp_lambdaE * this.eligibility + psi; | |
| return deltaW; | |
| } | |
| applyUpdate(dt, receptorAct, neighborDeltas, astrocyteState, modulatorySignal) { | |
| let deltaW = this.potentialDeltaW; | |
| if (neighborDeltas && neighborDeltas.length > 0) { | |
| const assoc = this.params.associativityStrength * | |
| neighborDeltas.slice(0, 3).reduce((sum, dw, i) => sum + dw / (i + 1), 0); | |
| deltaW += assoc; | |
| } | |
| // v4.0: AGMP — 4-factor rule: eligibility × modulation × astrocyte × learning | |
| const agmpDelta = this.params.agmp_eta * (modulatorySignal || 0.5) * astrocyteState * this.eligibility; | |
| deltaW += agmpDelta; | |
| // Update weights with serotonin modulation | |
| const hFast = this.preTrace; | |
| const hSlow = 0.5 * this.preTrace + 0.5 * this.postTrace; | |
| this.wFast += dt / this.params.tauFast * (-this.wFast + hFast + deltaW * 0.3); | |
| this.wFast = clamp(this.wFast, -1.0, 1.0); | |
| this.wSlow += dt / this.params.tauSlow * (-this.wSlow + hSlow + deltaW * 0.1); | |
| this.wSlow = clamp(this.wSlow, -1.0, 1.0); | |
| const ht2a = receptorAct['5HT2A'] || 0.5; | |
| const ht1a = receptorAct['5HT1A'] || 0.5; | |
| const metaFactor = 0.5 * ht2a + 0.1 * (1 - ht1a); | |
| this.wMeta += dt / this.params.tauMeta * (-this.wMeta + 0.05 * deltaW * metaFactor); | |
| this.wMeta = clamp(this.wMeta, -0.5, 0.5); | |
| const activity = Math.abs(this.wFast) + Math.abs(this.wSlow); | |
| if (activity < 0.01) this.integrity -= this.params.synapseDeathProb * dt; | |
| else this.integrity = Math.min(1.0, this.integrity + 0.001 * dt * activity); | |
| if (this.isSilent && this.preTrace > 0.5 && Math.random() < 0.01) this.isSilent = false; | |
| } | |
| getModulatoryEffect() { return this.isModulatory ? this.wMeta * 0.5 : 0.0; } | |
| } | |
| // --- Neuraxon v2.0 Unit with CTSN + AGMP --- | |
| class Neuraxon { | |
| constructor(id, type, params) { | |
| this.id = id; | |
| this.type = type; | |
| this.params = params; | |
| this.membranePotential = 0.0; | |
| this.trinaryState = 0; | |
| this.adaptation = 0.0; | |
| this.autoreceptor = 0.0; | |
| this.health = 1.0; | |
| this.isActive = true; | |
| this.dendriticBranches = []; | |
| for (let i = 0; i < params.numDendriticBranches; i++) { | |
| this.dendriticBranches.push(new DendriticBranch(i, id, params)); | |
| } | |
| this.energyLevel = params.energyBaseline; | |
| this.lastFiringTime = -1000.0; | |
| this.phase = Math.random() * 2 * Math.PI; | |
| this.naturalFrequency = random(0.5, 2.0); | |
| this.stateHistory = []; | |
| this.intrinsicTimescale = params.membraneTimeConstant; | |
| // v4.0: CTSN complement pathway | |
| this.complement = 0; | |
| // v4.0: AGMP astrocyte state | |
| this.astrocyteState = 0; | |
| // v4.0: DSN dynamic decay (input-conditioned) | |
| this.dynamicAlpha = 0.5; | |
| // v4.0: Running-average firing rate for homeostasis | |
| this.runningRate = 0.1; | |
| } | |
| setState(val) { this.trinaryState = val; this.membranePotential = val * 0.5; } | |
| nonlinearDendriticIntegration(synapticInputs, modulatoryInputs, dt) { | |
| let totalSynaptic = 0.0; | |
| for (let i = 0; i < this.dendriticBranches.length; i++) { | |
| const branchSynInputs = []; | |
| for (let j = i; j < synapticInputs.length; j += this.dendriticBranches.length) { | |
| branchSynInputs.push(synapticInputs[j]); | |
| } | |
| totalSynaptic += this.dendriticBranches[i].integrateInputs(branchSynInputs, dt); | |
| } | |
| const modEffect = (modulatoryInputs || []).reduce((s, v) => s + v, 0); | |
| return totalSynaptic + modEffect * 0.3; | |
| } | |
| update(synInputs, modInputs, extInput, receptorAct, dt, oscDrive) { | |
| if (!this.isActive) return; | |
| const prevState = this.trinaryState; | |
| if (this.type === 'input') { | |
| if (extInput !== undefined && extInput !== 0) { | |
| this.trinaryState = extInput > 0.25 ? 1 : (extInput < -0.25 ? -1 : 0); | |
| this.membranePotential = extInput; | |
| } | |
| this.stateHistory.push(this.trinaryState); | |
| if (this.stateHistory.length > 20) this.stateHistory.shift(); | |
| return; | |
| } | |
| const D = this.nonlinearDendriticIntegration(synInputs, modInputs, dt); | |
| // v4.0: DSN dynamic decay (input-conditioned α) | |
| const inputMag = Math.abs(D) + Math.abs(extInput || 0); | |
| this.dynamicAlpha = 1 / (1 + Math.exp(-(inputMag * 2 - 1))); | |
| this.dynamicAlpha = clamp(this.dynamicAlpha, 0.1, 0.95); | |
| // NA modulation of gain | |
| const gNA = 1 + 0.5 * (receptorAct.beta1 || 0) + 0.2 * (receptorAct.alpha2 || 0); | |
| const spont = (this.params.spontaneousFireRate + 0.3 * (receptorAct.alpha2 || 0)) * | |
| (Math.random() < 0.02 ? (Math.random() < 0.5 ? 1 : -1) : 0); | |
| // DSN update: s_t = α*s_{t-1} + (1-α)*(input) | |
| this.membranePotential = this.dynamicAlpha * this.membranePotential + | |
| (1 - this.dynamicAlpha) * (gNA * D + (extInput || 0) + oscDrive - this.adaptation + spont); | |
| // v4.0: CTSN complement pathway | |
| this.complement = this.params.ctsn_rho * this.complement + | |
| (1 - this.params.ctsn_rho) * Math.tanh(inputMag * 0.5); | |
| const augmented = this.membranePotential + this.complement; | |
| // v4.0: Saturated modulation & homeostatic threshold | |
| const rawMod = 0.3 * (receptorAct.M1 || 0) - 0.2 * (receptorAct.M2 || 0) + | |
| (modInputs || []).reduce((s, v) => s + v, 0); | |
| const deltaMeta = 0.3 * Math.tanh(rawMod); | |
| const deltaHomeo = this.params.homeostaticPlasticityRate * (this.runningRate - this.params.targetFiringRate); | |
| const thetaEff1 = this.params.firingThresholdExcitatory - deltaMeta + deltaHomeo - 0.1 * this.autoreceptor; | |
| const thetaEff2 = this.params.firingThresholdInhibitory - deltaMeta + deltaHomeo + 0.1 * this.autoreceptor; | |
| // Trinary readout from augmented state | |
| if (augmented > thetaEff1) this.trinaryState = 1; | |
| else if (augmented < thetaEff2) this.trinaryState = -1; | |
| else this.trinaryState = 0; | |
| // Adaptation & autoreceptor | |
| this.adaptation += dt / (this.params.membraneTimeConstant * 2) * (-this.adaptation + 0.1 * Math.abs(this.trinaryState)); | |
| this.autoreceptor += dt / (this.params.membraneTimeConstant * 4) * (-this.autoreceptor + 0.2 * this.trinaryState); | |
| // v4.0: Update AGMP astrocyte state | |
| this.astrocyteState = this.params.agmp_lambdaA * this.astrocyteState + | |
| (1 - this.params.agmp_lambdaA) * Math.abs(augmented); | |
| // Running-average firing rate | |
| this.runningRate += 0.01 * (Math.abs(this.trinaryState) - this.runningRate) * dt; | |
| // Energy | |
| if (this.trinaryState !== 0) { | |
| this.energyLevel -= this.params.firingEnergyCost * dt / this.params.membraneTimeConstant; | |
| } | |
| this.energyLevel = Math.max(0, Math.min(this.params.energyBaseline, this.energyLevel + this.params.recoveryRate * dt / this.params.membraneTimeConstant)); | |
| // Health decay for inactive neurons | |
| if (this.type === 'hidden') { | |
| const recent = this.stateHistory.slice(-20); | |
| const activity = recent.length > 0 ? recent.reduce((s, v) => s + Math.abs(v), 0) / recent.length : 0; | |
| if (activity < 0.01) this.health -= this.params.neuronHealthDecay * dt; | |
| else this.health = Math.min(1.0, this.health + 0.0001 * dt); | |
| if (this.health <= this.params.neuronDeathThreshold) this.isActive = false; | |
| } | |
| this.stateHistory.push(this.trinaryState); | |
| if (this.stateHistory.length > 20) this.stateHistory.shift(); | |
| } | |
| } | |
| // --- Single Sphere (a self-contained Neuraxon v2.0 network) --- | |
| class Sphere { | |
| constructor(id, type, params, nI = 4, nH = 12, nO = 4) { | |
| this.id = id; | |
| this.type = type; | |
| this.typeInfo = SPHERE_TYPES[type] || SPHERE_TYPES.association; | |
| this.params = params; | |
| this.nI = nI; this.nH = nH; this.nO = nO; | |
| this.oscillators = new OscillatorBank(); | |
| this.oscillators.coupling = params.oscillatorCoupling || 0.1; | |
| this.neuromodulators = { dopamine: params.dopamineBaseline, serotonin: params.serotoninBaseline, acetylcholine: params.acetylcholineBaseline, norepinephrine: params.norepinephrineBaseline }; | |
| this.inputNeurons = []; this.hiddenNeurons = []; this.outputNeurons = []; | |
| let nid = 0; | |
| for (let i = 0; i < nI; i++) this.inputNeurons.push(new Neuraxon(nid++, 'input', params)); | |
| for (let i = 0; i < nH; i++) this.hiddenNeurons.push(new Neuraxon(nid++, 'hidden', params)); | |
| for (let i = 0; i < nO; i++) this.outputNeurons.push(new Neuraxon(nid++, 'output', params)); | |
| this.allNeurons = [...this.inputNeurons, ...this.hiddenNeurons, ...this.outputNeurons]; | |
| this.synapses = []; | |
| this.inPorts = this.inputNeurons.map(n => n.id); | |
| this.outPorts = this.outputNeurons.map(n => n.id); | |
| this.energy = 0; | |
| this.time = 0; | |
| this.stepCount = 0; | |
| this.branchingRatio = 1.0; | |
| this.activationHistory = []; | |
| this.buildSmallWorld(); | |
| } | |
| buildSmallWorld() { | |
| const N = this.allNeurons.length; | |
| const shuffled = [...Array(N).keys()].sort(() => Math.random() - 0.5); | |
| const ring = shuffled.map(i => this.allNeurons[i]); | |
| const k = Math.min(Math.max(4, Math.round(N * 0.3)), N - 1); | |
| const hk = Math.max(Math.floor(k / 2), 1); | |
| const edges = new Set(); | |
| for (let i = 0; i < N; i++) { | |
| for (let j = 1; j <= hk; j++) { | |
| for (const t of [(i + j) % N, (i - j + N) % N]) { | |
| if (ring[i].type === 'output' && ring[t].type === 'input') continue; | |
| if (i !== t) edges.add(`${ring[i].id},${ring[t].id}`); | |
| } | |
| } | |
| } | |
| const ea = [...edges]; | |
| for (const e of ea) { | |
| if (Math.random() < this.params.smallWorldRewireProb) { | |
| edges.delete(e); | |
| const s2 = ring[Math.floor(Math.random() * N)]; | |
| const t2 = ring[Math.floor(Math.random() * N)]; | |
| if (s2.id !== t2.id && !edges.has(`${s2.id},${t2.id}`)) { | |
| edges.add(`${s2.id},${t2.id}`); | |
| } else edges.add(e); | |
| } | |
| } | |
| for (const e of edges) { | |
| const [a, b] = e.split(',').map(Number); | |
| this.synapses.push(new Synapse(a, b, this.params)); | |
| } | |
| } | |
| simulateStep(externalInputs = {}, receptorAct = {}) { | |
| const dt = this.params.dt; | |
| this.oscillators.update(dt); | |
| const synInputs = {}, modInputs = {}; | |
| this.allNeurons.forEach(n => { synInputs[n.id] = []; modInputs[n.id] = []; }); | |
| for (const s of this.synapses) { | |
| if (s.integrity <= 0) continue; | |
| const pre = this.allNeurons[s.preId]; | |
| if (!pre || !pre.isActive) continue; | |
| s.updateChronoPlasticity(pre.trinaryState, dt); | |
| synInputs[s.postId].push(s.computeInput(pre.trinaryState)); | |
| const mod = s.getModulatoryEffect(); | |
| if (mod !== 0) modInputs[s.postId].push(mod); | |
| } | |
| for (const n of this.allNeurons) { | |
| if (!n.isActive) continue; | |
| const ext = externalInputs[n.id] || 0.0; | |
| const osc = this.oscillators.getDrive(n.id, this.allNeurons.length); | |
| n.update(synInputs[n.id], modInputs[n.id], ext + osc, receptorAct, dt, osc); | |
| this.activationHistory.push(Math.abs(n.trinaryState)); | |
| } | |
| if (this.activationHistory.length > 500) this.activationHistory.splice(0, 250); | |
| // STDP + AGMP | |
| for (const s of this.synapses) { | |
| if (s.integrity <= 0) continue; | |
| const pre = this.allNeurons[s.preId], post = this.allNeurons[s.postId]; | |
| if (pre && pre.isActive && post && post.isActive) { | |
| s.potentialDeltaW = s.calculateDeltaW(pre.trinaryState, post.trinaryState, receptorAct, dt); | |
| } | |
| } | |
| for (const s of this.synapses) { | |
| if (s.integrity <= 0) continue; | |
| const pre = this.allNeurons[s.preId], post = this.allNeurons[s.postId]; | |
| if (pre && pre.isActive && post && post.isActive) { | |
| const neighborDW = s.neighborSynapses ? s.neighborSynapses.map(ns => ns.potentialDeltaW) : []; | |
| const astState = post.astrocyteState || 0.5; | |
| const modSig = 0.5 + (receptorAct.D1 || 0.5); | |
| s.applyUpdate(dt, receptorAct, neighborDW, astState, modSig); | |
| } | |
| } | |
| // Structural plasticity | |
| this.synapses = this.synapses.filter(s => s.integrity > this.params.synapseIntegrityThreshold); | |
| if (Math.random() < this.params.synapseFormationProb) { | |
| const active = this.allNeurons.filter(n => n.isActive && n.energyLevel > 20); | |
| if (active.length >= 2) { | |
| const pre = randomChoice(active), post = randomChoice(active); | |
| if (pre.id !== post.id && !(pre.type === 'output' && post.type === 'input')) { | |
| if (!this.synapses.some(s => s.preId === pre.id && s.postId === post.id)) { | |
| this.synapses.push(new Synapse(pre.id, post.id, this.params)); | |
| } | |
| } | |
| } | |
| } | |
| // Branching ratio | |
| if (this.activationHistory.length > 2) { | |
| const acts = this.activationHistory.slice(-100); | |
| const d = acts.slice(0, -1).reduce((s, v) => s + v, 0); | |
| if (d > 0) this.branchingRatio = clamp(acts.slice(1).reduce((s, v) => s + v, 0) / d, 0.1, 10.0); | |
| } | |
| this.energy += 0.01 * this.allNeurons.filter(n => n.isActive && n.trinaryState !== 0).length * dt; | |
| this.time += dt; | |
| this.stepCount++; | |
| } | |
| setInputStates(states) { | |
| states.forEach((state, i) => { if (i < this.inputNeurons.length) this.inputNeurons[i].setState(state); }); | |
| } | |
| getOutputStates() { return this.outputNeurons.map(n => n.isActive ? n.trinaryState : 0); } | |
| getActivityStats() { | |
| const active = this.allNeurons.filter(n => n.isActive); | |
| const exc = active.filter(n => n.trinaryState === 1).length; | |
| const inh = active.filter(n => n.trinaryState === -1).length; | |
| return { total: active.length, exc, inh, neutral: active.length - exc - inh, energy: this.energy }; | |
| } | |
| } | |
| // --- Inter-Sphere Projection --- | |
| class InterSphereProjection { | |
| constructor(srcSphereId, tgtSphereId, type, srcOutPorts, tgtInPorts) { | |
| this.srcId = srcSphereId; | |
| this.tgtId = tgtSphereId; | |
| this.type = type; // 'FF', 'FB', 'LAT' | |
| this.weights = []; | |
| for (let r = 0; r < srcOutPorts.length; r++) { | |
| const row = []; | |
| for (let c = 0; c < tgtInPorts.length; c++) row.push((Math.random() - 0.3) * 0.5); | |
| this.weights.push(row); | |
| } | |
| this.plasticityRate = 0.005; | |
| } | |
| computeGatedSignals(srcSphere, tgtSphere) { | |
| let gate = 1; | |
| const srcOsc = srcSphere.oscillators.bands; | |
| const tgtOsc = tgtSphere.oscillators.bands; | |
| if (this.type === 'FF') { | |
| gate = 1 + 0.3 * Math.abs(Math.sin(srcOsc.gamma.phase)) * Math.max(0, Math.cos(srcOsc.gamma.phase)); | |
| } else if (this.type === 'FB') { | |
| gate = 1 + 0.3 * Math.max(0, -Math.cos(tgtOsc.alpha.phase)); | |
| } else if (this.type === 'LAT') { | |
| gate = 1 + 0.2 * Math.max(0, Math.cos(srcOsc.theta.phase - tgtOsc.theta.phase)); | |
| } | |
| const signals = {}; | |
| const srcOuts = srcSphere.getOutputStates(); | |
| for (let r = 0; r < Math.min(srcOuts.length, this.weights.length); r++) { | |
| for (let c = 0; c < this.weights[r].length; c++) { | |
| const tgtPort = tgtSphere.inPorts[c]; | |
| if (tgtPort !== undefined) { | |
| signals[tgtPort] = (signals[tgtPort] || 0) + gate * this.weights[r][c] * srcOuts[r]; | |
| } | |
| } | |
| } | |
| return signals; | |
| } | |
| updatePlasticity(srcSphere, tgtSphere, globalDA, dt) { | |
| const dg = 0.5 + globalDA; | |
| const srcOuts = srcSphere.getOutputStates(); | |
| for (let r = 0; r < Math.min(srcOuts.length, this.weights.length); r++) { | |
| for (let c = 0; c < this.weights[r].length; c++) { | |
| const tgtNeuron = tgtSphere.allNeurons[tgtSphere.inPorts[c]]; | |
| if (!tgtNeuron) continue; | |
| this.weights[r][c] = clamp( | |
| this.weights[r][c] + this.plasticityRate * srcOuts[r] * tgtNeuron.trinaryState * dg * dt, | |
| -1.5, 1.5 | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| // --- Multi-Sphere Network (the "brain" of each NxEr) --- | |
| class NeuraxonNetwork { | |
| constructor(params) { | |
| this.params = params; | |
| this.neuromodSystem = new NeuromodulatorSystem({ | |
| DA: params.dopamineBaseline, | |
| '5HT': params.serotoninBaseline, | |
| ACh: params.acetylcholineBaseline, | |
| NA: params.norepinephrineBaseline | |
| }); | |
| // Legacy accessor for neuromodulators | |
| this.neuromodulators = { | |
| dopamine: params.dopamineBaseline, | |
| serotonin: params.serotoninBaseline, | |
| acetylcholine: params.acetylcholineBaseline, | |
| norepinephrine: params.norepinephrineBaseline | |
| }; | |
| // Build spheres based on numSpheres parameter | |
| this.spheres = []; | |
| this.projections = []; | |
| const nS = params.numSpheres || 3; | |
| const nPS = params.neuronsPerSphere || 12; | |
| const nI = Math.max(2, Math.round(nPS * 0.3)); | |
| const nO = Math.max(2, Math.round(nPS * 0.25)); | |
| const nH = Math.max(3, nPS - nI - nO); | |
| if (params.brainArch === 'chc6') { | |
| // ----- v5.0 Lite: CHC g-capable 6-sphere topology ----- | |
| // Mirrors the Python build_chc_multisphere: a broad | |
| // sensory front-end (visual / auditory / intero), two | |
| // association spheres (fluid vs crystallised), and a | |
| // motor read-out. Cross-battery (κ) coupling — the | |
| // LATERAL assoc_fluid↔assoc_cryst link — is what lets a | |
| // single general factor emerge across the proxies. | |
| // Contract preserved: spheres[0] receives external | |
| // inputs (setInputStates) and spheres[last] is motor | |
| // (getOutputStates), so the rest of the engine is | |
| // unchanged. | |
| this.brainArch = 'chc6'; | |
| const S = (id, t, sI, sO) => | |
| new Sphere(id, t, params, sI, nH, sO); | |
| const visual = S('S0_visual', 'visual', | |
| Math.max(nI, params.numInputNeurons), nO); | |
| const auditory= S('S1_auditory', 'auditory', nI, nO); | |
| const intero = S('S2_intero', 'intero', nI, nO); | |
| const aFluid = S('S3_fluid', 'assoc_fluid', nI, nO); | |
| const aCryst = S('S4_cryst', 'assoc_cryst', nI, nO); | |
| const motor = S('S5_motor', 'motor', | |
| nI, Math.max(nO, params.numOutputNeurons)); | |
| this.spheres = [visual, auditory, intero, aFluid, aCryst, motor]; | |
| const P = (a, b, kind) => this.projections.push( | |
| new InterSphereProjection(a.id, b.id, kind, | |
| a.outPorts, b.inPorts)); | |
| // Sensory front-end → fluid/crystallised association | |
| P(visual, aFluid, 'FF'); | |
| P(auditory, aFluid, 'FF'); | |
| P(intero, aCryst, 'FF'); | |
| P(visual, aCryst, 'FF'); | |
| // Cross-battery coupling (κ) — the lesionable link | |
| P(aFluid, aCryst, 'LAT'); | |
| P(aCryst, aFluid, 'LAT'); | |
| // Association → motor read-out | |
| P(aFluid, motor, 'FF'); | |
| P(aCryst, motor, 'FF'); | |
| // Top-down feedback motor → association | |
| P(motor, aFluid, 'FB'); | |
| P(motor, aCryst, 'FB'); | |
| } else { | |
| this.brainArch = 'classic'; | |
| const sphereTypes = nS === 2 ? ['sensory', 'motor'] : | |
| nS === 3 ? ['sensory', 'association', 'motor'] : | |
| nS === 4 ? ['sensory', 'association', 'prefrontal', 'motor'] : | |
| ['sensory', 'association', 'prefrontal', 'executive', 'motor']; | |
| for (let i = 0; i < nS; i++) { | |
| const t = sphereTypes[i] || 'association'; | |
| const sI = i === 0 ? Math.max(nI, params.numInputNeurons) : nI; | |
| const sO = i === nS - 1 ? Math.max(nO, params.numOutputNeurons) : nO; | |
| this.spheres.push(new Sphere(`S${i}`, t, params, sI, nH, sO)); | |
| } | |
| // Build inter-sphere projections (feedforward chain + feedback) | |
| for (let i = 0; i < this.spheres.length - 1; i++) { | |
| const src = this.spheres[i], tgt = this.spheres[i + 1]; | |
| this.projections.push(new InterSphereProjection(src.id, tgt.id, 'FF', src.outPorts, tgt.inPorts)); | |
| // Feedback from next to current | |
| if (i < this.spheres.length - 2) { | |
| this.projections.push(new InterSphereProjection(tgt.id, src.id, 'FB', tgt.outPorts, src.inPorts)); | |
| } | |
| } | |
| // Lateral connections for spheres > 3 | |
| if (this.spheres.length >= 4) { | |
| const mid = Math.floor(this.spheres.length / 2); | |
| this.projections.push(new InterSphereProjection( | |
| this.spheres[0].id, this.spheres[mid].id, 'LAT', | |
| this.spheres[0].outPorts, this.spheres[mid].inPorts | |
| )); | |
| } | |
| } | |
| this.time = 0; | |
| this.stepCount = 0; | |
| this.totalEnergyConsumed = 0; | |
| this.branchingRatio = 1.0; | |
| } | |
| setInputStates(states) { | |
| // Feed inputs to the first sphere (sensory) | |
| this.spheres[0].setInputStates(states); | |
| } | |
| simulateStep() { | |
| const dt = this.params.dt; | |
| // Compute inter-sphere signals | |
| const interSignals = {}; | |
| this.spheres.forEach(s => interSignals[s.id] = {}); | |
| for (const proj of this.projections) { | |
| const src = this.spheres.find(s => s.id === proj.srcId); | |
| const tgt = this.spheres.find(s => s.id === proj.tgtId); | |
| if (!src || !tgt) continue; | |
| const signals = proj.computeGatedSignals(src, tgt); | |
| for (const [nid, val] of Object.entries(signals)) { | |
| interSignals[tgt.id][nid] = (interSignals[tgt.id][nid] || 0) + val; | |
| } | |
| } | |
| // Compute network activity for neuromodulator update | |
| let totalAct = 0, totalN = 0, prevStates = new Map(); | |
| this.spheres.forEach(s => s.allNeurons.forEach(n => { | |
| totalAct += Math.abs(n.trinaryState); totalN++; | |
| prevStates.set(n, n.trinaryState); | |
| })); | |
| let stateChanges = 0; | |
| const activity = { | |
| meanActivity: totalN > 0 ? totalAct / totalN : 0, | |
| excFraction: 0, | |
| stateChangeRate: 0 | |
| }; | |
| // Update global neuromodulator system | |
| this.neuromodSystem.update(activity, dt); | |
| const rAct = this.neuromodSystem.activations; | |
| // Sync legacy neuromodulators | |
| this.neuromodulators.dopamine = this.neuromodSystem.tonic.DA + this.neuromodSystem.phasic.DA; | |
| this.neuromodulators.serotonin = this.neuromodSystem.tonic['5HT'] + this.neuromodSystem.phasic['5HT']; | |
| this.neuromodulators.acetylcholine = this.neuromodSystem.tonic.ACh + this.neuromodSystem.phasic.ACh; | |
| this.neuromodulators.norepinephrine = this.neuromodSystem.tonic.NA + this.neuromodSystem.phasic.NA; | |
| // Simulate each sphere | |
| for (const sphere of this.spheres) { | |
| sphere.simulateStep(interSignals[sphere.id] || {}, rAct); | |
| } | |
| // Update inter-sphere projection plasticity | |
| const globalDA = this.neuromodSystem.tonic.DA + this.neuromodSystem.phasic.DA; | |
| for (const proj of this.projections) { | |
| const src = this.spheres.find(s => s.id === proj.srcId); | |
| const tgt = this.spheres.find(s => s.id === proj.tgtId); | |
| if (src && tgt) proj.updatePlasticity(src, tgt, globalDA, dt); | |
| } | |
| // Update branching ratio from last sphere | |
| this.branchingRatio = this.spheres[this.spheres.length - 1].branchingRatio; | |
| this.time += dt; | |
| this.stepCount++; | |
| } | |
| getOutputStates() { | |
| // Output from the last sphere (motor) | |
| return this.spheres[this.spheres.length - 1].getOutputStates(); | |
| } | |
| getEnergyStatus() { | |
| let totalEnergy = 0, count = 0; | |
| this.spheres.forEach(s => s.allNeurons.forEach(n => { | |
| if (n.isActive) { totalEnergy += n.energyLevel; count++; } | |
| })); | |
| return { | |
| totalEnergy, averageEnergy: count > 0 ? totalEnergy / count : 0, | |
| efficiency: this.stepCount > 0 ? this.totalEnergyConsumed / this.stepCount : 0, | |
| branchingRatio: this.branchingRatio | |
| }; | |
| } | |
| getSpheresSummary() { | |
| return this.spheres.map(s => ({ | |
| id: s.id, type: s.type, label: s.typeInfo.label, color: s.typeInfo.hex, | |
| stats: s.getActivityStats(), | |
| oscAmps: s.oscillators.getAmplitudes() | |
| })); | |
| } | |
| getProjectionsSummary() { | |
| return this.projections.map(p => ({ src: p.srcId, tgt: p.tgtId, type: p.type })); | |
| } | |
| getReceptorActivations() { return { ...this.neuromodSystem.activations }; } | |
| clone() { return new NeuraxonNetwork(this.params.clone()); } | |
| } | |
| // ===================================================================== | |
| // WORLD GENERATION (Data-Based Earth Map) | |
| // ===================================================================== | |
| const TERRAIN_SEA = 0; | |
| const TERRAIN_LAND = 1; | |
| const TERRAIN_ROCK = 2; | |
| class World { | |
| constructor(N, seaPct, rockPct, useEarth = false) { | |
| this.N = N; | |
| this.grid = Array(N).fill(null).map(() => Array(N).fill(TERRAIN_LAND)); | |
| this.noiseOffsets = [ | |
| [Math.random() * 1000, Math.random() * 1000], | |
| [Math.random() * 1000, Math.random() * 1000], | |
| [Math.random() * 1000, Math.random() * 1000], | |
| [Math.random() * 1000, Math.random() * 1000], | |
| [Math.random() * 1000, Math.random() * 1000] | |
| ]; | |
| if (useEarth) { | |
| this.generateEarth(); | |
| } else { | |
| this.generate(seaPct, rockPct); | |
| } | |
| } | |
| // --- REAL EARTH DATA MAP --- | |
| generateEarth() { | |
| // . = Sea | |
| // : = Land | |
| // ^ = Mountains (Andes, Rockies, Himalayas, Alps) | |
| // = = Ice (polar regions) | |
| const earthMap = [ | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "..............................................................................................^^^^^^^^^.........................................................................................................................................", | |
| "...........................................................^^^^^^^^^^^^^^^^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^...^^^.................................................................................................................................", | |
| "........................................................^^^^^^...^^^^^^^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........................^^^^^^.............^^............................^^^^^......................................................", | |
| ".............................................^....^^^.^^.^^^^.^^^^^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....................^^^^^......................................................^^^..................................................", | |
| "........................................^^...................^^^^^^.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......................^^^........................................................^^..................................................", | |
| "..........................................^^^^.^^...^^^...^^....^...............^^^^^^^^^^^^^^^^^^^^^^^^^^^....................................................^^^^...................^^^^^^^^^^^^^^................^^^^........................", | |
| ".....................................^^^..........................................^^^^^^^^^^^^^^^^^^^^^^^^...................................................^^...................^^^^^^^^^^^^^^^...............................................", | |
| ".....................................^^^^.^^^.^.^^..^^^.^^..^^^^^^.................^^^^^^^^^^^^^^^^^^^^^^..................................................^^.........^^......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......^^.........................", | |
| "^^.............^......................^...^^^^^^^^......^^..^^^^^^^^^^^^.............^^^^^^^^^^^^^^^^^^^^..................................................^^^.......^^^.^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..^^^^^^^^^^^^^..................^", | |
| "...........^^^^^^^^^^^^^^^......^^^^..^...^^^^^^^^^^^.^.^^.....^....^^^^^^^.........^^^^^^^^^^^^^^^^^^^^.............................^^^^^^^^^..................^....^^^.^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......^^^^^..", | |
| "^^.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....^^....^^.^^^^..^^......^^^^..........^^^^^^^^^^^^^^^..............................^^^^^^^^^^^^^^^^...^^...^^.^^^^^^^^.^^^.^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", | |
| "^^^.^^......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......^^^^^^^^.....^^^^^^^^^^^^^................................^^^^^^^^^^^^^^^^^^...^^^^^^^^^^^^^^^^^^.^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", | |
| "...^^....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.^....^^^^^^^..^.......^^^^^^^^............^^^^^^.................^^^^^^...^^^^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", | |
| ".............^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..^^.^^.....^^^^^.........^^^^^^^.............^^..................^^^^^^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.", | |
| "..........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......^...^^^...^^...........^^^^^................................^^^^^^^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.^^^^^^^^..", | |
| "..........^^^^^^^^^^^.^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...........^^^^^^...............^^................................^^^^^^^^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....^^^^^......", | |
| "............:::::..........::::::::::::::::::::::::::::::...........::::::..::..............................................::::::::....::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::..:..::....::...........", | |
| "...............:.:.............:::::::::::::::::::::::::::...........::::::::::.....................................:...........:::.....:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::............:::............", | |
| ".............:..................::::::::::::::::::::::::::::::.......::::::::::.....................................::.......::.:::...::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::............:::::...........", | |
| "..........:......................::::::::::::::::::::::::::::::::..:::::::::::::::.................................:.::.......:....:.:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::..............::::............", | |
| "...............................:..:::::::::::::::::::::::::::::::..::::::::::::::::...............................::.:::...::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::.........:::.............", | |
| "...................................:::::::::::::::::::::::::::::::.:::::::::::::::...................................::::..:::::::::::^^:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::.........:...............", | |
| "...................................:::::::::::::::::::::::::::::::::::::::::..::....................................::...::::::::::::::^^:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::.:........................", | |
| "....................................::::::::::::::::::::::::::::::::::::::.::....:::.................................:.:::::::::::::::^^^^:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::...........................", | |
| ".....................................::::::::::::::::::::::::::::::::::::::::.......:.................................:::::::::::::::::^^::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::...........................", | |
| ".....................................::::::^^:::::::::::::::::::::::::::::::::::.......................................:::::::::::::::::::::..:..:::::::...:::::::::::::::::::::::::::::::::::::::::::::::::::::::::............................", | |
| ".....................................::::::^^:::::::::::::::::::::::::::::..:.........................................^^::::::::..:::::::::.......:::::...:::::::::::::::::::::::::::::::::::::::::::::::::::::::::...::........................", | |
| ".....................................::::::^^::::::::::::::::::::::::::::.........................................::::::::.....::...:::::::.........::::...:::::::::::::::::::::::::::::::::::::::::::::::::::::.....:::........................", | |
| ".....................................:::::::^^::::::::::::::::::::::::::..........................................:::::::........::..::::::..::::..::::::...::::::::::::::::::::::::::::::::::::::::::::::::::..................................", | |
| ".....................................::::::::^^::::::::::::::::::::::::...........................................::::::..........:..::...:::::::::::::::...::::::::::::::::::::::::::::::::::::::::::::.:.::........::.........................", | |
| "......................................:::::::^^:::::::::::::::::::::::............................................::::::..........:...::..:::::::::::::::...:::::::::::::::::::::::::::::::::::::::::::....:::.......:..........................", | |
| ".......................................::::::::::::::::::::::::::::::.......................................................:::...........:::::::::::::::::::::::::::::^^^^^^^^^^^^^^^:::::::::::::::::::...::.....:::..........................", | |
| ".......................................::::::::::::::::::::::::::::::...............................................:::::::::::.........::....:.:::::::::::::::::::::::::^^^^^^^^^^^^^^:::::::::::::::::....::..::::::..........................", | |
| ".........................................:::::::::::::::::::::::::::...............................................::::::::::::.................::::::::::::::::::::::::::^^^^^^^^^^^^^:::::::::::::::::........:::.............................", | |
| "..........................................::::::::::::::::::::::::................................................::::::::::::::::...::........:::::::::::::::::::::::::::::::::^^^^^^^^:::::::::::::::::......:................................", | |
| "...........................................:.:::::::::::::::::::::................................................::::::::::::::::::.::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::.......................................", | |
| "...........................................:.::::::::::::..:.....:...............................................:::::::::::::::::::::::::::::::::::::::..:::::::::::::::::::::::::::::::::::::::::::::::.......................................", | |
| "...........................................::.:::::::::..........:.............................................:::::::::::::::::::::::::::::::..:::::::::..::::::::::::::::::::::::::::::::::::::::::::::.......................................", | |
| ".............................................:.::::::::..........::.:.........................................:::::::::::::::::::::::::::::::::.:::::::::....:::::::::::::::::::::::::::::::::::::::::::........................................", | |
| ".............................................:..:::::::.......................................................:::::::::::::::::::::::::::::::::..:::::::::..::.......::::::::::::::::::::::::::::::::::.........................................", | |
| ".................................................::::::......................................................:::::::::::::::::::::::::::::::::::..:::::::::::::.......::::::::::::::::::::::::::::::::..:.......................................", | |
| "..................................................:::::............:.........................................::::::::::::::::::::::::::::::::::::.::::::::::::::......:::::::::::::..::::::::::::::.............................................", | |
| "..................................................:::::.....::......:::......................................::::::::::::::::::::::::::::::::::::..::::::::::::..........:::::::::....:::::::::.................................................", | |
| "...................................................:::::...:::..........::...................................::::::::::::::::::::::::::::::::::::..:::::::::::...........:::::::.......::::::::.::..............................................", | |
| ".....................................................::::::::................................................:::::::::::::::::::::::::::::::::::::..:::::::::............::::::........::::::::.........::......................................", | |
| ".......................................................:::::::::.............................................:::::::::::::::::::::::::::::::::::::..:::::::..............:::::.........:.:::::::........:.......................................", | |
| "...........................................................::::::............................................:::::::::::::::::::::::::::::::::::::::.::::.................:::............::::::::.......:.......................................", | |
| ".............................................................:::.............................................:::::::::::::::::::::::::::::::::::::::::....................:::.............:::::::.........:.....................................", | |
| "..............................................................:::......:.:....................................:::::::::::::::::::::::::::::::::::::::...::.................::................:::.........:.:....................................", | |
| "..............................................................:::::::::::::::::................................:::::::::::::::::::::::::::::::::::::::::::.................::.................:.................................................", | |
| ".................................................................:::::::::::::::...............................::::::::::::::::::::::::::::::::::::::::::....................:...........::...............::....................................", | |
| "....................................................................:::::::::::::...............................:::::::::::::::::::::::::::::::::::::::::....................:.............:...............:....................................", | |
| "....................................................................:::::::::::::::::.............................::::::...:::::::::::::::::::::::::::::................................:..::........::.........................................", | |
| ".....................................................................:::::::::::::::::........................................::::::::::::::::::::::::::................................::.::.......::..........................................", | |
| "....................................................................::^^::::::::::::::........................................:::::::::::::::::::::::::..................................::.:.....:::::.........................................", | |
| "...................................................................::::^^::::::::::::::.......................................:::::::::::::::::::::::.....................................:::....::::::.:::..:..................................", | |
| "..................................................................:::::^^::::::::::::::::.....................................::::::::::::::::::::::.......................................::....:::::.......:..:...............................", | |
| "..................................................................:::::^^:::::::::::::::::....................................:::::::::::::::::::::........................................:::...:::::.::........:.::...........................", | |
| "..................................................................:::::^^:::::::::::::::::::::.................................::::::::::::::::::::.........................................:::.....:....:..:....:::::::........................", | |
| "..................................................................:::::^^:::::::::::::::::::::::................................::::::::::::::::::...........................................::.....................:::::.......................", | |
| "..................................................................:::::^^::::::::::::::::::::::::...............................::::::::::::::::::............................................::..:..............:..::::::......:...............", | |
| "...................................................................:::::^^:::::::::::::::::::::::................................:::::::::::::::::................................................:::...............::::.::.......:.............", | |
| "....................................................................::::^^::::::::::::::::::::::.................................::::::::::::::::::.......................................................:...............::....................", | |
| "....................................................................::::^^:::::::::::::::::::::..................................::::::::::::::::::.............................................................................................", | |
| ".....................................................................::::^^::::::::::::::::::::..................................::::::::::::::::::............................................................::::....:........................", | |
| ".....................................................................::::^^^::::::::::::::::::..................................:::::::::::::::::::.....:...................................................:.:::::...::........................", | |
| "......................................................................:::::^^::::::::::::::::..................................:::::::::::::::::::....:::.................................................::::::::...:::.......................", | |
| "........................................................................:::^^:::::::::::::::::..................................:::::::::::::::::....::::.................................................:::::::::::.:::.......................", | |
| ".........................................................................::^^:::::::::::::::::..................................::::::::::::::::.....::::................................................:::::::::::::::::......................", | |
| ".........................................................................::^^::::::::::::::::....................................::::::::::::::.......::...............................................::::::::::::::::::::..........:..........", | |
| ".........................................................................::^^::::::::::::::::....................................:::::::::::::::.....:::.............................................:::::::::::::::::::::::..........:.........", | |
| ".........................................................................::^^::::::::::::::.......................................::::::::::::::.....:::............................................:::::::::::::::::::::::::...................", | |
| ".........................................................................::^^:::::::::::..........................................:::::::::::::......::.............................................::::::::::::::::::::::::::..................", | |
| ".........................................................................::^^:::::::::::..........................................::::::::::::......................................................::::::::::::::::::::::::::..................", | |
| ".........................................................................:^^:::::::::::..........................................::::::::::::......................................................::::::::::::::::::::::::::..................", | |
| "........................................................................::^^:::::::::::............................................::::::::::........................................................:::::::::::::::::::::::::..................", | |
| "........................................................................::^^::::::::::..............................................::::::::.........................................................:::::::::::::::::::::::::..................", | |
| "........................................................................::^^:::::::::...............................................:::::::..........................................................::::::::....:::::::::::::..................", | |
| "........................................................................:^::::::::::................................................:::::............................................................::::::.......:.:::::::::...................", | |
| "........................................................................:^::::::::..................................................................................................................................::::::::...............:....", | |
| ".......................................................................::^::::::::...................................................................................................................................:::::::................:...", | |
| ".......................................................................::^:::::::.....................................................................................................................................::.:..................:::.", | |
| ".......................................................................::^:::::.............................................................................................................................................................::..", | |
| ".......................................................................::^:::............................................................................................................................................::.................:...", | |
| ".......................................................................::^:::............................................................................................................................................::...............::....", | |
| "......................................................................::::::............................................................................................................................................................::......", | |
| "......................................................................:::::............................................................................................................................................................:::......", | |
| "......................................................................::::::....................................................................................................................................................................", | |
| "......................................................................:::::.....................................................................................................................................................................", | |
| "......................................................................::::......................................................................................................................................................................", | |
| "......................................................................::::.....::...............................................................................................................................................................", | |
| ".......................................................................::::.....................................................................................................................................................................", | |
| "........................................................................:::::...................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................................................................................................................................................................................", | |
| "................................................................................^^..............................................................................................................................................................", | |
| ".............................................................................^..................................................................................................................................................................", | |
| "...........................................................................^^.............................................................................^^^^....................^........^^^^..^^^^^...^^^^^^^^^^.............................", | |
| "...........................................................................^.........................................................................^^^^^^^^^^^^^^^^^.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....................", | |
| "........................................................................^^^^^^...............................................................^^^^^^^^^^^^^^^^^^^^^^^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..............", | |
| ".......................................................................^^^.^^^^....................................^..^^.^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......", | |
| "............................................................^..............^^^^................................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......", | |
| ".........................................^^^^........^^^^^^^^^^^^^^^^^^^^^^^^^^...............................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.........", | |
| "......................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...............................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...........", | |
| "..............^.^^..^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..............................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...........", | |
| "...........^^...^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...................^^^^.....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.........", | |
| "......................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.........^....^^^^^^...........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.............", | |
| ".................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.........^^...^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^............", | |
| "..................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........", | |
| "^^^^^^^^^^...............^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", | |
| "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", | |
| "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^", | |
| "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^" | |
| ]; | |
| const mapH = earthMap.length; | |
| const mapW = earthMap[0].length; | |
| // Use the N from the constructor (which we set to 150 in HTML now) | |
| const N = this.N; | |
| for (let y = 0; y < N; y++) { | |
| for (let x = 0; x < N; x++) { | |
| // Nearest Neighbor Scaling | |
| const mapY = Math.floor((y / N) * mapH); | |
| const mapX = Math.floor((x / N) * mapW); | |
| // Safety check to prevent crashing if mapX is out of bounds | |
| const char = (earthMap[mapY] && earthMap[mapY][mapX]) ? earthMap[mapY][mapX] : '.'; | |
| if (char === '^' || char === '=') { | |
| this.grid[y][x] = TERRAIN_ROCK; | |
| } else if (char === ':') { | |
| this.grid[y][x] = TERRAIN_LAND; | |
| } else { | |
| this.grid[y][x] = TERRAIN_SEA; | |
| } | |
| } | |
| } | |
| } | |
| // --- STANDARD GENERATION --- | |
| generate(seaPct, rockPct) { | |
| const N = this.N; | |
| const elevation = Array(N).fill(null).map(() => Array(N).fill(0)); | |
| const rockDensity = Array(N).fill(null).map(() => Array(N).fill(0)); | |
| for (let y = 0; y < N; y++) { | |
| for (let x = 0; x < N; x++) { | |
| const r = Math.hypot(x - N/2, y - N/2) / (N * 0.5); | |
| let h = noise(x, y, 0.5, this.noiseOffsets[0]) + | |
| noise(x, y, 1.0, this.noiseOffsets[1]) + | |
| noise(x, y, 2.0, this.noiseOffsets[2]) * 0.5; | |
| h = h / 2.5 - Math.pow(r, 2) * 0.8; | |
| elevation[y][x] = h; | |
| rockDensity[y][x] = noise(x, y, 3.0, this.noiseOffsets[4]); | |
| } | |
| } | |
| const flat = elevation.flat().sort((a, b) => a - b); | |
| const seaThresh = flat[Math.floor(flat.length * seaPct)] || flat[0]; | |
| for (let y = 0; y < N; y++) { | |
| for (let x = 0; x < N; x++) { | |
| this.grid[y][x] = elevation[y][x] <= seaThresh ? TERRAIN_SEA : TERRAIN_LAND; | |
| } | |
| } | |
| // Hydraulic Rivers | |
| const numRivers = Math.floor(N/2); | |
| for(let i=0; i<numRivers; i++) { | |
| let cx=Math.floor(Math.random()*N), cy=Math.floor(Math.random()*N); | |
| if(this.grid[cy][cx]!==TERRAIN_LAND) continue; | |
| let steps=0; | |
| while(steps++ < N*2) { | |
| let minH = elevation[cy][cx], bx=-1, by=-1; | |
| for(let dy=-1; dy<=1; dy++) for(let dx=-1; dx<=1; dx++) { | |
| if(dx===0 && dy===0) continue; | |
| const nx=(cx+dx+N)%N, ny=cy+dy; | |
| if(ny>=0 && ny<N && elevation[ny][nx]<minH) { minH=elevation[ny][nx]; bx=nx; by=ny; } | |
| } | |
| if(bx!==-1) { | |
| cx=bx; cy=by; this.grid[cy][cx]=TERRAIN_SEA; | |
| if(this.grid[cy][cx]===TERRAIN_SEA && Math.random()<0.1) break; | |
| } else break; | |
| } | |
| } | |
| // Rocks | |
| const landCells = []; | |
| for(let y=0; y<N; y++) for(let x=0; x<N; x++) if(this.grid[y][x]===TERRAIN_LAND) landCells.push({x,y,v:rockDensity[y][x]}); | |
| landCells.sort((a,b)=>b.v - a.v); | |
| const nR = Math.floor(landCells.length*rockPct); | |
| for(let i=0; i<nR; i++) this.grid[landCells[i].y][landCells[i].x] = TERRAIN_ROCK; | |
| } | |
| terrain(x, y) { | |
| x = ((x % this.N) + this.N) % this.N; | |
| if (y < 0 || y >= this.N) return TERRAIN_SEA; | |
| return this.grid[y][x]; | |
| } | |
| } | |
| class NxEr { | |
| constructor(config) { | |
| Object.assign(this, config); | |
| this.alive = true; | |
| // v4.0: Now 10 inputs (added SphereSync) | |
| this.lastInputs = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; | |
| this.tickAccum = 0; | |
| this.lastPos = [...this.pos]; | |
| this.matingWith = null; | |
| this.matingEndTick = null; | |
| this.matingIntentUntilTick = 0; | |
| this.mateCooldownUntilTick = 0; | |
| this.parents = config.parents || [null, null]; | |
| this.stats = config.stats || { foodFound: 0, foodTaken: 0, explored: 0, timeLived: 0, matesPerformed: 0, energyEfficiency: 0, temporalSyncScore: 0, fitness: 0, g: 0 }; | |
| if (this.stats.g === undefined) this.stats.g = 0; // v5.0 Lite — best-g NxEr | |
| this.visited = new Set([`${this.pos[0]},${this.pos[1]}`]); | |
| this.dopamineBoostTicks = 0; | |
| this.lastO4 = 0; | |
| // NEW: Sensory & Clan attributes | |
| this.visionRange = config.visionRange || randomInt(2, 15); | |
| this.smellRadius = config.smellRadius || randomInt(2, 5); | |
| this.heading = config.heading !== undefined ? config.heading : randomInt(0, 7); // 0=NW...7=W | |
| this.clanId = config.clanId || null; | |
| // v3.0: NEW - Temperature system (bioinspired thermoregulation) | |
| this.bodyTemperature = config.bodyTemperature || 37.0; // Normal body temp | |
| this.optimalTempLow = 35.0; | |
| this.optimalTempHigh = 39.0; | |
| // v3.0: NEW - Proprioception (rock hit counter for obstacle avoidance) | |
| this.consecutiveRockHits = 0; | |
| // v3.0: Track resting state for reduced metabolism | |
| this.isResting = false; | |
| // v4.5: VOICE & HEARING — bio-inspired tonal communication | |
| // Each NxEr has 6 harmonic tones forming its voice. Voices can | |
| // be inherited (children), learned (during mating), are heard | |
| // by clan members nearby, and become more pleasing when paired | |
| // with high musicality. | |
| this.voice = config.voice || generateHarmonicVoice(); | |
| this.musicality = musicalityScore(this.voice); | |
| this.lastSungTick = -10000; | |
| this.singCooldownTicks = 30; // base cooldown between songs | |
| this.knownFoodIds = new Set(); // for "first time finding food" trigger | |
| this.songsSung = 0; | |
| this.tonesLearned = 0; | |
| this.lastHeardSongTick = -10000; // when this NxEr last heard a clan-mate sing | |
| this.lastHeardSongPos = null; // attraction target | |
| this.lastHeardSongFromId = null; | |
| } | |
| updateStats(dt) { | |
| this.stats.timeLived += dt; | |
| this.stats.explored = this.visited.size; | |
| const energyStatus = this.net.getEnergyStatus(); | |
| this.stats.energyEfficiency = energyStatus.efficiency || 0; | |
| this.stats.temporalSyncScore = energyStatus.branchingRatio || 1.0; | |
| this.stats.fitness = (Math.min(this.stats.foodFound/100, 1)*0.3 + Math.min(this.stats.explored/1000, 1)*0.2 + Math.min(this.stats.timeLived/1000, 1)*0.2 + Math.min(this.stats.energyEfficiency/10, 1)*0.15 + Math.min(this.stats.temporalSyncScore/2, 1)*0.15); | |
| } | |
| } | |
| class Food { | |
| constructor(id, pos) { | |
| this.id = id; | |
| this.anchor = [...pos]; | |
| this.pos = [...pos]; | |
| this.alive = true; | |
| this.respawnAtTick = null; | |
| this.remaining = 25; | |
| this.progress = {}; | |
| } | |
| } | |
| // ===================================================================== | |
| // GAME ENGINE | |
| // ===================================================================== | |
| class GameEngine { | |
| // v3.0: Day/Night cycle constants | |
| static DAY_PHASE = 0; | |
| static DUSK_PHASE = 1; | |
| static NIGHT_PHASE = 2; | |
| static DAWN_PHASE = 3; | |
| // v3.0: Temperature constants | |
| static TEMP_TRANSFER_RATE = 0.1; | |
| constructor(config) { | |
| this.config = config; | |
| this.world = new World(config.worldSize, config.seaPct / 100, config.rockPct / 100, config.useEarth); | |
| this.nxers = new Map(); | |
| this.foods = new Map(); | |
| this.occupied = new Set(); | |
| this.effects = []; | |
| this.stepTick = 0; | |
| this.birthsCount = 0; | |
| this.deathsCount = 0; | |
| this.gameIndex = 1; | |
| this.paused = true; | |
| this.gameOver = false; | |
| this.usedColors = new Set(); | |
| this.championCounts = {}; | |
| this.allTimeBest = { foodFound: [], foodTaken: [], explored: [], timeLived: [], matesPerformed: [], fitness: [], g: [] }; | |
| // NEW: Clan Counter | |
| this.nextClanId = 1; | |
| // v3.0: Day/Night cycle state | |
| this.dayNightPhase = 0; // 0=day, 1=dusk, 2=night, 3=dawn | |
| this.dayNightProgress = 0.0; // 0.0 to 1.0 within phase | |
| this.globalTimeOfDay = 0.0; // 0.0 to 1.0 full cycle | |
| // v3.1: Sun position for spatial day/night (longitude angle) | |
| this.sunLongitude = 0.0; // 0 to 2*PI - where the sun is pointing | |
| this.environmentTemperature = config.baseTemperature || 25; | |
| this.initializeFood(); | |
| this.initializeNxErs(); | |
| } | |
| mate(a, b) { | |
| const dur = Math.max(a.net.params.simulationSteps, b.net.params.simulationSteps); | |
| a.matingWith = b.id; | |
| b.matingWith = a.id; | |
| a.matingEndTick = this.stepTick + dur; | |
| b.matingEndTick = this.stepTick + dur; | |
| // Cost | |
| a.food -= 3; | |
| b.food -= 3; | |
| a.stats.matesPerformed++; | |
| b.stats.matesPerformed++; | |
| // v4.5: VOICE LEARNING during mating — each partner has a chance | |
| // to learn one tone from the other. More musical voices are | |
| // easier to learn from. | |
| const learnChanceA = 0.3 + 0.4 * b.musicality; | |
| const learnChanceB = 0.3 + 0.4 * a.musicality; | |
| if (Math.random() < learnChanceA) { | |
| const learnedTone = randomChoice(b.voice); | |
| const replaceIdx = Math.floor(Math.random() * a.voice.length); | |
| // Don't replace if the tone is already present (within 50 cents) | |
| const exists = a.voice.some(t => Math.abs(12 * Math.log2(t / learnedTone)) < 0.5); | |
| if (!exists) { | |
| a.voice[replaceIdx] = learnedTone; | |
| a.voice.sort((x, y) => x - y); | |
| a.musicality = musicalityScore(a.voice); | |
| a.tonesLearned++; | |
| } | |
| } | |
| if (Math.random() < learnChanceB) { | |
| const learnedTone = randomChoice(a.voice); | |
| const replaceIdx = Math.floor(Math.random() * b.voice.length); | |
| const exists = b.voice.some(t => Math.abs(12 * Math.log2(t / learnedTone)) < 0.5); | |
| if (!exists) { | |
| b.voice[replaceIdx] = learnedTone; | |
| b.voice.sort((x, y) => x - y); | |
| b.musicality = musicalityScore(b.voice); | |
| b.tonesLearned++; | |
| } | |
| } | |
| this.effects.push({ type: 'heart', pos: [...a.pos], startTick: this.stepTick }); | |
| this.spawnChild(a, b, a.pos); | |
| } | |
| initializeFood() { | |
| const count = Math.floor(this.config.maxFood / 2); | |
| for (let i = 0; i < count; i++) { | |
| const pos = this.findFreePosition(true, true); | |
| if (pos) this.foods.set(i, new Food(i, pos)); | |
| } | |
| } | |
| initializeNxErs() { | |
| for (let i = 0; i < this.config.startingNxErs; i++) { | |
| const nxer = this.createNxEr(i); | |
| this.nxers.set(nxer.id, nxer); | |
| this.occupied.add(`${nxer.pos[0]},${nxer.pos[1]}`); | |
| } | |
| } | |
| createNxEr(id, params = null) { | |
| const nPS = this.config.neuronsPerSphere || 12; | |
| const arch = this.config.brainArch || 'classic'; // v5.0 Lite | |
| // CHC g-capable brain is a fixed 6-sphere layout; classic | |
| // chain keeps the user-selected sphere count. | |
| const nSph = arch === 'chc6' ? 6 : (this.config.brainSpheres || 3); | |
| const netParams = params || new NetworkParameters(nPS, nSph, arch); | |
| netParams.oscillatorCoupling = (this.config.oscCoupling || 10) / 100; | |
| const net = new NeuraxonNetwork(netParams); | |
| const pos = this.findFreePosition(true, true) || [ | |
| randomInt(0, this.world.N - 1), | |
| randomInt(0, this.world.N - 1) | |
| ]; | |
| const terrain = this.world.terrain(pos[0], pos[1]); | |
| let canLand, canSea; | |
| if (terrain === TERRAIN_LAND) { canLand = true; canSea = false; } | |
| else if (terrain === TERRAIN_SEA) { canLand = false; canSea = true; } | |
| else { canLand = true; canSea = false; } | |
| const color = this.randomColor(); | |
| const isMale = Math.random() < 0.5; | |
| const ticksPerAction = Math.max(2, Math.floor((this.config.globalTimeSteps * 1.2) / Math.max(1, netParams.simulationSteps))); | |
| // NEW: Init Vision, Smell, Heading | |
| const vision = randomInt(2, 15); | |
| const smell = randomInt(2, 5); | |
| const heading = randomInt(0, 7); | |
| const nxer = new NxEr({ | |
| id, | |
| name: base26Name(id), | |
| color, | |
| pos, | |
| canLand, | |
| canSea, | |
| net, | |
| food: this.config.startFood, | |
| isMale, | |
| ticksPerAction, | |
| bornTick: this.stepTick, | |
| lastMoveTick: this.stepTick, | |
| visionRange: vision, | |
| smellRadius: smell, | |
| heading: heading, | |
| clanId: null // No clan for generated starters | |
| }); | |
| // v3.0: Initialize temperature based on terrain (terrain already computed above) | |
| return nxer; | |
| } | |
| spawnChild(A, B, nearPos) { | |
| if (this.nxers.size >= this.config.maxNxErs) return null; | |
| const childId = this.nxers.size > 0 ? Math.max(...this.nxers.keys()) + 1 : 0; | |
| const baseParams = Math.random() < 0.5 ? A.net.params.clone() : B.net.params.clone(); | |
| // v4.0: Mutate neuronsPerSphere slightly | |
| baseParams.neuronsPerSphere = randomInt( | |
| Math.max(5, baseParams.neuronsPerSphere - 2), | |
| Math.min(30, baseParams.neuronsPerSphere + 2) | |
| ); | |
| let canLand, canSea; | |
| const isAMarine = A.canSea && !A.canLand; | |
| const isALand = A.canLand && !A.canSea; | |
| const isBMarine = B.canSea && !B.canLand; | |
| const isBLand = B.canLand && !B.canSea; | |
| if ((isAMarine && isBLand) || (isALand && isBMarine)) { | |
| canLand = true; canSea = true; | |
| } else { | |
| canLand = A.canLand || B.canLand; | |
| canSea = A.canSea || B.canSea; | |
| } | |
| const pos = this.findFreePosition(canSea, canLand, nearPos, 3) || nearPos; | |
| const transfer = Math.min(5.0, Math.min(A.food / 2, B.food / 2)); | |
| A.food -= transfer; B.food -= transfer; | |
| // NEW: Clan Inheritance Logic | |
| let familyClan = null; | |
| if (A.clanId === null && B.clanId === null) { | |
| familyClan = this.nextClanId++; | |
| A.clanId = familyClan; | |
| B.clanId = familyClan; | |
| } else if (A.clanId !== null) { | |
| familyClan = A.clanId; | |
| if (B.clanId === null) B.clanId = familyClan; | |
| } else if (B.clanId !== null) { | |
| familyClan = B.clanId; | |
| if (A.clanId === null) A.clanId = familyClan; | |
| } | |
| // NEW: Inherit sensory traits | |
| const vision = randomChoice([A.visionRange, B.visionRange, randomInt(2, 15)]); | |
| const smell = randomChoice([A.smellRadius, B.smellRadius, randomInt(2, 5)]); | |
| const heading = randomInt(0, 7); | |
| // v4.5: VOICE INHERITANCE — child gets a mix of parent tones plus | |
| // 1-2 of its own freshly generated tones (its "proper voice"). | |
| const fromA = sampleFrom(A.voice, 2 + (Math.random() < 0.5 ? 1 : 0)); | |
| const fromB = sampleFrom(B.voice, 2 + (Math.random() < 0.5 ? 1 : 0)); | |
| // Use the average of parent fundamentals as a tonal centre to keep the | |
| // child's own tones harmonically related to its inherited tones. | |
| const parentRoot = (A.voice[0] + B.voice[0]) / 2; | |
| const ownScale = MUSICAL_SCALES[randomChoice(SCALE_NAMES)]; | |
| const ownTones = []; | |
| const need = Math.max(1, 6 - fromA.length - fromB.length); | |
| for (let i = 0; i < need; i++) { | |
| ownTones.push(semitoneToFreq(parentRoot, randomChoice(ownScale))); | |
| } | |
| let childVoice = [...fromA, ...fromB, ...ownTones]; | |
| // Trim/pad to exactly 6 unique-ish tones | |
| childVoice = childVoice.slice(0, 6); | |
| while (childVoice.length < 6) { | |
| childVoice.push(semitoneToFreq(parentRoot, randomChoice(ownScale))); | |
| } | |
| childVoice.sort((a, b) => a - b); | |
| const child = new NxEr({ | |
| id: childId, | |
| name: `${stripLeadingDigits(A.name).replace(/^-+/, '')}-${stripLeadingDigits(B.name).replace(/^-+/, '')}`, | |
| color: this.randomColor(), | |
| pos, | |
| canLand, | |
| canSea, | |
| net: new NeuraxonNetwork(baseParams), | |
| food: transfer * 2, | |
| isMale: Math.random() < 0.5, | |
| ticksPerAction: Math.max(2, Math.floor((this.config.globalTimeSteps * 1.2) / Math.max(1, baseParams.simulationSteps))), | |
| parents: [A.id, B.id], | |
| bornTick: this.stepTick, | |
| lastMoveTick: this.stepTick, | |
| // Pass new props | |
| visionRange: vision, | |
| smellRadius: smell, | |
| heading: heading, | |
| bodyTemperature: (A.bodyTemperature + B.bodyTemperature) / 2 + random(-0.5, 0.5), | |
| consecutiveRockHits: 0, | |
| clanId: familyClan, | |
| voice: childVoice // v4.5: inherited harmonic voice | |
| }); | |
| child.stats.fitness = (A.stats.fitness + B.stats.fitness) / 2; | |
| this.nxers.set(childId, child); | |
| this.occupied.add(`${pos[0]},${pos[1]}`); | |
| A.stats.matesPerformed++; | |
| B.stats.matesPerformed++; | |
| this.birthsCount++; | |
| return child; | |
| } | |
| findFreePosition(allowSea = true, allowLand = true, near = null, searchRadius = 5) { | |
| const N = this.world.N; | |
| if (near) { | |
| for (let r = 0; r < searchRadius; r++) { | |
| const candidates = []; | |
| for (let dy = -r; dy <= r; dy++) { | |
| for (let dx = -r; dx <= r; dx++) { | |
| // X Wraps | |
| const x = (near[0] + dx + N) % N; | |
| // Y Clamps (No Wrap) | |
| const y = near[1] + dy; | |
| if (y < 0 || y >= N) continue; // Skip out of bounds | |
| const key = `${x},${y}`; | |
| if (this.occupied.has(key)) continue; | |
| const terrain = this.world.terrain(x, y); | |
| if (terrain === TERRAIN_ROCK) continue; | |
| if (terrain === TERRAIN_LAND && !allowLand) continue; | |
| if (terrain === TERRAIN_SEA && !allowSea) continue; | |
| candidates.push([x, y]); | |
| } | |
| } | |
| if (candidates.length > 0) { | |
| return randomChoice(candidates); | |
| } | |
| } | |
| } | |
| for (let i = 0; i < 100; i++) { | |
| const x = randomInt(0, N - 1); | |
| const y = randomInt(0, N - 1); // Random pick is always safe | |
| const key = `${x},${y}`; | |
| if (this.occupied.has(key)) continue; | |
| const terrain = this.world.terrain(x, y); | |
| if (terrain === TERRAIN_ROCK) continue; | |
| if (terrain === TERRAIN_LAND && !allowLand) continue; | |
| if (terrain === TERRAIN_SEA && !allowSea) continue; | |
| return [x, y]; | |
| } | |
| return null; | |
| } | |
| randomColor() { | |
| const r = randomInt(30, 235), g = randomInt(30, 235), b = randomInt(30, 235); | |
| return `rgb(${r},${g},${b})`; | |
| } | |
| // v3.0: NEW - Update day/night cycle | |
| updateDayNightCycle(dt) { | |
| const cycleDuration = this.config.dayNightCycle || 180; // seconds | |
| const cycleTickDuration = cycleDuration * this.config.globalTimeSteps; | |
| this.globalTimeOfDay = (this.stepTick % cycleTickDuration) / cycleTickDuration; | |
| // Determine phase: day (0-0.4), dusk (0.4-0.5), night (0.5-0.9), dawn (0.9-1.0) | |
| if (this.globalTimeOfDay < 0.4) { | |
| this.dayNightPhase = GameEngine.DAY_PHASE; | |
| this.dayNightProgress = this.globalTimeOfDay / 0.4; | |
| } else if (this.globalTimeOfDay < 0.5) { | |
| this.dayNightPhase = GameEngine.DUSK_PHASE; | |
| this.dayNightProgress = (this.globalTimeOfDay - 0.4) / 0.1; | |
| } else if (this.globalTimeOfDay < 0.9) { | |
| this.dayNightPhase = GameEngine.NIGHT_PHASE; | |
| this.dayNightProgress = (this.globalTimeOfDay - 0.5) / 0.4; | |
| } else { | |
| this.dayNightPhase = GameEngine.DAWN_PHASE; | |
| this.dayNightProgress = (this.globalTimeOfDay - 0.9) / 0.1; | |
| } | |
| // v3.1: Update sun position (rotates around planet) | |
| this.sunLongitude = this.globalTimeOfDay * Math.PI * 2; | |
| // v3.0: Update environment temperature based on day/night | |
| const baseTemp = this.config.baseTemperature || 25; | |
| const dayBonus = 8; // Warmer during day | |
| const nightPenalty = -6; // Colder at night | |
| if (this.dayNightPhase === GameEngine.DAY_PHASE) { | |
| this.environmentTemperature = baseTemp + dayBonus * Math.sin(this.dayNightProgress * Math.PI); | |
| } else if (this.dayNightPhase === GameEngine.NIGHT_PHASE) { | |
| this.environmentTemperature = baseTemp + nightPenalty * Math.sin(this.dayNightProgress * Math.PI); | |
| } else if (this.dayNightPhase === GameEngine.DUSK_PHASE) { | |
| this.environmentTemperature = baseTemp + dayBonus * (1 - this.dayNightProgress); | |
| } else { // DAWN | |
| this.environmentTemperature = baseTemp + nightPenalty * (1 - this.dayNightProgress); | |
| } | |
| } | |
| // v3.1: Get local daylight factor based on position on sphere | |
| getLocalDaylightFactor(x, y) { | |
| const N = this.world.N; | |
| // Convert grid position to longitude (0 to 2*PI) | |
| const longitude = (x / N) * Math.PI * 2; | |
| // Calculate angle difference from sun position | |
| let angleDiff = Math.abs(longitude - this.sunLongitude); | |
| if (angleDiff > Math.PI) angleDiff = Math.PI * 2 - angleDiff; | |
| // Daylight factor: 1.0 when facing sun, 0.0 when opposite | |
| // Use cosine for smooth transition (terminator) | |
| const rawFactor = Math.cos(angleDiff); | |
| // Map from [-1, 1] to [0, 1] with sharper transition | |
| const daylight = Math.max(0, Math.min(1, (rawFactor + 0.3) / 1.3)); | |
| return daylight; | |
| } | |
| // v3.1: Get local temperature based on position (sun exposure + latitude) | |
| getLocalTemperature(x, y) { | |
| const baseTemp = this.config.baseTemperature || 25; | |
| const localDaylight = this.getLocalDaylightFactor(x, y); | |
| // Latitude effect (equator warmer, poles colder) | |
| const N = this.world.N; | |
| const latitude = Math.abs(y - N/2) / (N/2); // 0 at equator, 1 at poles | |
| const latitudePenalty = latitude * 8; // Up to 8°C colder at poles | |
| // Sun exposure effect | |
| const sunBonus = localDaylight * 12; // Up to 12°C warmer in direct sunlight | |
| return baseTemp + sunBonus - latitudePenalty; | |
| } | |
| // v3.0: Get daylight factor for metabolic/behavior modulation | |
| getDaylightFactor() { | |
| // Returns 0.0 (full night) to 1.0 (full day) | |
| if (this.dayNightPhase === GameEngine.DAY_PHASE) { | |
| return 0.7 + 0.3 * Math.sin(this.dayNightProgress * Math.PI); | |
| } else if (this.dayNightPhase === GameEngine.NIGHT_PHASE) { | |
| return 0.2 - 0.15 * Math.sin(this.dayNightProgress * Math.PI); | |
| } else if (this.dayNightPhase === GameEngine.DUSK_PHASE) { | |
| return 0.7 - 0.5 * this.dayNightProgress; | |
| } else { // DAWN | |
| return 0.2 + 0.5 * this.dayNightProgress; | |
| } | |
| } | |
| // v3.0: Bioinspired neurotransmitter modulation based on circadian rhythm | |
| getCircadianNeuromodFactors() { | |
| const daylight = this.getDaylightFactor(); | |
| return { | |
| dopamineBoost: daylight * 0.3, // More active during day | |
| serotoninBoost: (1 - daylight) * 0.25, // Melatonin-like, higher at night | |
| norepinephrineBoost: Math.abs(daylight - 0.5) * 0.2 // Alert during transitions | |
| }; | |
| } | |
| step() { | |
| if (this.paused || this.gameOver) return; | |
| this.stepTick++; | |
| const dt = 1.0 / this.config.globalTimeSteps; | |
| // v3.0: Update day/night cycle | |
| this.updateDayNightCycle(dt); | |
| const daylightFactor = this.getDaylightFactor(); | |
| const circadianFactors = this.getCircadianNeuromodFactors(); | |
| // Pre-calc maps for sensory efficiency | |
| const occupantAt = new Map(); | |
| this.nxers.forEach(n => { if(n.alive) occupantAt.set(`${n.pos[0]},${n.pos[1]}`, n); }); | |
| const foodAt = new Set(); | |
| this.foods.forEach(f => { if(f.alive) foodAt.add(`${f.pos[0]},${f.pos[1]}`); }); | |
| // Direction Vectors (0=NW to 7=W) | |
| const DIR_OFFSETS = [ | |
| [-1, -1], [0, -1], [1, -1], [1, 0], | |
| [1, 1], [0, 1], [-1, 1], [-1, 0] | |
| ]; | |
| for (const nxer of this.nxers.values()) { | |
| if (!nxer.alive) continue; | |
| nxer.updateStats(dt); | |
| // v3.0: Metabolic rate affected by day/night and temperature | |
| let metabolicMultiplier = 1.0; | |
| // Slower metabolism at night (bioinspired circadian rhythm) | |
| metabolicMultiplier *= (0.6 + 0.4 * daylightFactor); | |
| // Temperature affects metabolism (colder = more energy to maintain temp) | |
| const tempDiff = Math.abs(nxer.bodyTemperature - 37.0); | |
| metabolicMultiplier *= (1.0 + tempDiff * 0.05); | |
| // v3.0: Much lower metabolism when resting (not moving) | |
| if (nxer.isResting) { | |
| metabolicMultiplier *= 0.3; // 70% reduction when resting | |
| } | |
| nxer.food -= 0.01 * dt * metabolicMultiplier; | |
| // v3.0: Apply circadian neuromodulator effects | |
| nxer.net.neuromodulators.dopamine = clamp( | |
| nxer.net.neuromodulators.dopamine + circadianFactors.dopamineBoost * dt * 0.1, | |
| 0, 2 | |
| ); | |
| nxer.net.neuromodulators.serotonin = clamp( | |
| nxer.net.neuromodulators.serotonin + circadianFactors.serotoninBoost * dt * 0.1, | |
| 0, 2 | |
| ); | |
| nxer.net.neuromodulators.norepinephrine = clamp( | |
| nxer.net.neuromodulators.norepinephrine + circadianFactors.norepinephrineBoost * dt * 0.1, | |
| 0, 2 | |
| ); | |
| // v3.1: Update local environment temperature based on position | |
| const localTemp = this.getLocalTemperature(nxer.pos[0], nxer.pos[1]); | |
| this.updateNxErTemperature(nxer, dt, occupantAt, localTemp); | |
| if (nxer.food <= 0 || this.stepTick - nxer.lastMoveTick > 10 * this.config.globalTimeSteps) this.killNxEr(nxer); | |
| if (nxer.matingWith !== null && this.stepTick >= nxer.matingEndTick) { | |
| nxer.matingWith = null; | |
| nxer.matingEndTick = null; | |
| nxer.mateCooldownUntilTick = this.stepTick + this.config.mateCooldown * this.config.globalTimeSteps; | |
| } | |
| } | |
| const processNxErs = Array.from(this.nxers.values()).filter(n => n.alive && n.matingWith === null); | |
| processNxErs.forEach(nxer => { | |
| nxer.tickAccum++; | |
| if (nxer.tickAccum >= nxer.ticksPerAction) { | |
| nxer.tickAccum = 0; | |
| // --- SENSORY LOGIC --- | |
| // 4. Hunger | |
| const hungerVal = nxer.food < (this.config.startFood * 0.2) ? -1 : 0; | |
| // 5. Vision (Raycast in Heading) | |
| let sightVal = -1; // -1=Nothing/Enemy, 0=Clan, 1=Food | |
| const dir = DIR_OFFSETS[nxer.heading]; | |
| let foundObj = false; | |
| for (let d = 1; d <= nxer.visionRange; d++) { | |
| const tx = (nxer.pos[0] + (dir[0] * d) + this.world.N) % this.world.N; | |
| const ty = nxer.pos[1] + (dir[1] * d); // Y doesn't wrap in lookups usually, but safe check | |
| if (ty < 0 || ty >= this.world.N) break; | |
| const k = `${tx},${ty}`; | |
| if (this.world.terrain(tx, ty) === TERRAIN_ROCK) break; // Blocked | |
| if (foodAt.has(k)) { sightVal = 1; foundObj = true; break; } | |
| if (occupantAt.has(k)) { | |
| const other = occupantAt.get(k); | |
| if (other.clanId !== null && nxer.clanId !== null && other.clanId === nxer.clanId) { | |
| sightVal = 0; // Friendly | |
| } else { | |
| sightVal = -1; // Neutral/Enemy | |
| } | |
| foundObj = true; break; | |
| } | |
| } | |
| // 6. Smell (Square Radius) | |
| let smellVal = -1; | |
| let foundFoodSmell = false, foundNxerSmell = false; | |
| for(let dy = -nxer.smellRadius; dy <= nxer.smellRadius; dy++) { | |
| for(let dx = -nxer.smellRadius; dx <= nxer.smellRadius; dx++) { | |
| if(dx===0 && dy===0) continue; | |
| const sx = (nxer.pos[0] + dx + this.world.N) % this.world.N; | |
| const sy = nxer.pos[1] + dy; | |
| if(sy < 0 || sy >= this.world.N) continue; | |
| const k = `${sx},${sy}`; | |
| if(foodAt.has(k)) foundFoodSmell = true; | |
| if(occupantAt.has(k)) foundNxerSmell = true; | |
| } | |
| } | |
| if(foundFoodSmell) smellVal = 1; | |
| else if(foundNxerSmell) smellVal = 0; | |
| // v3.0: NEW INPUTS - DayNight, Temperature, Proprioception | |
| // Input 7: Day/Night signal (-1=night, 0=transition, 1=day) | |
| let dayNightInput = 0; | |
| if (this.dayNightPhase === GameEngine.DAY_PHASE) { | |
| dayNightInput = 1; | |
| } else if (this.dayNightPhase === GameEngine.NIGHT_PHASE) { | |
| dayNightInput = -1; | |
| } else { | |
| dayNightInput = 0; // Transitions (dusk/dawn) | |
| } | |
| // v3.1: Override with LOCAL daylight for this NxEr's position | |
| const localDaylight = this.getLocalDaylightFactor(nxer.pos[0], nxer.pos[1]); | |
| if (localDaylight > 0.6) dayNightInput = 1; | |
| else if (localDaylight < 0.3) dayNightInput = -1; | |
| else dayNightInput = 0; | |
| // Input 8: Temperature comfort (-1=cold, 0=optimal, 1=hot) | |
| let tempInput = 0; | |
| if (nxer.bodyTemperature < nxer.optimalTempLow) { | |
| tempInput = -1; // Too cold | |
| } else if (nxer.bodyTemperature > nxer.optimalTempHigh) { | |
| tempInput = 1; // Too hot | |
| } else { | |
| tempInput = 0; // Comfortable | |
| } | |
| // Input 9: Proprioception - obstacle awareness (-1=stuck, 0=clear, 1=recent hit) | |
| let proprioInput = 0; | |
| if (nxer.consecutiveRockHits >= 3) { | |
| proprioInput = -1; // Stuck - need to change direction | |
| // v3.0: Force direction change when stuck | |
| nxer.heading = (nxer.heading + randomInt(2, 6)) % 8; | |
| nxer.consecutiveRockHits = 0; // Reset after forced turn | |
| // Stress response - increase norepinephrine | |
| nxer.net.neuromodulators.norepinephrine = Math.min(2.0, | |
| nxer.net.neuromodulators.norepinephrine + 0.3); | |
| } else if (nxer.consecutiveRockHits > 0) { | |
| proprioInput = 1; // Recent hit, be cautious | |
| } | |
| // v3.0: Night behavior - tendency to rest | |
| let restTendency = 0; | |
| if (this.dayNightPhase === GameEngine.NIGHT_PHASE && nxer.food > this.config.startFood * 0.5) { | |
| // Well-fed NxErs rest more at night | |
| restTendency = Math.random() < 0.3 ? 1 : 0; | |
| } | |
| // v4.0: Construct full inputs (Last 3 + 3 Sensory + 3 Environment + 1 SphereSync) | |
| // Input 10: Sphere synchronization (average branching ratio signal) | |
| const sphereSync = nxer.net.branchingRatio > 1.2 ? 1 : (nxer.net.branchingRatio < 0.8 ? -1 : 0); | |
| const inputs = [...nxer.lastInputs.slice(0, 3), hungerVal, sightVal, smellVal, dayNightInput, tempInput, proprioInput, sphereSync]; | |
| const steps = Math.max(1, Math.floor(nxer.net.params.simulationSteps / this.config.globalTimeSteps)); | |
| if (nxer.dopamineBoostTicks > 0) { | |
| nxer.net.neuromodulators.dopamine = Math.max(nxer.net.neuromodulators.dopamine, 0.9); | |
| nxer.dopamineBoostTicks--; | |
| } | |
| for(let i=0; i<steps; i++) { nxer.net.setInputStates(inputs); nxer.net.simulateStep(); } | |
| // v4.0: Get Outputs (6 outputs from motor sphere) | |
| // O1=X, O2=Y, O3=Steal, O4=Mate, O5=GiveFood, O6=Rest | |
| let outs = nxer.net.getOutputStates(); | |
| while(outs.length < 6) outs.push(0); | |
| let [O1, O2, O3, O4, O5, O6] = outs; | |
| // v3.0: Rest output - if resting, reduce movement | |
| if (O6 === 1 || restTendency === 1) { | |
| // Resting behavior - stay still, recover energy slightly | |
| O1 = 0; | |
| O2 = 0; | |
| nxer.isResting = true; | |
| nxer.food += 0.02 * dt; // Improved energy recovery from rest | |
| // Resting helps regulate temperature | |
| nxer.bodyTemperature += (37.0 - nxer.bodyTemperature) * 0.01; | |
| } else { | |
| nxer.isResting = false; | |
| } | |
| if (O1 === 0 && O2 === 0 && Math.random() < 0.4) { | |
| if(Math.random() < 0.5) O1 = Math.random()<0.5?-1:1; else O2 = Math.random()<0.5?-1:1; | |
| } | |
| if (O4 === 0 && Math.random() < 0.08) O4 = Math.random() < 0.5 ? -1 : 1; | |
| // v3.0: Temperature-seeking behavior | |
| if (tempInput === -1 && this.dayNightPhase === GameEngine.DAY_PHASE) { | |
| // Cold NxEr during day - seek sunlight (move towards equator) | |
| if (nxer.pos[1] < this.world.N / 2) O2 = 1; // Move south | |
| else O2 = -1; // Move north | |
| } | |
| // --- OUTPUT 5: GIVE FOOD (Altruism) --- | |
| if (O5 >= 0 && nxer.food > this.config.startFood) { | |
| // Check neighbors | |
| for(let ndy=-1; ndy<=1; ndy++) { | |
| for(let ndx=-1; ndx<=1; ndx++) { | |
| if(ndx===0 && ndy===0) continue; | |
| const tx = (nxer.pos[0] + ndx + this.world.N) % this.world.N; | |
| const ty = nxer.pos[1] + ndy; | |
| if(ty < 0 || ty >= this.world.N) continue; | |
| const rec = occupantAt.get(`${tx},${ty}`); | |
| // Only give to Clan members | |
| if(rec && nxer.clanId !== null && rec.clanId === nxer.clanId) { | |
| if(rec.food < this.config.startFood) { | |
| const amt = (O5 === 1) ? 5.0 : 2.0; | |
| if(nxer.food > amt) { | |
| nxer.food -= amt; | |
| rec.food += amt; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| const dx = -O1; | |
| const dy = -O2; | |
| // v4.5: HEARING-DRIVEN ATTRACTION | |
| // If this NxEr recently heard a clan-mate sing, bias its | |
| // movement toward the song's location. The bias is gentle | |
| // (only when the brain hasn't decided something urgent), | |
| // and decays with time since the song was heard. | |
| let biasedDx = dx, biasedDy = dy; | |
| if (nxer.lastHeardSongPos && (this.stepTick - nxer.lastHeardSongTick) < 4 * this.config.globalTimeSteps) { | |
| const songFreshness = 1 - ((this.stepTick - nxer.lastHeardSongTick) / (4 * this.config.globalTimeSteps)); | |
| // Only override if not chasing food/mate (sightVal != 1, O4 != 1) | |
| if (sightVal !== 1 && O4 !== 1 && Math.random() < 0.5 * songFreshness) { | |
| const N = this.world.N; | |
| // Wrapped X delta toward song | |
| let dxToSong = nxer.lastHeardSongPos[0] - nxer.pos[0]; | |
| if (dxToSong > N/2) dxToSong -= N; | |
| else if (dxToSong < -N/2) dxToSong += N; | |
| const dyToSong = nxer.lastHeardSongPos[1] - nxer.pos[1]; | |
| if (Math.abs(dxToSong) <= 1 && Math.abs(dyToSong) <= 1) { | |
| // Already next to the singer — clear the call | |
| nxer.lastHeardSongPos = null; | |
| } else { | |
| biasedDx = Math.sign(dxToSong); | |
| biasedDy = Math.sign(dyToSong); | |
| // Tiny serotonin uplift while following the call | |
| if (nxer.net && nxer.net.neuromodulators) { | |
| nxer.net.neuromodulators.serotonin = Math.min(2.0, | |
| (nxer.net.neuromodulators.serotonin || 0) + 0.005); | |
| } | |
| } | |
| } | |
| } | |
| // v4.5: VOICE-SIMILARITY FOLLOWING | |
| // NxErs with very similar voices tend to drift toward each | |
| // other, producing visible "tone clans" within larger clans. | |
| if (biasedDx === 0 && biasedDy === 0 && Math.random() < 0.15) { | |
| let bestSim = 0, bestTarget = null; | |
| for (const [k, occ] of occupantAt) { | |
| if (occ.id === nxer.id || !occ.voice) continue; | |
| const sim = voiceSimilarity(nxer.voice, occ.voice); | |
| if (sim < 0.5) continue; | |
| const N = this.world.N; | |
| let ddx = occ.pos[0] - nxer.pos[0]; | |
| if (ddx > N/2) ddx -= N; else if (ddx < -N/2) ddx += N; | |
| const ddy = occ.pos[1] - nxer.pos[1]; | |
| const d2 = ddx*ddx + ddy*ddy; | |
| if (d2 > 25) continue; // within ~5 tiles | |
| if (sim > bestSim) { | |
| bestSim = sim; | |
| bestTarget = [Math.sign(ddx), Math.sign(ddy)]; | |
| } | |
| } | |
| if (bestTarget) { | |
| biasedDx = bestTarget[0]; | |
| biasedDy = bestTarget[1]; | |
| } | |
| } | |
| // Update Heading based on (biased) move | |
| const finalDx = biasedDx, finalDy = biasedDy; | |
| if(finalDx !== 0 || finalDy !== 0) { | |
| // Map dx,dy to 0-7 | |
| if(finalDx===-1 && finalDy===-1) nxer.heading=0; | |
| else if(finalDx===0 && finalDy===-1) nxer.heading=1; | |
| else if(finalDx===1 && finalDy===-1) nxer.heading=2; | |
| else if(finalDx===1 && finalDy===0) nxer.heading=3; | |
| else if(finalDx===1 && finalDy===1) nxer.heading=4; | |
| else if(finalDx===0 && finalDy===1) nxer.heading=5; | |
| else if(finalDx===-1 && finalDy===1) nxer.heading=6; | |
| else if(finalDx===-1 && finalDy===0) nxer.heading=7; | |
| } | |
| nxer.pendingMove = {dx: finalDx, dy: finalDy, O3, O4}; | |
| } | |
| }); | |
| this.processMovements(processNxErs.filter(n => n.pendingMove)); | |
| this.tryRespawnFood(); | |
| // v4.5: VOICE / SINGING pass — process song triggers, broadcast to clan, | |
| // and emit visual+audio effects. | |
| this.processSinging(dt); | |
| this.effects = this.effects.filter(e => this.stepTick - e.startTick < this.config.globalTimeSteps); | |
| // v5.0 Lite — population g (best-g NxEr). Throttled to every | |
| // 30 ticks (cheap: O(N·7) + a 7×7 power iteration). Writes | |
| // each living NxEr's PC1 factor score to stats.g; the result | |
| // flows through the existing rankings / champions unchanged. | |
| if (this.stepTick % 30 === 0) { | |
| const aliveArr = []; | |
| for (const a of this.nxers.values()) if (a.alive) aliveArr.push(a); | |
| this.lastG = computePopulationG(aliveArr); | |
| } | |
| if (Array.from(this.nxers.values()).filter(n => n.alive).length === 0) this.gameOver = true; | |
| } | |
| // v4.5: SINGING / HEARING SYSTEM | |
| // Decides when an NxEr sings, what it sounds like, and which nearby | |
| // clan-mates can hear it. Also handles "grouping when observed" | |
| // — when zoomed in, musical clan-mates clustered together harmonise. | |
| processSinging(dt) { | |
| const SING_RADIUS = 6; // tiles within which a song is heard | |
| const GROUP_RADIUS = 4; // tiles for clan grouping | |
| const zoomedIn = this.cameraIsClose === true; // set by renderer each frame | |
| const N = this.world.N; | |
| // Build a quick spatial index of alive NxErs | |
| const aliveList = []; | |
| this.nxers.forEach(n => { if (n.alive) aliveList.push(n); }); | |
| for (const nxer of aliveList) { | |
| if (!nxer.voice) continue; | |
| // 1) Cooldown gate | |
| const ticksSinceSong = this.stepTick - nxer.lastSungTick; | |
| const onCooldown = ticksSinceSong < (nxer.singCooldownTicks * this.config.globalTimeSteps / 30); | |
| // 2) Decide whether to sing this tick | |
| let trigger = null; | |
| if (nxer.wantsToSing === 'discovery' && !onCooldown) { | |
| trigger = 'discovery'; // first-time food | |
| } else if (zoomedIn && !onCooldown && nxer.musicality > 0.45) { | |
| // Look for nearby clan-mates — group singing when observed | |
| let clanNeighbours = 0; | |
| for (const other of aliveList) { | |
| if (other.id === nxer.id || other.clanId === null) continue; | |
| if (other.clanId !== nxer.clanId) continue; | |
| const dx = Math.min(Math.abs(nxer.pos[0] - other.pos[0]), N - Math.abs(nxer.pos[0] - other.pos[0])); | |
| const dy = Math.abs(nxer.pos[1] - other.pos[1]); | |
| if (dx <= GROUP_RADIUS && dy <= GROUP_RADIUS) clanNeighbours++; | |
| } | |
| if (clanNeighbours >= 1 && Math.random() < 0.04 * nxer.musicality) { | |
| trigger = clanNeighbours >= 2 ? 'chorus' : 'solo'; | |
| } | |
| } else if (!onCooldown && nxer.musicality > 0.7 && Math.random() < 0.002) { | |
| // Rare spontaneous song from very musical individuals | |
| trigger = 'solo'; | |
| } | |
| if (!trigger) { | |
| nxer.wantsToSing = null; | |
| continue; | |
| } | |
| // 3) Compute pitch bonus from food (more food → higher tone) | |
| const foodNorm = Math.min(1, Math.max(0, (nxer.food - this.config.startFood) / Math.max(1, this.config.startFood))); | |
| const foodBonus = foodNorm * 5; // up to ~5 semitones higher | |
| // 4) Emit song effect (visual notes + audio queue) | |
| this.effects.push({ | |
| type: 'sing', | |
| pos: [...nxer.pos], | |
| startTick: this.stepTick, | |
| voice: [...nxer.voice], | |
| foodBonus, | |
| musicality: nxer.musicality, | |
| nxerId: nxer.id, | |
| trigger | |
| }); | |
| nxer.lastSungTick = this.stepTick; | |
| nxer.songsSung++; | |
| nxer.wantsToSing = null; | |
| // 5) Broadcast to nearby clan-mates — they "hear" the song | |
| for (const other of aliveList) { | |
| if (other.id === nxer.id) continue; | |
| // Only same clan hears as an attractive call | |
| if (nxer.clanId === null || other.clanId !== nxer.clanId) continue; | |
| const dx = Math.min(Math.abs(nxer.pos[0] - other.pos[0]), N - Math.abs(nxer.pos[0] - other.pos[0])); | |
| const dy = Math.abs(nxer.pos[1] - other.pos[1]); | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist <= SING_RADIUS) { | |
| other.lastHeardSongTick = this.stepTick; | |
| other.lastHeardSongPos = [...nxer.pos]; | |
| other.lastHeardSongFromId = nxer.id; | |
| // A pleasing song boosts serotonin slightly in the listener | |
| if (other.net && other.net.neuromodulators) { | |
| other.net.neuromodulators.serotonin = Math.min(2.0, | |
| (other.net.neuromodulators.serotonin || 0) + 0.05 * nxer.musicality); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| processMovements(nxers) { | |
| const intents = []; | |
| for (const nxer of nxers) { | |
| if (!nxer.pendingMove) continue; | |
| const {dx, dy, O3, O4} = nxer.pendingMove; delete nxer.pendingMove; | |
| if (dx===0 && dy===0) { | |
| nxer.lastInputs = [-1, 0, this.world.terrain(nxer.pos[0], nxer.pos[1]) === TERRAIN_LAND ? 1 : 0]; | |
| nxer.isResting = true; // v3.0: Mark as resting when not moving | |
| continue; | |
| } | |
| // Cylinder Topology (Wrap X, Clamp Y) | |
| const tx = (nxer.pos[0] + dx + this.world.N) % this.world.N; | |
| const rawTy = nxer.pos[1] + dy; | |
| if (rawTy < 0 || rawTy >= this.world.N) { | |
| nxer.lastInputs = [-1, 0, -1]; // Wall hit | |
| nxer.consecutiveRockHits++; // v3.0: Track wall hits too | |
| continue; | |
| } | |
| const ty = rawTy; | |
| intents.push({nxer, target: [tx, ty], O3, O4}); | |
| } | |
| const byPos = {}; | |
| intents.forEach(i => { | |
| const k = `${i.target[0]},${i.target[1]}`; | |
| if(!byPos[k]) byPos[k] = []; | |
| byPos[k].push(i); | |
| }); | |
| const occupants = {}; this.nxers.forEach(n => { if(n.alive) occupants[`${n.pos[0]},${n.pos[1]}`] = n; }); | |
| const foods = {}; this.foods.forEach(f => { if(f.alive) foods[`${f.pos[0]},${f.pos[1]}`] = f; }); | |
| for (const [key, list] of Object.entries(byPos)) { | |
| const [tx, ty] = key.split(',').map(Number); | |
| const terrain = this.world.terrain(tx, ty); | |
| if (terrain === TERRAIN_ROCK) { | |
| list.forEach(i => i.nxer.lastInputs = [-1, 0, -1]); | |
| list.forEach(i => i.nxer.consecutiveRockHits++); // v3.0: Track rock hits | |
| continue; | |
| } | |
| const valid = list.filter(i => { | |
| const n = i.nxer; | |
| const ot = this.world.terrain(n.pos[0], n.pos[1]); | |
| if ((ot===1 && terrain===0) || (ot===0 && terrain===1)) return true; | |
| if (terrain===1 && !n.canLand) { n.lastInputs = [-1,0,1]; return false; } | |
| if (terrain===0 && !n.canSea) { n.lastInputs = [-1,0,0]; return false; } | |
| return true; | |
| }); | |
| if (!valid.length) continue; | |
| // v3.0: Clear rock hit counter on successful terrain validation | |
| // Food Interaction | |
| if (foods[key]) { | |
| const f = foods[key]; | |
| valid.forEach(i => { | |
| i.nxer.dopamineBoostTicks = 30; | |
| if(f.remaining>0) { f.remaining--; i.nxer.food++; i.nxer.stats.foodFound++; } | |
| i.nxer.lastInputs = [1,0,terrain===1?1:0]; | |
| // v4.5: First-time food discovery → sing! | |
| if (!i.nxer.knownFoodIds.has(f.id)) { | |
| i.nxer.knownFoodIds.add(f.id); | |
| i.nxer.wantsToSing = 'discovery'; // flag picked up by step loop | |
| } | |
| }); | |
| // v3.0: Food consumption generates heat | |
| valid.forEach(i => i.nxer.bodyTemperature += 0.1); | |
| valid.forEach(i => i.nxer.consecutiveRockHits = 0); | |
| if(f.remaining <= 0) { | |
| const winner = valid.sort((a,b) => a.nxer.ticksPerAction - b.nxer.ticksPerAction)[0].nxer; | |
| this.moveNxEr(winner, [tx, ty]); | |
| f.alive = false; f.respawnAtTick = this.stepTick + this.config.foodRespawn * this.config.globalTimeSteps; | |
| } | |
| continue; | |
| } | |
| // Occupied Interaction | |
| if (occupants[key]) { | |
| const occ = occupants[key]; | |
| valid.forEach(i => { | |
| const n = i.nxer; | |
| if (n.id === occ.id) return; | |
| n.lastInputs = [-1, 1, terrain===1?1:0]; | |
| // --- TUNED MATING LOGIC --- | |
| // 1. Explicit Desire (Neural Output) OR Very Low Chance (0.2%) | |
| // 2. Must have significant food reserves ( > 15) to prioritize survival over reproduction | |
| const naturalUrge = Math.random() < 0.002; | |
| const wantsMate = (i.O4 === 1 || naturalUrge) && n.food > 15; | |
| if (wantsMate && this.canMate(n, occ)) { | |
| this.mate(n, occ); | |
| } else if ((i.O4 === -1 || i.O3 === -1) && occ.food > 0) { | |
| occ.food--; n.food++; n.stats.foodTaken++; | |
| } | |
| n.lastO4 = i.O4; | |
| }); | |
| continue; | |
| } | |
| // Empty Cell Meeting | |
| const mates = valid.filter(i => | |
| (i.O4===1 || Math.random() < 0.002) && | |
| i.nxer.food >= 15 && | |
| !i.nxer.matingWith | |
| ); | |
| if(mates.length >= 2) { | |
| const a = mates[0].nxer; | |
| const b = mates[1].nxer; | |
| if(this.canMate(a, b)) this.mate(a, b); | |
| valid.forEach(i => { i.nxer.lastInputs = [-1, 1, terrain===1?1:0]; i.nxer.lastO4 = i.O4; }); | |
| continue; | |
| } | |
| // Move Winner | |
| const winner = valid.sort((a,b) => a.nxer.ticksPerAction - b.nxer.ticksPerAction)[0].nxer; | |
| this.moveNxEr(winner, [tx, ty]); | |
| // v3.0: Successful movement clears rock hit counter | |
| winner.consecutiveRockHits = 0; | |
| valid.forEach(i => { if(i.nxer.id !== winner.id) i.nxer.lastInputs=[-1,1,terrain===1?1:0]; i.nxer.lastO4 = i.O4; }); | |
| } | |
| } | |
| canMate(A, B) { | |
| // 1. Self Check | |
| if (A.id === B.id) return false; | |
| // 2. Life Check | |
| if (!A.alive || !B.alive) return false; | |
| // 3. Gender Check (Must be opposites) | |
| if (A.isMale === B.isMale) return false; | |
| // 4. Status Check (Must be free) | |
| if (A.matingWith !== null || B.matingWith !== null) return false; | |
| // 5. Energy Check (Must have enough food) | |
| if (A.food < 5 || B.food < 5) return false; | |
| // 6. Cooldown Check | |
| if (this.stepTick < A.mateCooldownUntilTick || this.stepTick < B.mateCooldownUntilTick) return false; | |
| // 7. Incest Check | |
| if (A.parents.includes(B.id) || B.parents.includes(A.id)) return false; | |
| return true; | |
| } | |
| moveNxEr(nxer, target, terrain) { | |
| this.occupied.delete(`${nxer.pos[0]},${nxer.pos[1]}`); | |
| if (nxer.pos[0] !== target[0] || nxer.pos[1] !== target[1]) { | |
| nxer.lastPos = [...nxer.pos]; nxer.lastMoveTick = this.stepTick; | |
| nxer.isResting = false; // v3.0: Reset resting state on movement | |
| } | |
| nxer.pos = [...target]; | |
| this.occupied.add(`${target[0]},${target[1]}`); | |
| nxer.visited.add(`${target[0]},${target[1]}`); | |
| nxer.lastInputs = [-1, 0, terrain === TERRAIN_LAND ? 1 : 0]; | |
| nxer.food -= 0.1; | |
| // v3.0: Movement generates heat | |
| nxer.bodyTemperature += 0.02; | |
| if (nxer.food <= 0) this.killNxEr(nxer); | |
| } | |
| killNxEr(nxer) { | |
| if (!nxer.alive) return; | |
| nxer.alive = false; this.deathsCount++; | |
| this.occupied.delete(`${nxer.pos[0]},${nxer.pos[1]}`); | |
| this.effects.push({type: 'skull', pos: [...nxer.pos], startTick: this.stepTick}); | |
| } | |
| tryRespawnFood() { | |
| const aliveCount = Array.from(this.foods.values()).filter(f => f.alive).length; | |
| if (aliveCount >= this.config.maxFood) return; | |
| for (const f of this.foods.values()) { | |
| if (!f.alive && f.respawnAtTick && this.stepTick >= f.respawnAtTick) { | |
| const pos = this.findFreePosition(true, true, f.anchor, 6); | |
| if (pos) { f.pos = pos; f.alive = true; f.respawnAtTick = null; f.remaining = 25; } | |
| if (Array.from(this.foods.values()).filter(x => x.alive).length >= this.config.maxFood) break; | |
| } | |
| } | |
| } | |
| updateAllTimeBest() { | |
| const current = Array.from(this.nxers.values()); | |
| if (!current.length) return; | |
| const cats = { foodFound: 'foodFound', foodTaken: 'foodTaken', explored: 'explored', timeLived: 'timeLived', matesPerformed: 'matesPerformed', fitness: 'fitness', g: 'g' }; | |
| for (const [key, prop] of Object.entries(cats)) { | |
| const combined = [...this.allTimeBest[key], ...current.slice().sort((a, b) => b.stats[prop] - a.stats[prop]).slice(0, 3)]; | |
| const seen = new Set(), unique = []; | |
| combined.forEach(n => { const b = stripLeadingDigits(n.name).replace(/^-+/, '') || n.name; if(!seen.has(b)) { seen.add(b); unique.push(n); } }); | |
| this.allTimeBest[key] = unique.sort((a, b) => b.stats[prop] - a.stats[prop]).slice(0, 5); | |
| } | |
| } | |
| restartWithChampions() { | |
| this.updateAllTimeBest(); | |
| const champions = []; | |
| const addChamp = (list) => { if(list && list.length) champions.push(list[0]); }; | |
| Object.values(this.allTimeBest).forEach(list => addChamp(list)); | |
| this.gameIndex++; | |
| this.stepTick = 0; | |
| this.birthsCount = 0; | |
| this.deathsCount = 0; | |
| this.effects = []; | |
| this.paused = false; | |
| this.gameOver = false; | |
| this.world = new World(this.config.worldSize, this.config.seaPct/100, this.config.rockPct/100, this.config.useEarth); | |
| this.nxers.clear(); | |
| this.foods.clear(); | |
| this.occupied.clear(); | |
| this.initializeFood(); | |
| let id = 0; | |
| champions.slice(0, 10).forEach(c => { | |
| const name = stripLeadingDigits(c.name).replace(/^-+/, '') || c.name; | |
| this.championCounts[name] = (this.championCounts[name]||0)+1; | |
| const pos = this.findFreePosition(c.canSea, c.canLand) || [0,0]; | |
| const p = c.net.params.clone(); | |
| // Inherit new sensory stats or randomize if champion is from old version | |
| const vision = c.visionRange || 5; | |
| const smell = c.smellRadius || 3; | |
| const n = new NxEr({ | |
| id: id++, | |
| name: `${this.championCounts[name]}${name}`, | |
| color: this.randomColor(), | |
| pos, | |
| canLand: c.canLand, | |
| canSea: c.canSea, | |
| net: new NeuraxonNetwork(p), | |
| food: this.config.startFood * 1.1, | |
| isMale: c.isMale, | |
| bornTick: 0, | |
| lastMoveTick: 0, | |
| ticksPerAction: Math.max(2, Math.floor((this.config.globalTimeSteps * 1.2) / Math.max(1, p.simulationSteps))), | |
| visionRange: vision, | |
| smellRadius: smell, | |
| heading: randomInt(0, 7), | |
| clanId: null // Reset clan for fresh game start | |
| }); | |
| this.nxers.set(n.id, n); | |
| this.occupied.add(`${pos[0]},${pos[1]}`); | |
| }); | |
| while (this.nxers.size < this.config.startingNxErs) { | |
| const n = this.createNxEr(id++); | |
| this.nxers.set(n.id, n); | |
| this.occupied.add(`${n.pos[0]},${n.pos[1]}`); | |
| } | |
| } | |
| // v3.0: NEW - Temperature regulation system | |
| updateNxErTemperature(nxer, dt, occupantAt, localEnvTemp = null) { | |
| const baseTemp = 37.0; // Normal body temperature | |
| // v3.1: Use local temperature if provided | |
| const envTemp = localEnvTemp !== null ? localEnvTemp : this.environmentTemperature; | |
| // Heat gain from food metabolism | |
| const foodHeat = nxer.food > this.config.startFood ? 0.02 : -0.01; | |
| // Heat gain from activity (movement increases temp) | |
| const activityHeat = nxer.tickAccum > 0 ? 0.05 : 0.0; | |
| // Environmental heat exchange (using local temp) | |
| const envDiff = envTemp - nxer.bodyTemperature; | |
| const envHeatExchange = envDiff * GameEngine.TEMP_TRANSFER_RATE * dt; | |
| // Social thermoregulation - heat from nearby NxErs (bioinspired huddling) | |
| let socialHeat = 0; | |
| for (let dy = -1; dy <= 1; dy++) { | |
| for (let dx = -1; dx <= 1; dx++) { | |
| if (dx === 0 && dy === 0) continue; | |
| const nx = (nxer.pos[0] + dx + this.world.N) % this.world.N; | |
| const ny = nxer.pos[1] + dy; | |
| if (ny < 0 || ny >= this.world.N) continue; | |
| const neighbor = occupantAt.get(`${nx},${ny}`); | |
| if (neighbor && neighbor.alive) { | |
| // Heat transfer between NxErs | |
| socialHeat += (neighbor.bodyTemperature - nxer.bodyTemperature) * 0.02; | |
| } | |
| } | |
| } | |
| // Update temperature | |
| nxer.bodyTemperature += activityHeat + foodHeat + envHeatExchange + socialHeat; | |
| // Homeostatic regulation (body tries to maintain 37°C) | |
| const homeostaticCorrection = (baseTemp - nxer.bodyTemperature) * 0.05 * dt; | |
| nxer.bodyTemperature += homeostaticCorrection; | |
| // Clamp to survivable range | |
| nxer.bodyTemperature = clamp(nxer.bodyTemperature, 30.0, 42.0); | |
| // Extreme temperatures cause damage | |
| if (nxer.bodyTemperature < 32.0 || nxer.bodyTemperature > 41.0) { | |
| nxer.food -= 0.1 * dt; // Energy drain from thermal stress | |
| nxer.net.neuromodulators.norepinephrine = Math.min(2.0, | |
| nxer.net.neuromodulators.norepinephrine + 0.1); // Stress response | |
| } | |
| } | |
| getStats() { | |
| const alive = Array.from(this.nxers.values()).filter(n => n.alive); | |
| let avgE = 0, avgB = 0; | |
| if (alive.length) { | |
| avgE = alive.reduce((s, n) => s + n.net.getEnergyStatus().averageEnergy, 0) / alive.length; | |
| avgB = alive.reduce((s, n) => s + n.net.branchingRatio, 0) / alive.length; | |
| } | |
| // v3.0: Add day/night and temperature stats | |
| const phaseNames = ['Day', 'Dusk', 'Night', 'Dawn']; | |
| let avgTemp = 0; | |
| if (alive.length) { | |
| avgTemp = alive.reduce((s, n) => s + n.bodyTemperature, 0) / alive.length; | |
| } | |
| return { | |
| alive: alive.length, | |
| dead: this.deathsCount, | |
| born: this.birthsCount, | |
| avgEnergy: avgE.toFixed(1), | |
| avgBranching: avgB.toFixed(2), | |
| dayNightPhase: phaseNames[this.dayNightPhase], | |
| envTemp: this.environmentTemperature.toFixed(1), | |
| avgBodyTemp: avgTemp.toFixed(1) | |
| }; | |
| } | |
| } | |
| // ===================================================================== | |
| // 3D RENDERER (THREE.JS) | |
| // ===================================================================== | |
| // ===================================================================== | |
| // PARTICLE SYSTEM FOR LIFE EVENTS | |
| // ===================================================================== | |
| class ParticleSystem { | |
| constructor(scene) { | |
| this.scene = scene; | |
| this.particleGroups = []; | |
| this.maxParticles = 5000; | |
| // Shared geometries and materials for performance | |
| this.sparkGeometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(this.maxParticles * 3); | |
| const colors = new Float32Array(this.maxParticles * 3); | |
| const sizes = new Float32Array(this.maxParticles); | |
| const alphas = new Float32Array(this.maxParticles); | |
| this.sparkGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| this.sparkGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| this.sparkGeometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); | |
| this.sparkGeometry.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1)); | |
| this.sparkMaterial = new THREE.ShaderMaterial({ | |
| uniforms: { time: { value: 0 } }, | |
| vertexShader: ` | |
| attribute float size; | |
| attribute float alpha; | |
| attribute vec3 color; | |
| varying vec3 vColor; | |
| varying float vAlpha; | |
| void main() { | |
| vColor = color; | |
| vAlpha = alpha; | |
| vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); | |
| gl_PointSize = size * (300.0 / -mvPosition.z); | |
| gl_Position = projectionMatrix * mvPosition; | |
| } | |
| `, | |
| fragmentShader: ` | |
| varying vec3 vColor; | |
| varying float vAlpha; | |
| void main() { | |
| float r = distance(gl_PointCoord, vec2(0.5)); | |
| if (r > 0.5) discard; | |
| float glow = 1.0 - smoothstep(0.0, 0.5, r); | |
| gl_FragColor = vec4(vColor, vAlpha * glow); | |
| } | |
| `, | |
| transparent: true, | |
| depthWrite: false, | |
| blending: THREE.AdditiveBlending | |
| }); | |
| this.particles = []; | |
| this.activeCount = 0; | |
| } | |
| emit(type, position, count = 20) { | |
| const configs = { | |
| birth: { color: [0.3, 1.0, 0.5], size: 2.5, speed: 0.08, life: 80, gravity: -0.001 }, | |
| death: { color: [0.6, 0.4, 0.8], size: 1.8, speed: 0.04, life: 120, gravity: 0.002 }, | |
| mate: { color: [1.0, 0.3, 0.5], size: 2.0, speed: 0.05, life: 60, gravity: 0.0 }, | |
| eat: { color: [1.0, 0.8, 0.2], size: 1.5, speed: 0.03, life: 40, gravity: -0.001 } | |
| }; | |
| const cfg = configs[type] || configs.birth; | |
| for (let i = 0; i < count; i++) { | |
| const theta = Math.random() * Math.PI * 2; | |
| const phi = Math.random() * Math.PI; | |
| const vel = new THREE.Vector3( | |
| Math.sin(phi) * Math.cos(theta), | |
| Math.sin(phi) * Math.sin(theta), | |
| Math.cos(phi) | |
| ).multiplyScalar(cfg.speed * (0.5 + Math.random())); | |
| this.particles.push({ | |
| pos: position.clone(), | |
| vel: vel, | |
| color: cfg.color.map(c => c * (0.8 + Math.random() * 0.4)), | |
| size: cfg.size * (0.7 + Math.random() * 0.6), | |
| life: cfg.life, | |
| maxLife: cfg.life, | |
| gravity: cfg.gravity | |
| }); | |
| } | |
| } | |
| update() { | |
| const positions = this.sparkGeometry.attributes.position.array; | |
| const colors = this.sparkGeometry.attributes.color.array; | |
| const sizes = this.sparkGeometry.attributes.size.array; | |
| const alphas = this.sparkGeometry.attributes.alpha.array; | |
| let idx = 0; | |
| this.particles = this.particles.filter(p => { | |
| p.life--; | |
| if (p.life <= 0 || idx >= this.maxParticles) return false; | |
| p.vel.y += p.gravity; | |
| p.pos.add(p.vel); | |
| const i3 = idx * 3; | |
| positions[i3] = p.pos.x; positions[i3+1] = p.pos.y; positions[i3+2] = p.pos.z; | |
| colors[i3] = p.color[0]; colors[i3+1] = p.color[1]; colors[i3+2] = p.color[2]; | |
| sizes[idx] = p.size * (p.life / p.maxLife); | |
| alphas[idx] = p.life / p.maxLife; | |
| idx++; | |
| return true; | |
| }); | |
| this.sparkGeometry.setDrawRange(0, idx); | |
| this.sparkGeometry.attributes.position.needsUpdate = true; | |
| this.sparkGeometry.attributes.color.needsUpdate = true; | |
| this.sparkGeometry.attributes.size.needsUpdate = true; | |
| this.sparkGeometry.attributes.alpha.needsUpdate = true; | |
| } | |
| getMesh() { | |
| if (!this.mesh) { | |
| this.mesh = new THREE.Points(this.sparkGeometry, this.sparkMaterial); | |
| this.mesh.frustumCulled = false; | |
| this.scene.add(this.mesh); | |
| } | |
| return this.mesh; | |
| } | |
| } | |
| class Renderer3D { | |
| constructor(canvas) { | |
| this.canvas = canvas; | |
| this.scene = new THREE.Scene(); | |
| // v3.0: Dynamic background for day/night | |
| this.dayColor = new THREE.Color(0x2a3a55); | |
| this.nightColor = new THREE.Color(0x030305); | |
| this.currentBgColor = new THREE.Color(0x101015); | |
| this.scene.background = this.currentBgColor; | |
| this.scene.fog = new THREE.Fog(this.currentBgColor, 500, 2000); | |
| this.ambientLight = null; | |
| this.dirLight = null; | |
| // Animation time tracking | |
| this.clock = new THREE.Clock(); | |
| this.animTime = 0; | |
| // v3.1: Sun position tracking | |
| this.sunAngle = 0; | |
| this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| this.camera.position.set(25, 40, 50); | |
| this.webgl = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true }); | |
| this.webgl.setSize(window.innerWidth, window.innerHeight); | |
| this.webgl.shadowMap.enabled = true; | |
| this.webgl.toneMapping = THREE.ACESFilmicToneMapping; | |
| this.webgl.toneMappingExposure = 1.2; | |
| // Post-processing setup | |
| this.composer = new EffectComposer(this.webgl); | |
| const renderPass = new RenderPass(this.scene, this.camera); | |
| this.composer.addPass(renderPass); | |
| // Bloom pass for glow effects | |
| this.bloomPass = new UnrealBloomPass( | |
| new THREE.Vector2(window.innerWidth, window.innerHeight), | |
| 0.8, // strength | |
| 0.4, // radius | |
| 0.85 // threshold | |
| ); | |
| this.composer.addPass(this.bloomPass); | |
| // Particle system | |
| this.particles = new ParticleSystem(this.scene); | |
| this.controls = new OrbitControls(this.camera, this.canvas); | |
| this.controls.enableDamping = true; | |
| this.controls.dampingFactor = 0.05; | |
| // v3.1: Lower ambient for more contrast between day/night sides | |
| this.ambientLight = new THREE.AmbientLight(0x303040, 0.4); | |
| this.scene.add(this.ambientLight); | |
| const hemiLight = new THREE.HemisphereLight(0xffffff, 0x111122, 1.0); | |
| this.scene.add(hemiLight); | |
| // v3.1: Main sun light - will orbit around planet | |
| this.dirLight = new THREE.DirectionalLight(0xffffee, 4.0); | |
| this.dirLight.position.set(200, 50, 0); | |
| this.dirLight.castShadow = true; | |
| this.dirLight.shadow.mapSize.width = 4096; | |
| this.dirLight.shadow.mapSize.height = 4096; | |
| const d = 200; | |
| this.dirLight.shadow.camera.left = -d; this.dirLight.shadow.camera.right = d; | |
| this.dirLight.shadow.camera.top = d; this.dirLight.shadow.camera.bottom = -d; | |
| this.dirLight.shadow.camera.near = 0.5; this.dirLight.shadow.camera.far = 500; | |
| this.scene.add(this.dirLight); | |
| // Sun sphere (visible glowing sun) | |
| const sunGeo = new THREE.SphereGeometry(8, 32, 32); | |
| const sunMat = new THREE.MeshBasicMaterial({ | |
| color: 0xffdd44, | |
| transparent: true, | |
| opacity: 1.0 | |
| }); | |
| this.sunMesh = new THREE.Mesh(sunGeo, sunMat); | |
| this.sunMesh.position.set(200, 50, 0); | |
| this.sunMesh.layers.enable(1); // Bloom layer | |
| this.scene.add(this.sunMesh); | |
| // Atmosphere glow around planet | |
| this.atmosphereGlow = null; | |
| // v3.1: Add a subtle backlight for night side visibility | |
| this.nightLight = new THREE.DirectionalLight(0x4455aa, 0.08); | |
| this.nightLight.position.set(-200, 30, 0); | |
| this.scene.add(this.nightLight); | |
| // v3.1: Add point light at sun position for glow effect | |
| this.sunGlow = new THREE.PointLight(0xffaa44, 2.0, 500); | |
| this.sunGlow.position.set(200, 50, 0); | |
| this.scene.add(this.sunGlow); | |
| this.nxerMeshes = new Map(); | |
| this.foodMeshes = new Map(); | |
| this.effectMeshes = []; | |
| this.terrainMesh = null; | |
| // NEW: Visual helpers for selection | |
| this.selectionHelpers = { | |
| sightLine: null, | |
| smellRing: null, | |
| group: new THREE.Group() | |
| }; | |
| this.scene.add(this.selectionHelpers.group); | |
| this.raycaster = new THREE.Raycaster(); | |
| this.mouse = new THREE.Vector2(); | |
| this.selectedNxerId = null; | |
| window.addEventListener('resize', () => this.onWindowResize()); | |
| } | |
| // ... [Keep onWindowResize, getSpherePos, createEmojiTexture, initTerrain as they were] ... | |
| onWindowResize() { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.webgl.setSize(window.innerWidth, window.innerHeight); | |
| this.composer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| getSpherePos(x, y, worldN, radius) { | |
| const phi = (x / worldN) * Math.PI * 2; | |
| const v = y / worldN; | |
| const theta = (v * 0.99 + 0.005) * Math.PI; | |
| const px = radius * Math.sin(theta) * Math.cos(phi); | |
| const py = radius * Math.cos(theta); | |
| const pz = radius * Math.sin(theta) * Math.sin(phi); | |
| return new THREE.Vector3(px, py, pz); | |
| } | |
| createEmojiTexture(emoji) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 64; canvas.height = 64; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.font = '48px "Segoe UI Emoji", sans-serif'; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText(emoji, 32, 32); | |
| return new THREE.CanvasTexture(canvas); | |
| } | |
| initTerrain(world) { | |
| // [Code same as provided in previous turn] | |
| if (this.terrainMesh) { | |
| if(this.terrainMesh.geometry) this.terrainMesh.geometry.dispose(); | |
| if(this.terrainMesh.material) this.terrainMesh.material.dispose(); | |
| this.scene.remove(this.terrainMesh); | |
| } | |
| if (this.coreSphere) this.scene.remove(this.coreSphere); | |
| this.planetRadius = (world.N * 0.8) / 2; | |
| const coreGeo = new THREE.SphereGeometry(this.planetRadius, 64, 64); | |
| const coreMat = new THREE.MeshStandardMaterial({ color: 0x1964c8, roughness: 0.2, metalness: 0.1 }); | |
| this.coreSphere = new THREE.Mesh(coreGeo, coreMat); | |
| this.coreSphere.receiveShadow = true; | |
| this.scene.add(this.coreSphere); | |
| // Atmosphere glow effect | |
| if (this.atmosphereGlow) this.scene.remove(this.atmosphereGlow); | |
| const atmosGeo = new THREE.SphereGeometry(this.planetRadius * 1.05, 64, 64); | |
| const atmosMat = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| glowColor: { value: new THREE.Color(0x4488ff) }, | |
| viewVector: { value: this.camera.position } | |
| }, | |
| vertexShader: ` | |
| varying vec3 vNormal; | |
| void main() { | |
| vNormal = normalize(normalMatrix * normal); | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| }`, | |
| fragmentShader: ` | |
| uniform vec3 glowColor; | |
| varying vec3 vNormal; | |
| void main() { | |
| float intensity = pow(0.7 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0); | |
| gl_FragColor = vec4(glowColor, intensity * 0.4); | |
| }`, | |
| side: THREE.BackSide, | |
| blending: THREE.AdditiveBlending, | |
| transparent: true | |
| }); | |
| this.atmosphereGlow = new THREE.Mesh(atmosGeo, atmosMat); | |
| this.scene.add(this.atmosphereGlow); | |
| const geometry = new THREE.IcosahedronGeometry(this.planetRadius, 40); | |
| const count = geometry.attributes.position.count; | |
| const posAttr = geometry.attributes.position; | |
| const colAttr = new THREE.BufferAttribute(new Float32Array(count * 3), 3); | |
| const vertex = new THREE.Vector3(); | |
| const color = new THREE.Color(); | |
| for (let i = 0; i < count; i++) { | |
| vertex.fromBufferAttribute(posAttr, i); | |
| const normal = vertex.clone().normalize(); | |
| let phi = Math.atan2(vertex.z, vertex.x); | |
| if (phi < 0) phi += Math.PI * 2; | |
| const u = 1.0 - (phi / (Math.PI * 2)); | |
| const theta = Math.acos(Math.max(-1, Math.min(1, vertex.y / this.planetRadius))); | |
| const v = theta / Math.PI; | |
| const uOffset = (u + 0.25) % 1.0; | |
| const gx = Math.floor(uOffset * world.N) % world.N; | |
| const gy = Math.floor(v * world.N) % world.N; | |
| const t = world.terrain(gx, gy); | |
| let h = -0.1; | |
| if (t === 1) { h = 0.3; color.setHex(0x28b43c); } | |
| else if (t === 2) { h = 1.0; color.setHex(0x6e6e6e); } | |
| else { color.setHex(0x1964c8); } | |
| vertex.copy(normal).multiplyScalar(this.planetRadius + h); | |
| posAttr.setXYZ(i, vertex.x, vertex.y, vertex.z); | |
| colAttr.setXYZ(i, color.r, color.g, color.b); | |
| } | |
| geometry.setAttribute('color', colAttr); | |
| geometry.computeVertexNormals(); | |
| const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.8, flatShading: false }); | |
| this.terrainMesh = new THREE.Mesh(geometry, mat); | |
| this.terrainMesh.castShadow = true; | |
| this.terrainMesh.receiveShadow = true; | |
| this.scene.add(this.terrainMesh); | |
| this.controls.target.set(0, 0, 0); | |
| this.camera.position.set(0, 0, this.planetRadius * 2.5); | |
| this.controls.minPolarAngle = 0; | |
| this.controls.maxPolarAngle = Math.PI; | |
| this.controls.minDistance = this.planetRadius * 1.2; | |
| this.controls.maxDistance = this.planetRadius * 6; | |
| } | |
| clearEntities() { | |
| this.nxerMeshes.forEach(m => this.scene.remove(m)); this.nxerMeshes.clear(); | |
| this.foodMeshes.forEach(m => this.scene.remove(m)); this.foodMeshes.clear(); | |
| this.effectMeshes.forEach(e => this.scene.remove(e.mesh)); this.effectMeshes = []; | |
| this.clearSelectionVisuals(); | |
| } | |
| // NEW: Clears visual helpers | |
| clearSelectionVisuals() { | |
| // Remove Line | |
| if (this.selectionHelpers.sightLine) { | |
| this.selectionHelpers.group.remove(this.selectionHelpers.sightLine); | |
| if (this.selectionHelpers.sightLine.geometry) this.selectionHelpers.sightLine.geometry.dispose(); | |
| if (this.selectionHelpers.sightLine.material) this.selectionHelpers.sightLine.material.dispose(); | |
| this.selectionHelpers.sightLine = null; | |
| } | |
| // Hide Ring (don't dispose geometry, just hide/reset) | |
| if (this.selectionHelpers.smellRing) { | |
| this.selectionHelpers.smellRing.visible = false; | |
| } | |
| } | |
| updateSelectionVisuals(nxer, world) { | |
| if (!nxer || !nxer.alive) { | |
| this.clearSelectionVisuals(); | |
| return; | |
| } | |
| // --- 1. SETUP HELPERS IF MISSING --- | |
| if (!this.selectionHelpers.smellRing) { | |
| // TUBE THICKNESS INCREASED: 0.03 -> 0.1 for visibility | |
| const geometry = new THREE.TorusGeometry(1, 0.1, 16, 64); | |
| // DOUBLE SIDE & DEPTH TEST FALSE ensures it's seen even if slightly underground | |
| const material = new THREE.MeshBasicMaterial({ | |
| color: 0xffff00, | |
| transparent: true, | |
| opacity: 0.6, | |
| side: THREE.DoubleSide, | |
| depthTest: false // Optional: makes it always render on top | |
| }); | |
| const ring = new THREE.Mesh(geometry, material); | |
| // Important: renderOrder ensures it draws on top of terrain | |
| ring.renderOrder = 999; | |
| this.selectionHelpers.group.add(ring); | |
| this.selectionHelpers.smellRing = ring; | |
| } | |
| // --- 2. CALCULATE START POSITION --- | |
| const t = world.terrain(nxer.pos[0], nxer.pos[1]); | |
| // INCREASED OFFSET: Lifted higher (+0.8) to avoid z-fighting with mountains | |
| const hOffset = (t === 1 ? 0.3 : (t === 2 ? 1.0 : 0.0)) + 0.8; | |
| const startPos = this.getSpherePos(nxer.pos[0], nxer.pos[1], world.N, this.planetRadius + hOffset); | |
| // --- 3. UPDATE SMELL RING (Yellow) --- | |
| const ring = this.selectionHelpers.smellRing; | |
| ring.visible = true; | |
| ring.position.copy(startPos); | |
| // Orient ring tangent to sphere surface | |
| ring.lookAt(0, 0, 0); | |
| // Calculate Scale | |
| const gridUnitSize = (2 * Math.PI * this.planetRadius) / world.N; | |
| const ringRadius = (nxer.smellRadius || 3) * gridUnitSize; | |
| ring.scale.set(ringRadius, ringRadius, 1); | |
| // --- 4. UPDATE SIGHT LINE (Blue Curve) --- | |
| if (this.selectionHelpers.sightLine) { | |
| this.selectionHelpers.group.remove(this.selectionHelpers.sightLine); | |
| if(this.selectionHelpers.sightLine.geometry) this.selectionHelpers.sightLine.geometry.dispose(); | |
| this.selectionHelpers.sightLine = null; | |
| } | |
| const DIR_OFFSETS = [ | |
| [-1, -1], [0, -1], [1, -1], [1, 0], | |
| [1, 1], [0, 1], [-1, 1], [-1, 0] | |
| ]; // 0=NW to 7=W | |
| // Fallback if heading is undefined | |
| const headingIndex = (nxer.heading !== undefined) ? nxer.heading : 0; | |
| const dir = DIR_OFFSETS[headingIndex] || [0,0]; | |
| // Calculate Target | |
| const vRange = nxer.visionRange || 5; | |
| // Handle wrapping for X, clamping for Y | |
| let tx = (nxer.pos[0] + (dir[0] * vRange)); | |
| tx = ((tx % world.N) + world.N) % world.N; | |
| let ty = nxer.pos[1] + (dir[1] * vRange); | |
| ty = clamp(ty, 0, world.N - 1); | |
| const tt = world.terrain(tx, ty); | |
| const hOffsetT = (tt === 1 ? 0.3 : (tt === 2 ? 1.0 : 0.0)) + 0.8; | |
| const endPos = this.getSpherePos(tx, ty, world.N, this.planetRadius + hOffsetT); | |
| const midPos = startPos.clone().add(endPos).normalize().multiplyScalar(this.planetRadius + hOffset + (vRange * 0.15)); | |
| const curve = new THREE.QuadraticBezierCurve3(startPos, midPos, endPos); | |
| const points = curve.getPoints(12); | |
| const lineGeo = new THREE.BufferGeometry().setFromPoints(points); | |
| const lineMat = new THREE.LineBasicMaterial({ | |
| color: 0x00ffff, | |
| linewidth: 2, | |
| depthTest: false // Always visible | |
| }); | |
| const line = new THREE.Line(lineGeo, lineMat); | |
| line.renderOrder = 999; // Draw on top | |
| this.selectionHelpers.sightLine = line; | |
| this.selectionHelpers.group.add(line); | |
| } | |
| updateNxErs(nxers, world) { | |
| const delta = this.clock.getDelta(); | |
| this.animTime += delta; | |
| let coldestId = null, hottestId = null, minTemp = Infinity, maxTemp = -Infinity; | |
| for (const nx of nxers.values()) { | |
| if (!nx.alive) continue; | |
| if (nx.bodyTemperature < minTemp) { minTemp = nx.bodyTemperature; coldestId = nx.id; } | |
| if (nx.bodyTemperature > maxTemp) { maxTemp = nx.bodyTemperature; hottestId = nx.id; } | |
| } | |
| const N = world.N; | |
| for(const [id, m] of this.nxerMeshes) { | |
| if(!nxers.has(id) || !nxers.get(id).alive) { this.scene.remove(m); this.nxerMeshes.delete(id); } | |
| } | |
| for(const nxer of nxers.values()) { | |
| if(!nxer.alive) continue; | |
| let mesh = this.nxerMeshes.get(nxer.id); | |
| const t = world.terrain(nxer.pos[0], nxer.pos[1]); | |
| let hOffset = 0.0; | |
| if (t === 1) hOffset = 0.3; else if (t === 2) hOffset = 1.0; | |
| const targetPos = this.getSpherePos(nxer.pos[0], nxer.pos[1], N, this.planetRadius + hOffset + 0.35); | |
| if(!mesh) { | |
| const g = new THREE.SphereGeometry(0.35, 12, 12); | |
| // Emissive material for glow effect | |
| const baseColor = new THREE.Color(nxer.color); | |
| const m = new THREE.MeshStandardMaterial({ | |
| color: nxer.color, | |
| emissive: baseColor, | |
| emissiveIntensity: 0.15, | |
| roughness: 0.4, | |
| metalness: 0.3 | |
| }); | |
| mesh = new THREE.Mesh(g, m); | |
| mesh.castShadow = true; | |
| mesh.userData = { id: nxer.id, type: 'nxer' }; | |
| // Energy ring with glow | |
| const ringMat = new THREE.MeshBasicMaterial({ | |
| color: 0xff3333, | |
| transparent: true, | |
| opacity: 0.8 | |
| }); | |
| const r = new THREE.Mesh(new THREE.TorusGeometry(0.5, 0.04, 8, 24), ringMat); | |
| r.rotation.x = Math.PI/2; | |
| mesh.add(r); | |
| // v4.0: Multi-sphere indicator rings (concentric colored rings for each sphere) | |
| const sphereColors = nxer.net.spheres.map(s => { | |
| const info = SPHERE_TYPES[s.type] || SPHERE_TYPES.association; | |
| return new THREE.Color(info.hex); | |
| }); | |
| for (let si = 0; si < Math.min(sphereColors.length, 5); si++) { | |
| const sRing = new THREE.Mesh( | |
| new THREE.TorusGeometry(0.3 + si * 0.12, 0.02, 8, 20), | |
| new THREE.MeshBasicMaterial({ color: sphereColors[si], transparent: true, opacity: 0.5 }) | |
| ); | |
| sRing.rotation.x = Math.PI / 2; | |
| sRing.userData.sphereRing = true; | |
| sRing.userData.sphereIndex = si; | |
| mesh.add(sRing); | |
| } | |
| // Gender indicator (small sphere on top) | |
| const genderColor = nxer.isMale ? 0x4488ff : 0xff88aa; | |
| const genderGeo = new THREE.SphereGeometry(0.08, 8, 8); | |
| const genderMat = new THREE.MeshBasicMaterial({ color: genderColor }); | |
| const genderMarker = new THREE.Mesh(genderGeo, genderMat); | |
| genderMarker.position.y = 0.4; | |
| mesh.add(genderMarker); | |
| const clanCanvas = document.createElement('canvas'); | |
| clanCanvas.width = 64; clanCanvas.height = 64; | |
| const clanCtx = clanCanvas.getContext('2d'); | |
| const clanTexture = new THREE.CanvasTexture(clanCanvas); | |
| const clanSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: clanTexture, transparent: true })); | |
| clanSprite.scale.set(0.4, 0.4, 1); | |
| clanSprite.position.y = 0.7; | |
| mesh.add(clanSprite); | |
| mesh.userData.clanSprite = clanSprite; | |
| mesh.userData.clanCtx = clanCtx; | |
| mesh.userData.clanTexture = clanTexture; | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = 64; tempCanvas.height = 64; | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| const tempTexture = new THREE.CanvasTexture(tempCanvas); | |
| const tempSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tempTexture, transparent: true })); | |
| tempSprite.scale.set(0.5, 0.5, 1); | |
| tempSprite.position.y = 1.0; | |
| tempSprite.visible = false; | |
| mesh.add(tempSprite); | |
| mesh.userData.tempSprite = tempSprite; | |
| mesh.userData.tempCtx = tempCtx; | |
| mesh.userData.tempTexture = tempTexture; | |
| this.scene.add(mesh); this.nxerMeshes.set(nxer.id, mesh); | |
| mesh.position.copy(targetPos); | |
| } | |
| const dist = mesh.position.distanceTo(targetPos); | |
| if(dist > this.planetRadius) { | |
| mesh.position.copy(targetPos); | |
| mesh.visible = false; | |
| } else { | |
| mesh.visible = true; | |
| const c = mesh.position.clone().normalize(); | |
| const t = targetPos.clone().normalize(); | |
| c.lerp(t, 0.15); | |
| mesh.position.copy(c.multiplyScalar(this.planetRadius + hOffset + 0.35)); | |
| } | |
| const up = new THREE.Vector3(0,1,0); | |
| const norm = mesh.position.clone().normalize(); | |
| mesh.setRotationFromQuaternion(new THREE.Quaternion().setFromUnitVectors(up, norm)); | |
| // Animated idle behavior - breathing/pulsing | |
| const energyRatio = Math.max(0.3, Math.min(1.0, nxer.food / 100)); | |
| const breathe = 1.0 + Math.sin(this.animTime * 3 + nxer.id) * 0.05 * energyRatio; | |
| mesh.scale.setScalar(breathe); | |
| // Resting creatures bob slower and dimmer | |
| if (nxer.isResting) { | |
| mesh.scale.multiplyScalar(0.9); | |
| mesh.material.emissiveIntensity = 0.05; | |
| } else { | |
| mesh.material.emissiveIntensity = 0.1 + energyRatio * 0.2; | |
| } | |
| // Low energy = more transparent/dimmer | |
| if (nxer.food < 30) { | |
| mesh.material.opacity = 0.5 + (nxer.food / 30) * 0.5; | |
| mesh.material.transparent = true; | |
| } else { | |
| mesh.material.opacity = 1.0; | |
| mesh.material.transparent = false; | |
| } | |
| const ring = mesh.children[0]; | |
| if(ring) { | |
| const es = nxer.net.getEnergyStatus(); | |
| const s = Math.max(0.1, Math.min(1.5, es.averageEnergy/80)); | |
| ring.scale.set(s, s, s); | |
| ring.rotation.z = this.animTime * 2; // Spinning ring | |
| ring.material.color.setHex(nxer.id === this.selectedNxerId ? 0x00ffff : 0xff3333); | |
| ring.material.opacity = 0.6 + Math.sin(this.animTime * 5) * 0.2; | |
| } | |
| // v4.0: Animate sphere indicator rings based on activity | |
| mesh.children.forEach(child => { | |
| if (child.userData && child.userData.sphereRing) { | |
| const si = child.userData.sphereIndex; | |
| const sphere = nxer.net.spheres[si]; | |
| if (sphere) { | |
| const act = sphere.getActivityStats(); | |
| const actRatio = act.total > 0 ? (act.exc + act.inh) / act.total : 0; | |
| child.rotation.z = this.animTime * (1.5 + si * 0.5); | |
| child.material.opacity = 0.2 + actRatio * 0.6; | |
| const pulse = 1.0 + Math.sin(this.animTime * 4 + si * 1.5) * 0.1 * actRatio; | |
| child.scale.setScalar(pulse); | |
| } | |
| } | |
| }); | |
| if (mesh.userData.clanSprite) { | |
| if (nxer.clanId !== null) { | |
| const ctx = mesh.userData.clanCtx; | |
| ctx.clearRect(0, 0, 64, 64); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 40px Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(nxer.clanId, 32, 32); | |
| mesh.userData.clanTexture.needsUpdate = true; | |
| mesh.userData.clanSprite.visible = true; | |
| } else { | |
| mesh.userData.clanSprite.visible = false; | |
| } | |
| } | |
| if (mesh.userData.tempSprite) { | |
| const ctx = mesh.userData.tempCtx; | |
| ctx.clearRect(0, 0, 64, 64); | |
| if (nxer.id === coldestId) { | |
| ctx.font = '48px Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('❄️', 32, 32); | |
| mesh.userData.tempTexture.needsUpdate = true; | |
| mesh.userData.tempSprite.visible = true; | |
| } else if (nxer.id === hottestId) { | |
| ctx.font = '48px Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('🔥', 32, 32); | |
| mesh.userData.tempTexture.needsUpdate = true; | |
| mesh.userData.tempSprite.visible = true; | |
| } else { | |
| mesh.userData.tempSprite.visible = false; | |
| } | |
| } | |
| } | |
| } | |
| // [updateFood, updateEffects, handleClick remain same] | |
| updateFood(foods, world) { | |
| for(const [id, m] of this.foodMeshes) if(!foods.has(id) || !foods.get(id).alive) { this.scene.remove(m); this.foodMeshes.delete(id); } | |
| for(const food of foods.values()) { | |
| if(!food.alive) continue; | |
| let mesh = this.foodMeshes.get(food.id); | |
| if(!mesh) { | |
| const g = new THREE.OctahedronGeometry(0.4, 0); | |
| const m = new THREE.MeshStandardMaterial({ | |
| color: 0xff4444, | |
| emissive: 0xff2200, | |
| emissiveIntensity: 0.4, | |
| roughness: 0.3, | |
| metalness: 0.5 | |
| }); | |
| mesh = new THREE.Mesh(g, m); mesh.castShadow = true; | |
| this.scene.add(mesh); this.foodMeshes.set(food.id, mesh); | |
| } | |
| const t = world.terrain(food.pos[0], food.pos[1]); | |
| let h = 0.0; if(t===1) h=0.3; else if(t===2) h=1.0; | |
| const pos = this.getSpherePos(food.pos[0], food.pos[1], world.N, this.planetRadius + h + 0.45); | |
| mesh.position.copy(pos); | |
| const up = new THREE.Vector3(0,1,0); | |
| const norm = pos.clone().normalize(); | |
| mesh.setRotationFromQuaternion(new THREE.Quaternion().setFromUnitVectors(up, norm)); | |
| // Floating and rotating animation | |
| mesh.rotateY(0.03); | |
| const float = Math.sin(this.animTime * 2 + food.id * 0.5) * 0.1; | |
| mesh.position.add(norm.multiplyScalar(float)); | |
| } | |
| } | |
| updateEffects(engine) { | |
| this.effectMeshes = this.effectMeshes.filter(ef => { | |
| const age = engine.stepTick - ef.startTick; | |
| const upVec = ef.mesh.position.clone().normalize(); | |
| ef.mesh.position.add(upVec.multiplyScalar(0.08)); | |
| ef.mesh.material.opacity = 1.0 - (age / 60.0); | |
| ef.mesh.scale.multiplyScalar(1.02); // Growing effect | |
| if (age >= 80) { | |
| this.scene.remove(ef.mesh); | |
| if(ef.mesh.material.map) ef.mesh.material.map.dispose(); | |
| ef.mesh.material.dispose(); | |
| return false; | |
| } | |
| return true; | |
| }); | |
| if (!this.processedEffects) this.processedEffects = new Set(); | |
| engine.effects.forEach(e => { | |
| const eid = `${e.type}-${e.pos[0]}-${e.pos[1]}-${e.startTick}`; | |
| if (!this.processedEffects.has(eid)) { | |
| this.processedEffects.add(eid); | |
| // Emit particles for events | |
| const pos3d = this.getSpherePos(e.pos[0], e.pos[1], engine.world.N, this.planetRadius + 1.0); | |
| if (e.type === 'heart') { | |
| this.particles.emit('birth', pos3d, 30); | |
| this.particles.emit('mate', pos3d, 15); | |
| } else if (e.type === 'skull') { | |
| this.particles.emit('death', pos3d, 40); | |
| } else if (e.type === 'sing') { | |
| // v4.5: Musical note sprite + audio playback (gated by zoom) | |
| const noteIcons = ['🎵', '🎶', '♪', '♫', '🎼']; | |
| const numNotes = e.trigger === 'chorus' ? 3 : (e.trigger === 'discovery' ? 2 : 1); | |
| for (let i = 0; i < numNotes; i++) { | |
| const icon = noteIcons[Math.floor(Math.random() * noteIcons.length)]; | |
| const map = this.createEmojiTexture(icon); | |
| const mat = new THREE.SpriteMaterial({ map: map, transparent: true }); | |
| const sprite = new THREE.Sprite(mat); | |
| const pos = this.getSpherePos(e.pos[0], e.pos[1], engine.world.N, this.planetRadius + 1.2 + i * 0.2); | |
| sprite.position.copy(pos); | |
| // More musical voices = brighter/larger notes | |
| const noteSize = 0.7 + (e.musicality || 0.5) * 0.7; | |
| sprite.scale.set(noteSize, noteSize, noteSize); | |
| // Slight horizontal drift per note | |
| sprite.userData.drift = (Math.random() - 0.5) * 0.05; | |
| this.scene.add(sprite); | |
| this.effectMeshes.push({ mesh: sprite, startTick: e.startTick + i * 4 }); | |
| } | |
| // Audio — only if camera is close enough (proximity > 0) | |
| if (audioEngine.enabled && audioEngine.targetVolume > 0.05 && e.voice) { | |
| if (e.trigger === 'chorus') { | |
| audioEngine.playChord(e.voice, 0.9, 0.05, e.foodBonus || 0); | |
| } else { | |
| audioEngine.playMelody(e.voice, e.foodBonus || 0, | |
| e.trigger === 'discovery' ? 0.13 : 0.10); | |
| } | |
| } | |
| return; // sprite already added above | |
| } | |
| if (e.type === 'heart' || e.type === 'skull') { | |
| const icon = e.type === 'heart' ? '❤️' : '💀'; | |
| const map = this.createEmojiTexture(icon); | |
| const mat = new THREE.SpriteMaterial({ map: map, transparent: true }); | |
| const sprite = new THREE.Sprite(mat); | |
| const pos = this.getSpherePos(e.pos[0], e.pos[1], engine.world.N, this.planetRadius + 1.5); | |
| sprite.position.copy(pos); | |
| sprite.scale.set(1.5, 1.5, 1.5); | |
| this.scene.add(sprite); | |
| this.effectMeshes.push({ mesh: sprite, startTick: e.startTick }); | |
| } | |
| } | |
| }); | |
| if (this.processedEffects.size > 100) this.processedEffects.clear(); | |
| // Update particle system | |
| this.particles.update(); | |
| } | |
| handleClick(x, y) { | |
| this.mouse.x = (x/window.innerWidth)*2-1; this.mouse.y = -(y/window.innerHeight)*2+1; | |
| this.raycaster.setFromCamera(this.mouse, this.camera); | |
| const hits = this.raycaster.intersectObjects(this.scene.children, true); | |
| for(const hit of hits) { | |
| let o = hit.object; | |
| while(o) { if(o.userData && o.userData.type==='nxer') return o.userData.id; o = o.parent; } | |
| } | |
| return null; | |
| } | |
| // v3.1: Update lighting with sun orbiting the planet | |
| updateDayNightLighting(engine) { | |
| if (!engine) return; | |
| // v3.1: Get sun longitude and orbit the light around the planet | |
| const sunLong = engine.sunLongitude || 0; | |
| const orbitRadius = this.planetRadius ? this.planetRadius * 3 : 200; | |
| // Calculate sun position (orbits in XZ plane) | |
| const sunX = Math.cos(sunLong) * orbitRadius; | |
| const sunZ = Math.sin(sunLong) * orbitRadius; | |
| const sunY = 50; // Slight elevation | |
| // Update directional light position | |
| if (this.dirLight) { | |
| this.dirLight.position.set(sunX, sunY, sunZ); | |
| this.dirLight.target.position.set(0, 0, 0); | |
| // Update sun mesh position | |
| if (this.sunMesh) { | |
| this.sunMesh.position.set(sunX, sunY, sunZ); | |
| } | |
| // Warm sunrise/sunset colors | |
| const globalDaylight = engine.getDaylightFactor(); | |
| const warmth = Math.sin(globalDaylight * Math.PI); // Peak at midday | |
| const sunColor = new THREE.Color().setHSL(0.1 - warmth * 0.05, 0.8, 0.5 + globalDaylight * 0.3); | |
| this.dirLight.color = sunColor; | |
| this.dirLight.intensity = 2.0 + globalDaylight * 3.0; | |
| } | |
| // Update night light (opposite side) | |
| if (this.nightLight) { | |
| this.nightLight.position.set(-sunX, sunY * 0.5, -sunZ); | |
| } | |
| // Update sun glow position | |
| if (this.sunGlow) { | |
| this.sunGlow.position.set(sunX, sunY, sunZ); | |
| } | |
| // Update atmosphere glow color based on time of day | |
| if (this.atmosphereGlow && this.atmosphereGlow.material.uniforms) { | |
| const daylightFactor = engine.getDaylightFactor(); | |
| const atmosColor = new THREE.Color().lerpColors( | |
| new THREE.Color(0x2244aa), // Night - deep blue | |
| new THREE.Color(0x88ccff), // Day - bright blue | |
| daylightFactor | |
| ); | |
| this.atmosphereGlow.material.uniforms.glowColor.value = atmosColor; | |
| this.atmosphereGlow.material.uniforms.viewVector.value.copy(this.camera.position); | |
| } | |
| // v3.1: Background based on camera's view of sun | |
| // If camera is looking at lit side, brighter background | |
| const camDir = new THREE.Vector3(); | |
| this.camera.getWorldDirection(camDir); | |
| const sunDir = new THREE.Vector3(sunX, sunY, sunZ).normalize(); | |
| const viewingSun = camDir.dot(sunDir); | |
| const bgFactor = Math.max(0.1, Math.min(0.9, (viewingSun + 1) / 2)); | |
| const daylightFactor = engine.getDaylightFactor(); | |
| this.currentBgColor.copy(this.nightColor).lerp(this.dayColor, daylightFactor); | |
| this.scene.background = this.currentBgColor; | |
| this.scene.fog.color = this.currentBgColor; | |
| // Adjust bloom intensity based on time of day | |
| if (this.bloomPass) { | |
| this.bloomPass.strength = 0.5 + daylightFactor * 0.5; | |
| } | |
| // Adjust ambient light intensity | |
| if (this.ambientLight) { | |
| this.ambientLight.intensity = 0.8 + daylightFactor * 1.2; | |
| } | |
| } | |
| render(engine) { | |
| this.updateNxErs(engine.nxers, engine.world); | |
| this.updateFood(engine.foods, engine.world); | |
| this.updateEffects(engine); | |
| // v3.0: Update day/night lighting | |
| this.updateDayNightLighting(engine); | |
| // NEW: Update Selection Visuals every frame | |
| if (this.selectedNxerId !== null && engine.nxers.has(this.selectedNxerId)) { | |
| this.updateSelectionVisuals(engine.nxers.get(this.selectedNxerId), engine.world); | |
| } else if (this.selectionHelpers.sightLine || this.selectionHelpers.smellRing) { | |
| this.clearSelectionVisuals(); | |
| } | |
| // Ensure particle mesh is created | |
| this.particles.getMesh(); | |
| // v4.5: CAMERA PROXIMITY → audio gate + group-singing trigger | |
| // The closer the camera is to the planet surface, the more audible | |
| // the NxEr conversations become. Far away = silence. | |
| if (this.planetRadius) { | |
| const camDist = this.camera.position.length(); | |
| const surfaceDist = camDist - this.planetRadius; | |
| const closeThreshold = this.planetRadius * 0.6; // very close | |
| const farThreshold = this.planetRadius * 2.0; // out of earshot | |
| let proximity = 1.0 - (surfaceDist - closeThreshold) / (farThreshold - closeThreshold); | |
| proximity = Math.max(0, Math.min(1, proximity)); | |
| audioEngine.setProximity(proximity); | |
| engine.cameraIsClose = proximity > 0.45; | |
| this.lastProximity = proximity; | |
| // Update on-screen badge | |
| if (!this._badgeRefs) { | |
| this._badgeRefs = { | |
| icon: document.getElementById('audioBadgeIcon'), | |
| text: document.getElementById('audioBadgeText') | |
| }; | |
| } | |
| if (this._badgeRefs.icon && this._badgeRefs.text) { | |
| if (proximity > 0.7) { | |
| this._badgeRefs.icon.textContent = '🔊'; | |
| this._badgeRefs.text.textContent = 'Listening to NxEr songs'; | |
| } else if (proximity > 0.45) { | |
| this._badgeRefs.icon.textContent = '🔉'; | |
| this._badgeRefs.text.textContent = 'Faint singing nearby'; | |
| } else if (proximity > 0.05) { | |
| this._badgeRefs.icon.textContent = '🔈'; | |
| this._badgeRefs.text.textContent = 'Whispers in the distance'; | |
| } else { | |
| this._badgeRefs.icon.textContent = '🔇'; | |
| this._badgeRefs.text.textContent = 'Zoom in to hear NxErs sing'; | |
| } | |
| } | |
| } | |
| // Floating notes: gentle horizontal drift while rising | |
| this.effectMeshes.forEach(ef => { | |
| if (ef.mesh.userData && ef.mesh.userData.drift) { | |
| const tangent = new THREE.Vector3(-ef.mesh.position.z, 0, ef.mesh.position.x).normalize(); | |
| ef.mesh.position.add(tangent.multiplyScalar(ef.mesh.userData.drift)); | |
| } | |
| }); | |
| this.controls.update(); | |
| this.composer.render(); // Use composer instead of direct render | |
| } | |
| } | |
| // ===================================================================== | |
| // UI CONTROLLER | |
| // ===================================================================== | |
| class UIController { | |
| constructor(engine, renderer) { | |
| this.engine = engine; | |
| this.renderer = renderer; | |
| this.hud = document.getElementById('hud'); | |
| this.hudTitle = document.getElementById('hudTitle'); | |
| this.hudContent = document.getElementById('hudContent'); | |
| this.hudStats = document.getElementById('hudStats'); | |
| this.buttonGroup = document.getElementById('buttonGroup'); | |
| this.detailPanel = document.getElementById('detailPanel'); | |
| this.detailTitle = document.getElementById('detailTitle'); | |
| this.detailContent = document.getElementById('detailContent'); | |
| this.gameOverModal = document.getElementById('gameOverModal'); | |
| this.gameOverOverlay = document.getElementById('gameOverOverlay'); | |
| this.controlsHint = document.getElementById('controlsHint'); | |
| this.setupButtons(); | |
| this.setupKeyboard(); | |
| this.setupMouse(); | |
| } | |
| setupButtons() { | |
| this.buttonGroup.innerHTML = ` | |
| <div class="button-row"><button class="hud-button" id="pauseBtn">Play</button></div> | |
| <div class="button-row"><button class="hud-button" id="saveBestBtn">Save Bests</button></div> | |
| <div class="button-row"><button class="hud-button" id="exitBtn">Exit</button></div> | |
| `; | |
| document.getElementById('pauseBtn').addEventListener('click', () => { | |
| this.engine.paused = !this.engine.paused; | |
| document.getElementById('pauseBtn').textContent = this.engine.paused ? 'Play' : 'Pause'; | |
| }); | |
| document.getElementById('exitBtn').addEventListener('click', () => { if (confirm('Return to menu?')) location.reload(); }); | |
| document.getElementById('saveBestBtn').addEventListener('click', () => this.saveBestChampions()); | |
| document.getElementById('restartYes').addEventListener('click', () => { | |
| this.engine.restartWithChampions(); | |
| this.renderer.clearEntities(); | |
| this.renderer.initTerrain(this.engine.world); | |
| this.gameOverModal.classList.add('hidden'); | |
| this.gameOverOverlay.classList.add('hidden'); | |
| }); | |
| document.getElementById('restartNo').addEventListener('click', () => location.reload()); | |
| document.getElementById('hideControlsHint').addEventListener('click', () => this.controlsHint.classList.add('hidden')); | |
| } | |
| setupKeyboard() { | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === ' ') { | |
| e.preventDefault(); | |
| if (!this.engine.gameOver) { | |
| this.engine.paused = !this.engine.paused; | |
| document.getElementById('pauseBtn').textContent = this.engine.paused ? 'Play' : 'Pause'; | |
| } | |
| } else if (e.key.toLowerCase() === 'h') { | |
| e.preventDefault(); | |
| this.hud.classList.toggle('hidden'); | |
| } | |
| }); | |
| } | |
| setupMouse() { | |
| this.renderer.canvas.addEventListener('click', (e) => { | |
| // Don't trigger if dragging | |
| const nxerId = this.renderer.handleClick(e.clientX, e.clientY); | |
| this.updateDetailPanel(nxerId); | |
| }); | |
| } | |
| update() { | |
| // 1. Update Title | |
| this.hudTitle.textContent = `Metrics: Round #${this.engine.gameIndex}`; | |
| // 2. Update Rankings | |
| const rankings = this.getRankings(); | |
| // v3.1: Add sun position indicator | |
| const sunPos = ((this.engine.sunLongitude || 0) / (Math.PI * 2) * 360).toFixed(0); | |
| const phaseIcon = this.engine.dayNightPhase === 0 ? '☀️' : | |
| this.engine.dayNightPhase === 2 ? '🌙' : '🌓'; | |
| let html = `<div class="hud-section"><div class="hud-section-title">${phaseIcon} ${this.engine.getStats().dayNightPhase} | Sun: ${sunPos}° | Env: ${this.engine.getStats().envTemp}°C</div></div>`; | |
| for (const [title, data] of Object.entries(rankings)) { | |
| html += `<div class="hud-section"><div class="hud-section-title">${title} (${title === 'Best g' ? (data.best >= 0 ? '+' : '') + data.best.toFixed(3) : data.best.toFixed(1)})</div>`; | |
| for (const entry of data.entries) { | |
| html += `<div class="hud-entry" data-nxer-id="${entry.id}"><span><span class="hud-dot" style="background:${entry.color}"></span>${entry.name}</span><span>${entry.value}</span></div>`; | |
| } | |
| html += `</div>`; | |
| } | |
| this.hudContent.innerHTML = html; | |
| // Re-attach click listeners for HUD items | |
| this.hudContent.querySelectorAll('.hud-entry').forEach(entry => { | |
| entry.addEventListener('click', () => { | |
| const nxerId = parseInt(entry.dataset.nxerId); | |
| this.renderer.selectedNxerId = nxerId; | |
| this.updateDetailPanel(nxerId); | |
| }); | |
| }); | |
| // 3. Stats Display | |
| const stats = this.engine.getStats(); | |
| this.hudStats.innerHTML = ` | |
| <div class="hud-stats">Alive: ${stats.alive} | Born: ${stats.born} | Dead: ${stats.dead}</div><div class="hud-stats">Avg Energy: ${stats.avgEnergy} | Branching: ${stats.avgBranching}</div><div class="hud-stats">Avg Body: ${stats.avgBodyTemp}°C | 🧠 ${this.engine.config.brainArch === 'chc6' ? 'CHC g-capable (6)' : 'Classic ×' + (this.engine.config.brainSpheres || 3)}${this.engine.lastG ? ' | g PC1: ' + this.engine.lastG.pc1.toFixed(2) + ' manifold: ' + (this.engine.lastG.posManifold*100).toFixed(0) + '%' : ''}</div> | |
| `; | |
| // 4. Game Over Modal | |
| if (this.engine.gameOver) { | |
| this.gameOverModal.classList.remove('hidden'); | |
| this.gameOverOverlay.classList.remove('hidden'); | |
| } else { | |
| this.gameOverModal.classList.add('hidden'); | |
| this.gameOverOverlay.classList.add('hidden'); | |
| } | |
| // 5. REAL-TIME DETAIL UPDATE | |
| // This ensures the numbers and the 3D visuals update even when not paused | |
| if (this.renderer.selectedNxerId !== null) { | |
| this.updateDetailPanel(this.renderer.selectedNxerId); | |
| } | |
| } | |
| getRankings() { | |
| const all = Array.from(this.engine.nxers.values()); | |
| if (all.length === 0) return {}; | |
| // Sorters | |
| const byFood = all.slice().sort((a, b) => b.stats.foodFound - a.stats.foodFound); | |
| const byFoodTaken = all.slice().sort((a, b) => b.stats.foodTaken - a.stats.foodTaken); | |
| const byExplored = all.slice().sort((a, b) => b.stats.explored - a.stats.explored); | |
| const byLived = all.slice().sort((a, b) => b.stats.timeLived - a.stats.timeLived); | |
| const byMates = all.slice().sort((a, b) => b.stats.matesPerformed - a.stats.matesPerformed); | |
| const byFitness = all.slice().sort((a, b) => b.stats.fitness - a.stats.fitness); | |
| const byG = all.slice().sort((a, b) => (b.stats.g || 0) - (a.stats.g || 0)); // v5.0 Lite | |
| // Helper to format entries (digits: g needs sign + 3 decimals) | |
| const format = (arr, key, digits = 1) => arr.slice(0, 3).map(n => ({ | |
| name: n.alive ? n.name : `${n.name}†`, | |
| value: typeof n.stats[key] === 'number' | |
| ? (digits === 3 ? (n.stats[key] >= 0 ? '+' : '') + n.stats[key].toFixed(3) | |
| : n.stats[key].toFixed(digits)) | |
| : '0.0', | |
| color: n.color, | |
| id: n.id | |
| })); | |
| // Helper to get best historical score | |
| const getBestScore = (category, arr) => { | |
| const allCandidates = [...arr, ...(this.engine.allTimeBest[category] || [])]; | |
| if (allCandidates.length === 0) return 0; | |
| return Math.max(...allCandidates.map(n => n.stats[category] || 0)); | |
| }; | |
| return { | |
| 'Food Found': { | |
| entries: format(byFood, 'foodFound'), | |
| best: getBestScore('foodFound', byFood) | |
| }, | |
| 'Food Stolen': { | |
| entries: format(byFoodTaken, 'foodTaken'), | |
| best: getBestScore('foodTaken', byFoodTaken) | |
| }, | |
| 'Explored': { | |
| entries: format(byExplored, 'explored'), | |
| best: getBestScore('explored', byExplored) | |
| }, | |
| 'Time Lived': { | |
| entries: format(byLived, 'timeLived'), | |
| best: getBestScore('timeLived', byLived) | |
| }, | |
| 'Mates': { | |
| entries: format(byMates, 'matesPerformed'), | |
| best: getBestScore('matesPerformed', byMates) | |
| }, | |
| 'Fitness': { | |
| entries: format(byFitness, 'fitness'), | |
| best: getBestScore('fitness', byFitness) | |
| }, | |
| 'Best g': { | |
| entries: format(byG, 'g', 3), | |
| best: getBestScore('g', byG) | |
| } | |
| }; | |
| } | |
| updateDetailPanel(nxerId) { | |
| // Check if valid | |
| if (nxerId === null || !this.engine.nxers.has(nxerId)) { | |
| this.detailPanel.classList.add('hidden'); | |
| this.renderer.clearSelectionVisuals(); | |
| return; | |
| } | |
| const nxer = this.engine.nxers.get(nxerId); | |
| this.detailPanel.classList.remove('hidden'); | |
| // Update Title | |
| const gender = nxer.isMale ? 'Male' : 'Female'; | |
| this.detailTitle.textContent = `${nxer.name} (id ${nxer.id}) - ${gender}`; | |
| // Helper strings | |
| const terrain = nxer.canLand && !nxer.canSea ? 'Land' : | |
| nxer.canSea && !nxer.canLand ? 'Sea' : 'Both'; | |
| const energyStatus = nxer.net.getEnergyStatus(); | |
| const params = nxer.net.params; | |
| const clanDisplay = nxer.clanId !== null ? `Clan #${nxer.clanId}` : "No Clan"; | |
| const headings = ["NW", "N", "NE", "E", "SE", "S", "SW", "W"]; | |
| const headingStr = headings[nxer.heading] || "?"; | |
| // v4.0: Get multi-sphere data | |
| const spheresSummary = nxer.net.getSpheresSummary(); | |
| const projsSummary = nxer.net.getProjectionsSummary(); | |
| const receptors = nxer.net.getReceptorActivations(); | |
| // v4.5: Voice / tonal info | |
| const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; | |
| const freqToNote = (f) => { | |
| // A4 = 440 Hz reference | |
| const semis = 12 * Math.log2(f / 440); | |
| const midi = Math.round(69 + semis); | |
| const note = NOTE_NAMES[((midi % 12) + 12) % 12]; | |
| const octave = Math.floor(midi / 12) - 1; | |
| return `${note}${octave}`; | |
| }; | |
| const voiceArr = nxer.voice || []; | |
| const muScore = nxer.musicality || 0; | |
| const muPct = Math.round(muScore * 100); | |
| const muColor = muScore > 0.7 ? '#7be37b' : (muScore > 0.45 ? '#ffd166' : '#ff8888'); | |
| const voiceTonesHTML = voiceArr.map(f => { | |
| const note = freqToNote(f); | |
| return `<span style="display:inline-block;background:#1c1c28;border:1px solid #3a3a4a;padding:2px 5px;border-radius:4px;margin:1px;font-family:monospace;font-size:10px;color:#9fdfff">${note} <span style="color:#888;font-size:9px">${f.toFixed(0)}Hz</span></span>`; | |
| }).join(''); | |
| const songFresh = nxer.lastSungTick > 0 && (nxer.engine ? false : true); | |
| const songsSung = nxer.songsSung || 0; | |
| const tonesLearned = nxer.tonesLearned || 0; | |
| const lastHeardTicksAgo = nxer.lastHeardSongTick > 0 | |
| ? `${songsSung > 0 ? 'recent' : '—'}` : '—'; | |
| const voiceHTML = ` | |
| <div class="detail-section" style="margin-top: 8px;"> | |
| <div style="color: #c084ff; font-weight: bold; margin-bottom: 4px;">🎼 Voice & Hearing</div> | |
| <div class="detail-info">Tones: ${voiceTonesHTML}</div> | |
| <div class="detail-info" style="margin-top:4px"> | |
| 🎵 Musicality: | |
| <span style="display:inline-block;width:80px;height:6px;background:#1a1a22;border-radius:3px;vertical-align:middle;overflow:hidden"> | |
| <span style="display:block;height:100%;width:${muPct}%;background:${muColor}"></span> | |
| </span> | |
| <span style="color:${muColor};font-family:monospace">${muPct}%</span> | |
| </div> | |
| <div class="detail-info">🎤 Songs: ${songsSung} 📚 Tones learned: ${tonesLearned}</div> | |
| </div> | |
| `; | |
| // Build sphere cards HTML | |
| let sphereHTML = ''; | |
| for (const s of spheresSummary) { | |
| const oscAmps = s.oscAmps || {}; | |
| const oscBars = ['infraslow','slow','theta','alpha','gamma'].map(b => { | |
| const h = Math.round((oscAmps[b] || 0) * 20) + 2; | |
| const colors = {infraslow:'#666',slow:'#4488ff',theta:'#44ddaa',alpha:'#ffcc44',gamma:'#ff5588'}; | |
| return `<div class="osc-bar" style="height:${h}px;background:${colors[b]}"></div>`; | |
| }).join(''); | |
| sphereHTML += `<div class="sphere-card"> | |
| <div class="sphere-header"> | |
| <div class="sphere-dot" style="background:${s.color}"></div> | |
| <span class="sphere-name">${s.label}</span> | |
| <span class="sphere-type">${s.id}</span> | |
| </div> | |
| <div class="sphere-stats">E:${s.stats.exc} I:${s.stats.inh} N:${s.stats.neutral} nrg:${s.stats.energy.toFixed(1)}</div> | |
| <div class="osc-bars">${oscBars}</div> | |
| </div>`; | |
| } | |
| // Build projection links HTML | |
| let linkHTML = projsSummary.map(p => { | |
| const typeClass = `link-type-${p.type}`; | |
| return `<div class="link-card"><span class="link-type ${typeClass}">${p.type}</span> ${p.src} → ${p.tgt}</div>`; | |
| }).join(''); | |
| // Build receptor grid HTML | |
| const rNames = ['D1','D2','5HT1A','5HT2A','5HT4','M1','M2','β1','α2']; | |
| const rKeys = ['D1','D2','5HT1A','5HT2A','5HT4','M1','M2','beta1','alpha2']; | |
| let receptorHTML = rNames.map((n, i) => { | |
| const val = (receptors[rKeys[i]] || 0).toFixed(2); | |
| const pct = Math.round((receptors[rKeys[i]] || 0) * 100); | |
| return `<div class="receptor-cell"><div class="rname">${n}</div><div class="rval">${val}</div></div>`; | |
| }).join(''); | |
| // Build neuromod bars HTML | |
| const nmData = [ | |
| {label:'🎯', key:'dopamine', color:'#ff5090'}, | |
| {label:'😊', key:'serotonin', color:'#44ddaa'}, | |
| {label:'💡', key:'acetylcholine', color:'#ffc040'}, | |
| {label:'⚡', key:'norepinephrine', color:'#4488ff'} | |
| ]; | |
| let nmHTML = nmData.map(nm => { | |
| const val = (nxer.net.neuromodulators[nm.key] || 0); | |
| const pct = Math.min(100, Math.round(val * 50)); | |
| return `<div class="nm-bar-row"> | |
| <span class="nm-bar-label">${nm.label}</span> | |
| <div class="nm-bar-track"><div class="nm-bar-fill" style="width:${pct}%;background:${nm.color}"></div></div> | |
| <span class="nm-bar-val">${val.toFixed(2)}</span> | |
| </div>`; | |
| }).join(''); | |
| this.detailContent.innerHTML = ` | |
| <div class="detail-tabs"> | |
| <div class="detail-tab active" onclick="this.parentNode.querySelectorAll('.detail-tab').forEach(t=>t.classList.remove('active'));this.classList.add('active');this.parentNode.parentNode.querySelectorAll('.detail-tab-content').forEach(c=>c.classList.remove('active'));this.parentNode.parentNode.querySelector('#dtab-bio').classList.add('active');">Bio</div> | |
| <div class="detail-tab" onclick="this.parentNode.querySelectorAll('.detail-tab').forEach(t=>t.classList.remove('active'));this.classList.add('active');this.parentNode.parentNode.querySelectorAll('.detail-tab-content').forEach(c=>c.classList.remove('active'));this.parentNode.parentNode.querySelector('#dtab-brain').classList.add('active');">Brain</div> | |
| <div class="detail-tab" onclick="this.parentNode.querySelectorAll('.detail-tab').forEach(t=>t.classList.remove('active'));this.classList.add('active');this.parentNode.parentNode.querySelectorAll('.detail-tab-content').forEach(c=>c.classList.remove('active'));this.parentNode.parentNode.querySelector('#dtab-chem').classList.add('active');">Chem</div> | |
| </div> | |
| <div class="detail-tab-content active" id="dtab-bio"> | |
| <div class="detail-info">Color: <span style="display:inline-block;width:10px;height:10px;background:${nxer.color};border-radius:50%"></span> ${nxer.color}</div> | |
| <div class="detail-info">Pos: [${nxer.pos[0]}, ${nxer.pos[1]}] Food: ${nxer.food.toFixed(1)}</div> | |
| <div class="detail-info">Alive: ${nxer.alive} Terrain: ${terrain} <strong>${clanDisplay}</strong></div> | |
| <div class="detail-section" style="border-top: 1px solid #444; margin-top: 5px; padding-top: 5px;"> | |
| <div style="color: #4a9eff; font-weight: bold; margin-bottom: 2px;">Sensory Data:</div> | |
| <div class="detail-info">👁️ Vision: ${nxer.visionRange} 🧭 Facing: ${headingStr} 👃 Smell: ${nxer.smellRadius}</div> | |
| </div> | |
| <div class="detail-section"> | |
| <div class="detail-info">Lived: ${nxer.stats.timeLived.toFixed(1)}s Mates: ${nxer.stats.matesPerformed}</div> | |
| <div class="detail-info">Found: ${nxer.stats.foodFound.toFixed(1)} Stolen: ${nxer.stats.foodTaken.toFixed(1)} Explored: ${nxer.stats.explored}</div> | |
| <div class="detail-info">Energy: ${energyStatus.averageEnergy.toFixed(1)} Fitness: ${nxer.stats.fitness.toFixed(3)} ⭐ g: ${((nxer.stats.g||0)>=0?'+':'')+(nxer.stats.g||0).toFixed(3)}</div> | |
| </div> | |
| <div class="detail-section"> | |
| <div style="color: #ff9944; font-weight: bold; margin-bottom: 2px;">v4.0 Bioinspired:</div> | |
| <div class="detail-info">🌡️ Body: ${nxer.bodyTemperature.toFixed(1)}°C 🧱 Rocks: ${nxer.consecutiveRockHits}</div> | |
| <div class="detail-info">🧠 ${params.brainArch === 'chc6' ? 'CHC g-capable' : 'Classic chain'} · Spheres: ${params.numSpheres} ⚡ Neurons/Sph: ${params.neuronsPerSphere}</div> | |
| </div> | |
| ${voiceHTML} | |
| </div> | |
| <div class="detail-tab-content" id="dtab-brain"> | |
| <div style="color: #40d0ff; font-weight: bold; font-size: 11px; margin-bottom: 4px;">🧠 Multi-Sphere Architecture</div> | |
| ${sphereHTML} | |
| <div style="color: #ffc040; font-weight: bold; font-size: 11px; margin: 6px 0 4px;">🔗 Inter-Sphere Projections</div> | |
| ${linkHTML || '<div style="color:#666;font-size:10px;">No projections</div>'} | |
| <div class="detail-section" style="margin-top: 8px;"> | |
| <div style="font-size: 10px; color: #888; font-family: monospace;"> | |
| steps=${params.simulationSteps} conn=${params.connectionProbability.toFixed(2)}<br> | |
| τ_f=${params.tauFast.toFixed(1)} τ_s=${params.tauSlow.toFixed(1)} τ_m=${params.tauMeta.toFixed(0)}<br> | |
| θ₊=${params.firingThresholdExcitatory.toFixed(2)} θ₋=${params.firingThresholdInhibitory.toFixed(2)}<br> | |
| lr=${params.learningRate.toFixed(3)} chrono_ρ=${params.ctsn_rho.toFixed(2)} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="detail-tab-content" id="dtab-chem"> | |
| <div style="color: #ff5090; font-weight: bold; font-size: 11px; margin-bottom: 4px;">💊 Neuromodulators</div> | |
| ${nmHTML} | |
| <div style="color: #aa77dd; font-weight: bold; font-size: 11px; margin: 8px 0 4px;">🧬 Receptor Activations (9 subtypes)</div> | |
| <div class="receptor-grid">${receptorHTML}</div> | |
| <div class="detail-section" style="margin-top: 8px;"> | |
| <div style="font-size: 10px; color: #888;"> | |
| Branching: ${energyStatus.branchingRatio.toFixed(2)} | | |
| AGMP: active | CTSN: ρ=${params.ctsn_rho.toFixed(2)} | | |
| Chrono: α_f=${params.chronoAlphaFast} | |
| </div> | |
| </div> | |
| </div> | |
| <div style="margin-top: 8px;"><button class="hud-button" id="saveSingleBtn" style="width:100%">💾 Save NxEr</button></div> | |
| `; | |
| // Re-bind save button | |
| const btn = document.getElementById('saveSingleBtn'); | |
| if(btn) btn.onclick = () => this.saveNxEr(nxer); | |
| } | |
| saveNxEr(nxer) { | |
| const data = { | |
| meta: { | |
| created: new Date().toISOString(), | |
| type: 'NxEr', | |
| engine: 'Neuraxon v2.0 Multi-Sphere v4' | |
| }, | |
| nxer: { | |
| name: nxer.name, | |
| color: nxer.color, | |
| canLand: nxer.canLand, | |
| canSea: nxer.canSea, | |
| isMale: nxer.isMale, | |
| visionRange: nxer.visionRange, | |
| smellRadius: nxer.smellRadius, | |
| heading: nxer.heading, | |
| clanId: nxer.clanId, | |
| stats: nxer.stats, | |
| bodyTemperature: nxer.bodyTemperature, | |
| consecutiveRockHits: nxer.consecutiveRockHits, | |
| networkParams: nxer.net.params, | |
| // v4.0: Save multi-sphere architecture info | |
| brainArchitecture: { | |
| numSpheres: nxer.net.params.numSpheres, | |
| neuronsPerSphere: nxer.net.params.neuronsPerSphere, | |
| sphereTypes: nxer.net.spheres.map(s => s.type), | |
| projections: nxer.net.getProjectionsSummary(), | |
| receptorActivations: nxer.net.getReceptorActivations() | |
| }, | |
| // v4.5: Save voice/tonal info | |
| voice: { | |
| tones: nxer.voice, | |
| musicality: nxer.musicality, | |
| songsSung: nxer.songsSung, | |
| tonesLearned: nxer.tonesLearned | |
| } | |
| } | |
| }; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `nxer_${nxer.name}_multisphere_v4.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| saveBestChampions() { | |
| this.engine.updateAllTimeBest(); | |
| const data = { | |
| meta: { created: new Date().toISOString(), type: 'Neuraxon v2.0 Multi-Sphere v4', version: '4.0' }, | |
| champions: {} | |
| }; | |
| for (const [cat, nxers] of Object.entries(this.engine.allTimeBest)) { | |
| data.champions[cat] = nxers.map(n => ({ | |
| name: n.name, color: n.color, stats: n.stats, params: n.net.params, | |
| brainArch: { | |
| spheres: n.net.params.numSpheres, | |
| neuronsPerSphere: n.net.params.neuronsPerSphere, | |
| types: n.net.spheres ? n.net.spheres.map(s => s.type) : [] | |
| } | |
| })); | |
| } | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); a.href = url; a.download = `neuraxon_multisphere_v4_bests.json`; a.click(); | |
| } | |
| } | |
| // ===================================================================== | |
| // MAIN APPLICATION | |
| // ===================================================================== | |
| class Application { | |
| constructor() { | |
| this.setupSplashScreen(); | |
| } | |
| setupSplashScreen() { | |
| const splash = document.getElementById('splashScreen'); | |
| splash.addEventListener('click', () => { | |
| // v4.5: Initialize AudioEngine on first user gesture (browser requirement) | |
| audioEngine.init(); | |
| audioEngine.resume(); | |
| splash.classList.add('hidden'); | |
| this.showConfigScreen(); | |
| }); | |
| } | |
| showConfigScreen() { | |
| const configScreen = document.getElementById('configScreen'); | |
| configScreen.classList.remove('hidden'); | |
| const sliders = ['worldSize','seaPct','rockPct','startingNxErs','maxNxErs','maxFood','foodRespawn','startFood','maxNeurons','globalTimeSteps','mateCooldown','dayNightCycle','baseTemperature','brainSpheres','neuronsPerSphere','oscCoupling']; | |
| sliders.forEach(id => { | |
| const el = document.getElementById(id); | |
| const span = document.getElementById(id+'Value'); | |
| el.addEventListener('input', () => span.textContent = el.value + (id.includes('Pct')?'%':'')); | |
| }); | |
| // v5.0 Lite — brain-architecture selector. CHC g-capable | |
| // is a fixed 6-sphere layout, so the sphere-count slider | |
| // is disabled (and pinned at 6) while it is selected. | |
| const archSel = document.getElementById('brainArch'); | |
| const sphRow = document.getElementById('brainSpheresRow'); | |
| const sphEl = document.getElementById('brainSpheres'); | |
| const sphVal = document.getElementById('brainSpheresValue'); | |
| const applyArch = () => { | |
| if (archSel.value === 'chc6') { | |
| sphEl.disabled = true; | |
| sphRow.style.opacity = '0.45'; | |
| sphVal.textContent = '6 (CHC fixed)'; | |
| } else { | |
| sphEl.disabled = false; | |
| sphRow.style.opacity = '1'; | |
| sphVal.textContent = sphEl.value; | |
| } | |
| }; | |
| archSel.addEventListener('change', applyArch); | |
| applyArch(); | |
| document.getElementById('startButton').addEventListener('click', () => { | |
| const config = {}; | |
| sliders.forEach(id => config[id] = parseFloat(document.getElementById(id).value)); | |
| // v5.0 Lite — brain architecture. CHC pins 6 spheres. | |
| config.brainArch = archSel.value; | |
| if (config.brainArch === 'chc6') config.brainSpheres = 6; | |
| config.useEarth = document.getElementById('useEarth').checked; | |
| configScreen.classList.add('hidden'); | |
| document.getElementById('loader').style.display = 'block'; | |
| setTimeout(() => this.startGame(config), 100); | |
| }); | |
| } | |
| startGame(config) { | |
| const canvas = document.getElementById('gameCanvas'); | |
| canvas.classList.remove('hidden'); | |
| document.getElementById('hud').classList.remove('hidden'); | |
| document.getElementById('controlsHint').classList.remove('hidden'); | |
| document.getElementById('audioBadge').style.display = 'block'; | |
| document.getElementById('loader').style.display = 'none'; | |
| const engine = new GameEngine(config); | |
| const renderer = new Renderer3D(canvas); | |
| renderer.initTerrain(engine.world); | |
| const ui = new UIController(engine, renderer); | |
| let lastTime = performance.now(); | |
| let accumulator = 0; | |
| const fixedDt = 1.0 / 60.0; | |
| const gameLoop = (currentTime) => { | |
| const dt = Math.min((currentTime - lastTime) / 1000, 0.1); | |
| lastTime = currentTime; | |
| accumulator += dt; | |
| let steps = 0; | |
| while (accumulator >= fixedDt && steps < 10) { | |
| engine.step(); | |
| accumulator -= fixedDt; | |
| steps++; | |
| } | |
| if (steps >= 5) accumulator = 0; | |
| renderer.render(engine); | |
| ui.update(); | |
| requestAnimationFrame(gameLoop); | |
| }; | |
| requestAnimationFrame(gameLoop); | |
| } | |
| } | |
| new Application(); | |
| </script> | |
| </body> | |
| </html> |