| 1 | // physics.js - Web physics and strand management |
| 2 | |
| 3 | class WebStrand { |
| 4 | constructor(start, end) { |
| 5 | this.start = start; |
| 6 | this.end = end; |
| 7 | this.strength = 1; |
| 8 | this.vibration = 0; |
| 9 | this.path = []; |
| 10 | this.segments = []; // For physics simulation |
| 11 | this.maxLength = 260; // Maximum strand length before it breaks (increased by 30%) |
| 12 | this.tension = 0; |
| 13 | this.broken = false; |
| 14 | this.recoil = 0; // Recoil amplitude for spring physics |
| 15 | this.recoilVelocity = 0; // Velocity of recoil oscillation |
| 16 | this.damping = 0.75; // Damping factor for recoil (much faster damping to prevent accumulation) |
| 17 | this.springConstant = 0.04; // Spring stiffness (much softer spring) |
| 18 | this.flexibility = 1.0; // How much the web can be dragged by flies |
| 19 | } |
| 20 | |
| 21 | update() { |
| 22 | this.vibration *= 0.95; |
| 23 | |
| 24 | // Update recoil physics (spring oscillation) |
| 25 | if (abs(this.recoil) > 0.01 || abs(this.recoilVelocity) > 0.01) { |
| 26 | // Apply spring force (Hooke's law) |
| 27 | let springForce = -this.springConstant * this.recoil; |
| 28 | this.recoilVelocity += springForce; |
| 29 | |
| 30 | // Apply damping |
| 31 | this.recoilVelocity *= this.damping; |
| 32 | |
| 33 | // Update recoil position |
| 34 | this.recoil += this.recoilVelocity; |
| 35 | |
| 36 | // Clamp small values to stop oscillation |
| 37 | if (abs(this.recoil) < 0.01 && abs(this.recoilVelocity) < 0.01) { |
| 38 | this.recoil = 0; |
| 39 | this.recoilVelocity = 0; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // Calculate strand length and tension |
| 44 | if (this.end) { |
| 45 | let length = dist(this.start.x, this.start.y, this.end.x, this.end.y); |
| 46 | this.tension = length / this.maxLength; |
| 47 | |
| 48 | // Calculate flexibility factor (longer, less taut webs are more flexible) |
| 49 | this.flexibility = map(this.tension, 0.2, 1.0, 1.5, 0.3); // More flexible when less taut |
| 50 | this.flexibility = constrain(this.flexibility, 0.3, 1.5); |
| 51 | |
| 52 | // Break if overstretched or unsupported arc |
| 53 | if (this.tension > 1.5 || this.checkUnsupportedArc()) { |
| 54 | this.broken = true; |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // Apply gravity to path points for realistic sagging, with wind and smoothing |
| 59 | if (this.path && this.path.length > 2 && !this.broken) { |
| 60 | // low-frequency wind using Perlin noise (stable over time) |
| 61 | let windX = (noise(frameCount * 0.005, 12.3) - 0.5) * 0.6; |
| 62 | let windY = (noise(frameCount * 0.005, 91.7) - 0.5) * 0.4; |
| 63 | |
| 64 | for (let i = 1; i < this.path.length - 1; i++) { |
| 65 | let point = this.path[i]; |
| 66 | |
| 67 | // Check if supported by an obstacle |
| 68 | let supported = false; |
| 69 | for (let obstacle of obstacles) { |
| 70 | if (dist(point.x, point.y, obstacle.x, obstacle.y) < obstacle.radius + 5) { |
| 71 | supported = true; |
| 72 | break; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | if (!supported) { |
| 77 | // gravity (slightly softer) and wind drift |
| 78 | point.y += 0.22; |
| 79 | point.x += windX * (0.6 + i / this.path.length * 0.8); |
| 80 | point.y += windY * 0.4; |
| 81 | |
| 82 | // Apply recoil to path points (very subtle) |
| 83 | point.y += this.recoil * (1 + sin(i * 0.3) * 0.5); |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // Laplacian smoothing to create flowing catenary-like curves |
| 88 | for (let iter = 0; iter < 2; iter++) { |
| 89 | for (let i = 1; i < this.path.length - 1; i++) { |
| 90 | let prev = this.path[i - 1]; |
| 91 | let curr = this.path[i]; |
| 92 | let next = this.path[i + 1]; |
| 93 | curr.x = lerp(curr.x, (prev.x + next.x) * 0.5, 0.18); |
| 94 | curr.y = lerp(curr.y, (prev.y + next.y) * 0.5, 0.18); |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | for (let node of webNodes) { |
| 100 | const nearStart = dist(node.x, node.y, this.start.x, this.start.y) < 5; |
| 101 | const nearEnd = this.end ? (dist(node.x, node.y, this.end.x, this.end.y) < 5) : false; |
| 102 | if (nearStart || nearEnd) { |
| 103 | node.applyForce(0, 0.1); |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | checkUnsupportedArc() { |
| 109 | if (!this.path || this.path.length < 3) return false; |
| 110 | |
| 111 | // Check if the web forms an unsupported arc (both ends lower than middle) |
| 112 | let startY = this.start.y; |
| 113 | let endY = this.end ? this.end.y : this.path[this.path.length - 1].y; |
| 114 | let lowestPoint = startY; |
| 115 | let highestPoint = startY; |
| 116 | |
| 117 | for (let point of this.path) { |
| 118 | if (point.y > lowestPoint) lowestPoint = point.y; |
| 119 | if (point.y < highestPoint) highestPoint = point.y; |
| 120 | } |
| 121 | |
| 122 | // If the arc goes up significantly and both ends are near bottom, it's unsupported |
| 123 | let arcHeight = lowestPoint - highestPoint; |
| 124 | let bothEndsLow = startY > height - 200 && endY > height - 200; |
| 125 | let significantArc = arcHeight > 100; |
| 126 | |
| 127 | // Check if there's any support in the middle |
| 128 | let hasMiddleSupport = false; |
| 129 | for (let i = Math.floor(this.path.length * 0.3); i < Math.floor(this.path.length * 0.7); i++) { |
| 130 | let point = this.path[i]; |
| 131 | for (let obstacle of obstacles) { |
| 132 | if (dist(point.x, point.y, obstacle.x, obstacle.y) < obstacle.radius + 10) { |
| 133 | hasMiddleSupport = true; |
| 134 | break; |
| 135 | } |
| 136 | } |
| 137 | if (hasMiddleSupport) break; |
| 138 | } |
| 139 | |
| 140 | return bothEndsLow && significantArc && !hasMiddleSupport; |
| 141 | } |
| 142 | |
| 143 | display() { |
| 144 | if (this.broken) return; // Don't display broken strands |
| 145 | |
| 146 | push(); |
| 147 | |
| 148 | // If the strand's end hasn't been established yet (e.g., just started deploying on touch), |
| 149 | // skip physics rendering here. The in-progress strand is drawn from game.js. |
| 150 | if (!this.end) { |
| 151 | pop(); |
| 152 | return; |
| 153 | } |
| 154 | |
| 155 | // Change color based on tension |
| 156 | if (this.tension > 0.8) { |
| 157 | stroke(255, 200, 200, 200); // Reddish when strained |
| 158 | } else if (gamePhase === 'NIGHT') { |
| 159 | stroke(255, 255, 255, 250); |
| 160 | } else { |
| 161 | stroke(255, 255, 255, 200); |
| 162 | } |
| 163 | |
| 164 | strokeWeight(gamePhase === 'NIGHT' ? 2 : 1.5); |
| 165 | noFill(); |
| 166 | |
| 167 | if (this.path && this.path.length > 2) { |
| 168 | beginShape(); |
| 169 | curveVertex(this.path[0].x, this.path[0].y + this.vibration * sin(frameCount * 0.3)); |
| 170 | |
| 171 | for (let i = 0; i < this.path.length; i++) { |
| 172 | let point = this.path[i]; |
| 173 | let vibOffset = this.vibration * sin(frameCount * 0.3 + i * 0.1) * (i / this.path.length); |
| 174 | curveVertex(point.x, point.y + vibOffset); |
| 175 | } |
| 176 | |
| 177 | let lastPoint = this.path[this.path.length - 1]; |
| 178 | curveVertex(lastPoint.x, lastPoint.y + this.vibration * sin(frameCount * 0.3)); |
| 179 | endShape(); |
| 180 | |
| 181 | stroke(255, 255, 255, 50); |
| 182 | strokeWeight(4); |
| 183 | beginShape(); |
| 184 | curveVertex(this.path[0].x, this.path[0].y); |
| 185 | for (let point of this.path) { |
| 186 | curveVertex(point.x, point.y); |
| 187 | } |
| 188 | curveVertex(lastPoint.x, lastPoint.y); |
| 189 | endShape(); |
| 190 | } else { |
| 191 | let midX = (this.start.x + this.end.x) / 2; |
| 192 | let midY = (this.start.y + this.end.y) / 2 + this.vibration * sin(frameCount * 0.3); |
| 193 | |
| 194 | // Add sag based on horizontal distance |
| 195 | let horizontalDist = abs(this.end.x - this.start.x); |
| 196 | let sag = horizontalDist * 0.12; |
| 197 | midY += sag * (1 - cos(PI * 0.5)); |
| 198 | |
| 199 | // Apply recoil deformation to the web (very subtle) |
| 200 | midY += this.recoil * 2; // Further reduced from 3 |
| 201 | |
| 202 | beginShape(); |
| 203 | curveVertex(this.start.x, this.start.y); |
| 204 | curveVertex(this.start.x, this.start.y); |
| 205 | curveVertex(midX, midY); |
| 206 | curveVertex(this.end.x, this.end.y); |
| 207 | curveVertex(this.end.x, this.end.y); |
| 208 | endShape(); |
| 209 | |
| 210 | stroke(255, 255, 255, 50); |
| 211 | strokeWeight(4); |
| 212 | beginShape(); |
| 213 | curveVertex(this.start.x, this.start.y); |
| 214 | curveVertex(this.start.x, this.start.y); |
| 215 | curveVertex(midX, midY); |
| 216 | curveVertex(this.end.x, this.end.y); |
| 217 | curveVertex(this.end.x, this.end.y); |
| 218 | endShape(); |
| 219 | } |
| 220 | |
| 221 | pop(); |
| 222 | } |
| 223 | |
| 224 | vibrate(amount) { |
| 225 | this.vibration = min(this.vibration + amount, 10); |
| 226 | } |
| 227 | |
| 228 | // Apply recoil force when spider interacts with the web |
| 229 | applyRecoil(force) { |
| 230 | // Newton's third law - the web recoils opposite to the applied force |
| 231 | this.recoilVelocity += force; |
| 232 | |
| 233 | // Also trigger vibration for visual feedback (scaled down) |
| 234 | this.vibrate(abs(force) * 1); |
| 235 | |
| 236 | // Add some energy dissipation through the web network (more subtle) |
| 237 | for (let node of webNodes) { |
| 238 | const d1 = dist(node.x, node.y, this.start.x, this.start.y); |
| 239 | const d2 = this.end ? dist(node.x, node.y, this.end.x, this.end.y) : Infinity; |
| 240 | const minDist = Math.min(d1, d2); |
| 241 | if (minDist < 100) { |
| 242 | const forceFalloff = map(minDist, 0, 100, 0.3, 0); |
| 243 | node.applyForce(0, force * forceFalloff * 0.15); |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | |
| 250 | class WebNode { |
| 251 | constructor(x, y) { |
| 252 | this.x = x; |
| 253 | this.y = y; |
| 254 | this.vx = 0; |
| 255 | this.vy = 0; |
| 256 | this.pinned = false; |
| 257 | } |
| 258 | |
| 259 | applyForce(fx, fy) { |
| 260 | if (!this.pinned) { |
| 261 | this.vx += fx; |
| 262 | this.vy += fy; |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | update() { |
| 267 | if (!this.pinned) { |
| 268 | this.x += this.vx; |
| 269 | this.y += this.vy; |
| 270 | this.vx *= 0.98; |
| 271 | this.vy *= 0.98; |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | // Helper function to spawn food boxes |
| 277 | function spawnFoodBox() { |
| 278 | let x, y; |
| 279 | let attempts = 0; |
| 280 | let valid = false; |
| 281 | |
| 282 | while (!valid && attempts < 50) { |
| 283 | x = random(50, width - 50); |
| 284 | y = random(50, height - 100); |
| 285 | valid = true; |
| 286 | |
| 287 | for (let obstacle of obstacles) { |
| 288 | if (dist(x, y, obstacle.x, obstacle.y) < obstacle.radius + 30) { |
| 289 | valid = false; |
| 290 | break; |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | for (let box of foodBoxes) { |
| 295 | if (dist(x, y, box.pos.x, box.pos.y) < 50) { |
| 296 | valid = false; |
| 297 | break; |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | attempts++; |
| 302 | } |
| 303 | |
| 304 | if (valid) { |
| 305 | foodBoxes.push(new FoodBox(x, y)); |
| 306 | } |
| 307 | } |