Add the simulator files
Browse files- css/animations.css +256 -0
- css/main.css +337 -0
- css/neon-effects.css +278 -0
- index.html +118 -17
- js/ecosystem.js +350 -0
- js/entities.js +476 -0
- js/entityManager.js +82 -0
- js/genetics.js +324 -0
- js/neuralNetwork.js +286 -0
- js/particles.js +269 -0
- js/stats.js +270 -0
- js/ui.js +293 -0
- js/utils.js +257 -0
- js/world.js +456 -0
css/animations.css
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@keyframes fadeSlideIn {
|
| 2 |
+
0% {
|
| 3 |
+
opacity: 0;
|
| 4 |
+
transform: translateY(10px);
|
| 5 |
+
}
|
| 6 |
+
100% {
|
| 7 |
+
opacity: 1;
|
| 8 |
+
transform: translateY(0);
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
@keyframes fadeSlideInRight {
|
| 13 |
+
0% {
|
| 14 |
+
opacity: 0;
|
| 15 |
+
transform: translateX(10px);
|
| 16 |
+
}
|
| 17 |
+
100% {
|
| 18 |
+
opacity: 1;
|
| 19 |
+
transform: translateX(0);
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
@keyframes fadeSlideInCenter {
|
| 24 |
+
0% {
|
| 25 |
+
opacity: 0;
|
| 26 |
+
transform: translateX(-50%) translateY(-10px);
|
| 27 |
+
}
|
| 28 |
+
100% {
|
| 29 |
+
opacity: 1;
|
| 30 |
+
transform: translateX(-50%) translateY(0);
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
@keyframes pulse {
|
| 35 |
+
0%,
|
| 36 |
+
100% {
|
| 37 |
+
opacity: 1;
|
| 38 |
+
}
|
| 39 |
+
50% {
|
| 40 |
+
opacity: 0.5;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@keyframes glowPulse {
|
| 45 |
+
0%,
|
| 46 |
+
100% {
|
| 47 |
+
text-shadow:
|
| 48 |
+
0 0 4px currentColor,
|
| 49 |
+
0 0 8px currentColor;
|
| 50 |
+
}
|
| 51 |
+
50% {
|
| 52 |
+
text-shadow:
|
| 53 |
+
0 0 6px currentColor,
|
| 54 |
+
0 0 14px currentColor,
|
| 55 |
+
0 0 20px currentColor;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@keyframes borderGlow {
|
| 60 |
+
0%,
|
| 61 |
+
100% {
|
| 62 |
+
border-color: rgba(100, 200, 255, 0.15);
|
| 63 |
+
box-shadow: 0 0 8px rgba(0, 240, 255, 0.05);
|
| 64 |
+
}
|
| 65 |
+
50% {
|
| 66 |
+
border-color: rgba(100, 200, 255, 0.3);
|
| 67 |
+
box-shadow: 0 0 16px rgba(0, 240, 255, 0.1);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@keyframes dotBlink {
|
| 72 |
+
0%,
|
| 73 |
+
100% {
|
| 74 |
+
opacity: 1;
|
| 75 |
+
}
|
| 76 |
+
50% {
|
| 77 |
+
opacity: 0.3;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
@keyframes shimmer {
|
| 82 |
+
0% {
|
| 83 |
+
background-position: -100% 0;
|
| 84 |
+
}
|
| 85 |
+
100% {
|
| 86 |
+
background-position: 200% 0;
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
@keyframes eventSlideIn {
|
| 91 |
+
0% {
|
| 92 |
+
opacity: 0;
|
| 93 |
+
transform: translateX(-20px);
|
| 94 |
+
max-height: 0;
|
| 95 |
+
}
|
| 96 |
+
100% {
|
| 97 |
+
opacity: 1;
|
| 98 |
+
transform: translateX(0);
|
| 99 |
+
max-height: 30px;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@keyframes generationFlash {
|
| 104 |
+
0% {
|
| 105 |
+
color: var(--neon-cyan);
|
| 106 |
+
text-shadow:
|
| 107 |
+
0 0 10px var(--neon-cyan),
|
| 108 |
+
0 0 20px var(--neon-cyan),
|
| 109 |
+
0 0 40px var(--neon-cyan);
|
| 110 |
+
}
|
| 111 |
+
100% {
|
| 112 |
+
color: var(--neon-cyan);
|
| 113 |
+
text-shadow:
|
| 114 |
+
0 0 4px var(--neon-cyan),
|
| 115 |
+
0 0 8px var(--neon-cyan);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
@keyframes announcementPulse {
|
| 120 |
+
0% {
|
| 121 |
+
background: rgba(0, 240, 255, 0.1);
|
| 122 |
+
border-color: rgba(0, 240, 255, 0.3);
|
| 123 |
+
}
|
| 124 |
+
50% {
|
| 125 |
+
background: rgba(0, 240, 255, 0.15);
|
| 126 |
+
border-color: rgba(0, 240, 255, 0.5);
|
| 127 |
+
}
|
| 128 |
+
100% {
|
| 129 |
+
background: rgba(0, 240, 255, 0.1);
|
| 130 |
+
border-color: rgba(0, 240, 255, 0.3);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
@keyframes breathe {
|
| 135 |
+
0%,
|
| 136 |
+
100% {
|
| 137 |
+
opacity: 0.85;
|
| 138 |
+
}
|
| 139 |
+
50% {
|
| 140 |
+
opacity: 0.95;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@keyframes scanLine {
|
| 145 |
+
0% {
|
| 146 |
+
transform: translateY(-100%);
|
| 147 |
+
}
|
| 148 |
+
100% {
|
| 149 |
+
transform: translateY(200%);
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
@keyframes dataPop {
|
| 154 |
+
0% {
|
| 155 |
+
transform: scale(0);
|
| 156 |
+
opacity: 0;
|
| 157 |
+
}
|
| 158 |
+
60% {
|
| 159 |
+
transform: scale(1.3);
|
| 160 |
+
opacity: 1;
|
| 161 |
+
}
|
| 162 |
+
100% {
|
| 163 |
+
transform: scale(1);
|
| 164 |
+
opacity: 1;
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
#panel-stats {
|
| 169 |
+
animation: fadeSlideIn 0.5s ease-out both;
|
| 170 |
+
animation-delay: 0.1s;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
#panel-performance {
|
| 174 |
+
animation: fadeSlideInRight 0.5s ease-out both;
|
| 175 |
+
animation-delay: 0.2s;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
#panel-generation {
|
| 179 |
+
animation: fadeSlideInCenter 0.6s ease-out both;
|
| 180 |
+
animation-delay: 0.05s;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
#panel-events {
|
| 184 |
+
animation: fadeSlideIn 0.5s ease-out both;
|
| 185 |
+
animation-delay: 0.3s;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
#panel-charts {
|
| 189 |
+
animation: fadeSlideInRight 0.5s ease-out both;
|
| 190 |
+
animation-delay: 0.4s;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.hud-panel {
|
| 194 |
+
animation: borderGlow 4s ease-in-out infinite;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.stat-value {
|
| 198 |
+
animation: glowPulse 3s ease-in-out infinite;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.event-entry {
|
| 202 |
+
animation: eventSlideIn 0.3s ease-out both;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.live-indicator {
|
| 206 |
+
display: inline-block;
|
| 207 |
+
width: 6px;
|
| 208 |
+
height: 6px;
|
| 209 |
+
border-radius: 50%;
|
| 210 |
+
background: var(--neon-green);
|
| 211 |
+
margin-right: 6px;
|
| 212 |
+
animation: dotBlink 1.5s ease-in-out infinite;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.generation-flash {
|
| 216 |
+
animation: generationFlash 1s ease-out;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.announcement {
|
| 220 |
+
position: fixed;
|
| 221 |
+
top: 60px;
|
| 222 |
+
left: 50%;
|
| 223 |
+
transform: translateX(-50%);
|
| 224 |
+
z-index: var(--z-announcements);
|
| 225 |
+
padding: 8px 24px;
|
| 226 |
+
border-radius: 8px;
|
| 227 |
+
border: 1px solid var(--neon-cyan);
|
| 228 |
+
background: rgba(6, 6, 15, 0.9);
|
| 229 |
+
backdrop-filter: blur(12px);
|
| 230 |
+
font-family: var(--font-mono);
|
| 231 |
+
font-size: 13px;
|
| 232 |
+
color: var(--neon-cyan);
|
| 233 |
+
white-space: nowrap;
|
| 234 |
+
pointer-events: none;
|
| 235 |
+
animation: announcementPulse 2s ease-in-out;
|
| 236 |
+
opacity: 0;
|
| 237 |
+
transition: opacity 0.3s ease;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.announcement.visible {
|
| 241 |
+
opacity: 1;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
@media (prefers-reduced-motion: reduce) {
|
| 245 |
+
.hud-panel,
|
| 246 |
+
.stat-value,
|
| 247 |
+
.event-entry,
|
| 248 |
+
.live-indicator,
|
| 249 |
+
.announcement {
|
| 250 |
+
animation: none !important;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.announcement {
|
| 254 |
+
transition: none !important;
|
| 255 |
+
}
|
| 256 |
+
}
|
css/main.css
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg-primary: #06060f;
|
| 3 |
+
--bg-secondary: #0c0c1e;
|
| 4 |
+
--bg-panel: rgba(12, 12, 30, 0.75);
|
| 5 |
+
--border-glow: rgba(100, 200, 255, 0.2);
|
| 6 |
+
--text-primary: #e0e8ff;
|
| 7 |
+
--text-secondary: #8090b0;
|
| 8 |
+
--text-muted: #506080;
|
| 9 |
+
--neon-cyan: #00f0ff;
|
| 10 |
+
--neon-green: #00ff88;
|
| 11 |
+
--neon-red: #ff3366;
|
| 12 |
+
--neon-blue: #3399ff;
|
| 13 |
+
--neon-amber: #ffaa00;
|
| 14 |
+
--neon-purple: #cc66ff;
|
| 15 |
+
--neon-pink: #ff66cc;
|
| 16 |
+
--neon-white: #eef4ff;
|
| 17 |
+
--gatherer-color: #00ff88;
|
| 18 |
+
--predator-color: #ff3366;
|
| 19 |
+
--builder-color: #3399ff;
|
| 20 |
+
--explorer-color: #ffaa00;
|
| 21 |
+
--hybrid-color: #cc66ff;
|
| 22 |
+
--food-color: #00ffaa;
|
| 23 |
+
--energy-color: #ffee44;
|
| 24 |
+
--panel-radius: 12px;
|
| 25 |
+
--panel-blur: 16px;
|
| 26 |
+
--font-mono: "Courier New", "Consolas", monospace;
|
| 27 |
+
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 28 |
+
--z-canvas: 1;
|
| 29 |
+
--z-particles: 2;
|
| 30 |
+
--z-hud: 10;
|
| 31 |
+
--z-announcements: 20;
|
| 32 |
+
--z-modal: 30;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
*,
|
| 36 |
+
*::before,
|
| 37 |
+
*::after {
|
| 38 |
+
box-sizing: border-box;
|
| 39 |
+
margin: 0;
|
| 40 |
+
padding: 0;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
html,
|
| 44 |
+
body {
|
| 45 |
+
width: 100%;
|
| 46 |
+
height: 100%;
|
| 47 |
+
overflow: hidden;
|
| 48 |
+
background: var(--bg-primary);
|
| 49 |
+
color: var(--text-primary);
|
| 50 |
+
font-family: var(--font-sans);
|
| 51 |
+
font-size: 14px;
|
| 52 |
+
line-height: 1.4;
|
| 53 |
+
-webkit-font-smoothing: antialiased;
|
| 54 |
+
-moz-osx-font-smoothing: grayscale;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#app {
|
| 58 |
+
position: relative;
|
| 59 |
+
width: 100%;
|
| 60 |
+
height: 100%;
|
| 61 |
+
overflow: hidden;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#simulation-canvas {
|
| 65 |
+
position: absolute;
|
| 66 |
+
top: 0;
|
| 67 |
+
left: 0;
|
| 68 |
+
width: 100%;
|
| 69 |
+
height: 100%;
|
| 70 |
+
z-index: var(--z-canvas);
|
| 71 |
+
display: block;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
#particle-canvas {
|
| 75 |
+
position: absolute;
|
| 76 |
+
top: 0;
|
| 77 |
+
left: 0;
|
| 78 |
+
width: 100%;
|
| 79 |
+
height: 100%;
|
| 80 |
+
z-index: var(--z-particles);
|
| 81 |
+
pointer-events: none;
|
| 82 |
+
display: block;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.hud-panel {
|
| 86 |
+
position: absolute;
|
| 87 |
+
z-index: var(--z-hud);
|
| 88 |
+
background: var(--bg-panel);
|
| 89 |
+
backdrop-filter: blur(var(--panel-blur));
|
| 90 |
+
-webkit-backdrop-filter: blur(var(--panel-blur));
|
| 91 |
+
border: 1px solid var(--border-glow);
|
| 92 |
+
border-radius: var(--panel-radius);
|
| 93 |
+
padding: 12px 16px;
|
| 94 |
+
pointer-events: auto;
|
| 95 |
+
transition:
|
| 96 |
+
opacity 0.3s ease,
|
| 97 |
+
transform 0.3s ease;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.hud-panel:focus-within {
|
| 101 |
+
border-color: var(--neon-cyan);
|
| 102 |
+
box-shadow: 0 0 12px rgba(0, 240, 255, 0.15);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
#panel-stats {
|
| 106 |
+
top: 16px;
|
| 107 |
+
left: 16px;
|
| 108 |
+
min-width: 220px;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#panel-performance {
|
| 112 |
+
top: 16px;
|
| 113 |
+
right: 16px;
|
| 114 |
+
min-width: 160px;
|
| 115 |
+
text-align: right;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
#panel-events {
|
| 119 |
+
bottom: 16px;
|
| 120 |
+
left: 16px;
|
| 121 |
+
right: 16px;
|
| 122 |
+
max-width: 600px;
|
| 123 |
+
max-height: 120px;
|
| 124 |
+
overflow-y: auto;
|
| 125 |
+
scrollbar-width: thin;
|
| 126 |
+
scrollbar-color: var(--text-muted) transparent;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#panel-charts {
|
| 130 |
+
bottom: 16px;
|
| 131 |
+
right: 16px;
|
| 132 |
+
width: 280px;
|
| 133 |
+
height: 200px;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
#panel-generation {
|
| 137 |
+
top: 16px;
|
| 138 |
+
left: 50%;
|
| 139 |
+
transform: translateX(-50%);
|
| 140 |
+
text-align: center;
|
| 141 |
+
min-width: 180px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.panel-title {
|
| 145 |
+
font-family: var(--font-mono);
|
| 146 |
+
font-size: 10px;
|
| 147 |
+
font-weight: 700;
|
| 148 |
+
text-transform: uppercase;
|
| 149 |
+
letter-spacing: 2px;
|
| 150 |
+
color: var(--text-muted);
|
| 151 |
+
margin-bottom: 8px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.stat-value {
|
| 155 |
+
font-family: var(--font-mono);
|
| 156 |
+
font-size: 22px;
|
| 157 |
+
font-weight: 700;
|
| 158 |
+
color: var(--neon-cyan);
|
| 159 |
+
line-height: 1.1;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.stat-label {
|
| 163 |
+
font-family: var(--font-mono);
|
| 164 |
+
font-size: 11px;
|
| 165 |
+
color: var(--text-secondary);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.stat-row {
|
| 169 |
+
display: flex;
|
| 170 |
+
justify-content: space-between;
|
| 171 |
+
align-items: baseline;
|
| 172 |
+
padding: 3px 0;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.stat-row .dot {
|
| 176 |
+
display: inline-block;
|
| 177 |
+
width: 8px;
|
| 178 |
+
height: 8px;
|
| 179 |
+
border-radius: 50%;
|
| 180 |
+
margin-right: 6px;
|
| 181 |
+
vertical-align: middle;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.stat-row .count {
|
| 185 |
+
font-family: var(--font-mono);
|
| 186 |
+
font-size: 13px;
|
| 187 |
+
font-weight: 600;
|
| 188 |
+
color: var(--text-primary);
|
| 189 |
+
min-width: 30px;
|
| 190 |
+
text-align: right;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.sparkline-container {
|
| 194 |
+
width: 60px;
|
| 195 |
+
height: 20px;
|
| 196 |
+
display: inline-block;
|
| 197 |
+
vertical-align: middle;
|
| 198 |
+
margin-left: 8px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.event-entry {
|
| 202 |
+
font-family: var(--font-mono);
|
| 203 |
+
font-size: 11px;
|
| 204 |
+
color: var(--text-secondary);
|
| 205 |
+
padding: 2px 0;
|
| 206 |
+
border-bottom: 1px solid rgba(100, 200, 255, 0.05);
|
| 207 |
+
white-space: nowrap;
|
| 208 |
+
overflow: hidden;
|
| 209 |
+
text-overflow: ellipsis;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.event-entry .timestamp {
|
| 213 |
+
color: var(--text-muted);
|
| 214 |
+
margin-right: 8px;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.event-entry.event-birth {
|
| 218 |
+
color: var(--neon-green);
|
| 219 |
+
}
|
| 220 |
+
.event-entry.event-death {
|
| 221 |
+
color: var(--neon-red);
|
| 222 |
+
}
|
| 223 |
+
.event-entry.event-evolution {
|
| 224 |
+
color: var(--neon-purple);
|
| 225 |
+
}
|
| 226 |
+
.event-entry.event-world {
|
| 227 |
+
color: var(--neon-amber);
|
| 228 |
+
}
|
| 229 |
+
.event-entry.event-milestone {
|
| 230 |
+
color: var(--neon-cyan);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.chart-canvas {
|
| 234 |
+
width: 100%;
|
| 235 |
+
height: 100%;
|
| 236 |
+
display: block;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.hud-panel::-webkit-scrollbar {
|
| 240 |
+
width: 4px;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.hud-panel::-webkit-scrollbar-track {
|
| 244 |
+
background: transparent;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.hud-panel::-webkit-scrollbar-thumb {
|
| 248 |
+
background: var(--text-muted);
|
| 249 |
+
border-radius: 2px;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
@media (max-width: 768px) {
|
| 253 |
+
.hud-panel {
|
| 254 |
+
padding: 8px 12px;
|
| 255 |
+
border-radius: 8px;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
#panel-stats {
|
| 259 |
+
top: 8px;
|
| 260 |
+
left: 8px;
|
| 261 |
+
min-width: 180px;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
#panel-performance {
|
| 265 |
+
top: 8px;
|
| 266 |
+
right: 8px;
|
| 267 |
+
min-width: 120px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
#panel-events {
|
| 271 |
+
bottom: 8px;
|
| 272 |
+
left: 8px;
|
| 273 |
+
right: 8px;
|
| 274 |
+
max-height: 80px;
|
| 275 |
+
font-size: 10px;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
#panel-charts {
|
| 279 |
+
display: none;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
#panel-generation {
|
| 283 |
+
top: 8px;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.stat-value {
|
| 287 |
+
font-size: 18px;
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
@media (max-width: 480px) {
|
| 292 |
+
#panel-charts {
|
| 293 |
+
display: none;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
#panel-stats {
|
| 297 |
+
min-width: 150px;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
#panel-performance {
|
| 301 |
+
min-width: 100px;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.stat-value {
|
| 305 |
+
font-size: 16px;
|
| 306 |
+
}
|
| 307 |
+
.stat-label {
|
| 308 |
+
font-size: 10px;
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.sr-only {
|
| 313 |
+
position: absolute;
|
| 314 |
+
width: 1px;
|
| 315 |
+
height: 1px;
|
| 316 |
+
margin: -1px;
|
| 317 |
+
padding: 0;
|
| 318 |
+
overflow: hidden;
|
| 319 |
+
clip: rect(0, 0, 0, 0);
|
| 320 |
+
border: 0;
|
| 321 |
+
white-space: nowrap;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
*:focus-visible {
|
| 325 |
+
outline: 2px solid var(--neon-cyan);
|
| 326 |
+
outline-offset: 2px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
@media (prefers-reduced-motion: reduce) {
|
| 330 |
+
*,
|
| 331 |
+
*::before,
|
| 332 |
+
*::after {
|
| 333 |
+
animation-duration: 0.01ms !important;
|
| 334 |
+
animation-iteration-count: 1 !important;
|
| 335 |
+
transition-duration: 0.01ms !important;
|
| 336 |
+
}
|
| 337 |
+
}
|
css/neon-effects.css
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.glass-panel {
|
| 2 |
+
background: linear-gradient(
|
| 3 |
+
135deg,
|
| 4 |
+
rgba(15, 15, 35, 0.8) 0%,
|
| 5 |
+
rgba(10, 10, 25, 0.6) 100%
|
| 6 |
+
);
|
| 7 |
+
backdrop-filter: blur(16px) saturate(1.2);
|
| 8 |
+
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
| 9 |
+
border: 1px solid rgba(100, 200, 255, 0.12);
|
| 10 |
+
box-shadow:
|
| 11 |
+
0 4px 24px rgba(0, 0, 0, 0.4),
|
| 12 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.glass-panel::before {
|
| 16 |
+
content: "";
|
| 17 |
+
position: absolute;
|
| 18 |
+
top: 0;
|
| 19 |
+
left: 0;
|
| 20 |
+
right: 0;
|
| 21 |
+
height: 1px;
|
| 22 |
+
background: linear-gradient(
|
| 23 |
+
90deg,
|
| 24 |
+
transparent 0%,
|
| 25 |
+
rgba(100, 200, 255, 0.2) 50%,
|
| 26 |
+
transparent 100%
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.neon-text-cyan {
|
| 31 |
+
color: var(--neon-cyan);
|
| 32 |
+
text-shadow:
|
| 33 |
+
0 0 4px var(--neon-cyan),
|
| 34 |
+
0 0 8px rgba(0, 240, 255, 0.4);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.neon-text-green {
|
| 38 |
+
color: var(--neon-green);
|
| 39 |
+
text-shadow:
|
| 40 |
+
0 0 4px var(--neon-green),
|
| 41 |
+
0 0 8px rgba(0, 255, 136, 0.4);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.neon-text-red {
|
| 45 |
+
color: var(--neon-red);
|
| 46 |
+
text-shadow:
|
| 47 |
+
0 0 4px var(--neon-red),
|
| 48 |
+
0 0 8px rgba(255, 51, 102, 0.4);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.neon-text-blue {
|
| 52 |
+
color: var(--neon-blue);
|
| 53 |
+
text-shadow:
|
| 54 |
+
0 0 4px var(--neon-blue),
|
| 55 |
+
0 0 8px rgba(51, 153, 255, 0.4);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.neon-text-amber {
|
| 59 |
+
color: var(--neon-amber);
|
| 60 |
+
text-shadow:
|
| 61 |
+
0 0 4px var(--neon-amber),
|
| 62 |
+
0 0 8px rgba(255, 170, 0, 0.4);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.neon-text-purple {
|
| 66 |
+
color: var(--neon-purple);
|
| 67 |
+
text-shadow:
|
| 68 |
+
0 0 4px var(--neon-purple),
|
| 69 |
+
0 0 8px rgba(204, 102, 255, 0.4);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.neon-border-cyan {
|
| 73 |
+
border-color: var(--neon-cyan);
|
| 74 |
+
box-shadow:
|
| 75 |
+
0 0 6px rgba(0, 240, 255, 0.15),
|
| 76 |
+
inset 0 0 6px rgba(0, 240, 255, 0.05);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.neon-border-green {
|
| 80 |
+
border-color: var(--neon-green);
|
| 81 |
+
box-shadow:
|
| 82 |
+
0 0 6px rgba(0, 255, 136, 0.15),
|
| 83 |
+
inset 0 0 6px rgba(0, 255, 136, 0.05);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.depth-1 {
|
| 87 |
+
box-shadow:
|
| 88 |
+
0 2px 8px rgba(0, 0, 0, 0.3),
|
| 89 |
+
0 0 1px rgba(100, 200, 255, 0.1);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.depth-2 {
|
| 93 |
+
box-shadow:
|
| 94 |
+
0 4px 16px rgba(0, 0, 0, 0.4),
|
| 95 |
+
0 0 2px rgba(100, 200, 255, 0.12);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.depth-3 {
|
| 99 |
+
box-shadow:
|
| 100 |
+
0 8px 32px rgba(0, 0, 0, 0.5),
|
| 101 |
+
0 0 4px rgba(100, 200, 255, 0.15);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.neon-divider {
|
| 105 |
+
width: 100%;
|
| 106 |
+
height: 1px;
|
| 107 |
+
border: none;
|
| 108 |
+
margin: 8px 0;
|
| 109 |
+
background: linear-gradient(
|
| 110 |
+
90deg,
|
| 111 |
+
transparent 0%,
|
| 112 |
+
rgba(100, 200, 255, 0.2) 20%,
|
| 113 |
+
rgba(100, 200, 255, 0.2) 80%,
|
| 114 |
+
transparent 100%
|
| 115 |
+
);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.gradient-accent {
|
| 119 |
+
background: linear-gradient(
|
| 120 |
+
135deg,
|
| 121 |
+
var(--neon-cyan) 0%,
|
| 122 |
+
var(--neon-purple) 100%
|
| 123 |
+
);
|
| 124 |
+
-webkit-background-clip: text;
|
| 125 |
+
background-clip: text;
|
| 126 |
+
-webkit-text-fill-color: transparent;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#panel-stats.hud-panel {
|
| 130 |
+
background: linear-gradient(
|
| 131 |
+
160deg,
|
| 132 |
+
rgba(0, 255, 136, 0.03) 0%,
|
| 133 |
+
rgba(12, 12, 30, 0.75) 30%
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#panel-performance.hud-panel {
|
| 138 |
+
background: linear-gradient(
|
| 139 |
+
200deg,
|
| 140 |
+
rgba(0, 240, 255, 0.03) 0%,
|
| 141 |
+
rgba(12, 12, 30, 0.75) 30%
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
#panel-generation.hud-panel {
|
| 146 |
+
background: linear-gradient(
|
| 147 |
+
180deg,
|
| 148 |
+
rgba(204, 102, 255, 0.05) 0%,
|
| 149 |
+
rgba(12, 12, 30, 0.8) 50%
|
| 150 |
+
);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
#panel-events.hud-panel {
|
| 154 |
+
background: linear-gradient(
|
| 155 |
+
0deg,
|
| 156 |
+
rgba(12, 12, 30, 0.85) 0%,
|
| 157 |
+
rgba(12, 12, 30, 0.7) 100%
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
#panel-charts.hud-panel {
|
| 162 |
+
background: linear-gradient(
|
| 163 |
+
225deg,
|
| 164 |
+
rgba(51, 153, 255, 0.03) 0%,
|
| 165 |
+
rgba(12, 12, 30, 0.8) 30%
|
| 166 |
+
);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.scan-overlay {
|
| 170 |
+
position: absolute;
|
| 171 |
+
top: 0;
|
| 172 |
+
left: 0;
|
| 173 |
+
right: 0;
|
| 174 |
+
bottom: 0;
|
| 175 |
+
pointer-events: none;
|
| 176 |
+
z-index: 3;
|
| 177 |
+
background: repeating-linear-gradient(
|
| 178 |
+
0deg,
|
| 179 |
+
transparent 0px,
|
| 180 |
+
transparent 2px,
|
| 181 |
+
rgba(100, 200, 255, 0.01) 2px,
|
| 182 |
+
rgba(100, 200, 255, 0.01) 4px
|
| 183 |
+
);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.type-gatherer {
|
| 187 |
+
color: var(--gatherer-color);
|
| 188 |
+
}
|
| 189 |
+
.type-predator {
|
| 190 |
+
color: var(--predator-color);
|
| 191 |
+
}
|
| 192 |
+
.type-builder {
|
| 193 |
+
color: var(--builder-color);
|
| 194 |
+
}
|
| 195 |
+
.type-explorer {
|
| 196 |
+
color: var(--explorer-color);
|
| 197 |
+
}
|
| 198 |
+
.type-hybrid {
|
| 199 |
+
color: var(--hybrid-color);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.dot-gatherer {
|
| 203 |
+
background: var(--gatherer-color);
|
| 204 |
+
}
|
| 205 |
+
.dot-predator {
|
| 206 |
+
background: var(--predator-color);
|
| 207 |
+
}
|
| 208 |
+
.dot-builder {
|
| 209 |
+
background: var(--builder-color);
|
| 210 |
+
}
|
| 211 |
+
.dot-explorer {
|
| 212 |
+
background: var(--explorer-color);
|
| 213 |
+
}
|
| 214 |
+
.dot-hybrid {
|
| 215 |
+
background: var(--hybrid-color);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.entity-tooltip {
|
| 219 |
+
position: absolute;
|
| 220 |
+
z-index: var(--z-modal);
|
| 221 |
+
padding: 8px 12px;
|
| 222 |
+
border-radius: 8px;
|
| 223 |
+
background: rgba(6, 6, 15, 0.95);
|
| 224 |
+
backdrop-filter: blur(12px);
|
| 225 |
+
border: 1px solid rgba(100, 200, 255, 0.2);
|
| 226 |
+
font-family: var(--font-mono);
|
| 227 |
+
font-size: 11px;
|
| 228 |
+
color: var(--text-primary);
|
| 229 |
+
pointer-events: none;
|
| 230 |
+
white-space: nowrap;
|
| 231 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.entity-tooltip .tooltip-row {
|
| 235 |
+
padding: 1px 0;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.entity-tooltip .tooltip-label {
|
| 239 |
+
color: var(--text-muted);
|
| 240 |
+
margin-right: 8px;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.neon-bar {
|
| 244 |
+
height: 3px;
|
| 245 |
+
border-radius: 2px;
|
| 246 |
+
background: var(--bg-secondary);
|
| 247 |
+
overflow: hidden;
|
| 248 |
+
margin: 4px 0;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.neon-bar-fill {
|
| 252 |
+
height: 100%;
|
| 253 |
+
border-radius: 2px;
|
| 254 |
+
transition: width 0.3s ease;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.neon-bar-fill.cyan {
|
| 258 |
+
background: var(--neon-cyan);
|
| 259 |
+
box-shadow: 0 0 6px var(--neon-cyan);
|
| 260 |
+
}
|
| 261 |
+
.neon-bar-fill.green {
|
| 262 |
+
background: var(--neon-green);
|
| 263 |
+
box-shadow: 0 0 6px var(--neon-green);
|
| 264 |
+
}
|
| 265 |
+
.neon-bar-fill.red {
|
| 266 |
+
background: var(--neon-red);
|
| 267 |
+
box-shadow: 0 0 6px var(--neon-red);
|
| 268 |
+
}
|
| 269 |
+
.neon-bar-fill.amber {
|
| 270 |
+
background: var(--neon-amber);
|
| 271 |
+
box-shadow: 0 0 6px var(--neon-amber);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
@media (prefers-reduced-motion: reduce) {
|
| 275 |
+
.neon-bar-fill {
|
| 276 |
+
transition: none;
|
| 277 |
+
}
|
| 278 |
+
}
|
index.html
CHANGED
|
@@ -1,19 +1,120 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta
|
| 6 |
+
name="viewport"
|
| 7 |
+
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
| 8 |
+
/>
|
| 9 |
+
<meta
|
| 10 |
+
name="description"
|
| 11 |
+
content="Digital Life Evolution Simulator - An autonomous AI ecosystem demonstration"
|
| 12 |
+
/>
|
| 13 |
+
<title>Digital Life Evolution Simulator</title>
|
| 14 |
+
|
| 15 |
+
<link rel="stylesheet" href="css/main.css" />
|
| 16 |
+
<link rel="stylesheet" href="css/animations.css" />
|
| 17 |
+
<link rel="stylesheet" href="css/neon-effects.css" />
|
| 18 |
+
|
| 19 |
+
<style>
|
| 20 |
+
html,
|
| 21 |
+
body {
|
| 22 |
+
background: #06060f;
|
| 23 |
+
}
|
| 24 |
+
#loading {
|
| 25 |
+
position: fixed;
|
| 26 |
+
top: 0;
|
| 27 |
+
left: 0;
|
| 28 |
+
right: 0;
|
| 29 |
+
bottom: 0;
|
| 30 |
+
display: flex;
|
| 31 |
+
align-items: center;
|
| 32 |
+
justify-content: center;
|
| 33 |
+
background: #06060f;
|
| 34 |
+
z-index: 9999;
|
| 35 |
+
font-family: "Courier New", monospace;
|
| 36 |
+
color: #00f0ff;
|
| 37 |
+
font-size: 14px;
|
| 38 |
+
letter-spacing: 2px;
|
| 39 |
+
transition: opacity 0.5s ease;
|
| 40 |
+
}
|
| 41 |
+
#loading.fade-out {
|
| 42 |
+
opacity: 0;
|
| 43 |
+
pointer-events: none;
|
| 44 |
+
}
|
| 45 |
+
#loading .pulse {
|
| 46 |
+
animation: loadPulse 1.5s ease-in-out infinite;
|
| 47 |
+
}
|
| 48 |
+
@keyframes loadPulse {
|
| 49 |
+
0%,
|
| 50 |
+
100% {
|
| 51 |
+
opacity: 0.4;
|
| 52 |
+
}
|
| 53 |
+
50% {
|
| 54 |
+
opacity: 1;
|
| 55 |
+
text-shadow:
|
| 56 |
+
0 0 10px #00f0ff,
|
| 57 |
+
0 0 20px #00f0ff;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
</style>
|
| 61 |
+
</head>
|
| 62 |
+
<body>
|
| 63 |
+
<div id="loading" role="alert" aria-live="polite">
|
| 64 |
+
<span class="pulse">INITIALIZING ECOSYSTEM...</span>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div
|
| 68 |
+
id="app"
|
| 69 |
+
role="application"
|
| 70 |
+
aria-label="Digital Life Evolution Simulator"
|
| 71 |
+
>
|
| 72 |
+
<a
|
| 73 |
+
href="#panel-stats"
|
| 74 |
+
class="sr-only"
|
| 75 |
+
style="position: absolute; z-index: 100"
|
| 76 |
+
>
|
| 77 |
+
Skip to simulation stats
|
| 78 |
+
</a>
|
| 79 |
+
|
| 80 |
+
<canvas
|
| 81 |
+
id="simulation-canvas"
|
| 82 |
+
aria-label="Simulation world with AI entities, resources, and environmental events"
|
| 83 |
+
role="img"
|
| 84 |
+
></canvas>
|
| 85 |
+
|
| 86 |
+
<canvas id="particle-canvas" aria-hidden="true"></canvas>
|
| 87 |
+
|
| 88 |
+
<div class="scan-overlay" aria-hidden="true"></div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<script type="module">
|
| 92 |
+
import { bootstrap } from "./js/ecosystem.js";
|
| 93 |
+
|
| 94 |
+
if (document.readyState === "loading") {
|
| 95 |
+
document.addEventListener("DOMContentLoaded", start);
|
| 96 |
+
} else {
|
| 97 |
+
start();
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function start() {
|
| 101 |
+
requestAnimationFrame(() => {
|
| 102 |
+
try {
|
| 103 |
+
bootstrap();
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.error("Bootstrap failed:", err);
|
| 106 |
+
document.getElementById("loading").innerHTML =
|
| 107 |
+
'<span style="color:#ff3366">ERROR: ' + err.message + "</span>";
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const loading = document.getElementById("loading");
|
| 112 |
+
if (loading) {
|
| 113 |
+
loading.classList.add("fade-out");
|
| 114 |
+
setTimeout(() => loading.remove(), 600);
|
| 115 |
+
}
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
</script>
|
| 119 |
+
</body>
|
| 120 |
</html>
|
js/ecosystem.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { World, EVENT_TYPES } from "./world.js";
|
| 2 |
+
import { EntityManager } from "./entityManager.js";
|
| 3 |
+
import { Entity } from "./entities.js";
|
| 4 |
+
import { ParticleSystem } from "./particles.js";
|
| 5 |
+
import { StatsTracker } from "./stats.js";
|
| 6 |
+
import { UI } from "./ui.js";
|
| 7 |
+
import { createGenome, ENTITY_TYPES } from "./genetics.js";
|
| 8 |
+
import { randomRange } from "./utils.js";
|
| 9 |
+
|
| 10 |
+
const SAVE_KEY = "digital-life-simulator-state";
|
| 11 |
+
const TARGET_FPS = 60;
|
| 12 |
+
const MIN_POPULATION = 15;
|
| 13 |
+
const MAX_POPULATION = 80;
|
| 14 |
+
const INITIAL_POPULATION = 35;
|
| 15 |
+
const GENERATION_INTERVAL = 45;
|
| 16 |
+
|
| 17 |
+
export class Ecosystem {
|
| 18 |
+
constructor() {
|
| 19 |
+
this.world = null;
|
| 20 |
+
this.entityManager = null;
|
| 21 |
+
this.particles = null;
|
| 22 |
+
this.stats = null;
|
| 23 |
+
this.ui = null;
|
| 24 |
+
|
| 25 |
+
this.simCanvas = null;
|
| 26 |
+
this.simCtx = null;
|
| 27 |
+
this.particleCanvas = null;
|
| 28 |
+
this.particleCtx = null;
|
| 29 |
+
|
| 30 |
+
this.running = false;
|
| 31 |
+
this._rafId = null;
|
| 32 |
+
this._lastTime = 0;
|
| 33 |
+
this._fpsAccum = 0;
|
| 34 |
+
this._fpsFrames = 0;
|
| 35 |
+
this._currentFps = 60;
|
| 36 |
+
|
| 37 |
+
this._genTimer = 0;
|
| 38 |
+
this._saveTimer = 0;
|
| 39 |
+
this._eventCheckTimer = 0;
|
| 40 |
+
this._lastEventCount = 0;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
init() {
|
| 44 |
+
this.simCanvas = document.getElementById("simulation-canvas");
|
| 45 |
+
this.particleCanvas = document.getElementById("particle-canvas");
|
| 46 |
+
|
| 47 |
+
if (!this.simCanvas || !this.particleCanvas) {
|
| 48 |
+
console.error("Canvas elements not found");
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
this.simCtx = this.simCanvas.getContext("2d");
|
| 53 |
+
this.particleCtx = this.particleCanvas.getContext("2d");
|
| 54 |
+
|
| 55 |
+
this._resizeCanvases();
|
| 56 |
+
window.addEventListener("resize", () => this._resizeCanvases());
|
| 57 |
+
|
| 58 |
+
const w = this.simCanvas.width;
|
| 59 |
+
const h = this.simCanvas.height;
|
| 60 |
+
|
| 61 |
+
const saved = this._loadState();
|
| 62 |
+
|
| 63 |
+
if (saved) {
|
| 64 |
+
this.world = World.fromJSON(saved.world);
|
| 65 |
+
this.stats = StatsTracker.fromJSON(saved.stats);
|
| 66 |
+
this.entityManager = new EntityManager(w, h);
|
| 67 |
+
|
| 68 |
+
if (saved.entities && saved.entities.length > 0) {
|
| 69 |
+
for (const ed of saved.entities) {
|
| 70 |
+
try {
|
| 71 |
+
const { Genome } = await_import_workaround();
|
| 72 |
+
} catch (_) {}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
this.entityManager.spawnRandom(INITIAL_POPULATION, this.world);
|
| 76 |
+
} else {
|
| 77 |
+
this.world = new World(w, h);
|
| 78 |
+
this.stats = new StatsTracker();
|
| 79 |
+
this.entityManager = new EntityManager(w, h);
|
| 80 |
+
this.entityManager.spawnRandom(INITIAL_POPULATION, this.world);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
this.particles = new ParticleSystem(2000);
|
| 84 |
+
|
| 85 |
+
const container = document.getElementById("app");
|
| 86 |
+
this.ui = new UI(container);
|
| 87 |
+
this.ui.init();
|
| 88 |
+
this.ui.addEvent("Ecosystem initialized", "milestone");
|
| 89 |
+
this.ui.announce("Digital Life Simulator Active");
|
| 90 |
+
|
| 91 |
+
const types = Object.keys(ENTITY_TYPES);
|
| 92 |
+
for (const type of types) {
|
| 93 |
+
const count = this.entityManager.entities.filter(
|
| 94 |
+
(e) => e.type === type,
|
| 95 |
+
).length;
|
| 96 |
+
if (count > 0) {
|
| 97 |
+
this.ui.addEvent(
|
| 98 |
+
`${ENTITY_TYPES[type].name}: ${count} spawned`,
|
| 99 |
+
"birth",
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
start() {
|
| 106 |
+
if (this.running) return;
|
| 107 |
+
this.running = true;
|
| 108 |
+
this._lastTime = performance.now();
|
| 109 |
+
this._loop(this._lastTime);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
stop() {
|
| 113 |
+
this.running = false;
|
| 114 |
+
if (this._rafId) {
|
| 115 |
+
cancelAnimationFrame(this._rafId);
|
| 116 |
+
this._rafId = null;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
_loop(timestamp) {
|
| 121 |
+
if (!this.running) return;
|
| 122 |
+
this._rafId = requestAnimationFrame((t) => this._loop(t));
|
| 123 |
+
|
| 124 |
+
const rawDt = (timestamp - this._lastTime) / 1000;
|
| 125 |
+
const dt = Math.min(rawDt, 0.05);
|
| 126 |
+
this._lastTime = timestamp;
|
| 127 |
+
|
| 128 |
+
this._fpsAccum += rawDt;
|
| 129 |
+
this._fpsFrames++;
|
| 130 |
+
if (this._fpsAccum >= 0.5) {
|
| 131 |
+
this._currentFps = this._fpsFrames / this._fpsAccum;
|
| 132 |
+
this._fpsFrames = 0;
|
| 133 |
+
this._fpsAccum = 0;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
this._update(dt);
|
| 137 |
+
|
| 138 |
+
this._render();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
_update(dt) {
|
| 142 |
+
this.world.update(dt);
|
| 143 |
+
|
| 144 |
+
this._eventCheckTimer += dt;
|
| 145 |
+
if (this._eventCheckTimer >= 1) {
|
| 146 |
+
this._eventCheckTimer = 0;
|
| 147 |
+
if (this.world.events.length > this._lastEventCount) {
|
| 148 |
+
const newEvent = this.world.events[this.world.events.length - 1];
|
| 149 |
+
this.ui.announceWorldEvent(newEvent);
|
| 150 |
+
this._lastEventCount = this.world.events.length;
|
| 151 |
+
}
|
| 152 |
+
if (this.world.events.length < this._lastEventCount) {
|
| 153 |
+
this._lastEventCount = this.world.events.length;
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
this.entityManager.update(dt, this.world, this.particles, this.stats);
|
| 158 |
+
|
| 159 |
+
this.particles.update(dt);
|
| 160 |
+
|
| 161 |
+
for (const event of this.world.events) {
|
| 162 |
+
if (event.type === "storm" || event.type === "bloom") {
|
| 163 |
+
this.particles.emitWeather(event, dt);
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
this._managePopulation(dt);
|
| 168 |
+
|
| 169 |
+
this._genTimer += dt;
|
| 170 |
+
if (this._genTimer >= GENERATION_INTERVAL) {
|
| 171 |
+
this._genTimer = 0;
|
| 172 |
+
this.stats.onGeneration();
|
| 173 |
+
this.ui.addEvent(
|
| 174 |
+
`Generation ${this.stats.generationCount} reached`,
|
| 175 |
+
"evolution",
|
| 176 |
+
);
|
| 177 |
+
this.ui.announce(`Generation ${this.stats.generationCount}`);
|
| 178 |
+
|
| 179 |
+
const genEl = document.getElementById("gen-count");
|
| 180 |
+
if (genEl) {
|
| 181 |
+
genEl.classList.remove("generation-flash");
|
| 182 |
+
void genEl.offsetWidth;
|
| 183 |
+
genEl.classList.add("generation-flash");
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
this.stats.record(
|
| 188 |
+
this.entityManager.entities,
|
| 189 |
+
this.world,
|
| 190 |
+
dt,
|
| 191 |
+
this._currentFps,
|
| 192 |
+
this.particles.count,
|
| 193 |
+
);
|
| 194 |
+
|
| 195 |
+
this._saveTimer += dt;
|
| 196 |
+
if (this._saveTimer >= 30) {
|
| 197 |
+
this._saveTimer = 0;
|
| 198 |
+
this._saveState();
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
this.ui.update(this.stats, this.world, this.entityManager, dt);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
_render() {
|
| 205 |
+
const simCtx = this.simCtx;
|
| 206 |
+
const partCtx = this.particleCtx;
|
| 207 |
+
const w = this.world.width;
|
| 208 |
+
const h = this.world.height;
|
| 209 |
+
|
| 210 |
+
simCtx.fillStyle = "#06060f";
|
| 211 |
+
simCtx.fillRect(0, 0, w, h);
|
| 212 |
+
partCtx.clearRect(0, 0, w, h);
|
| 213 |
+
|
| 214 |
+
this.world.render(simCtx);
|
| 215 |
+
|
| 216 |
+
this._renderFrame = (this._renderFrame || 0) + 1;
|
| 217 |
+
if (this._renderFrame % 15 === 0) {
|
| 218 |
+
this.stats.renderHeatmap(simCtx, w, h);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
this.entityManager.render(simCtx);
|
| 222 |
+
|
| 223 |
+
this.particles.render(partCtx);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
_managePopulation(dt) {
|
| 227 |
+
const alive = this.entityManager.entities.length;
|
| 228 |
+
|
| 229 |
+
if (alive < MIN_POPULATION) {
|
| 230 |
+
const deficit = MIN_POPULATION - alive;
|
| 231 |
+
const toSpawn = Math.min(deficit, 5);
|
| 232 |
+
|
| 233 |
+
if (alive >= 2) {
|
| 234 |
+
this.entityManager.spawnFromPopulation(
|
| 235 |
+
this.entityManager.entities,
|
| 236 |
+
toSpawn,
|
| 237 |
+
this.world,
|
| 238 |
+
);
|
| 239 |
+
this.ui.addEvent(
|
| 240 |
+
`${toSpawn} entities evolved from survivors`,
|
| 241 |
+
"evolution",
|
| 242 |
+
);
|
| 243 |
+
} else {
|
| 244 |
+
this.entityManager.spawnRandom(toSpawn, this.world);
|
| 245 |
+
this.ui.addEvent(`${toSpawn} new entities seeded`, "birth");
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
this._diversityTimer = (this._diversityTimer || 0) + dt;
|
| 250 |
+
if (this._diversityTimer >= 8) {
|
| 251 |
+
this._diversityTimer = 0;
|
| 252 |
+
const types = Object.keys(ENTITY_TYPES);
|
| 253 |
+
const typeCounts = {};
|
| 254 |
+
for (const t of types) typeCounts[t] = 0;
|
| 255 |
+
for (const e of this.entityManager.entities) {
|
| 256 |
+
if (e.alive) typeCounts[e.type]++;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
for (const type of types) {
|
| 260 |
+
if (typeCounts[type] < 2 && alive < MAX_POPULATION - 3) {
|
| 261 |
+
const count = Math.min(3, MAX_POPULATION - alive);
|
| 262 |
+
for (let i = 0; i < count; i++) {
|
| 263 |
+
const genome = createGenome(type);
|
| 264 |
+
const x = randomRange(40, this.world.width - 40);
|
| 265 |
+
const y = randomRange(40, this.world.height - 40);
|
| 266 |
+
const entity = new Entity(x, y, genome, this.stats.generationCount);
|
| 267 |
+
this.entityManager.add(entity);
|
| 268 |
+
}
|
| 269 |
+
this.ui.addEvent(
|
| 270 |
+
`${ENTITY_TYPES[type].name} migration wave (${count})`,
|
| 271 |
+
"birth",
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
if (alive > MAX_POPULATION) {
|
| 278 |
+
const excess = alive - MAX_POPULATION;
|
| 279 |
+
const sorted = [...this.entityManager.entities].sort(
|
| 280 |
+
(a, b) => a.fitness - b.fitness,
|
| 281 |
+
);
|
| 282 |
+
for (let i = 0; i < excess && i < sorted.length; i++) {
|
| 283 |
+
sorted[i].alive = false;
|
| 284 |
+
sorted[i].energy = 0;
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
_resizeCanvases() {
|
| 290 |
+
const w = window.innerWidth;
|
| 291 |
+
const h = window.innerHeight;
|
| 292 |
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
| 293 |
+
|
| 294 |
+
for (const canvas of [this.simCanvas, this.particleCanvas]) {
|
| 295 |
+
if (!canvas) continue;
|
| 296 |
+
canvas.width = w * dpr;
|
| 297 |
+
canvas.height = h * dpr;
|
| 298 |
+
canvas.style.width = w + "px";
|
| 299 |
+
canvas.style.height = h + "px";
|
| 300 |
+
const ctx = canvas.getContext("2d");
|
| 301 |
+
ctx.scale(dpr, dpr);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
if (this.world) {
|
| 305 |
+
this.world.width = w;
|
| 306 |
+
this.world.height = h;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
if (this.entityManager) {
|
| 310 |
+
this.entityManager.spatialGrid = new (Object.getPrototypeOf(
|
| 311 |
+
this.entityManager.spatialGrid,
|
| 312 |
+
).constructor)(w, h, 60);
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
_saveState() {
|
| 317 |
+
try {
|
| 318 |
+
const state = {
|
| 319 |
+
world: this.world.toJSON(),
|
| 320 |
+
stats: this.stats.toJSON(),
|
| 321 |
+
version: 1,
|
| 322 |
+
};
|
| 323 |
+
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
|
| 324 |
+
} catch (e) {}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
_loadState() {
|
| 328 |
+
try {
|
| 329 |
+
const raw = localStorage.getItem(SAVE_KEY);
|
| 330 |
+
if (!raw) return null;
|
| 331 |
+
return JSON.parse(raw);
|
| 332 |
+
} catch (e) {
|
| 333 |
+
return null;
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function await_import_workaround() {
|
| 339 |
+
return null;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
export function bootstrap() {
|
| 343 |
+
const eco = new Ecosystem();
|
| 344 |
+
eco.init();
|
| 345 |
+
eco.start();
|
| 346 |
+
|
| 347 |
+
window.__ecosystem = eco;
|
| 348 |
+
|
| 349 |
+
return eco;
|
| 350 |
+
}
|
js/entities.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { distance, clamp, TAU, COLORS, randomRange } from "./utils.js";
|
| 2 |
+
import { calculateFitness, NET_TOPOLOGY } from "./genetics.js";
|
| 3 |
+
import { QuantumDecisionLayer } from "./neuralNetwork.js";
|
| 4 |
+
|
| 5 |
+
let nextEntityId = 1;
|
| 6 |
+
|
| 7 |
+
const _glowCache = {};
|
| 8 |
+
function getGlowSprite(type, size) {
|
| 9 |
+
const key = `${type}_${Math.round(size)}`;
|
| 10 |
+
if (_glowCache[key]) return _glowCache[key];
|
| 11 |
+
|
| 12 |
+
const c = COLORS[type] || COLORS.cyan;
|
| 13 |
+
const spriteSize = Math.ceil(size * 5);
|
| 14 |
+
const canvas = document.createElement("canvas");
|
| 15 |
+
canvas.width = spriteSize;
|
| 16 |
+
canvas.height = spriteSize;
|
| 17 |
+
const ctx = canvas.getContext("2d");
|
| 18 |
+
const cx = spriteSize / 2;
|
| 19 |
+
|
| 20 |
+
const grad = ctx.createRadialGradient(cx, cx, size * 0.3, cx, cx, cx);
|
| 21 |
+
grad.addColorStop(0, `rgba(${c.r},${c.g},${c.b},0.3)`);
|
| 22 |
+
grad.addColorStop(0.4, `rgba(${c.r},${c.g},${c.b},0.1)`);
|
| 23 |
+
grad.addColorStop(1, `rgba(${c.r},${c.g},${c.b},0)`);
|
| 24 |
+
ctx.fillStyle = grad;
|
| 25 |
+
ctx.fillRect(0, 0, spriteSize, spriteSize);
|
| 26 |
+
|
| 27 |
+
_glowCache[key] = canvas;
|
| 28 |
+
return canvas;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export class Entity {
|
| 32 |
+
constructor(x, y, genome, generation = 0) {
|
| 33 |
+
this.id = nextEntityId++;
|
| 34 |
+
this.x = x;
|
| 35 |
+
this.y = y;
|
| 36 |
+
this.genome = genome;
|
| 37 |
+
this.type = genome.type;
|
| 38 |
+
this.generation = generation;
|
| 39 |
+
this.brain = genome.buildBrain();
|
| 40 |
+
this.quantumLayer = generation > 5 ? new QuantumDecisionLayer(0.8) : null;
|
| 41 |
+
const t = genome.traits;
|
| 42 |
+
this.energy = 60;
|
| 43 |
+
this.maxEnergy = 100;
|
| 44 |
+
this.health = 100;
|
| 45 |
+
this.maxHealth = 100;
|
| 46 |
+
this.speed = t.speed;
|
| 47 |
+
this.size = t.size;
|
| 48 |
+
this.visionRange = t.vision;
|
| 49 |
+
this.attackPower = t.attack;
|
| 50 |
+
this.defensePower = t.defense;
|
| 51 |
+
this.metabolism = t.metabolism;
|
| 52 |
+
|
| 53 |
+
this.vx = 0;
|
| 54 |
+
this.vy = 0;
|
| 55 |
+
this.angle = Math.random() * TAU;
|
| 56 |
+
this.age = 0;
|
| 57 |
+
this.alive = true;
|
| 58 |
+
this.signal = 0;
|
| 59 |
+
this.signalStrength = 0;
|
| 60 |
+
this.stats = { survivalTime: 0, energyGathered: 0, offspring: 0, kills: 0 };
|
| 61 |
+
this.fitness = 0;
|
| 62 |
+
this._thinkTimer = Math.random() * 0.15;
|
| 63 |
+
this._thinkInterval = 0.12;
|
| 64 |
+
this._reproTimer = 0;
|
| 65 |
+
this._reproInterval = 5;
|
| 66 |
+
this.lastOutputs = new Array(NET_TOPOLOGY[NET_TOPOLOGY.length - 1]).fill(0);
|
| 67 |
+
this._trail = [];
|
| 68 |
+
this._trailTimer = 0;
|
| 69 |
+
this._nearbyEntities = [];
|
| 70 |
+
this._nearbyResources = [];
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
update(dt, world, _allEntities, spatialGrid, particles) {
|
| 74 |
+
if (!this.alive) return;
|
| 75 |
+
|
| 76 |
+
this.age += dt;
|
| 77 |
+
this.stats.survivalTime = this.age;
|
| 78 |
+
|
| 79 |
+
this.energy -= this.metabolism * dt * 0.5;
|
| 80 |
+
|
| 81 |
+
const envDmg = world.getCatastropheDamage(this.x, this.y);
|
| 82 |
+
if (envDmg > 0) {
|
| 83 |
+
this.health -= envDmg * dt * 30;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (this.energy <= 0 || this.health <= 0) {
|
| 87 |
+
this.alive = false;
|
| 88 |
+
particles.emit(this.x, this.y, "death", this._colorArray());
|
| 89 |
+
return;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
this._thinkTimer += dt;
|
| 93 |
+
if (this._thinkTimer >= this._thinkInterval) {
|
| 94 |
+
this._thinkTimer = 0;
|
| 95 |
+
this._sense(world, spatialGrid);
|
| 96 |
+
this._think();
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
this._act(dt, world, spatialGrid, particles);
|
| 100 |
+
|
| 101 |
+
this._trailTimer += dt;
|
| 102 |
+
if (this._trailTimer > 0.08) {
|
| 103 |
+
this._trailTimer = 0;
|
| 104 |
+
this._trail.push({ x: this.x, y: this.y });
|
| 105 |
+
if (this._trail.length > 8) this._trail.shift();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
this._reproTimer += dt;
|
| 109 |
+
|
| 110 |
+
this.fitness = calculateFitness(this.type, this.stats);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
_sense(world, spatialGrid) {
|
| 114 |
+
this._nearbyEntities = spatialGrid.query(this.x, this.y, this.visionRange);
|
| 115 |
+
|
| 116 |
+
this._nearbyResources = world.getResourcesNear(
|
| 117 |
+
this.x,
|
| 118 |
+
this.y,
|
| 119 |
+
this.visionRange,
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
_think() {
|
| 124 |
+
const inputs = this._buildInputs();
|
| 125 |
+
const outputs = this.brain.forward(inputs);
|
| 126 |
+
this.lastOutputs = outputs;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
_buildInputs() {
|
| 130 |
+
const inputs = new Array(NET_TOPOLOGY[0]).fill(0);
|
| 131 |
+
const vr = this.visionRange;
|
| 132 |
+
|
| 133 |
+
let nearestFood = null,
|
| 134 |
+
nearestFoodDist = Infinity;
|
| 135 |
+
for (const r of this._nearbyResources) {
|
| 136 |
+
const d = distance(this.x, this.y, r.x, r.y);
|
| 137 |
+
if (d < nearestFoodDist) {
|
| 138 |
+
nearestFoodDist = d;
|
| 139 |
+
nearestFood = r;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
if (nearestFood) {
|
| 144 |
+
inputs[0] = (nearestFood.x - this.x) / vr;
|
| 145 |
+
inputs[1] = (nearestFood.y - this.y) / vr;
|
| 146 |
+
inputs[2] = 1 - nearestFoodDist / vr;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
let nearestThreat = null,
|
| 150 |
+
nearestThreatDist = Infinity;
|
| 151 |
+
let nearestAlly = null,
|
| 152 |
+
nearestAllyDist = Infinity;
|
| 153 |
+
let allySignalSum = 0,
|
| 154 |
+
allyCount = 0;
|
| 155 |
+
let nearbyCount = 0;
|
| 156 |
+
|
| 157 |
+
for (const e of this._nearbyEntities) {
|
| 158 |
+
if (e.id === this.id || !e.alive) continue;
|
| 159 |
+
const d = distance(this.x, this.y, e.x, e.y);
|
| 160 |
+
nearbyCount++;
|
| 161 |
+
|
| 162 |
+
const isThreat =
|
| 163 |
+
(this.type !== "predator" && e.type === "predator") ||
|
| 164 |
+
e.attackPower > this.defensePower * 1.5;
|
| 165 |
+
|
| 166 |
+
if (isThreat && d < nearestThreatDist) {
|
| 167 |
+
nearestThreatDist = d;
|
| 168 |
+
nearestThreat = e;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const isAlly = e.type === this.type;
|
| 172 |
+
if (isAlly && d < nearestAllyDist) {
|
| 173 |
+
nearestAllyDist = d;
|
| 174 |
+
nearestAlly = e;
|
| 175 |
+
allySignalSum += e.signal;
|
| 176 |
+
allyCount++;
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if (nearestThreat) {
|
| 181 |
+
inputs[3] = (nearestThreat.x - this.x) / vr;
|
| 182 |
+
inputs[4] = (nearestThreat.y - this.y) / vr;
|
| 183 |
+
inputs[5] = 1 - nearestThreatDist / vr;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if (nearestAlly) {
|
| 187 |
+
inputs[6] = (nearestAlly.x - this.x) / vr;
|
| 188 |
+
inputs[7] = (nearestAlly.y - this.y) / vr;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
inputs[8] = this.energy / this.maxEnergy;
|
| 192 |
+
inputs[9] = this.health / this.maxHealth;
|
| 193 |
+
inputs[10] = clamp(nearbyCount / 10, 0, 1);
|
| 194 |
+
inputs[11] = allyCount > 0 ? allySignalSum / allyCount / 7 : 0;
|
| 195 |
+
inputs[12] = Math.sin(this.age * 0.5);
|
| 196 |
+
inputs[13] = 1;
|
| 197 |
+
|
| 198 |
+
return inputs;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
_act(dt, world, spatialGrid, particles) {
|
| 202 |
+
const o = this.lastOutputs;
|
| 203 |
+
|
| 204 |
+
const moveX = o[0];
|
| 205 |
+
const moveY = o[1];
|
| 206 |
+
const moveLen = Math.sqrt(moveX * moveX + moveY * moveY);
|
| 207 |
+
if (moveLen > 0.01) {
|
| 208 |
+
const nx = moveX / moveLen;
|
| 209 |
+
const ny = moveY / moveLen;
|
| 210 |
+
this.vx = nx * this.speed * 30;
|
| 211 |
+
this.vy = ny * this.speed * 30;
|
| 212 |
+
this.angle = Math.atan2(ny, nx);
|
| 213 |
+
} else {
|
| 214 |
+
this.vx *= 0.9;
|
| 215 |
+
this.vy *= 0.9;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
this.x += this.vx * dt;
|
| 219 |
+
this.y += this.vy * dt;
|
| 220 |
+
|
| 221 |
+
if (this.x < 0) this.x += world.width;
|
| 222 |
+
if (this.x >= world.width) this.x -= world.width;
|
| 223 |
+
if (this.y < 0) this.y += world.height;
|
| 224 |
+
if (this.y >= world.height) this.y -= world.height;
|
| 225 |
+
|
| 226 |
+
this._tryEat(world, particles, o[2]);
|
| 227 |
+
|
| 228 |
+
if (o[3] > 0.3 && this.attackPower > 0.3) {
|
| 229 |
+
this._tryAttack(spatialGrid, particles);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
this.signal = Math.floor((o[5] + 1) * 4) % 8;
|
| 233 |
+
this.signalStrength = Math.abs(o[5]);
|
| 234 |
+
if (this.signalStrength > 0.5) {
|
| 235 |
+
particles.emit(this.x, this.y, "signal", [
|
| 236 |
+
COLORS[this.type].r,
|
| 237 |
+
COLORS[this.type].g,
|
| 238 |
+
COLORS[this.type].b,
|
| 239 |
+
]);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
_tryEat(world, particles, eagerness = 0) {
|
| 244 |
+
const eatRange = this.size + 20;
|
| 245 |
+
const resources = world.getResourcesNear(this.x, this.y, eatRange);
|
| 246 |
+
for (const r of resources) {
|
| 247 |
+
const d = distance(this.x, this.y, r.x, r.y);
|
| 248 |
+
if (d < eatRange) {
|
| 249 |
+
const efficiency = 0.4 + Math.max(0, eagerness) * 0.6;
|
| 250 |
+
const amount = r.consume(6 * efficiency);
|
| 251 |
+
this.energy = Math.min(this.maxEnergy, this.energy + amount * 1.5);
|
| 252 |
+
this.stats.energyGathered += amount;
|
| 253 |
+
if (amount > 0.5) particles.emit(r.x, r.y, "eat");
|
| 254 |
+
break;
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
_tryAttack(spatialGrid, particles) {
|
| 260 |
+
const attackRange = this.size + 12;
|
| 261 |
+
const nearby = spatialGrid.query(this.x, this.y, attackRange);
|
| 262 |
+
for (const e of nearby) {
|
| 263 |
+
if (e.id === this.id || !e.alive) continue;
|
| 264 |
+
if (e.type === this.type) continue;
|
| 265 |
+
const d = distance(this.x, this.y, e.x, e.y);
|
| 266 |
+
if (d < attackRange) {
|
| 267 |
+
const dmg = this.attackPower * 15 - e.defensePower * 5;
|
| 268 |
+
if (dmg > 0) {
|
| 269 |
+
e.health -= dmg;
|
| 270 |
+
this.energy = Math.min(this.maxEnergy, this.energy + dmg * 0.3);
|
| 271 |
+
particles.emit(e.x, e.y, "attack");
|
| 272 |
+
if (e.health <= 0) {
|
| 273 |
+
e.alive = false;
|
| 274 |
+
this.stats.kills++;
|
| 275 |
+
this.energy = Math.min(this.maxEnergy, this.energy + 20);
|
| 276 |
+
particles.emit(e.x, e.y, "death", this._colorArray());
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
break;
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
canReproduce() {
|
| 285 |
+
return (
|
| 286 |
+
this.alive &&
|
| 287 |
+
this.energy > 40 &&
|
| 288 |
+
this._reproTimer >= this._reproInterval &&
|
| 289 |
+
this.age > 2
|
| 290 |
+
);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
tryReproduce(spatialGrid, particles) {
|
| 294 |
+
if (!this.canReproduce() || this.lastOutputs[4] < -0.2) return null;
|
| 295 |
+
|
| 296 |
+
const mateRange = this.visionRange * 0.5;
|
| 297 |
+
const nearby = spatialGrid.query(this.x, this.y, mateRange);
|
| 298 |
+
for (const e of nearby) {
|
| 299 |
+
if (e.id === this.id || !e.alive || e.type !== this.type) continue;
|
| 300 |
+
if (!e.canReproduce()) continue;
|
| 301 |
+
|
| 302 |
+
this.energy -= 18;
|
| 303 |
+
e.energy -= 18;
|
| 304 |
+
this._reproTimer = 0;
|
| 305 |
+
e._reproTimer = 0;
|
| 306 |
+
this.stats.offspring++;
|
| 307 |
+
e.stats.offspring++;
|
| 308 |
+
|
| 309 |
+
const childGenome = this.genome.crossover(e.genome);
|
| 310 |
+
childGenome.mutate(0.2, 0.06, 0.25);
|
| 311 |
+
|
| 312 |
+
const cx = (this.x + e.x) / 2 + randomRange(-10, 10);
|
| 313 |
+
const cy = (this.y + e.y) / 2 + randomRange(-10, 10);
|
| 314 |
+
const child = new Entity(
|
| 315 |
+
cx,
|
| 316 |
+
cy,
|
| 317 |
+
childGenome,
|
| 318 |
+
Math.max(this.generation, e.generation) + 1,
|
| 319 |
+
);
|
| 320 |
+
|
| 321 |
+
particles.emit(cx, cy, "reproduce", [
|
| 322 |
+
COLORS[this.type].r,
|
| 323 |
+
COLORS[this.type].g,
|
| 324 |
+
COLORS[this.type].b,
|
| 325 |
+
]);
|
| 326 |
+
return child;
|
| 327 |
+
}
|
| 328 |
+
return null;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
_colorArray() {
|
| 332 |
+
const c = COLORS[this.type];
|
| 333 |
+
return c ? [c.r, c.g, c.b] : [255, 255, 255];
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
render(ctx) {
|
| 337 |
+
if (!this.alive) return;
|
| 338 |
+
const c = COLORS[this.type] || COLORS.cyan;
|
| 339 |
+
const pulse = 0.85 + 0.15 * Math.sin(this.age * 3 + this.id);
|
| 340 |
+
const energyRatio = this.energy / this.maxEnergy;
|
| 341 |
+
|
| 342 |
+
if (this._trail.length > 1) {
|
| 343 |
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.15)`;
|
| 344 |
+
ctx.lineWidth = 2;
|
| 345 |
+
ctx.beginPath();
|
| 346 |
+
ctx.moveTo(this._trail[0].x, this._trail[0].y);
|
| 347 |
+
for (let i = 1; i < this._trail.length; i++) {
|
| 348 |
+
ctx.lineTo(this._trail[i].x, this._trail[i].y);
|
| 349 |
+
}
|
| 350 |
+
ctx.stroke();
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
const glow = getGlowSprite(this.type, this.size);
|
| 354 |
+
const glowSize = glow.width;
|
| 355 |
+
ctx.drawImage(glow, this.x - glowSize / 2, this.y - glowSize / 2);
|
| 356 |
+
|
| 357 |
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.5)`;
|
| 358 |
+
ctx.lineWidth = 1.5;
|
| 359 |
+
ctx.beginPath();
|
| 360 |
+
ctx.arc(this.x, this.y, this.size * pulse, 0, TAU);
|
| 361 |
+
ctx.stroke();
|
| 362 |
+
|
| 363 |
+
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.65)`;
|
| 364 |
+
ctx.beginPath();
|
| 365 |
+
ctx.arc(this.x, this.y, this.size * 0.8 * pulse, 0, TAU);
|
| 366 |
+
ctx.fill();
|
| 367 |
+
ctx.fillStyle = `rgba(${Math.min(255, c.r + 100)},${Math.min(255, c.g + 100)},${Math.min(255, c.b + 100)},0.9)`;
|
| 368 |
+
ctx.beginPath();
|
| 369 |
+
ctx.arc(this.x, this.y, this.size * 0.3, 0, TAU);
|
| 370 |
+
ctx.fill();
|
| 371 |
+
|
| 372 |
+
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.45)`;
|
| 373 |
+
const s = this.size * 0.55;
|
| 374 |
+
switch (this.type) {
|
| 375 |
+
case "predator":
|
| 376 |
+
this._drawTriangle(ctx, this.x, this.y, s);
|
| 377 |
+
break;
|
| 378 |
+
case "builder":
|
| 379 |
+
ctx.fillRect(this.x - s * 0.6, this.y - s * 0.6, s * 1.2, s * 1.2);
|
| 380 |
+
break;
|
| 381 |
+
case "explorer":
|
| 382 |
+
this._drawDiamond(ctx, this.x, this.y, s);
|
| 383 |
+
break;
|
| 384 |
+
case "hybrid":
|
| 385 |
+
this._drawStar(ctx, this.x, this.y, s, 5);
|
| 386 |
+
break;
|
| 387 |
+
default:
|
| 388 |
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.3)`;
|
| 389 |
+
ctx.lineWidth = 1;
|
| 390 |
+
ctx.beginPath();
|
| 391 |
+
ctx.arc(this.x, this.y, this.size * 0.5, 0, TAU);
|
| 392 |
+
ctx.stroke();
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
const dirLen = this.size + 5;
|
| 396 |
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.6)`;
|
| 397 |
+
ctx.lineWidth = 1.5;
|
| 398 |
+
ctx.beginPath();
|
| 399 |
+
ctx.moveTo(this.x, this.y);
|
| 400 |
+
ctx.lineTo(
|
| 401 |
+
this.x + Math.cos(this.angle) * dirLen,
|
| 402 |
+
this.y + Math.sin(this.angle) * dirLen,
|
| 403 |
+
);
|
| 404 |
+
ctx.stroke();
|
| 405 |
+
|
| 406 |
+
if (energyRatio < 0.9) {
|
| 407 |
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.3)`;
|
| 408 |
+
ctx.lineWidth = 2;
|
| 409 |
+
ctx.beginPath();
|
| 410 |
+
ctx.arc(
|
| 411 |
+
this.x,
|
| 412 |
+
this.y,
|
| 413 |
+
this.size + 3,
|
| 414 |
+
-Math.PI / 2,
|
| 415 |
+
-Math.PI / 2 + TAU * energyRatio,
|
| 416 |
+
);
|
| 417 |
+
ctx.stroke();
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
if (this.health < this.maxHealth * 0.9) {
|
| 421 |
+
const barW = this.size * 3;
|
| 422 |
+
const barH = 2.5;
|
| 423 |
+
const barX = this.x - barW / 2;
|
| 424 |
+
const barY = this.y - this.size - 8;
|
| 425 |
+
ctx.fillStyle = "rgba(60,20,20,0.6)";
|
| 426 |
+
ctx.fillRect(barX, barY, barW, barH);
|
| 427 |
+
const healthRatio = this.health / this.maxHealth;
|
| 428 |
+
ctx.fillStyle =
|
| 429 |
+
healthRatio > 0.5
|
| 430 |
+
? `rgba(0,255,80,0.7)`
|
| 431 |
+
: `rgba(255,${Math.floor(healthRatio * 2 * 255)},80,0.7)`;
|
| 432 |
+
ctx.fillRect(barX, barY, barW * healthRatio, barH);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
if (this.signalStrength > 0.4) {
|
| 436 |
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${this.signalStrength * 0.2})`;
|
| 437 |
+
ctx.lineWidth = 1;
|
| 438 |
+
ctx.beginPath();
|
| 439 |
+
ctx.arc(this.x, this.y, this.size + 8 + this.signalStrength * 5, 0, TAU);
|
| 440 |
+
ctx.stroke();
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
_drawTriangle(ctx, cx, cy, s) {
|
| 445 |
+
ctx.beginPath();
|
| 446 |
+
ctx.moveTo(cx, cy - s);
|
| 447 |
+
ctx.lineTo(cx - s * 0.87, cy + s * 0.5);
|
| 448 |
+
ctx.lineTo(cx + s * 0.87, cy + s * 0.5);
|
| 449 |
+
ctx.closePath();
|
| 450 |
+
ctx.fill();
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
_drawDiamond(ctx, cx, cy, s) {
|
| 454 |
+
ctx.beginPath();
|
| 455 |
+
ctx.moveTo(cx, cy - s);
|
| 456 |
+
ctx.lineTo(cx + s, cy);
|
| 457 |
+
ctx.lineTo(cx, cy + s);
|
| 458 |
+
ctx.lineTo(cx - s, cy);
|
| 459 |
+
ctx.closePath();
|
| 460 |
+
ctx.fill();
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
_drawStar(ctx, cx, cy, r, points) {
|
| 464 |
+
ctx.beginPath();
|
| 465 |
+
for (let i = 0; i < points * 2; i++) {
|
| 466 |
+
const rad = i % 2 === 0 ? r : r * 0.4;
|
| 467 |
+
const angle = (i * Math.PI) / points - Math.PI / 2;
|
| 468 |
+
const px = cx + Math.cos(angle) * rad;
|
| 469 |
+
const py = cy + Math.sin(angle) * rad;
|
| 470 |
+
if (i === 0) ctx.moveTo(px, py);
|
| 471 |
+
else ctx.lineTo(px, py);
|
| 472 |
+
}
|
| 473 |
+
ctx.closePath();
|
| 474 |
+
ctx.fill();
|
| 475 |
+
}
|
| 476 |
+
}
|
js/entityManager.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SpatialGrid, randomRange } from "./utils.js";
|
| 2 |
+
import { ENTITY_TYPES, createGenome, reproduce } from "./genetics.js";
|
| 3 |
+
import { Entity } from "./entities.js";
|
| 4 |
+
|
| 5 |
+
export class EntityManager {
|
| 6 |
+
constructor(worldWidth, worldHeight) {
|
| 7 |
+
this.entities = [];
|
| 8 |
+
this.spatialGrid = new SpatialGrid(worldWidth, worldHeight, 60);
|
| 9 |
+
this.worldWidth = worldWidth;
|
| 10 |
+
this.worldHeight = worldHeight;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
add(entity) {
|
| 14 |
+
this.entities.push(entity);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
update(dt, world, particles, stats) {
|
| 18 |
+
this.spatialGrid.clear();
|
| 19 |
+
for (const e of this.entities) {
|
| 20 |
+
if (e.alive) this.spatialGrid.insert(e, e.x, e.y);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const newborns = [];
|
| 24 |
+
for (const e of this.entities) {
|
| 25 |
+
e.update(dt, world, this.entities, this.spatialGrid, particles);
|
| 26 |
+
|
| 27 |
+
const child = e.tryReproduce(this.spatialGrid, particles);
|
| 28 |
+
if (child) {
|
| 29 |
+
newborns.push(child);
|
| 30 |
+
stats.onBirth();
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
for (const child of newborns) {
|
| 35 |
+
this.entities.push(child);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
for (let i = this.entities.length - 1; i >= 0; i--) {
|
| 39 |
+
if (!this.entities[i].alive) {
|
| 40 |
+
stats.onDeath();
|
| 41 |
+
this.entities.splice(i, 1);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
render(ctx) {
|
| 47 |
+
this.entities.sort((a, b) => a.y - b.y);
|
| 48 |
+
for (const e of this.entities) {
|
| 49 |
+
e.render(ctx);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
getAlive() {
|
| 54 |
+
return this.entities.filter((e) => e.alive);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
spawnRandom(count, world) {
|
| 58 |
+
const types = Object.keys(ENTITY_TYPES);
|
| 59 |
+
for (let i = 0; i < count; i++) {
|
| 60 |
+
const type = types[Math.floor(Math.random() * types.length)];
|
| 61 |
+
const genome = createGenome(type);
|
| 62 |
+
const x = randomRange(20, world.width - 20);
|
| 63 |
+
const y = randomRange(20, world.height - 20);
|
| 64 |
+
this.entities.push(new Entity(x, y, genome, 0));
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
spawnFromPopulation(population, count, world) {
|
| 69 |
+
if (population.length < 2) return;
|
| 70 |
+
const candidates = population.map((e) => ({
|
| 71 |
+
genome: e.genome,
|
| 72 |
+
fitness: e.fitness,
|
| 73 |
+
}));
|
| 74 |
+
for (let i = 0; i < count; i++) {
|
| 75 |
+
const childGenome = reproduce(candidates);
|
| 76 |
+
const x = randomRange(20, world.width - 20);
|
| 77 |
+
const y = randomRange(20, world.height - 20);
|
| 78 |
+
const gen = Math.max(...population.map((e) => e.generation)) + 1;
|
| 79 |
+
this.entities.push(new Entity(x, y, childGenome, gen));
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
}
|
js/genetics.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NeuralNetwork } from "./neuralNetwork.js";
|
| 2 |
+
import { randomRange, randomGaussian, clamp } from "./utils.js";
|
| 3 |
+
|
| 4 |
+
/** Neural network topology shared by all entities */
|
| 5 |
+
export const NET_TOPOLOGY = [14, 12, 8];
|
| 6 |
+
|
| 7 |
+
/** Entity archetype definitions */
|
| 8 |
+
export const ENTITY_TYPES = {
|
| 9 |
+
gatherer: {
|
| 10 |
+
name: "Gatherer",
|
| 11 |
+
baseTraits: {
|
| 12 |
+
speed: 2.5,
|
| 13 |
+
size: 8,
|
| 14 |
+
vision: 120,
|
| 15 |
+
attack: 0.2,
|
| 16 |
+
defense: 0.5,
|
| 17 |
+
metabolism: 0.8,
|
| 18 |
+
},
|
| 19 |
+
color: "#00ff88",
|
| 20 |
+
fitnessWeights: { survival: 1.0, energy: 2.0, offspring: 1.5, kills: 0.0 },
|
| 21 |
+
description: "Herbivores that seek food resources",
|
| 22 |
+
},
|
| 23 |
+
predator: {
|
| 24 |
+
name: "Predator",
|
| 25 |
+
baseTraits: {
|
| 26 |
+
speed: 3.5,
|
| 27 |
+
size: 10,
|
| 28 |
+
vision: 140,
|
| 29 |
+
attack: 1.5,
|
| 30 |
+
defense: 0.3,
|
| 31 |
+
metabolism: 1.2,
|
| 32 |
+
},
|
| 33 |
+
color: "#ff3366",
|
| 34 |
+
fitnessWeights: { survival: 0.5, energy: 1.0, offspring: 1.0, kills: 2.5 },
|
| 35 |
+
description: "Carnivores that hunt other entities",
|
| 36 |
+
},
|
| 37 |
+
builder: {
|
| 38 |
+
name: "Builder",
|
| 39 |
+
baseTraits: {
|
| 40 |
+
speed: 1.8,
|
| 41 |
+
size: 9,
|
| 42 |
+
vision: 100,
|
| 43 |
+
attack: 0.1,
|
| 44 |
+
defense: 1.5,
|
| 45 |
+
metabolism: 0.6,
|
| 46 |
+
},
|
| 47 |
+
color: "#3399ff",
|
| 48 |
+
fitnessWeights: { survival: 2.0, energy: 1.5, offspring: 2.0, kills: 0.0 },
|
| 49 |
+
description: "Defensive entities that build and fortify",
|
| 50 |
+
},
|
| 51 |
+
explorer: {
|
| 52 |
+
name: "Explorer",
|
| 53 |
+
baseTraits: {
|
| 54 |
+
speed: 4.0,
|
| 55 |
+
size: 6,
|
| 56 |
+
vision: 180,
|
| 57 |
+
attack: 0.3,
|
| 58 |
+
defense: 0.3,
|
| 59 |
+
metabolism: 1.0,
|
| 60 |
+
},
|
| 61 |
+
color: "#ffaa00",
|
| 62 |
+
fitnessWeights: { survival: 1.5, energy: 1.0, offspring: 1.0, kills: 0.5 },
|
| 63 |
+
description: "Fast scouts with wide vision range",
|
| 64 |
+
},
|
| 65 |
+
hybrid: {
|
| 66 |
+
name: "Hybrid",
|
| 67 |
+
baseTraits: {
|
| 68 |
+
speed: 3.0,
|
| 69 |
+
size: 8,
|
| 70 |
+
vision: 130,
|
| 71 |
+
attack: 0.8,
|
| 72 |
+
defense: 0.8,
|
| 73 |
+
metabolism: 1.0,
|
| 74 |
+
},
|
| 75 |
+
color: "#cc66ff",
|
| 76 |
+
fitnessWeights: { survival: 1.0, energy: 1.0, offspring: 1.5, kills: 1.0 },
|
| 77 |
+
description: "Balanced generalists that adapt to conditions",
|
| 78 |
+
},
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
/** Trait constraints to prevent runaway evolution */
|
| 82 |
+
const TRAIT_BOUNDS = {
|
| 83 |
+
speed: { min: 0.5, max: 8.0 },
|
| 84 |
+
size: { min: 2.0, max: 14.0 },
|
| 85 |
+
vision: { min: 30, max: 250 },
|
| 86 |
+
attack: { min: 0.0, max: 3.0 },
|
| 87 |
+
defense: { min: 0.0, max: 3.0 },
|
| 88 |
+
metabolism: { min: 0.2, max: 2.5 },
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Genome encapsulates an entity's heritable traits and neural weights.
|
| 93 |
+
*/
|
| 94 |
+
export class Genome {
|
| 95 |
+
/**
|
| 96 |
+
* @param {string} type - Entity archetype key
|
| 97 |
+
* @param {object} [traits] - Physical traits (or auto-generated)
|
| 98 |
+
* @param {number[]} [neuralWeights] - Flat weight array (or auto-generated)
|
| 99 |
+
*/
|
| 100 |
+
constructor(type, traits = null, neuralWeights = null) {
|
| 101 |
+
this.type = type;
|
| 102 |
+
const base = ENTITY_TYPES[type].baseTraits;
|
| 103 |
+
|
| 104 |
+
if (traits) {
|
| 105 |
+
this.traits = { ...traits };
|
| 106 |
+
} else {
|
| 107 |
+
this.traits = {};
|
| 108 |
+
for (const key of Object.keys(base)) {
|
| 109 |
+
const variance = base[key] * 0.15;
|
| 110 |
+
this.traits[key] = clamp(
|
| 111 |
+
base[key] + randomGaussian(0, variance),
|
| 112 |
+
TRAIT_BOUNDS[key].min,
|
| 113 |
+
TRAIT_BOUNDS[key].max,
|
| 114 |
+
);
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
if (neuralWeights) {
|
| 119 |
+
this.neuralWeights = neuralWeights.slice();
|
| 120 |
+
} else {
|
| 121 |
+
const tempNet = new NeuralNetwork(NET_TOPOLOGY);
|
| 122 |
+
this.neuralWeights = tempNet.serialize();
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/** Communication symbol preference (0-7), evolvable */
|
| 126 |
+
this.signalPreference = traits
|
| 127 |
+
? traits._signal || Math.floor(Math.random() * 8)
|
| 128 |
+
: Math.floor(Math.random() * 8);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/** Create a brain (NeuralNetwork) from this genome's stored weights */
|
| 132 |
+
buildBrain() {
|
| 133 |
+
const nn = new NeuralNetwork(NET_TOPOLOGY);
|
| 134 |
+
nn.deserialize(this.neuralWeights);
|
| 135 |
+
return nn;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/** Deep copy */
|
| 139 |
+
clone() {
|
| 140 |
+
const g = new Genome(
|
| 141 |
+
this.type,
|
| 142 |
+
{ ...this.traits },
|
| 143 |
+
this.neuralWeights.slice(),
|
| 144 |
+
);
|
| 145 |
+
g.signalPreference = this.signalPreference;
|
| 146 |
+
return g;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Single-point crossover of two genomes.
|
| 151 |
+
* Child inherits type from the fitter parent.
|
| 152 |
+
* @param {Genome} other
|
| 153 |
+
* @param {string} [childType] - Override child type
|
| 154 |
+
* @returns {Genome}
|
| 155 |
+
*/
|
| 156 |
+
crossover(other, childType = null) {
|
| 157 |
+
const type = childType || this.type;
|
| 158 |
+
|
| 159 |
+
const childTraits = {};
|
| 160 |
+
for (const key of Object.keys(this.traits)) {
|
| 161 |
+
if (key === "_signal") continue;
|
| 162 |
+
const t = Math.random();
|
| 163 |
+
const raw = this.traits[key] * t + other.traits[key] * (1 - t);
|
| 164 |
+
childTraits[key] = clamp(
|
| 165 |
+
raw,
|
| 166 |
+
TRAIT_BOUNDS[key].min,
|
| 167 |
+
TRAIT_BOUNDS[key].max,
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const w1 = this.neuralWeights;
|
| 172 |
+
const w2 = other.neuralWeights;
|
| 173 |
+
const len = Math.min(w1.length, w2.length);
|
| 174 |
+
const childWeights = new Array(len);
|
| 175 |
+
const crossPoint = Math.floor(Math.random() * len);
|
| 176 |
+
for (let i = 0; i < len; i++) {
|
| 177 |
+
childWeights[i] = i < crossPoint ? w1[i] : w2[i];
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const child = new Genome(type, childTraits, childWeights);
|
| 181 |
+
|
| 182 |
+
child.signalPreference =
|
| 183 |
+
Math.random() < 0.5 ? this.signalPreference : other.signalPreference;
|
| 184 |
+
|
| 185 |
+
return child;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* Apply mutations to traits and weights.
|
| 190 |
+
* @param {number} traitRate - Probability per trait (e.g. 0.3)
|
| 191 |
+
* @param {number} weightRate - Probability per weight (e.g. 0.05)
|
| 192 |
+
* @param {number} magnitude - Gaussian std for weight mutation
|
| 193 |
+
*/
|
| 194 |
+
mutate(traitRate = 0.3, weightRate = 0.05, magnitude = 0.3) {
|
| 195 |
+
const base = ENTITY_TYPES[this.type].baseTraits;
|
| 196 |
+
|
| 197 |
+
for (const key of Object.keys(this.traits)) {
|
| 198 |
+
if (key === "_signal") continue;
|
| 199 |
+
if (Math.random() < traitRate) {
|
| 200 |
+
const scale = base[key] * 0.1;
|
| 201 |
+
this.traits[key] = clamp(
|
| 202 |
+
this.traits[key] + randomGaussian(0, scale),
|
| 203 |
+
TRAIT_BOUNDS[key].min,
|
| 204 |
+
TRAIT_BOUNDS[key].max,
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
for (let i = 0; i < this.neuralWeights.length; i++) {
|
| 210 |
+
if (Math.random() < weightRate) {
|
| 211 |
+
this.neuralWeights[i] += randomGaussian(0, magnitude);
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
if (Math.random() < 0.05) {
|
| 216 |
+
this.signalPreference = Math.floor(Math.random() * 8);
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/** Serialize to a plain object (for LocalStorage) */
|
| 221 |
+
toJSON() {
|
| 222 |
+
return {
|
| 223 |
+
type: this.type,
|
| 224 |
+
traits: { ...this.traits },
|
| 225 |
+
neuralWeights: this.neuralWeights,
|
| 226 |
+
signalPreference: this.signalPreference,
|
| 227 |
+
};
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/** Deserialize from a plain object */
|
| 231 |
+
static fromJSON(data) {
|
| 232 |
+
const g = new Genome(data.type, data.traits, data.neuralWeights);
|
| 233 |
+
g.signalPreference = data.signalPreference || 0;
|
| 234 |
+
return g;
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/**
|
| 239 |
+
* Create a new random genome of a given type.
|
| 240 |
+
* @param {string} type - Archetype key
|
| 241 |
+
* @returns {Genome}
|
| 242 |
+
*/
|
| 243 |
+
export function createGenome(type) {
|
| 244 |
+
return new Genome(type);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/**
|
| 248 |
+
* Tournament selection: pick the fittest from a random subset.
|
| 249 |
+
* @param {Array<{genome: Genome, fitness: number}>} population
|
| 250 |
+
* @param {number} tournamentSize
|
| 251 |
+
* @returns {Genome}
|
| 252 |
+
*/
|
| 253 |
+
export function tournamentSelect(population, tournamentSize = 3) {
|
| 254 |
+
let best = null;
|
| 255 |
+
let bestFitness = -Infinity;
|
| 256 |
+
for (let i = 0; i < tournamentSize; i++) {
|
| 257 |
+
const idx = Math.floor(Math.random() * population.length);
|
| 258 |
+
const candidate = population[idx];
|
| 259 |
+
if (candidate.fitness > bestFitness) {
|
| 260 |
+
best = candidate;
|
| 261 |
+
bestFitness = candidate.fitness;
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
return best.genome;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/**
|
| 268 |
+
* Calculate fitness score for an entity's lifetime stats.
|
| 269 |
+
* @param {string} type - Entity type
|
| 270 |
+
* @param {object} stats - { survivalTime, energyGathered, offspring, kills }
|
| 271 |
+
* @returns {number}
|
| 272 |
+
*/
|
| 273 |
+
export function calculateFitness(type, stats) {
|
| 274 |
+
const w = ENTITY_TYPES[type].fitnessWeights;
|
| 275 |
+
return (
|
| 276 |
+
w.survival * stats.survivalTime +
|
| 277 |
+
w.energy * stats.energyGathered +
|
| 278 |
+
w.offspring * stats.offspring * 10 +
|
| 279 |
+
w.kills * stats.kills * 5
|
| 280 |
+
);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/**
|
| 284 |
+
* Measure genetic diversity as average pairwise trait distance.
|
| 285 |
+
* @param {Genome[]} genomes
|
| 286 |
+
* @returns {number} 0-1 normalized diversity score
|
| 287 |
+
*/
|
| 288 |
+
export function measureDiversity(genomes) {
|
| 289 |
+
if (genomes.length < 2) return 0;
|
| 290 |
+
|
| 291 |
+
const traitKeys = Object.keys(TRAIT_BOUNDS);
|
| 292 |
+
let totalDist = 0;
|
| 293 |
+
let pairs = 0;
|
| 294 |
+
|
| 295 |
+
const sampleSize = Math.min(genomes.length, 30);
|
| 296 |
+
for (let i = 0; i < sampleSize; i++) {
|
| 297 |
+
for (let j = i + 1; j < sampleSize; j++) {
|
| 298 |
+
let dist = 0;
|
| 299 |
+
for (const key of traitKeys) {
|
| 300 |
+
const range = TRAIT_BOUNDS[key].max - TRAIT_BOUNDS[key].min;
|
| 301 |
+
const diff =
|
| 302 |
+
Math.abs(genomes[i].traits[key] - genomes[j].traits[key]) / range;
|
| 303 |
+
dist += diff * diff;
|
| 304 |
+
}
|
| 305 |
+
totalDist += Math.sqrt(dist / traitKeys.length);
|
| 306 |
+
pairs++;
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
return pairs > 0 ? totalDist / pairs : 0;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* Produce a child genome from the population using selection + crossover + mutation.
|
| 315 |
+
* @param {Array<{genome: Genome, fitness: number}>} population
|
| 316 |
+
* @returns {Genome}
|
| 317 |
+
*/
|
| 318 |
+
export function reproduce(population) {
|
| 319 |
+
const parent1 = tournamentSelect(population, 3);
|
| 320 |
+
const parent2 = tournamentSelect(population, 3);
|
| 321 |
+
const child = parent1.crossover(parent2);
|
| 322 |
+
child.mutate(0.25, 0.05, 0.2);
|
| 323 |
+
return child;
|
| 324 |
+
}
|
js/neuralNetwork.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Lightweight feedforward neural network for entity decision-making.
|
| 3 |
+
* Supports arbitrary topology, weight serialization, mutation, and
|
| 4 |
+
* activation visualization for the HUD neural activity overlay.
|
| 5 |
+
*/
|
| 6 |
+
export class NeuralNetwork {
|
| 7 |
+
/**
|
| 8 |
+
* @param {number[]} topology - Layer sizes, e.g. [12, 10, 6]
|
| 9 |
+
*/
|
| 10 |
+
constructor(topology) {
|
| 11 |
+
this.topology = topology;
|
| 12 |
+
this.weights = [];
|
| 13 |
+
this.biases = [];
|
| 14 |
+
this.activations = [];
|
| 15 |
+
this._initWeights();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/** Xavier-style initialization for stable gradient flow */
|
| 19 |
+
_initWeights() {
|
| 20 |
+
for (let i = 1; i < this.topology.length; i++) {
|
| 21 |
+
const fanIn = this.topology[i - 1];
|
| 22 |
+
const fanOut = this.topology[i];
|
| 23 |
+
const scale = Math.sqrt(2 / (fanIn + fanOut));
|
| 24 |
+
const layerW = new Array(fanOut);
|
| 25 |
+
const layerB = new Array(fanOut);
|
| 26 |
+
for (let j = 0; j < fanOut; j++) {
|
| 27 |
+
layerW[j] = new Array(fanIn);
|
| 28 |
+
for (let k = 0; k < fanIn; k++) {
|
| 29 |
+
layerW[j][k] = (Math.random() * 2 - 1) * scale;
|
| 30 |
+
}
|
| 31 |
+
layerB[j] = (Math.random() * 2 - 1) * 0.1;
|
| 32 |
+
}
|
| 33 |
+
this.weights.push(layerW);
|
| 34 |
+
this.biases.push(layerB);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Run a forward pass through the network.
|
| 40 |
+
* @param {number[]} inputs - Input activations
|
| 41 |
+
* @returns {number[]} Output activations
|
| 42 |
+
*/
|
| 43 |
+
forward(inputs) {
|
| 44 |
+
let current = inputs;
|
| 45 |
+
this.activations = [inputs.slice()];
|
| 46 |
+
|
| 47 |
+
for (let layer = 0; layer < this.weights.length; layer++) {
|
| 48 |
+
const w = this.weights[layer];
|
| 49 |
+
const b = this.biases[layer];
|
| 50 |
+
const numNeurons = w.length;
|
| 51 |
+
const next = new Array(numNeurons);
|
| 52 |
+
const isOutput = layer === this.weights.length - 1;
|
| 53 |
+
|
| 54 |
+
for (let j = 0; j < numNeurons; j++) {
|
| 55 |
+
let sum = b[j];
|
| 56 |
+
const wj = w[j];
|
| 57 |
+
for (let k = 0; k < current.length; k++) {
|
| 58 |
+
sum += wj[k] * current[k];
|
| 59 |
+
}
|
| 60 |
+
next[j] = Math.tanh(sum);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
current = next;
|
| 64 |
+
this.activations.push(current.slice());
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return current;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/** Count total trainable parameters */
|
| 71 |
+
get totalWeights() {
|
| 72 |
+
let count = 0;
|
| 73 |
+
for (let i = 0; i < this.weights.length; i++) {
|
| 74 |
+
count += this.weights[i].length * this.weights[i][0].length;
|
| 75 |
+
count += this.biases[i].length;
|
| 76 |
+
}
|
| 77 |
+
return count;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** Flatten all weights + biases into a single array */
|
| 81 |
+
serialize() {
|
| 82 |
+
const flat = new Array(this.totalWeights);
|
| 83 |
+
let idx = 0;
|
| 84 |
+
for (let i = 0; i < this.weights.length; i++) {
|
| 85 |
+
const w = this.weights[i];
|
| 86 |
+
for (let j = 0; j < w.length; j++) {
|
| 87 |
+
for (let k = 0; k < w[j].length; k++) {
|
| 88 |
+
flat[idx++] = w[j][k];
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
const b = this.biases[i];
|
| 92 |
+
for (let j = 0; j < b.length; j++) {
|
| 93 |
+
flat[idx++] = b[j];
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
return flat;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/** Restore weights from a flat array */
|
| 100 |
+
deserialize(flat) {
|
| 101 |
+
let idx = 0;
|
| 102 |
+
for (let i = 0; i < this.weights.length; i++) {
|
| 103 |
+
const w = this.weights[i];
|
| 104 |
+
for (let j = 0; j < w.length; j++) {
|
| 105 |
+
for (let k = 0; k < w[j].length; k++) {
|
| 106 |
+
w[j][k] = flat[idx++];
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
const b = this.biases[i];
|
| 110 |
+
for (let j = 0; j < b.length; j++) {
|
| 111 |
+
b[j] = flat[idx++];
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/** Create an exact copy of this network */
|
| 117 |
+
clone() {
|
| 118 |
+
const nn = new NeuralNetwork(this.topology.slice());
|
| 119 |
+
nn.deserialize(this.serialize());
|
| 120 |
+
return nn;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Apply Gaussian mutations to a fraction of weights.
|
| 125 |
+
* @param {number} rate - Probability of mutating each weight (0-1)
|
| 126 |
+
* @param {number} magnitude - Standard deviation of mutation noise
|
| 127 |
+
*/
|
| 128 |
+
mutate(rate, magnitude) {
|
| 129 |
+
for (let i = 0; i < this.weights.length; i++) {
|
| 130 |
+
const w = this.weights[i];
|
| 131 |
+
for (let j = 0; j < w.length; j++) {
|
| 132 |
+
for (let k = 0; k < w[j].length; k++) {
|
| 133 |
+
if (Math.random() < rate) {
|
| 134 |
+
w[j][k] += gaussianNoise() * magnitude;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
const b = this.biases[i];
|
| 139 |
+
for (let j = 0; j < b.length; j++) {
|
| 140 |
+
if (Math.random() < rate) {
|
| 141 |
+
b[j] += gaussianNoise() * magnitude;
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* Uniform crossover between two networks of the same topology.
|
| 149 |
+
* @param {NeuralNetwork} other
|
| 150 |
+
* @returns {NeuralNetwork} child network
|
| 151 |
+
*/
|
| 152 |
+
crossover(other) {
|
| 153 |
+
const child = new NeuralNetwork(this.topology.slice());
|
| 154 |
+
const w1 = this.serialize();
|
| 155 |
+
const w2 = other.serialize();
|
| 156 |
+
const childW = new Array(w1.length);
|
| 157 |
+
|
| 158 |
+
const crossPoint = Math.floor(Math.random() * w1.length);
|
| 159 |
+
for (let i = 0; i < w1.length; i++) {
|
| 160 |
+
childW[i] = i < crossPoint ? w1[i] : w2[i];
|
| 161 |
+
}
|
| 162 |
+
child.deserialize(childW);
|
| 163 |
+
return child;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/**
|
| 167 |
+
* Get the maximum absolute activation across all layers.
|
| 168 |
+
* Useful for visualization intensity scaling.
|
| 169 |
+
*/
|
| 170 |
+
getMaxActivation() {
|
| 171 |
+
let max = 0;
|
| 172 |
+
for (const layer of this.activations) {
|
| 173 |
+
for (const v of layer) {
|
| 174 |
+
const abs = Math.abs(v);
|
| 175 |
+
if (abs > max) max = abs;
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
return max;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Render a mini neural network diagram to a canvas context.
|
| 183 |
+
* @param {CanvasRenderingContext2D} ctx
|
| 184 |
+
* @param {number} x - Top-left x
|
| 185 |
+
* @param {number} y - Top-left y
|
| 186 |
+
* @param {number} w - Width
|
| 187 |
+
* @param {number} h - Height
|
| 188 |
+
*/
|
| 189 |
+
renderMini(ctx, x, y, w, h) {
|
| 190 |
+
const layers = this.topology.length;
|
| 191 |
+
const layerSpacing = w / (layers - 1);
|
| 192 |
+
|
| 193 |
+
ctx.lineWidth = 0.5;
|
| 194 |
+
for (let l = 0; l < this.weights.length; l++) {
|
| 195 |
+
const fromCount = this.topology[l];
|
| 196 |
+
const toCount = this.topology[l + 1];
|
| 197 |
+
const fromX = x + l * layerSpacing;
|
| 198 |
+
const toX = x + (l + 1) * layerSpacing;
|
| 199 |
+
|
| 200 |
+
for (let j = 0; j < toCount; j++) {
|
| 201 |
+
const toY = y + (j + 0.5) * (h / toCount);
|
| 202 |
+
for (let k = 0; k < fromCount; k++) {
|
| 203 |
+
const fromY = y + (k + 0.5) * (h / fromCount);
|
| 204 |
+
const weight = this.weights[l][j][k];
|
| 205 |
+
const alpha = Math.min(Math.abs(weight) * 0.5, 0.6);
|
| 206 |
+
ctx.strokeStyle =
|
| 207 |
+
weight > 0
|
| 208 |
+
? `rgba(0, 240, 255, ${alpha})`
|
| 209 |
+
: `rgba(255, 51, 102, ${alpha})`;
|
| 210 |
+
ctx.beginPath();
|
| 211 |
+
ctx.moveTo(fromX, fromY);
|
| 212 |
+
ctx.lineTo(toX, toY);
|
| 213 |
+
ctx.stroke();
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
for (let l = 0; l < layers; l++) {
|
| 219 |
+
const count = this.topology[l];
|
| 220 |
+
const lx = x + l * layerSpacing;
|
| 221 |
+
const act = this.activations[l] || [];
|
| 222 |
+
|
| 223 |
+
for (let n = 0; n < count; n++) {
|
| 224 |
+
const ny = y + (n + 0.5) * (h / count);
|
| 225 |
+
const val = act[n] || 0;
|
| 226 |
+
const intensity = Math.abs(val);
|
| 227 |
+
const r = Math.max(2, 4 - layers * 0.3);
|
| 228 |
+
|
| 229 |
+
ctx.beginPath();
|
| 230 |
+
ctx.arc(lx, ny, r, 0, Math.PI * 2);
|
| 231 |
+
ctx.fillStyle =
|
| 232 |
+
val > 0
|
| 233 |
+
? `rgba(0, 240, 255, ${0.3 + intensity * 0.7})`
|
| 234 |
+
: `rgba(255, 51, 102, ${0.3 + intensity * 0.7})`;
|
| 235 |
+
ctx.fill();
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/** Gaussian noise using Box-Muller transform */
|
| 242 |
+
function gaussianNoise() {
|
| 243 |
+
let u = 0,
|
| 244 |
+
v = 0;
|
| 245 |
+
while (u === 0) u = Math.random();
|
| 246 |
+
while (v === 0) v = Math.random();
|
| 247 |
+
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* QuantumDecisionLayer - probabilistic branching for elite entities.
|
| 252 |
+
* Samples from a probability distribution over actions rather than
|
| 253 |
+
* taking the deterministic argmax.
|
| 254 |
+
*/
|
| 255 |
+
export class QuantumDecisionLayer {
|
| 256 |
+
/**
|
| 257 |
+
* @param {number} temperature - Controls exploration vs exploitation.
|
| 258 |
+
* Higher = more random, lower = more greedy.
|
| 259 |
+
*/
|
| 260 |
+
constructor(temperature = 1.0) {
|
| 261 |
+
this.temperature = temperature;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/**
|
| 265 |
+
* Apply softmax with temperature to raw outputs and sample.
|
| 266 |
+
* @param {number[]} logits - Raw network outputs
|
| 267 |
+
* @returns {{ action: number, probabilities: number[] }}
|
| 268 |
+
*/
|
| 269 |
+
sample(logits) {
|
| 270 |
+
const t = this.temperature;
|
| 271 |
+
const maxLogit = Math.max(...logits);
|
| 272 |
+
const exps = logits.map((l) => Math.exp((l - maxLogit) / t));
|
| 273 |
+
const sum = exps.reduce((a, b) => a + b, 0);
|
| 274 |
+
const probs = exps.map((e) => e / sum);
|
| 275 |
+
|
| 276 |
+
const r = Math.random();
|
| 277 |
+
let cumulative = 0;
|
| 278 |
+
for (let i = 0; i < probs.length; i++) {
|
| 279 |
+
cumulative += probs[i];
|
| 280 |
+
if (r <= cumulative) {
|
| 281 |
+
return { action: i, probabilities: probs };
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
return { action: probs.length - 1, probabilities: probs };
|
| 285 |
+
}
|
| 286 |
+
}
|
js/particles.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ObjectPool, TAU, randomRange, lerp } from "./utils.js";
|
| 2 |
+
|
| 3 |
+
class Particle {
|
| 4 |
+
constructor() {
|
| 5 |
+
this.reset();
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
reset() {
|
| 9 |
+
this.x = 0;
|
| 10 |
+
this.y = 0;
|
| 11 |
+
this.vx = 0;
|
| 12 |
+
this.vy = 0;
|
| 13 |
+
this.life = 0;
|
| 14 |
+
this.maxLife = 1;
|
| 15 |
+
this.size = 2;
|
| 16 |
+
this.r = 255;
|
| 17 |
+
this.g = 255;
|
| 18 |
+
this.b = 255;
|
| 19 |
+
this.alpha = 1;
|
| 20 |
+
this.decay = 1;
|
| 21 |
+
this.friction = 0.98;
|
| 22 |
+
this.gravity = 0;
|
| 23 |
+
this.type = "circle";
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const PRESETS = {
|
| 28 |
+
eat: {
|
| 29 |
+
count: 6,
|
| 30 |
+
speed: [20, 50],
|
| 31 |
+
life: [0.3, 0.6],
|
| 32 |
+
size: [1.5, 3],
|
| 33 |
+
color: [0, 255, 170],
|
| 34 |
+
friction: 0.92,
|
| 35 |
+
type: "circle",
|
| 36 |
+
},
|
| 37 |
+
attack: {
|
| 38 |
+
count: 10,
|
| 39 |
+
speed: [40, 80],
|
| 40 |
+
life: [0.2, 0.4],
|
| 41 |
+
size: [1.5, 3.5],
|
| 42 |
+
color: [255, 51, 102],
|
| 43 |
+
friction: 0.9,
|
| 44 |
+
type: "spark",
|
| 45 |
+
},
|
| 46 |
+
reproduce: {
|
| 47 |
+
count: 14,
|
| 48 |
+
speed: [30, 70],
|
| 49 |
+
life: [0.5, 1.0],
|
| 50 |
+
size: [2, 5],
|
| 51 |
+
color: [204, 102, 255],
|
| 52 |
+
friction: 0.94,
|
| 53 |
+
type: "circle",
|
| 54 |
+
},
|
| 55 |
+
death: {
|
| 56 |
+
count: 20,
|
| 57 |
+
speed: [20, 60],
|
| 58 |
+
life: [0.6, 1.2],
|
| 59 |
+
size: [2, 5],
|
| 60 |
+
color: [255, 100, 100],
|
| 61 |
+
friction: 0.96,
|
| 62 |
+
gravity: 15,
|
| 63 |
+
type: "spark",
|
| 64 |
+
},
|
| 65 |
+
energy: {
|
| 66 |
+
count: 4,
|
| 67 |
+
speed: [10, 30],
|
| 68 |
+
life: [0.4, 0.8],
|
| 69 |
+
size: [2, 4],
|
| 70 |
+
color: [255, 238, 68],
|
| 71 |
+
friction: 0.95,
|
| 72 |
+
type: "circle",
|
| 73 |
+
},
|
| 74 |
+
signal: {
|
| 75 |
+
count: 3,
|
| 76 |
+
speed: [5, 15],
|
| 77 |
+
life: [0.5, 0.8],
|
| 78 |
+
size: [1.5, 3],
|
| 79 |
+
color: [100, 200, 255],
|
| 80 |
+
friction: 0.97,
|
| 81 |
+
type: "circle",
|
| 82 |
+
},
|
| 83 |
+
storm: {
|
| 84 |
+
count: 1,
|
| 85 |
+
speed: [30, 80],
|
| 86 |
+
life: [0.8, 1.5],
|
| 87 |
+
size: [1, 2.5],
|
| 88 |
+
color: [255, 238, 68],
|
| 89 |
+
friction: 0.99,
|
| 90 |
+
gravity: 40,
|
| 91 |
+
type: "spark",
|
| 92 |
+
},
|
| 93 |
+
bloom: {
|
| 94 |
+
count: 1,
|
| 95 |
+
speed: [10, 40],
|
| 96 |
+
life: [0.6, 1.2],
|
| 97 |
+
size: [2, 4],
|
| 98 |
+
color: [0, 255, 170],
|
| 99 |
+
friction: 0.96,
|
| 100 |
+
gravity: -5,
|
| 101 |
+
type: "circle",
|
| 102 |
+
},
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export class ParticleSystem {
|
| 106 |
+
constructor(maxParticles = 2000) {
|
| 107 |
+
this.maxParticles = maxParticles;
|
| 108 |
+
this.active = [];
|
| 109 |
+
this._pool = new ObjectPool(
|
| 110 |
+
() => new Particle(),
|
| 111 |
+
(p) => p.reset(),
|
| 112 |
+
maxParticles,
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
emit(x, y, preset, colorOverride = null) {
|
| 117 |
+
const config = typeof preset === "string" ? PRESETS[preset] : preset;
|
| 118 |
+
if (!config) return;
|
| 119 |
+
|
| 120 |
+
const count = config.count || 5;
|
| 121 |
+
for (let i = 0; i < count; i++) {
|
| 122 |
+
if (this.active.length >= this.maxParticles) {
|
| 123 |
+
const old = this.active.shift();
|
| 124 |
+
this._pool.release(old);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const p = this._pool.acquire();
|
| 128 |
+
const angle = Math.random() * TAU;
|
| 129 |
+
const speed = randomRange(config.speed[0], config.speed[1]);
|
| 130 |
+
|
| 131 |
+
p.x = x;
|
| 132 |
+
p.y = y;
|
| 133 |
+
p.vx = Math.cos(angle) * speed;
|
| 134 |
+
p.vy = Math.sin(angle) * speed;
|
| 135 |
+
p.maxLife = randomRange(config.life[0], config.life[1]);
|
| 136 |
+
p.life = p.maxLife;
|
| 137 |
+
p.size = randomRange(config.size[0], config.size[1]);
|
| 138 |
+
p.friction = config.friction || 0.95;
|
| 139 |
+
p.gravity = config.gravity || 0;
|
| 140 |
+
p.type = config.type || "circle";
|
| 141 |
+
|
| 142 |
+
if (colorOverride) {
|
| 143 |
+
p.r = colorOverride[0];
|
| 144 |
+
p.g = colorOverride[1];
|
| 145 |
+
p.b = colorOverride[2];
|
| 146 |
+
} else {
|
| 147 |
+
p.r = config.color[0];
|
| 148 |
+
p.g = config.color[1];
|
| 149 |
+
p.b = config.color[2];
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
this.active.push(p);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
emitTrail(x, y, r, g, b, size = 1.5) {
|
| 157 |
+
if (this.active.length >= this.maxParticles) {
|
| 158 |
+
const old = this.active.shift();
|
| 159 |
+
this._pool.release(old);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const p = this._pool.acquire();
|
| 163 |
+
p.x = x;
|
| 164 |
+
p.y = y;
|
| 165 |
+
p.vx = 0;
|
| 166 |
+
p.vy = 0;
|
| 167 |
+
p.maxLife = 0.4;
|
| 168 |
+
p.life = 0.4;
|
| 169 |
+
p.size = size;
|
| 170 |
+
p.r = r;
|
| 171 |
+
p.g = g;
|
| 172 |
+
p.b = b;
|
| 173 |
+
p.friction = 1.0;
|
| 174 |
+
p.type = "trail";
|
| 175 |
+
this.active.push(p);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
update(dt) {
|
| 179 |
+
for (let i = this.active.length - 1; i >= 0; i--) {
|
| 180 |
+
const p = this.active[i];
|
| 181 |
+
p.life -= dt;
|
| 182 |
+
|
| 183 |
+
if (p.life <= 0) {
|
| 184 |
+
this.active.splice(i, 1);
|
| 185 |
+
this._pool.release(p);
|
| 186 |
+
continue;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
p.vy += p.gravity * dt;
|
| 190 |
+
p.vx *= p.friction;
|
| 191 |
+
p.vy *= p.friction;
|
| 192 |
+
p.x += p.vx * dt;
|
| 193 |
+
p.y += p.vy * dt;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
render(ctx) {
|
| 198 |
+
if (this.active.length === 0) return;
|
| 199 |
+
|
| 200 |
+
ctx.save();
|
| 201 |
+
|
| 202 |
+
for (const p of this.active) {
|
| 203 |
+
const t = p.life / p.maxLife;
|
| 204 |
+
const alpha = t * 0.8;
|
| 205 |
+
const size = p.size * (0.3 + 0.7 * t);
|
| 206 |
+
|
| 207 |
+
switch (p.type) {
|
| 208 |
+
case "circle":
|
| 209 |
+
ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${alpha * 0.3})`;
|
| 210 |
+
ctx.beginPath();
|
| 211 |
+
ctx.arc(p.x, p.y, size * 2, 0, TAU);
|
| 212 |
+
ctx.fill();
|
| 213 |
+
ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${alpha})`;
|
| 214 |
+
ctx.beginPath();
|
| 215 |
+
ctx.arc(p.x, p.y, size, 0, TAU);
|
| 216 |
+
ctx.fill();
|
| 217 |
+
break;
|
| 218 |
+
|
| 219 |
+
case "spark": {
|
| 220 |
+
const len = Math.sqrt(p.vx * p.vx + p.vy * p.vy) * 0.05 + 2;
|
| 221 |
+
const angle = Math.atan2(p.vy, p.vx);
|
| 222 |
+
ctx.strokeStyle = `rgba(${p.r},${p.g},${p.b},${alpha})`;
|
| 223 |
+
ctx.lineWidth = size * 0.6;
|
| 224 |
+
ctx.beginPath();
|
| 225 |
+
ctx.moveTo(p.x - Math.cos(angle) * len, p.y - Math.sin(angle) * len);
|
| 226 |
+
ctx.lineTo(
|
| 227 |
+
p.x + Math.cos(angle) * len * 0.3,
|
| 228 |
+
p.y + Math.sin(angle) * len * 0.3,
|
| 229 |
+
);
|
| 230 |
+
ctx.stroke();
|
| 231 |
+
break;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
case "trail":
|
| 235 |
+
ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${alpha * 0.4})`;
|
| 236 |
+
ctx.beginPath();
|
| 237 |
+
ctx.arc(p.x, p.y, size, 0, TAU);
|
| 238 |
+
ctx.fill();
|
| 239 |
+
break;
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
ctx.shadowBlur = 0;
|
| 244 |
+
ctx.restore();
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
emitWeather(event, dt) {
|
| 248 |
+
const preset = event.type === "storm" ? "storm" : "bloom";
|
| 249 |
+
const chance = event.type === "storm" ? 0.6 : 0.3;
|
| 250 |
+
if (Math.random() < chance * dt * 10) {
|
| 251 |
+
const angle = Math.random() * TAU;
|
| 252 |
+
const dist = Math.random() * event.radius;
|
| 253 |
+
const x = event.x + Math.cos(angle) * dist;
|
| 254 |
+
const y = event.y + Math.sin(angle) * dist;
|
| 255 |
+
this.emit(x, y, preset);
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
get count() {
|
| 260 |
+
return this.active.length;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
clear() {
|
| 264 |
+
for (const p of this.active) {
|
| 265 |
+
this._pool.release(p);
|
| 266 |
+
}
|
| 267 |
+
this.active.length = 0;
|
| 268 |
+
}
|
| 269 |
+
}
|
js/stats.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { RingBuffer, COLORS, EMA } from "./utils.js";
|
| 2 |
+
import { ENTITY_TYPES, measureDiversity } from "./genetics.js";
|
| 3 |
+
|
| 4 |
+
const HISTORY_LENGTH = 200;
|
| 5 |
+
|
| 6 |
+
export class StatsTracker {
|
| 7 |
+
constructor() {
|
| 8 |
+
this.generationCount = 0;
|
| 9 |
+
this.totalBirths = 0;
|
| 10 |
+
this.totalDeaths = 0;
|
| 11 |
+
this.totalTime = 0;
|
| 12 |
+
this.peakPopulation = 0;
|
| 13 |
+
|
| 14 |
+
this.popHistory = {};
|
| 15 |
+
this.totalPopHistory = new RingBuffer(HISTORY_LENGTH);
|
| 16 |
+
for (const type of Object.keys(ENTITY_TYPES)) {
|
| 17 |
+
this.popHistory[type] = new RingBuffer(HISTORY_LENGTH);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
this.avgFitnessHistory = new RingBuffer(HISTORY_LENGTH);
|
| 21 |
+
this.maxFitnessHistory = new RingBuffer(HISTORY_LENGTH);
|
| 22 |
+
|
| 23 |
+
this.diversityHistory = new RingBuffer(HISTORY_LENGTH);
|
| 24 |
+
|
| 25 |
+
this.fpsEMA = new EMA(0.1);
|
| 26 |
+
this.entityCountEMA = new EMA(0.2);
|
| 27 |
+
|
| 28 |
+
this.current = {
|
| 29 |
+
population: {},
|
| 30 |
+
totalPop: 0,
|
| 31 |
+
avgFitness: 0,
|
| 32 |
+
maxFitness: 0,
|
| 33 |
+
diversity: 0,
|
| 34 |
+
fps: 60,
|
| 35 |
+
entityCount: 0,
|
| 36 |
+
particleCount: 0,
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
this.heatmapCols = 40;
|
| 40 |
+
this.heatmapRows = 30;
|
| 41 |
+
this.heatmap = new Float32Array(this.heatmapCols * this.heatmapRows);
|
| 42 |
+
|
| 43 |
+
this._recordTimer = 0;
|
| 44 |
+
this._recordInterval = 0.5;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
record(entities, world, dt, fps, particleCount) {
|
| 48 |
+
this.totalTime += dt;
|
| 49 |
+
this._recordTimer += dt;
|
| 50 |
+
|
| 51 |
+
this.current.fps = this.fpsEMA.update(fps);
|
| 52 |
+
this.current.entityCount = entities.length;
|
| 53 |
+
this.current.particleCount = particleCount;
|
| 54 |
+
|
| 55 |
+
if (this._recordTimer < this._recordInterval) return;
|
| 56 |
+
this._recordTimer = 0;
|
| 57 |
+
|
| 58 |
+
const popCounts = {};
|
| 59 |
+
for (const type of Object.keys(ENTITY_TYPES)) {
|
| 60 |
+
popCounts[type] = 0;
|
| 61 |
+
}
|
| 62 |
+
let totalFitness = 0;
|
| 63 |
+
let maxFitness = 0;
|
| 64 |
+
const genomes = [];
|
| 65 |
+
|
| 66 |
+
this.heatmap.fill(0);
|
| 67 |
+
const cellW = world.width / this.heatmapCols;
|
| 68 |
+
const cellH = world.height / this.heatmapRows;
|
| 69 |
+
|
| 70 |
+
for (const e of entities) {
|
| 71 |
+
popCounts[e.type] = (popCounts[e.type] || 0) + 1;
|
| 72 |
+
totalFitness += e.fitness;
|
| 73 |
+
if (e.fitness > maxFitness) maxFitness = e.fitness;
|
| 74 |
+
genomes.push(e.genome);
|
| 75 |
+
|
| 76 |
+
const col = Math.floor(e.x / cellW);
|
| 77 |
+
const row = Math.floor(e.y / cellH);
|
| 78 |
+
if (
|
| 79 |
+
col >= 0 &&
|
| 80 |
+
col < this.heatmapCols &&
|
| 81 |
+
row >= 0 &&
|
| 82 |
+
row < this.heatmapRows
|
| 83 |
+
) {
|
| 84 |
+
this.heatmap[row * this.heatmapCols + col] += 1;
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const totalPop = entities.length;
|
| 89 |
+
this.current.population = popCounts;
|
| 90 |
+
this.current.totalPop = totalPop;
|
| 91 |
+
this.current.avgFitness = totalPop > 0 ? totalFitness / totalPop : 0;
|
| 92 |
+
this.current.maxFitness = maxFitness;
|
| 93 |
+
this.current.diversity = measureDiversity(genomes);
|
| 94 |
+
|
| 95 |
+
if (totalPop > this.peakPopulation) this.peakPopulation = totalPop;
|
| 96 |
+
|
| 97 |
+
this.totalPopHistory.push(totalPop);
|
| 98 |
+
for (const type of Object.keys(ENTITY_TYPES)) {
|
| 99 |
+
this.popHistory[type].push(popCounts[type] || 0);
|
| 100 |
+
}
|
| 101 |
+
this.avgFitnessHistory.push(this.current.avgFitness);
|
| 102 |
+
this.maxFitnessHistory.push(maxFitness);
|
| 103 |
+
this.diversityHistory.push(this.current.diversity);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
onBirth() {
|
| 107 |
+
this.totalBirths++;
|
| 108 |
+
}
|
| 109 |
+
onDeath() {
|
| 110 |
+
this.totalDeaths++;
|
| 111 |
+
}
|
| 112 |
+
onGeneration() {
|
| 113 |
+
this.generationCount++;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
renderSparkline(ctx, buffer, x, y, w, h, color) {
|
| 117 |
+
const data = buffer.toArray();
|
| 118 |
+
if (data.length < 2) return;
|
| 119 |
+
|
| 120 |
+
let max = -Infinity,
|
| 121 |
+
min = Infinity;
|
| 122 |
+
for (const v of data) {
|
| 123 |
+
if (v > max) max = v;
|
| 124 |
+
if (v < min) min = v;
|
| 125 |
+
}
|
| 126 |
+
const range = max - min || 1;
|
| 127 |
+
|
| 128 |
+
ctx.beginPath();
|
| 129 |
+
ctx.strokeStyle = color;
|
| 130 |
+
ctx.lineWidth = 1.5;
|
| 131 |
+
ctx.lineJoin = "round";
|
| 132 |
+
|
| 133 |
+
for (let i = 0; i < data.length; i++) {
|
| 134 |
+
const px = x + (i / (data.length - 1)) * w;
|
| 135 |
+
const py = y + h - ((data[i] - min) / range) * h;
|
| 136 |
+
if (i === 0) ctx.moveTo(px, py);
|
| 137 |
+
else ctx.lineTo(px, py);
|
| 138 |
+
}
|
| 139 |
+
ctx.stroke();
|
| 140 |
+
|
| 141 |
+
ctx.lineTo(x + w, y + h);
|
| 142 |
+
ctx.lineTo(x, y + h);
|
| 143 |
+
ctx.closePath();
|
| 144 |
+
const grad = ctx.createLinearGradient(x, y, x, y + h);
|
| 145 |
+
const rgbMatch = color.match(/(\d+),\s*(\d+),\s*(\d+)/);
|
| 146 |
+
if (rgbMatch) {
|
| 147 |
+
grad.addColorStop(
|
| 148 |
+
0,
|
| 149 |
+
`rgba(${rgbMatch[1]},${rgbMatch[2]},${rgbMatch[3]},0.15)`,
|
| 150 |
+
);
|
| 151 |
+
grad.addColorStop(
|
| 152 |
+
1,
|
| 153 |
+
`rgba(${rgbMatch[1]},${rgbMatch[2]},${rgbMatch[3]},0.0)`,
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
ctx.fillStyle = grad;
|
| 157 |
+
ctx.fill();
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
renderCharts(ctx, x, y, w, h) {
|
| 161 |
+
const chartH = h / 3 - 8;
|
| 162 |
+
const pad = 4;
|
| 163 |
+
|
| 164 |
+
ctx.fillStyle = "rgba(100, 200, 255, 0.4)";
|
| 165 |
+
ctx.font = "9px Courier New";
|
| 166 |
+
ctx.fillText("POPULATION", x + 2, y + 8);
|
| 167 |
+
this.renderSparkline(
|
| 168 |
+
ctx,
|
| 169 |
+
this.totalPopHistory,
|
| 170 |
+
x,
|
| 171 |
+
y + 12,
|
| 172 |
+
w,
|
| 173 |
+
chartH,
|
| 174 |
+
"rgb(0, 240, 255)",
|
| 175 |
+
);
|
| 176 |
+
|
| 177 |
+
const types = Object.keys(ENTITY_TYPES);
|
| 178 |
+
for (const type of types) {
|
| 179 |
+
const c = COLORS[type];
|
| 180 |
+
if (c) {
|
| 181 |
+
this.renderSparkline(
|
| 182 |
+
ctx,
|
| 183 |
+
this.popHistory[type],
|
| 184 |
+
x,
|
| 185 |
+
y + 12,
|
| 186 |
+
w,
|
| 187 |
+
chartH,
|
| 188 |
+
`rgb(${c.r},${c.g},${c.b})`,
|
| 189 |
+
);
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const fy = y + chartH + 20;
|
| 194 |
+
ctx.fillStyle = "rgba(100, 200, 255, 0.4)";
|
| 195 |
+
ctx.fillText("FITNESS", x + 2, fy + 8);
|
| 196 |
+
this.renderSparkline(
|
| 197 |
+
ctx,
|
| 198 |
+
this.avgFitnessHistory,
|
| 199 |
+
x,
|
| 200 |
+
fy + 12,
|
| 201 |
+
w,
|
| 202 |
+
chartH,
|
| 203 |
+
"rgb(0, 255, 136)",
|
| 204 |
+
);
|
| 205 |
+
this.renderSparkline(
|
| 206 |
+
ctx,
|
| 207 |
+
this.maxFitnessHistory,
|
| 208 |
+
x,
|
| 209 |
+
fy + 12,
|
| 210 |
+
w,
|
| 211 |
+
chartH,
|
| 212 |
+
"rgb(255, 170, 0)",
|
| 213 |
+
);
|
| 214 |
+
|
| 215 |
+
const dy = fy + chartH + 20;
|
| 216 |
+
ctx.fillStyle = "rgba(100, 200, 255, 0.4)";
|
| 217 |
+
ctx.fillText("DIVERSITY", x + 2, dy + 8);
|
| 218 |
+
this.renderSparkline(
|
| 219 |
+
ctx,
|
| 220 |
+
this.diversityHistory,
|
| 221 |
+
x,
|
| 222 |
+
dy + 12,
|
| 223 |
+
w,
|
| 224 |
+
chartH,
|
| 225 |
+
"rgb(204, 102, 255)",
|
| 226 |
+
);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
renderHeatmap(ctx, worldW, worldH) {
|
| 230 |
+
const cellW = worldW / this.heatmapCols;
|
| 231 |
+
const cellH = worldH / this.heatmapRows;
|
| 232 |
+
|
| 233 |
+
let maxDensity = 0;
|
| 234 |
+
for (let i = 0; i < this.heatmap.length; i++) {
|
| 235 |
+
if (this.heatmap[i] > maxDensity) maxDensity = this.heatmap[i];
|
| 236 |
+
}
|
| 237 |
+
if (maxDensity === 0) return;
|
| 238 |
+
|
| 239 |
+
for (let row = 0; row < this.heatmapRows; row++) {
|
| 240 |
+
for (let col = 0; col < this.heatmapCols; col++) {
|
| 241 |
+
const density = this.heatmap[row * this.heatmapCols + col];
|
| 242 |
+
if (density === 0) continue;
|
| 243 |
+
const intensity = density / maxDensity;
|
| 244 |
+
const alpha = intensity * 0.06;
|
| 245 |
+
ctx.fillStyle = `rgba(0, 240, 255, ${alpha})`;
|
| 246 |
+
ctx.fillRect(col * cellW, row * cellH, cellW + 1, cellH + 1);
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
toJSON() {
|
| 252 |
+
return {
|
| 253 |
+
generationCount: this.generationCount,
|
| 254 |
+
totalBirths: this.totalBirths,
|
| 255 |
+
totalDeaths: this.totalDeaths,
|
| 256 |
+
totalTime: this.totalTime,
|
| 257 |
+
peakPopulation: this.peakPopulation,
|
| 258 |
+
};
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
static fromJSON(data) {
|
| 262 |
+
const s = new StatsTracker();
|
| 263 |
+
s.generationCount = data.generationCount || 0;
|
| 264 |
+
s.totalBirths = data.totalBirths || 0;
|
| 265 |
+
s.totalDeaths = data.totalDeaths || 0;
|
| 266 |
+
s.totalTime = data.totalTime || 0;
|
| 267 |
+
s.peakPopulation = data.peakPopulation || 0;
|
| 268 |
+
return s;
|
| 269 |
+
}
|
| 270 |
+
}
|
js/ui.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ENTITY_TYPES } from "./genetics.js";
|
| 2 |
+
import { EVENT_TYPES } from "./world.js";
|
| 3 |
+
import { COLORS, formatNumber } from "./utils.js";
|
| 4 |
+
|
| 5 |
+
const MAX_EVENTS = 30;
|
| 6 |
+
|
| 7 |
+
export class UI {
|
| 8 |
+
constructor(container) {
|
| 9 |
+
this.container = container;
|
| 10 |
+
this.events = [];
|
| 11 |
+
this._announcement = null;
|
| 12 |
+
this._announcementTimeout = null;
|
| 13 |
+
this._elements = {};
|
| 14 |
+
this._chartCanvas = null;
|
| 15 |
+
this._chartCtx = null;
|
| 16 |
+
this._built = false;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
init() {
|
| 20 |
+
if (this._built) return;
|
| 21 |
+
this._built = true;
|
| 22 |
+
|
| 23 |
+
this._buildPanel(
|
| 24 |
+
"panel-generation",
|
| 25 |
+
`
|
| 26 |
+
<div class="panel-title">GENERATION</div>
|
| 27 |
+
<div class="stat-value neon-text-cyan" id="gen-count" aria-live="polite">0</div>
|
| 28 |
+
<div class="stat-label" id="gen-time">0:00</div>
|
| 29 |
+
`,
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
this._buildPanel(
|
| 33 |
+
"panel-stats",
|
| 34 |
+
`
|
| 35 |
+
<div class="panel-title"><span class="live-indicator" aria-hidden="true"></span>POPULATION</div>
|
| 36 |
+
<div id="pop-total" class="stat-value" style="margin-bottom:6px">0</div>
|
| 37 |
+
<div id="pop-breakdown" role="list" aria-label="Population by type"></div>
|
| 38 |
+
<hr class="neon-divider">
|
| 39 |
+
<div class="stat-row">
|
| 40 |
+
<span class="stat-label">Births</span>
|
| 41 |
+
<span class="count" id="stat-births">0</span>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="stat-row">
|
| 44 |
+
<span class="stat-label">Deaths</span>
|
| 45 |
+
<span class="count" id="stat-deaths">0</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="stat-row">
|
| 48 |
+
<span class="stat-label">Peak</span>
|
| 49 |
+
<span class="count" id="stat-peak">0</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="stat-row">
|
| 52 |
+
<span class="stat-label">Diversity</span>
|
| 53 |
+
<span class="count" id="stat-diversity">0%</span>
|
| 54 |
+
</div>
|
| 55 |
+
`,
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
this._buildPanel(
|
| 59 |
+
"panel-performance",
|
| 60 |
+
`
|
| 61 |
+
<div class="panel-title">SYSTEM</div>
|
| 62 |
+
<div class="stat-row">
|
| 63 |
+
<span class="stat-label">FPS</span>
|
| 64 |
+
<span class="count neon-text-green" id="perf-fps">60</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="stat-row">
|
| 67 |
+
<span class="stat-label">Entities</span>
|
| 68 |
+
<span class="count" id="perf-entities">0</span>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="stat-row">
|
| 71 |
+
<span class="stat-label">Particles</span>
|
| 72 |
+
<span class="count" id="perf-particles">0</span>
|
| 73 |
+
</div>
|
| 74 |
+
`,
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
this._buildPanel(
|
| 78 |
+
"panel-events",
|
| 79 |
+
`
|
| 80 |
+
<div class="panel-title">EVENT LOG</div>
|
| 81 |
+
<div id="event-list" role="log" aria-label="Simulation events" aria-live="polite"></div>
|
| 82 |
+
`,
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
this._buildPanel(
|
| 86 |
+
"panel-charts",
|
| 87 |
+
`
|
| 88 |
+
<canvas id="chart-canvas" class="chart-canvas" aria-label="Population and fitness charts" role="img"></canvas>
|
| 89 |
+
`,
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
this._buildPopRows();
|
| 93 |
+
|
| 94 |
+
const ann = document.createElement("div");
|
| 95 |
+
ann.className = "announcement";
|
| 96 |
+
ann.id = "announcement";
|
| 97 |
+
ann.setAttribute("role", "alert");
|
| 98 |
+
ann.setAttribute("aria-live", "assertive");
|
| 99 |
+
this.container.appendChild(ann);
|
| 100 |
+
this._announcement = ann;
|
| 101 |
+
|
| 102 |
+
this._chartCanvas = document.getElementById("chart-canvas");
|
| 103 |
+
if (this._chartCanvas) {
|
| 104 |
+
this._resizeChartCanvas();
|
| 105 |
+
this._chartCtx = this._chartCanvas.getContext("2d");
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const srSummary = document.createElement("div");
|
| 109 |
+
srSummary.className = "sr-only";
|
| 110 |
+
srSummary.id = "sr-summary";
|
| 111 |
+
srSummary.setAttribute("aria-live", "polite");
|
| 112 |
+
srSummary.setAttribute("aria-atomic", "true");
|
| 113 |
+
this.container.appendChild(srSummary);
|
| 114 |
+
this._srSummary = srSummary;
|
| 115 |
+
this._srTimer = 0;
|
| 116 |
+
|
| 117 |
+
window.addEventListener("resize", () => this._resizeChartCanvas());
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
_buildPanel(id, html) {
|
| 121 |
+
const panel = document.createElement("div");
|
| 122 |
+
panel.id = id;
|
| 123 |
+
panel.className = "hud-panel glass-panel";
|
| 124 |
+
panel.innerHTML = html;
|
| 125 |
+
panel.setAttribute("tabindex", "0");
|
| 126 |
+
this.container.appendChild(panel);
|
| 127 |
+
return panel;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
_buildPopRows() {
|
| 131 |
+
const container = document.getElementById("pop-breakdown");
|
| 132 |
+
if (!container) return;
|
| 133 |
+
|
| 134 |
+
for (const [type, info] of Object.entries(ENTITY_TYPES)) {
|
| 135 |
+
const row = document.createElement("div");
|
| 136 |
+
row.className = "stat-row";
|
| 137 |
+
row.setAttribute("role", "listitem");
|
| 138 |
+
row.innerHTML = `
|
| 139 |
+
<span>
|
| 140 |
+
<span class="dot dot-${type}" aria-hidden="true"></span>
|
| 141 |
+
<span class="stat-label">${info.name}</span>
|
| 142 |
+
</span>
|
| 143 |
+
<span class="count type-${type}" id="pop-${type}">0</span>
|
| 144 |
+
`;
|
| 145 |
+
container.appendChild(row);
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
_resizeChartCanvas() {
|
| 150 |
+
const canvas = this._chartCanvas;
|
| 151 |
+
if (!canvas) return;
|
| 152 |
+
const panel = canvas.parentElement;
|
| 153 |
+
const rect = panel.getBoundingClientRect();
|
| 154 |
+
canvas.width = rect.width - 32;
|
| 155 |
+
canvas.height = rect.height - 32;
|
| 156 |
+
this._chartCtx = canvas.getContext("2d");
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
update(stats, world, entityManager, dt) {
|
| 160 |
+
const c = stats.current;
|
| 161 |
+
|
| 162 |
+
this._setText("gen-count", stats.generationCount.toString());
|
| 163 |
+
this._setText("gen-time", this._formatTime(stats.totalTime));
|
| 164 |
+
|
| 165 |
+
this._setText("pop-total", c.totalPop.toString());
|
| 166 |
+
for (const type of Object.keys(ENTITY_TYPES)) {
|
| 167 |
+
this._setText(`pop-${type}`, (c.population[type] || 0).toString());
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
this._setText("stat-births", formatNumber(stats.totalBirths));
|
| 171 |
+
this._setText("stat-deaths", formatNumber(stats.totalDeaths));
|
| 172 |
+
this._setText("stat-peak", stats.peakPopulation.toString());
|
| 173 |
+
this._setText("stat-diversity", (c.diversity * 100).toFixed(0) + "%");
|
| 174 |
+
|
| 175 |
+
this._setText("perf-fps", Math.round(c.fps).toString());
|
| 176 |
+
this._setText("perf-entities", c.entityCount.toString());
|
| 177 |
+
this._setText("perf-particles", c.particleCount.toString());
|
| 178 |
+
|
| 179 |
+
const fpsEl = document.getElementById("perf-fps");
|
| 180 |
+
if (fpsEl) {
|
| 181 |
+
if (c.fps >= 50) fpsEl.className = "count neon-text-green";
|
| 182 |
+
else if (c.fps >= 30) fpsEl.className = "count neon-text-amber";
|
| 183 |
+
else fpsEl.className = "count neon-text-red";
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if (this._chartCtx && this._chartCanvas) {
|
| 187 |
+
this._chartCtx.clearRect(
|
| 188 |
+
0,
|
| 189 |
+
0,
|
| 190 |
+
this._chartCanvas.width,
|
| 191 |
+
this._chartCanvas.height,
|
| 192 |
+
);
|
| 193 |
+
stats.renderCharts(
|
| 194 |
+
this._chartCtx,
|
| 195 |
+
4,
|
| 196 |
+
0,
|
| 197 |
+
this._chartCanvas.width - 8,
|
| 198 |
+
this._chartCanvas.height,
|
| 199 |
+
);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
this._srTimer += dt;
|
| 203 |
+
if (this._srTimer >= 10) {
|
| 204 |
+
this._srTimer = 0;
|
| 205 |
+
this._updateScreenReaderSummary(stats);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
_setText(id, text) {
|
| 210 |
+
const el = document.getElementById(id);
|
| 211 |
+
if (el && el.textContent !== text) {
|
| 212 |
+
el.textContent = text;
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
_formatTime(seconds) {
|
| 217 |
+
const m = Math.floor(seconds / 60);
|
| 218 |
+
const s = Math.floor(seconds % 60);
|
| 219 |
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
addEvent(message, type = "world") {
|
| 223 |
+
const time = new Date();
|
| 224 |
+
const timeStr = time.toLocaleTimeString([], {
|
| 225 |
+
hour: "2-digit",
|
| 226 |
+
minute: "2-digit",
|
| 227 |
+
second: "2-digit",
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
this.events.push({ message, type, time: timeStr });
|
| 231 |
+
if (this.events.length > MAX_EVENTS) this.events.shift();
|
| 232 |
+
|
| 233 |
+
const list = document.getElementById("event-list");
|
| 234 |
+
if (!list) return;
|
| 235 |
+
|
| 236 |
+
const entry = document.createElement("div");
|
| 237 |
+
entry.className = `event-entry event-${type}`;
|
| 238 |
+
entry.innerHTML = `<span class="timestamp">${timeStr}</span>${this._escapeHtml(message)}`;
|
| 239 |
+
list.appendChild(entry);
|
| 240 |
+
|
| 241 |
+
while (list.children.length > MAX_EVENTS) {
|
| 242 |
+
list.removeChild(list.firstChild);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
const panel = document.getElementById("panel-events");
|
| 246 |
+
if (panel) panel.scrollTop = panel.scrollHeight;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
announce(message) {
|
| 250 |
+
if (!this._announcement) return;
|
| 251 |
+
this._announcement.textContent = message;
|
| 252 |
+
this._announcement.classList.add("visible");
|
| 253 |
+
|
| 254 |
+
if (this._announcementTimeout) clearTimeout(this._announcementTimeout);
|
| 255 |
+
this._announcementTimeout = setTimeout(() => {
|
| 256 |
+
this._announcement.classList.remove("visible");
|
| 257 |
+
}, 4000);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
announceWorldEvent(event) {
|
| 261 |
+
const info = EVENT_TYPES[event.type];
|
| 262 |
+
if (info) {
|
| 263 |
+
this.announce(`${info.icon} ${info.name} detected!`);
|
| 264 |
+
this.addEvent(
|
| 265 |
+
`${info.name} at (${Math.round(event.x)}, ${Math.round(event.y)})`,
|
| 266 |
+
"world",
|
| 267 |
+
);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
_updateScreenReaderSummary(stats) {
|
| 272 |
+
const c = stats.current;
|
| 273 |
+
const parts = [
|
| 274 |
+
`Generation ${stats.generationCount}.`,
|
| 275 |
+
`Total population: ${c.totalPop}.`,
|
| 276 |
+
];
|
| 277 |
+
for (const [type, info] of Object.entries(ENTITY_TYPES)) {
|
| 278 |
+
parts.push(`${info.name}: ${c.population[type] || 0}.`);
|
| 279 |
+
}
|
| 280 |
+
parts.push(`Average fitness: ${c.avgFitness.toFixed(1)}.`);
|
| 281 |
+
parts.push(`Diversity: ${(c.diversity * 100).toFixed(0)} percent.`);
|
| 282 |
+
if (this._srSummary) {
|
| 283 |
+
this._srSummary.textContent = parts.join(" ");
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
_escapeHtml(str) {
|
| 288 |
+
return str
|
| 289 |
+
.replace(/&/g, "&")
|
| 290 |
+
.replace(/</g, "<")
|
| 291 |
+
.replace(/>/g, ">");
|
| 292 |
+
}
|
| 293 |
+
}
|
js/utils.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const TAU = Math.PI * 2;
|
| 2 |
+
|
| 3 |
+
export function lerp(a, b, t) {
|
| 4 |
+
return a + (b - a) * t;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function clamp(v, min, max) {
|
| 8 |
+
return v < min ? min : v > max ? max : v;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function distance(x1, y1, x2, y2) {
|
| 12 |
+
const dx = x2 - x1;
|
| 13 |
+
const dy = y2 - y1;
|
| 14 |
+
return Math.sqrt(dx * dx + dy * dy);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function distanceSq(x1, y1, x2, y2) {
|
| 18 |
+
const dx = x2 - x1;
|
| 19 |
+
const dy = y2 - y1;
|
| 20 |
+
return dx * dx + dy * dy;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function randomRange(min, max) {
|
| 24 |
+
return min + Math.random() * (max - min);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export function randomInt(min, max) {
|
| 28 |
+
return Math.floor(randomRange(min, max + 1));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function randomGaussian(mean = 0, std = 1) {
|
| 32 |
+
let u = 0,
|
| 33 |
+
v = 0;
|
| 34 |
+
while (u === 0) u = Math.random();
|
| 35 |
+
while (v === 0) v = Math.random();
|
| 36 |
+
return mean + std * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(TAU * v);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function randomChoice(arr) {
|
| 40 |
+
return arr[Math.floor(Math.random() * arr.length)];
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function normalize(x, y) {
|
| 44 |
+
const len = Math.sqrt(x * x + y * y);
|
| 45 |
+
if (len === 0) return { x: 0, y: 0 };
|
| 46 |
+
return { x: x / len, y: y / len };
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function angleBetween(x1, y1, x2, y2) {
|
| 50 |
+
return Math.atan2(y2 - y1, x2 - x1);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function hslToHex(h, s, l) {
|
| 54 |
+
s /= 100;
|
| 55 |
+
l /= 100;
|
| 56 |
+
const a = s * Math.min(l, 1 - l);
|
| 57 |
+
const f = (n) => {
|
| 58 |
+
const k = (n + h / 30) % 12;
|
| 59 |
+
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
| 60 |
+
return Math.round(255 * color)
|
| 61 |
+
.toString(16)
|
| 62 |
+
.padStart(2, "0");
|
| 63 |
+
};
|
| 64 |
+
return `#${f(0)}${f(8)}${f(4)}`;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function hexToRgb(hex) {
|
| 68 |
+
const r = parseInt(hex.slice(1, 3), 16);
|
| 69 |
+
const g = parseInt(hex.slice(3, 5), 16);
|
| 70 |
+
const b = parseInt(hex.slice(5, 7), 16);
|
| 71 |
+
return { r, g, b };
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function rgbToString(r, g, b, a = 1) {
|
| 75 |
+
return a < 1 ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function smoothstep(edge0, edge1, x) {
|
| 79 |
+
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
|
| 80 |
+
return t * t * (3 - 2 * t);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** Simple seeded pseudo-random for reproducible sequences */
|
| 84 |
+
export function seededRandom(seed) {
|
| 85 |
+
let s = seed;
|
| 86 |
+
return function () {
|
| 87 |
+
s = (s * 1664525 + 1013904223) & 0xffffffff;
|
| 88 |
+
return (s >>> 0) / 0xffffffff;
|
| 89 |
+
};
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/** Object pool for reducing GC pressure */
|
| 93 |
+
export class ObjectPool {
|
| 94 |
+
constructor(factory, reset, initialSize = 100) {
|
| 95 |
+
this._factory = factory;
|
| 96 |
+
this._reset = reset;
|
| 97 |
+
this._pool = [];
|
| 98 |
+
for (let i = 0; i < initialSize; i++) {
|
| 99 |
+
this._pool.push(factory());
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
acquire() {
|
| 104 |
+
if (this._pool.length > 0) {
|
| 105 |
+
return this._pool.pop();
|
| 106 |
+
}
|
| 107 |
+
return this._factory();
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
release(obj) {
|
| 111 |
+
this._reset(obj);
|
| 112 |
+
this._pool.push(obj);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
get available() {
|
| 116 |
+
return this._pool.length;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/** Spatial hash grid for efficient proximity queries */
|
| 121 |
+
export class SpatialGrid {
|
| 122 |
+
constructor(width, height, cellSize) {
|
| 123 |
+
this.cellSize = cellSize;
|
| 124 |
+
this.cols = Math.ceil(width / cellSize);
|
| 125 |
+
this.rows = Math.ceil(height / cellSize);
|
| 126 |
+
this.cells = new Array(this.cols * this.rows);
|
| 127 |
+
this.clear();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
clear() {
|
| 131 |
+
for (let i = 0; i < this.cells.length; i++) {
|
| 132 |
+
if (this.cells[i]) {
|
| 133 |
+
this.cells[i].length = 0;
|
| 134 |
+
} else {
|
| 135 |
+
this.cells[i] = [];
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
_key(col, row) {
|
| 141 |
+
return row * this.cols + col;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
insert(item, x, y) {
|
| 145 |
+
const col = Math.floor(x / this.cellSize);
|
| 146 |
+
const row = Math.floor(y / this.cellSize);
|
| 147 |
+
if (col >= 0 && col < this.cols && row >= 0 && row < this.rows) {
|
| 148 |
+
this.cells[this._key(col, row)].push(item);
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
query(x, y, radius) {
|
| 153 |
+
const results = [];
|
| 154 |
+
const minCol = Math.max(0, Math.floor((x - radius) / this.cellSize));
|
| 155 |
+
const maxCol = Math.min(
|
| 156 |
+
this.cols - 1,
|
| 157 |
+
Math.floor((x + radius) / this.cellSize),
|
| 158 |
+
);
|
| 159 |
+
const minRow = Math.max(0, Math.floor((y - radius) / this.cellSize));
|
| 160 |
+
const maxRow = Math.min(
|
| 161 |
+
this.rows - 1,
|
| 162 |
+
Math.floor((y + radius) / this.cellSize),
|
| 163 |
+
);
|
| 164 |
+
const rSq = radius * radius;
|
| 165 |
+
|
| 166 |
+
for (let row = minRow; row <= maxRow; row++) {
|
| 167 |
+
for (let col = minCol; col <= maxCol; col++) {
|
| 168 |
+
const cell = this.cells[this._key(col, row)];
|
| 169 |
+
for (let i = 0; i < cell.length; i++) {
|
| 170 |
+
const item = cell[i];
|
| 171 |
+
const dx = item.x - x;
|
| 172 |
+
const dy = item.y - y;
|
| 173 |
+
if (dx * dx + dy * dy <= rSq) {
|
| 174 |
+
results.push(item);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
return results;
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/** Exponential moving average for smooth telemetry */
|
| 184 |
+
export class EMA {
|
| 185 |
+
constructor(alpha = 0.1) {
|
| 186 |
+
this.alpha = alpha;
|
| 187 |
+
this.value = null;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
update(v) {
|
| 191 |
+
if (this.value === null) {
|
| 192 |
+
this.value = v;
|
| 193 |
+
} else {
|
| 194 |
+
this.value = this.alpha * v + (1 - this.alpha) * this.value;
|
| 195 |
+
}
|
| 196 |
+
return this.value;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
reset() {
|
| 200 |
+
this.value = null;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/** Ring buffer for fixed-size history */
|
| 205 |
+
export class RingBuffer {
|
| 206 |
+
constructor(capacity) {
|
| 207 |
+
this.capacity = capacity;
|
| 208 |
+
this.data = new Array(capacity);
|
| 209 |
+
this.head = 0;
|
| 210 |
+
this.size = 0;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
push(value) {
|
| 214 |
+
this.data[this.head] = value;
|
| 215 |
+
this.head = (this.head + 1) % this.capacity;
|
| 216 |
+
if (this.size < this.capacity) this.size++;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
toArray() {
|
| 220 |
+
const arr = new Array(this.size);
|
| 221 |
+
for (let i = 0; i < this.size; i++) {
|
| 222 |
+
arr[i] =
|
| 223 |
+
this.data[(this.head - this.size + i + this.capacity) % this.capacity];
|
| 224 |
+
}
|
| 225 |
+
return arr;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
get last() {
|
| 229 |
+
if (this.size === 0) return undefined;
|
| 230 |
+
return this.data[(this.head - 1 + this.capacity) % this.capacity];
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/** Color constants for entity types */
|
| 235 |
+
export const COLORS = {
|
| 236 |
+
gatherer: { hex: "#00ff88", r: 0, g: 255, b: 136 },
|
| 237 |
+
predator: { hex: "#ff3366", r: 255, g: 51, b: 102 },
|
| 238 |
+
builder: { hex: "#3399ff", r: 51, g: 153, b: 255 },
|
| 239 |
+
explorer: { hex: "#ffaa00", r: 255, g: 170, b: 0 },
|
| 240 |
+
hybrid: { hex: "#cc66ff", r: 204, g: 102, b: 255 },
|
| 241 |
+
food: { hex: "#00ffaa", r: 0, g: 255, b: 170 },
|
| 242 |
+
energy: { hex: "#ffee44", r: 255, g: 238, b: 68 },
|
| 243 |
+
cyan: { hex: "#00f0ff", r: 0, g: 240, b: 255 },
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
/** Format a number for display (e.g., 1234 -> 1.2k) */
|
| 247 |
+
export function formatNumber(n) {
|
| 248 |
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
| 249 |
+
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
| 250 |
+
return Math.round(n).toString();
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/** Wrap value within bounds */
|
| 254 |
+
export function wrap(value, min, max) {
|
| 255 |
+
const range = max - min;
|
| 256 |
+
return ((((value - min) % range) + range) % range) + min;
|
| 257 |
+
}
|
js/world.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { randomRange, SpatialGrid, TAU, distance } from "./utils.js";
|
| 2 |
+
|
| 3 |
+
const RESOURCE_FOOD = 0;
|
| 4 |
+
const RESOURCE_ENERGY = 1;
|
| 5 |
+
|
| 6 |
+
let nextResourceId = 1;
|
| 7 |
+
|
| 8 |
+
class Resource {
|
| 9 |
+
constructor(x, y, type, amount) {
|
| 10 |
+
this.id = nextResourceId++;
|
| 11 |
+
this.x = x;
|
| 12 |
+
this.y = y;
|
| 13 |
+
this.type = type;
|
| 14 |
+
this.amount = amount;
|
| 15 |
+
this.maxAmount = amount;
|
| 16 |
+
this.regenRate = type === RESOURCE_FOOD ? 0.3 : 0.15;
|
| 17 |
+
this.pulsePhase = Math.random() * TAU;
|
| 18 |
+
this.alive = true;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
update(dt, regenMultiplier) {
|
| 22 |
+
if (this.amount < this.maxAmount) {
|
| 23 |
+
this.amount = Math.min(
|
| 24 |
+
this.maxAmount,
|
| 25 |
+
this.amount + this.regenRate * regenMultiplier * dt,
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
this.pulsePhase += dt * 2;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
consume(amount) {
|
| 32 |
+
const taken = Math.min(this.amount, amount);
|
| 33 |
+
this.amount -= taken;
|
| 34 |
+
if (this.amount <= 0.01) {
|
| 35 |
+
this.alive = false;
|
| 36 |
+
}
|
| 37 |
+
return taken;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
class WorldEvent {
|
| 42 |
+
constructor(type, x, y, radius, duration, intensity) {
|
| 43 |
+
this.type = type;
|
| 44 |
+
this.x = x;
|
| 45 |
+
this.y = y;
|
| 46 |
+
this.radius = radius;
|
| 47 |
+
this.duration = duration;
|
| 48 |
+
this.elapsed = 0;
|
| 49 |
+
this.intensity = intensity;
|
| 50 |
+
this.active = true;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
update(dt) {
|
| 54 |
+
this.elapsed += dt;
|
| 55 |
+
if (this.elapsed >= this.duration) {
|
| 56 |
+
this.active = false;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
get progress() {
|
| 61 |
+
return this.elapsed / this.duration;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
get currentIntensity() {
|
| 65 |
+
const t = this.progress;
|
| 66 |
+
if (t < 0.1) return this.intensity * (t / 0.1);
|
| 67 |
+
if (t > 0.8) return this.intensity * ((1 - t) / 0.2);
|
| 68 |
+
return this.intensity;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export const EVENT_TYPES = {
|
| 73 |
+
bloom: { name: "Resource Bloom", color: "#00ffaa", icon: "B" },
|
| 74 |
+
drought: { name: "Drought", color: "#ff8844", icon: "D" },
|
| 75 |
+
storm: { name: "Energy Storm", color: "#ffee44", icon: "S" },
|
| 76 |
+
catastrophe: { name: "Catastrophe", color: "#ff3366", icon: "!" },
|
| 77 |
+
abundance: { name: "Abundance", color: "#00ff88", icon: "A" },
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
export class World {
|
| 81 |
+
constructor(width, height) {
|
| 82 |
+
this.width = width;
|
| 83 |
+
this.height = height;
|
| 84 |
+
this.resources = [];
|
| 85 |
+
this.events = [];
|
| 86 |
+
this.resourceGrid = new SpatialGrid(width, height, 80);
|
| 87 |
+
this.eventTimer = 0;
|
| 88 |
+
this.eventInterval = randomRange(15, 25);
|
| 89 |
+
this.totalTime = 0;
|
| 90 |
+
this.regenMultiplier = 1.0;
|
| 91 |
+
|
| 92 |
+
this.memory = {
|
| 93 |
+
totalBlooms: 0,
|
| 94 |
+
totalDroughts: 0,
|
| 95 |
+
totalCatastrophes: 0,
|
| 96 |
+
recentEvents: [],
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
this._initResources();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
_initResources() {
|
| 103 |
+
const foodCount = Math.floor((this.width * this.height) / 8000);
|
| 104 |
+
for (let i = 0; i < foodCount; i++) {
|
| 105 |
+
this.resources.push(
|
| 106 |
+
new Resource(
|
| 107 |
+
randomRange(20, this.width - 20),
|
| 108 |
+
randomRange(20, this.height - 20),
|
| 109 |
+
RESOURCE_FOOD,
|
| 110 |
+
randomRange(15, 40),
|
| 111 |
+
),
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const energyCount = Math.floor(foodCount * 0.3);
|
| 116 |
+
for (let i = 0; i < energyCount; i++) {
|
| 117 |
+
this.resources.push(
|
| 118 |
+
new Resource(
|
| 119 |
+
randomRange(20, this.width - 20),
|
| 120 |
+
randomRange(20, this.height - 20),
|
| 121 |
+
RESOURCE_ENERGY,
|
| 122 |
+
randomRange(25, 60),
|
| 123 |
+
),
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
update(dt) {
|
| 129 |
+
this.totalTime += dt;
|
| 130 |
+
|
| 131 |
+
for (let i = this.resources.length - 1; i >= 0; i--) {
|
| 132 |
+
const r = this.resources[i];
|
| 133 |
+
r.update(dt, this.regenMultiplier);
|
| 134 |
+
if (!r.alive) {
|
| 135 |
+
this.resources.splice(i, 1);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const targetFood = Math.floor((this.width * this.height) / 8000);
|
| 140 |
+
if (this.resources.length < targetFood * 0.6) {
|
| 141 |
+
const count = Math.min(3, targetFood - this.resources.length);
|
| 142 |
+
for (let i = 0; i < count; i++) {
|
| 143 |
+
this.resources.push(
|
| 144 |
+
new Resource(
|
| 145 |
+
randomRange(20, this.width - 20),
|
| 146 |
+
randomRange(20, this.height - 20),
|
| 147 |
+
Math.random() < 0.75 ? RESOURCE_FOOD : RESOURCE_ENERGY,
|
| 148 |
+
randomRange(15, 40),
|
| 149 |
+
),
|
| 150 |
+
);
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
this.resourceGrid.clear();
|
| 155 |
+
for (const r of this.resources) {
|
| 156 |
+
this.resourceGrid.insert(r, r.x, r.y);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
| 160 |
+
this.events[i].update(dt);
|
| 161 |
+
if (!this.events[i].active) {
|
| 162 |
+
this.events.splice(i, 1);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
this.eventTimer += dt;
|
| 167 |
+
if (this.eventTimer >= this.eventInterval) {
|
| 168 |
+
this.eventTimer = 0;
|
| 169 |
+
this.eventInterval = randomRange(12, 22);
|
| 170 |
+
this._triggerRandomEvent();
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
this._updateMemoryEffects();
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
_triggerRandomEvent() {
|
| 177 |
+
const types = Object.keys(EVENT_TYPES);
|
| 178 |
+
const weights = [1, 1, 1, 0.3, 1];
|
| 179 |
+
if (this.memory.totalDroughts > this.memory.totalBlooms) {
|
| 180 |
+
weights[0] += 1;
|
| 181 |
+
}
|
| 182 |
+
if (this.memory.totalCatastrophes > 2) {
|
| 183 |
+
weights[3] *= 0.3;
|
| 184 |
+
weights[4] += 1;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
| 188 |
+
let r = Math.random() * totalWeight;
|
| 189 |
+
let typeIdx = 0;
|
| 190 |
+
for (let i = 0; i < weights.length; i++) {
|
| 191 |
+
r -= weights[i];
|
| 192 |
+
if (r <= 0) {
|
| 193 |
+
typeIdx = i;
|
| 194 |
+
break;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
const type = types[typeIdx];
|
| 199 |
+
const x = randomRange(100, this.width - 100);
|
| 200 |
+
const y = randomRange(100, this.height - 100);
|
| 201 |
+
const radius = randomRange(120, 250);
|
| 202 |
+
const duration = randomRange(8, 18);
|
| 203 |
+
|
| 204 |
+
const event = new WorldEvent(type, x, y, radius, duration, 1.0);
|
| 205 |
+
this.events.push(event);
|
| 206 |
+
this._applyEventEffects(event);
|
| 207 |
+
|
| 208 |
+
this.memory.recentEvents.push({ type, time: this.totalTime });
|
| 209 |
+
if (this.memory.recentEvents.length > 20) this.memory.recentEvents.shift();
|
| 210 |
+
|
| 211 |
+
if (type === "bloom" || type === "abundance") this.memory.totalBlooms++;
|
| 212 |
+
if (type === "drought") this.memory.totalDroughts++;
|
| 213 |
+
if (type === "catastrophe") this.memory.totalCatastrophes++;
|
| 214 |
+
|
| 215 |
+
return event;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
_applyEventEffects(event) {
|
| 219 |
+
switch (event.type) {
|
| 220 |
+
case "bloom": {
|
| 221 |
+
for (let i = 0; i < 8; i++) {
|
| 222 |
+
const angle = Math.random() * TAU;
|
| 223 |
+
const dist = Math.random() * event.radius;
|
| 224 |
+
const rx = event.x + Math.cos(angle) * dist;
|
| 225 |
+
const ry = event.y + Math.sin(angle) * dist;
|
| 226 |
+
if (
|
| 227 |
+
rx > 10 &&
|
| 228 |
+
rx < this.width - 10 &&
|
| 229 |
+
ry > 10 &&
|
| 230 |
+
ry < this.height - 10
|
| 231 |
+
) {
|
| 232 |
+
this.resources.push(
|
| 233 |
+
new Resource(rx, ry, RESOURCE_FOOD, randomRange(20, 50)),
|
| 234 |
+
);
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
break;
|
| 238 |
+
}
|
| 239 |
+
case "drought": {
|
| 240 |
+
for (const r of this.resources) {
|
| 241 |
+
if (distance(r.x, r.y, event.x, event.y) < event.radius) {
|
| 242 |
+
r.amount *= 0.5;
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
break;
|
| 246 |
+
}
|
| 247 |
+
case "storm": {
|
| 248 |
+
for (let i = 0; i < 5; i++) {
|
| 249 |
+
const angle = Math.random() * TAU;
|
| 250 |
+
const dist = Math.random() * event.radius;
|
| 251 |
+
const rx = event.x + Math.cos(angle) * dist;
|
| 252 |
+
const ry = event.y + Math.sin(angle) * dist;
|
| 253 |
+
if (
|
| 254 |
+
rx > 10 &&
|
| 255 |
+
rx < this.width - 10 &&
|
| 256 |
+
ry > 10 &&
|
| 257 |
+
ry < this.height - 10
|
| 258 |
+
) {
|
| 259 |
+
this.resources.push(
|
| 260 |
+
new Resource(rx, ry, RESOURCE_ENERGY, randomRange(30, 70)),
|
| 261 |
+
);
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
break;
|
| 265 |
+
}
|
| 266 |
+
case "abundance": {
|
| 267 |
+
this.regenMultiplier = 2.0;
|
| 268 |
+
setTimeout(() => {
|
| 269 |
+
this.regenMultiplier = 1.0;
|
| 270 |
+
}, event.duration * 1000);
|
| 271 |
+
break;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
_updateMemoryEffects() {
|
| 277 |
+
if (this.memory.totalCatastrophes > 3) {
|
| 278 |
+
this.regenMultiplier = Math.max(this.regenMultiplier, 1.2);
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
getResourcesNear(x, y, radius) {
|
| 283 |
+
return this.resourceGrid.query(x, y, radius);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
getEventsAt(x, y) {
|
| 287 |
+
const affecting = [];
|
| 288 |
+
for (const e of this.events) {
|
| 289 |
+
if (distance(x, y, e.x, e.y) < e.radius) {
|
| 290 |
+
affecting.push(e);
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
return affecting;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
getCatastropheDamage(x, y) {
|
| 297 |
+
let dmg = 0;
|
| 298 |
+
for (const e of this.events) {
|
| 299 |
+
if (e.type === "catastrophe") {
|
| 300 |
+
const d = distance(x, y, e.x, e.y);
|
| 301 |
+
if (d < e.radius) {
|
| 302 |
+
dmg += e.currentIntensity * (1 - d / e.radius) * 0.5;
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
return dmg;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
render(ctx) {
|
| 310 |
+
if (
|
| 311 |
+
!this._bgCache ||
|
| 312 |
+
this._bgCache.width !== this.width ||
|
| 313 |
+
this._bgCache.height !== this.height
|
| 314 |
+
) {
|
| 315 |
+
this._bgCache = document.createElement("canvas");
|
| 316 |
+
this._bgCache.width = this.width;
|
| 317 |
+
this._bgCache.height = this.height;
|
| 318 |
+
const bgCtx = this._bgCache.getContext("2d");
|
| 319 |
+
const spacing = 35;
|
| 320 |
+
bgCtx.fillStyle = "rgba(80, 160, 220, 0.025)";
|
| 321 |
+
for (let x = spacing; x < this.width; x += spacing) {
|
| 322 |
+
for (let y = spacing; y < this.height; y += spacing) {
|
| 323 |
+
bgCtx.fillRect(x, y, 1.2, 1.2);
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
bgCtx.strokeStyle = "rgba(60, 120, 180, 0.015)";
|
| 327 |
+
bgCtx.lineWidth = 0.5;
|
| 328 |
+
for (let x = spacing; x < this.width; x += spacing * 4) {
|
| 329 |
+
bgCtx.beginPath();
|
| 330 |
+
bgCtx.moveTo(x, 0);
|
| 331 |
+
bgCtx.lineTo(x, this.height);
|
| 332 |
+
bgCtx.stroke();
|
| 333 |
+
}
|
| 334 |
+
for (let y = spacing; y < this.height; y += spacing * 4) {
|
| 335 |
+
bgCtx.beginPath();
|
| 336 |
+
bgCtx.moveTo(0, y);
|
| 337 |
+
bgCtx.lineTo(this.width, y);
|
| 338 |
+
bgCtx.stroke();
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
ctx.drawImage(this._bgCache, 0, 0);
|
| 342 |
+
|
| 343 |
+
for (const e of this.events) {
|
| 344 |
+
const info = EVENT_TYPES[e.type];
|
| 345 |
+
const intensity = e.currentIntensity;
|
| 346 |
+
const rgb = this._hexToRgb(info.color);
|
| 347 |
+
|
| 348 |
+
const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.radius);
|
| 349 |
+
grad.addColorStop(
|
| 350 |
+
0,
|
| 351 |
+
`rgba(${rgb.r},${rgb.g},${rgb.b},${0.08 * intensity})`,
|
| 352 |
+
);
|
| 353 |
+
grad.addColorStop(
|
| 354 |
+
0.7,
|
| 355 |
+
`rgba(${rgb.r},${rgb.g},${rgb.b},${0.03 * intensity})`,
|
| 356 |
+
);
|
| 357 |
+
grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
|
| 358 |
+
ctx.fillStyle = grad;
|
| 359 |
+
ctx.beginPath();
|
| 360 |
+
ctx.arc(e.x, e.y, e.radius, 0, TAU);
|
| 361 |
+
ctx.fill();
|
| 362 |
+
|
| 363 |
+
const ringPulse = 0.5 + 0.5 * Math.sin(this.totalTime * 2);
|
| 364 |
+
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${0.15 * intensity * ringPulse})`;
|
| 365 |
+
ctx.lineWidth = 1.5;
|
| 366 |
+
ctx.setLineDash([8, 8]);
|
| 367 |
+
ctx.stroke();
|
| 368 |
+
ctx.setLineDash([]);
|
| 369 |
+
|
| 370 |
+
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${0.3 * intensity})`;
|
| 371 |
+
ctx.font = "10px Courier New";
|
| 372 |
+
ctx.textAlign = "center";
|
| 373 |
+
ctx.fillText(info.name.toUpperCase(), e.x, e.y - e.radius + 15);
|
| 374 |
+
ctx.textAlign = "start";
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
if (!this._foodGlow) {
|
| 378 |
+
this._foodGlow = this._makeGlowSprite(0, 255, 170, 20);
|
| 379 |
+
this._energyGlow = this._makeGlowSprite(255, 238, 68, 20);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
for (const r of this.resources) {
|
| 383 |
+
const pulse = 0.6 + 0.4 * Math.sin(r.pulsePhase);
|
| 384 |
+
const sizeRatio = r.amount / r.maxAmount;
|
| 385 |
+
const baseRadius = 3 + sizeRatio * 4;
|
| 386 |
+
const radius = baseRadius * (0.85 + 0.15 * pulse);
|
| 387 |
+
|
| 388 |
+
const sprite =
|
| 389 |
+
r.type === RESOURCE_FOOD ? this._foodGlow : this._energyGlow;
|
| 390 |
+
const gs = sprite.width;
|
| 391 |
+
ctx.globalAlpha = 0.4 + 0.4 * sizeRatio;
|
| 392 |
+
ctx.drawImage(sprite, r.x - gs / 2, r.y - gs / 2);
|
| 393 |
+
ctx.globalAlpha = 1;
|
| 394 |
+
|
| 395 |
+
if (r.type === RESOURCE_FOOD) {
|
| 396 |
+
ctx.fillStyle = `rgba(0,255,170,${0.5 + 0.4 * sizeRatio})`;
|
| 397 |
+
} else {
|
| 398 |
+
ctx.fillStyle = `rgba(255,238,68,${0.5 + 0.4 * sizeRatio})`;
|
| 399 |
+
}
|
| 400 |
+
ctx.beginPath();
|
| 401 |
+
ctx.arc(r.x, r.y, radius, 0, TAU);
|
| 402 |
+
ctx.fill();
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
_makeGlowSprite(r, g, b, size) {
|
| 407 |
+
const canvas = document.createElement("canvas");
|
| 408 |
+
const s = size * 3;
|
| 409 |
+
canvas.width = s;
|
| 410 |
+
canvas.height = s;
|
| 411 |
+
const ctx = canvas.getContext("2d");
|
| 412 |
+
const grad = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
| 413 |
+
grad.addColorStop(0, `rgba(${r},${g},${b},0.3)`);
|
| 414 |
+
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.08)`);
|
| 415 |
+
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
| 416 |
+
ctx.fillStyle = grad;
|
| 417 |
+
ctx.fillRect(0, 0, s, s);
|
| 418 |
+
return canvas;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
_hexToRgb(hex) {
|
| 422 |
+
const r = parseInt(hex.slice(1, 3), 16);
|
| 423 |
+
const g = parseInt(hex.slice(3, 5), 16);
|
| 424 |
+
const b = parseInt(hex.slice(5, 7), 16);
|
| 425 |
+
return { r, g, b };
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
toJSON() {
|
| 429 |
+
return {
|
| 430 |
+
width: this.width,
|
| 431 |
+
height: this.height,
|
| 432 |
+
totalTime: this.totalTime,
|
| 433 |
+
memory: { ...this.memory },
|
| 434 |
+
resources: this.resources.map((r) => ({
|
| 435 |
+
x: r.x,
|
| 436 |
+
y: r.y,
|
| 437 |
+
type: r.type,
|
| 438 |
+
amount: r.amount,
|
| 439 |
+
maxAmount: r.maxAmount,
|
| 440 |
+
})),
|
| 441 |
+
};
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
static fromJSON(data) {
|
| 445 |
+
const w = new World(data.width, data.height);
|
| 446 |
+
w.resources = [];
|
| 447 |
+
w.totalTime = data.totalTime || 0;
|
| 448 |
+
w.memory = data.memory || w.memory;
|
| 449 |
+
for (const rd of data.resources) {
|
| 450 |
+
const r = new Resource(rd.x, rd.y, rd.type, rd.maxAmount);
|
| 451 |
+
r.amount = rd.amount;
|
| 452 |
+
w.resources.push(r);
|
| 453 |
+
}
|
| 454 |
+
return w;
|
| 455 |
+
}
|
| 456 |
+
}
|