| 1 | // entities.js - All game entity classes |
| 2 | |
| 3 | class Spider { |
| 4 | constructor (x, y) { |
| 5 | this.pos = createVector(x, y) |
| 6 | this.vel = createVector(0, 0) |
| 7 | this.acc = createVector(0, 0) |
| 8 | this.radius = 8 |
| 9 | this.isAirborne = false |
| 10 | this.canJump = true |
| 11 | this.lastAnchorPoint = null |
| 12 | this.gravity = createVector(0, 0.3) |
| 13 | this.jumpPower = 12 |
| 14 | this.maxSpeed = 15 |
| 15 | this.munchRadius = 20 |
| 16 | this.munchCooldown = 0 |
| 17 | } |
| 18 | |
| 19 | jump(targetX, targetY) { |
| 20 | if (!this.canJump) return; |
| 21 | |
| 22 | let direction = createVector(targetX - this.pos.x, targetY - this.pos.y); |
| 23 | let clickDistance = direction.mag(); |
| 24 | direction.normalize(); |
| 25 | |
| 26 | // Scale jump power based on click distance (closer clicks = smaller jumps) |
| 27 | let actualJumpPower = map(clickDistance, 0, 200, 3, this.jumpPower); |
| 28 | actualJumpPower = constrain(actualJumpPower, 3, this.jumpPower); |
| 29 | direction.mult(actualJumpPower); |
| 30 | |
| 31 | this.vel = direction; |
| 32 | this.isAirborne = true; |
| 33 | this.canJump = false; |
| 34 | this.lastAnchorPoint = this.pos.copy(); |
| 35 | |
| 36 | // Check if we're jumping off a web strand |
| 37 | for (let strand of webStrands) { |
| 38 | if (strand === currentStrand) continue; |
| 39 | |
| 40 | if (this.checkStrandCollision(strand)) { |
| 41 | // Much simpler shimmy detection based on actual jump power used |
| 42 | let isShimmy = actualJumpPower < 6; // If we used less than half power, it's a shimmy |
| 43 | |
| 44 | // Apply appropriate recoil based on movement type |
| 45 | if (isShimmy) { |
| 46 | // Trigger shimmy visual effect |
| 47 | this.shimmyEffect = 20; |
| 48 | |
| 49 | // NO recoil at all for shimmying - just tiny vibration |
| 50 | strand.vibrate(0.3); |
| 51 | |
| 52 | // Tiny yellow particles |
| 53 | let p = new Particle(this.pos.x, this.pos.y); |
| 54 | p.color = color(255, 255, 100, 80); |
| 55 | p.vel = createVector(random(-0.3, 0.3), random(-0.3, 0.3)); |
| 56 | p.size = 2; |
| 57 | particles.push(p); |
| 58 | } else { |
| 59 | // Scale recoil based on actual jump power |
| 60 | let recoilForce = -(actualJumpPower / this.jumpPower) * 0.08; // Scale by power ratio |
| 61 | strand.applyRecoil(recoilForce); |
| 62 | |
| 63 | // Create particles only for real jumps |
| 64 | for (let i = 0; i < 2; i++) { |
| 65 | let p = new Particle(this.pos.x, this.pos.y); |
| 66 | p.color = color(255, 255, 255, 120); |
| 67 | p.vel = createVector(random(-0.8, 0.8), random(1, 2)); |
| 68 | p.size = 3; |
| 69 | particles.push(p); |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | break; |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | munch () { |
| 79 | if (this.munchCooldown > 0) return |
| 80 | |
| 81 | isMunching = true |
| 82 | this.munchCooldown = 30 |
| 83 | |
| 84 | for (let i = flies.length - 1; i >= 0; i--) { |
| 85 | let fly = flies[i] |
| 86 | let d = dist(this.pos.x, this.pos.y, fly.pos.x, fly.pos.y) |
| 87 | if (d < this.munchRadius) { |
| 88 | fliesMunched++ |
| 89 | webSilk = min(webSilk + 15, maxWebSilk) |
| 90 | |
| 91 | for (let j = 0; j < 12; j++) { |
| 92 | let p = new Particle(fly.pos.x, fly.pos.y) |
| 93 | p.color = color(255, random(100, 255), 0) |
| 94 | particles.push(p) |
| 95 | } |
| 96 | |
| 97 | flies.splice(i, 1) |
| 98 | break |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | update () { |
| 104 | if (this.isAirborne) { |
| 105 | this.acc.add(this.gravity) |
| 106 | } |
| 107 | |
| 108 | this.vel.add(this.acc) |
| 109 | this.vel.limit(this.maxSpeed) |
| 110 | this.pos.add(this.vel) |
| 111 | this.acc.mult(0) |
| 112 | |
| 113 | if (this.munchCooldown > 0) { |
| 114 | this.munchCooldown-- |
| 115 | if (this.munchCooldown === 0) { |
| 116 | isMunching = false |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // Check ground collision |
| 121 | if (this.pos.y >= height - this.radius) { |
| 122 | this.pos.y = height - this.radius |
| 123 | this.land() |
| 124 | } |
| 125 | |
| 126 | // Check wall collisions |
| 127 | if (this.pos.x <= this.radius || this.pos.x >= width - this.radius) { |
| 128 | this.pos.x = constrain(this.pos.x, this.radius, width - this.radius) |
| 129 | this.vel.x *= -0.5 |
| 130 | } |
| 131 | |
| 132 | // Check ceiling |
| 133 | if (this.pos.y <= this.radius) { |
| 134 | this.pos.y = this.radius |
| 135 | this.vel.y *= -0.5 // Bounce off ceiling, don't land |
| 136 | } |
| 137 | |
| 138 | // Check home branch collision (one-way platform) |
| 139 | if (window.homeBranch && this.isAirborne && this.vel.y > 0.1) { |
| 140 | // Only when actually falling |
| 141 | let branch = window.homeBranch |
| 142 | |
| 143 | // Check if spider is within branch X range |
| 144 | let branchStart = Math.min(branch.startX, branch.endX) |
| 145 | let branchEnd = Math.max(branch.startX, branch.endX) |
| 146 | |
| 147 | // Since the branch angle is very small (0.05 radians ≈ 3 degrees), |
| 148 | // we can use a simpler approximation |
| 149 | if (this.pos.x >= branchStart - 10 && this.pos.x <= branchEnd + 10) { |
| 150 | // Calculate position along branch (0 to 1) |
| 151 | let t = (this.pos.x - branchStart) / (branchEnd - branchStart) |
| 152 | t = constrain(t, 0, 1) |
| 153 | |
| 154 | // Branch visual thickness tapers from full at start to 35% at end |
| 155 | // This matches exactly how it's drawn in the bezier curves |
| 156 | let branchTopThickness = lerp( |
| 157 | branch.thickness * 0.9, |
| 158 | branch.thickness * 0.35, |
| 159 | t |
| 160 | ) |
| 161 | |
| 162 | // The branch is drawn centered at branch.y |
| 163 | // With small angle approximation: the top of the branch is at |
| 164 | let branchSurfaceY = branch.y - branchTopThickness |
| 165 | |
| 166 | // Add slight angle correction (for small angles, tan ≈ sin ≈ angle in radians) |
| 167 | let angleCorrection = (this.pos.x - branchStart) * branch.angle |
| 168 | branchSurfaceY += angleCorrection |
| 169 | |
| 170 | // Check if spider is crossing the branch from above |
| 171 | let prevY = this.pos.y - this.vel.y |
| 172 | |
| 173 | if ( |
| 174 | prevY <= branchSurfaceY && // Was above |
| 175 | this.pos.y + this.radius >= branchSurfaceY && // Now at or below |
| 176 | this.pos.y < branch.y + branch.thickness |
| 177 | ) { |
| 178 | // Not too far below |
| 179 | |
| 180 | // Place spider on the branch surface |
| 181 | this.pos.y = branchSurfaceY - this.radius |
| 182 | this.land() |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // Check obstacle collisions |
| 188 | for (let obstacle of obstacles) { |
| 189 | if (this.checkObstacleCollision(obstacle)) { |
| 190 | this.landOnObstacle(obstacle) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // Check web strand collisions |
| 195 | for (let strand of webStrands) { |
| 196 | if (strand === currentStrand) continue |
| 197 | |
| 198 | if (this.isAirborne && this.checkStrandCollision(strand)) { |
| 199 | this.landOnStrand(strand) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // Check food box collisions |
| 204 | for (let i = foodBoxes.length - 1; i >= 0; i--) { |
| 205 | let box = foodBoxes[i] |
| 206 | if ( |
| 207 | dist(this.pos.x, this.pos.y, box.pos.x, box.pos.y) < |
| 208 | this.radius + box.radius |
| 209 | ) { |
| 210 | box.collect() |
| 211 | foodBoxes.splice(i, 1) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | checkObstacleCollision (obstacle) { |
| 217 | let d = dist(this.pos.x, this.pos.y, obstacle.x, obstacle.y) |
| 218 | return d < this.radius + obstacle.radius |
| 219 | } |
| 220 | |
| 221 | checkStrandCollision (strand) { |
| 222 | let d = this.pointToLineDistance(this.pos, strand.start, strand.end) |
| 223 | return d < this.radius + 2 |
| 224 | } |
| 225 | |
| 226 | pointToLineDistance (point, lineStart, lineEnd) { |
| 227 | let line = p5.Vector.sub(lineEnd, lineStart) |
| 228 | let lineLength = line.mag() |
| 229 | line.normalize() |
| 230 | |
| 231 | let pointToStart = p5.Vector.sub(point, lineStart) |
| 232 | let projLength = constrain(pointToStart.dot(line), 0, lineLength) |
| 233 | |
| 234 | let closestPoint = p5.Vector.add( |
| 235 | lineStart, |
| 236 | p5.Vector.mult(line, projLength) |
| 237 | ) |
| 238 | return p5.Vector.dist(point, closestPoint) |
| 239 | } |
| 240 | |
| 241 | landOnObstacle (obstacle) { |
| 242 | let angle = atan2(this.pos.y - obstacle.y, this.pos.x - obstacle.x) |
| 243 | this.pos.x = obstacle.x + cos(angle) * (obstacle.radius + this.radius) |
| 244 | this.pos.y = obstacle.y + sin(angle) * (obstacle.radius + this.radius) |
| 245 | this.land() |
| 246 | } |
| 247 | |
| 248 | landOnStrand (strand) { |
| 249 | let line = p5.Vector.sub(strand.end, strand.start) |
| 250 | let lineLength = line.mag() |
| 251 | line.normalize() |
| 252 | |
| 253 | let pointToStart = p5.Vector.sub(this.pos, strand.start) |
| 254 | let projLength = constrain(pointToStart.dot(line), 0, lineLength) |
| 255 | |
| 256 | let closestPoint = p5.Vector.add( |
| 257 | strand.start, |
| 258 | p5.Vector.mult(line, projLength) |
| 259 | ) |
| 260 | this.pos = closestPoint |
| 261 | this.land() |
| 262 | } |
| 263 | |
| 264 | land () { |
| 265 | this.vel.mult(0) |
| 266 | this.isAirborne = false |
| 267 | this.canJump = true |
| 268 | |
| 269 | if (currentStrand && isDeployingWeb && spacePressed) { |
| 270 | currentStrand.end = this.pos.copy() |
| 271 | currentStrand.path.push(this.pos.copy()) |
| 272 | webNodes.push(new WebNode(this.pos.x, this.pos.y)) |
| 273 | } |
| 274 | |
| 275 | currentStrand = null |
| 276 | isDeployingWeb = false |
| 277 | } |
| 278 | |
| 279 | display () { |
| 280 | push() |
| 281 | translate(this.pos.x, this.pos.y) |
| 282 | |
| 283 | if (isMunching && this.munchCooldown > 15) { |
| 284 | push() |
| 285 | fill(255, 100, 100, 150) |
| 286 | noStroke() |
| 287 | let munchSize = 15 + sin(frameCount * 0.5) * 5 |
| 288 | arc(0, 0, munchSize, munchSize, 0, PI + HALF_PI, PIE) |
| 289 | pop() |
| 290 | } |
| 291 | |
| 292 | fill(20) |
| 293 | stroke(0) |
| 294 | strokeWeight(1) |
| 295 | ellipse(0, 0, this.radius * 2) |
| 296 | |
| 297 | fill(40) |
| 298 | noStroke() |
| 299 | ellipse(0, -2, this.radius * 1.2, this.radius * 1.5) |
| 300 | |
| 301 | if (gamePhase === 'NIGHT') { |
| 302 | fill(255, 100, 100) |
| 303 | } else { |
| 304 | fill(255, 0, 0) |
| 305 | } |
| 306 | ellipse(-3, -3, 3) |
| 307 | ellipse(3, -3, 3) |
| 308 | |
| 309 | stroke(0) |
| 310 | strokeWeight(1.5) |
| 311 | for (let i = 0; i < 4; i++) { |
| 312 | let angle = PI / 6 + (i * PI) / 8 |
| 313 | line(0, 0, cos(angle) * 12, sin(angle) * 8) |
| 314 | line(0, 0, -cos(angle) * 12, sin(angle) * 8) |
| 315 | } |
| 316 | |
| 317 | if (webSilk < 20) { |
| 318 | fill(255, 100, 100, 150 + sin(frameCount * 0.2) * 50) |
| 319 | noStroke() |
| 320 | ellipse(0, -15, 8) |
| 321 | } |
| 322 | |
| 323 | pop() |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | class Fly { |
| 328 | constructor () { |
| 329 | if (random() < 0.5) { |
| 330 | this.pos = createVector( |
| 331 | random() < 0.5 ? -20 : width + 20, |
| 332 | random(50, height - 100) |
| 333 | ) |
| 334 | } else { |
| 335 | this.pos = createVector(random(width), random() < 0.5 ? -20 : height + 20) |
| 336 | } |
| 337 | |
| 338 | this.vel = createVector(random(-2, 2), random(-1, 1)) |
| 339 | this.acc = createVector(0, 0) |
| 340 | this.radius = 4 |
| 341 | this.caught = false |
| 342 | this.stuck = false |
| 343 | this.wingPhase = random(TWO_PI) |
| 344 | this.wanderAngle = random(TWO_PI) |
| 345 | this.glowIntensity = random(150, 255) |
| 346 | this.touchedStrands = new Set() |
| 347 | this.slowedBy = new Set() // Track which strands are slowing us |
| 348 | this.baseSpeed = 3 |
| 349 | this.currentSpeed = this.baseSpeed |
| 350 | } |
| 351 | |
| 352 | update () { |
| 353 | if (this.stuck) return |
| 354 | |
| 355 | if (this.caught) { |
| 356 | this.vel.mult(0.95) |
| 357 | if (this.vel.mag() < 0.1) { |
| 358 | this.stuck = true |
| 359 | fliesCaught++ |
| 360 | webSilk = min(webSilk + 5, maxWebSilk) |
| 361 | } |
| 362 | return |
| 363 | } |
| 364 | |
| 365 | this.wanderAngle += random(-0.3, 0.3) |
| 366 | let wanderForce = createVector(cos(this.wanderAngle), sin(this.wanderAngle)) |
| 367 | wanderForce.mult(0.1) |
| 368 | this.acc.add(wanderForce) |
| 369 | |
| 370 | // Apply current speed (which may be slowed) |
| 371 | this.vel.add(this.acc) |
| 372 | this.vel.limit(this.currentSpeed) |
| 373 | this.pos.add(this.vel) |
| 374 | this.acc.mult(0) |
| 375 | |
| 376 | if (this.pos.x < -30) this.pos.x = width + 30 |
| 377 | if (this.pos.x > width + 30) this.pos.x = -30 |
| 378 | if (this.pos.y < -30) this.pos.y = height + 30 |
| 379 | if (this.pos.y > height + 30) this.pos.y = -30 |
| 380 | |
| 381 | // Check web collisions |
| 382 | this.checkWebCollisions() |
| 383 | } |
| 384 | |
| 385 | checkWebCollisions () { |
| 386 | let currentlyTouching = new Set() |
| 387 | |
| 388 | for (let strand of webStrands) { |
| 389 | let touching = false |
| 390 | |
| 391 | // Check collision with strand path |
| 392 | if (strand.path && strand.path.length > 1) { |
| 393 | for (let i = 0; i < strand.path.length - 1; i++) { |
| 394 | let p1 = strand.path[i] |
| 395 | let p2 = strand.path[i + 1] |
| 396 | let d = this.pointToLineDistance(this.pos, p1, p2) |
| 397 | if (d < this.radius + 3) { |
| 398 | touching = true |
| 399 | break |
| 400 | } |
| 401 | } |
| 402 | } else if (strand.start && strand.end) { |
| 403 | // Fallback for strands without path |
| 404 | let d = this.pointToLineDistance(this.pos, strand.start, strand.end) |
| 405 | if (d < this.radius + 3) { |
| 406 | touching = true |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | if (touching) { |
| 411 | currentlyTouching.add(strand) |
| 412 | |
| 413 | // If this is a new strand we're touching |
| 414 | if (!this.touchedStrands.has(strand)) { |
| 415 | this.touchedStrands.add(strand) |
| 416 | |
| 417 | // Vibrate the web when first touching |
| 418 | strand.vibrate(3) |
| 419 | |
| 420 | // First strand slows us down |
| 421 | if (this.touchedStrands.size === 1) { |
| 422 | this.currentSpeed = this.baseSpeed * 0.4 // Slow to 40% speed |
| 423 | this.slowedBy.add(strand) |
| 424 | |
| 425 | // Visual feedback - yellow particles for slowing |
| 426 | for (let j = 0; j < 3; j++) { |
| 427 | let p = new Particle(this.pos.x, this.pos.y) |
| 428 | p.color = color(255, 255, 0, 150) |
| 429 | p.vel = createVector(random(-1, 1), random(-1, 1)) |
| 430 | p.size = 3 |
| 431 | particles.push(p) |
| 432 | } |
| 433 | } |
| 434 | // Second strand catches us |
| 435 | else if (this.touchedStrands.size >= 2 && !this.caught) { |
| 436 | this.caught = true |
| 437 | this.currentSpeed = 0 |
| 438 | |
| 439 | // Stronger vibration when caught |
| 440 | strand.vibrate(8) |
| 441 | |
| 442 | // Also vibrate nearby strands |
| 443 | for (let otherStrand of webStrands) { |
| 444 | if (otherStrand !== strand) { |
| 445 | for (let touchedStrand of this.touchedStrands) { |
| 446 | let d1 = dist( |
| 447 | otherStrand.start.x, |
| 448 | otherStrand.start.y, |
| 449 | touchedStrand.start.x, |
| 450 | touchedStrand.start.y |
| 451 | ) |
| 452 | let d2 = dist( |
| 453 | otherStrand.start.x, |
| 454 | otherStrand.start.y, |
| 455 | touchedStrand.end.x, |
| 456 | touchedStrand.end.y |
| 457 | ) |
| 458 | let d3 = dist( |
| 459 | otherStrand.end.x, |
| 460 | otherStrand.end.y, |
| 461 | touchedStrand.start.x, |
| 462 | touchedStrand.start.y |
| 463 | ) |
| 464 | let d4 = dist( |
| 465 | otherStrand.end.x, |
| 466 | otherStrand.end.y, |
| 467 | touchedStrand.end.x, |
| 468 | touchedStrand.end.y |
| 469 | ) |
| 470 | if (min(d1, d2, d3, d4) < 50) { |
| 471 | otherStrand.vibrate(2) |
| 472 | break |
| 473 | } |
| 474 | } |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | // Create caught particles |
| 479 | for (let j = 0; j < 6; j++) { |
| 480 | let p = new Particle(this.pos.x, this.pos.y) |
| 481 | p.color = color(255, 200, 0, 200) |
| 482 | p.vel = createVector(random(-2, 2), random(-2, 2)) |
| 483 | particles.push(p) |
| 484 | } |
| 485 | } |
| 486 | } |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | // If we're no longer touching strands we were slowed by, speed back up |
| 491 | if (this.slowedBy.size > 0 && currentlyTouching.size === 0) { |
| 492 | this.currentSpeed = this.baseSpeed |
| 493 | this.slowedBy.clear() |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | pointToLineDistance (point, lineStart, lineEnd) { |
| 498 | let line = p5.Vector.sub(lineEnd, lineStart) |
| 499 | let lineLength = line.mag() |
| 500 | line.normalize() |
| 501 | |
| 502 | let pointToStart = p5.Vector.sub(point, lineStart) |
| 503 | let projLength = constrain(pointToStart.dot(line), 0, lineLength) |
| 504 | |
| 505 | let closestPoint = p5.Vector.add( |
| 506 | lineStart, |
| 507 | p5.Vector.mult(line, projLength) |
| 508 | ) |
| 509 | return p5.Vector.dist(point, closestPoint) |
| 510 | } |
| 511 | |
| 512 | display () { |
| 513 | push() |
| 514 | translate(this.pos.x, this.pos.y) |
| 515 | |
| 516 | // Show slowdown effect |
| 517 | if (this.slowedBy.size > 0 && !this.caught) { |
| 518 | stroke(255, 255, 0, 100) |
| 519 | strokeWeight(1) |
| 520 | noFill() |
| 521 | ellipse(0, 0, 20) |
| 522 | } |
| 523 | |
| 524 | if (gamePhase === 'NIGHT') { |
| 525 | noStroke() |
| 526 | fill(255, 255, 150, this.glowIntensity * 0.3) |
| 527 | ellipse(0, 0, 30) |
| 528 | fill(255, 255, 100, this.glowIntensity * 0.5) |
| 529 | ellipse(0, 0, 20) |
| 530 | } |
| 531 | |
| 532 | fill(30) |
| 533 | stroke(0) |
| 534 | strokeWeight(0.5) |
| 535 | ellipse(0, 0, this.radius * 2) |
| 536 | |
| 537 | if (!this.stuck) { |
| 538 | this.wingPhase += 0.5 |
| 539 | // Wing animation slows down when slowed |
| 540 | let wingSpeed = this.slowedBy.size > 0 ? 0.25 : 0.5 |
| 541 | this.wingPhase += wingSpeed |
| 542 | let wingSpread = sin(this.wingPhase) * 5 |
| 543 | |
| 544 | fill(255, 255, 255, 150) |
| 545 | noStroke() |
| 546 | ellipse(-wingSpread, 0, 6, 4) |
| 547 | ellipse(wingSpread, 0, 6, 4) |
| 548 | } |
| 549 | |
| 550 | if (gamePhase === 'NIGHT') { |
| 551 | fill(255, 255, 100, this.glowIntensity) |
| 552 | noStroke() |
| 553 | ellipse(0, 2, 3) |
| 554 | } |
| 555 | |
| 556 | pop() |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | class Obstacle { |
| 561 | constructor (x, y, radius, type) { |
| 562 | // Store original position for drift tracking |
| 563 | this.originalX = x |
| 564 | this.originalY = y |
| 565 | this.x = x |
| 566 | this.y = y |
| 567 | this.radius = radius |
| 568 | this.type = type || 'leaf' |
| 569 | this.rotation = random(TWO_PI) |
| 570 | this.leafPoints = [] |
| 571 | |
| 572 | // Movement properties for all types |
| 573 | this.bobOffset = random(TWO_PI) |
| 574 | this.bobSpeed = random(0.02, 0.04) |
| 575 | this.bobAmount = 0 |
| 576 | |
| 577 | // Type-specific initialization |
| 578 | if (this.type === 'balloon') { |
| 579 | this.bobAmount = 8 // Balloons bob more |
| 580 | this.balloonColors = [ |
| 581 | color(255, 100, 100), // Red |
| 582 | color(100, 200, 255), // Blue |
| 583 | color(255, 200, 100) // Yellow |
| 584 | ] |
| 585 | this.balloonColor = random(this.balloonColors) |
| 586 | this.stringWave = 0 |
| 587 | this.antLegPhase = random(TWO_PI) |
| 588 | |
| 589 | } else if (this.type === 'beetle') { |
| 590 | this.bobAmount = 4 |
| 591 | this.driftSpeed = random(0.15, 0.35) |
| 592 | this.driftAngle = random(TWO_PI) |
| 593 | this.driftChangeRate = random(0.005, 0.015) |
| 594 | this.wingPhase = random(TWO_PI) |
| 595 | this.beetleColor = random() < 0.5 ? |
| 596 | color(20, 60, 20) : // Dark green |
| 597 | color(40, 20, 60) // Purple |
| 598 | this.driftDistance = 0 // Track total drift |
| 599 | |
| 600 | } else if (this.type === 'leaf') { |
| 601 | this.bobAmount = 2 // Leaves bob slightly |
| 602 | let numPoints = 8 |
| 603 | for (let i = 0; i < numPoints; i++) { |
| 604 | let angle = (TWO_PI / numPoints) * i |
| 605 | let r = radius * random(0.7, 1.2) |
| 606 | if (i === 0 || i === numPoints / 2) r = radius * 1.3 |
| 607 | this.leafPoints.push({ angle: angle, radius: r }) |
| 608 | } |
| 609 | } else if (this.type === 'branch') { |
| 610 | // Keep for backwards compatibility |
| 611 | this.bobAmount = 0 |
| 612 | } |
| 613 | } |
| 614 | |
| 615 | update() { |
| 616 | // Bobbing motion for all types |
| 617 | let bob = sin(frameCount * this.bobSpeed + this.bobOffset) * this.bobAmount |
| 618 | this.y = this.originalY + bob |
| 619 | |
| 620 | // Beetle-specific drift |
| 621 | if (this.type === 'beetle') { |
| 622 | // Store initial position if not set |
| 623 | if (!this.initialX) { |
| 624 | this.initialX = this.x |
| 625 | this.initialY = this.y |
| 626 | } |
| 627 | |
| 628 | // Slowly change drift direction using Perlin noise |
| 629 | this.driftAngle += (noise(frameCount * this.driftChangeRate, this.originalX * 0.01) - 0.5) * 0.1 |
| 630 | |
| 631 | // Apply drift to original position |
| 632 | this.originalX += cos(this.driftAngle) * this.driftSpeed |
| 633 | this.originalY += sin(this.driftAngle) * this.driftSpeed * 0.5 |
| 634 | |
| 635 | // Calculate total drift distance from initial position |
| 636 | this.driftDistance = dist(this.originalX, this.originalY, this.initialX, this.initialY) |
| 637 | |
| 638 | // Keep beetles on screen with soft boundaries |
| 639 | if (this.originalX < 80) { |
| 640 | this.driftAngle = random(-PI/4, PI/4) |
| 641 | this.originalX = 80 |
| 642 | } |
| 643 | if (this.originalX > width - 80) { |
| 644 | this.driftAngle = random(3*PI/4, 5*PI/4) |
| 645 | this.originalX = width - 80 |
| 646 | } |
| 647 | if (this.originalY < 80) { |
| 648 | this.driftAngle = random(-3*PI/4, -PI/4) |
| 649 | this.originalY = 80 |
| 650 | } |
| 651 | if (this.originalY > height - 150) { |
| 652 | this.driftAngle = random(PI/4, 3*PI/4) |
| 653 | this.originalY = height - 150 |
| 654 | } |
| 655 | |
| 656 | // Update actual position (with bob already applied to y) |
| 657 | this.x = this.originalX |
| 658 | |
| 659 | // Check if beetle has drifted too far and break attached strands |
| 660 | if (this.driftDistance > 100) { |
| 661 | this.breakAttachedStrands() |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | // Update animation phases |
| 666 | if (this.type === 'balloon') { |
| 667 | this.stringWave = sin(frameCount * 0.05 + this.bobOffset) * 0.1 |
| 668 | this.antLegPhase += 0.1 |
| 669 | } else if (this.type === 'beetle') { |
| 670 | this.wingPhase += 0.15 |
| 671 | } |
| 672 | |
| 673 | // For all moving obstacles, update any attached web strands |
| 674 | if (this.bobAmount > 0 || this.type === 'beetle') { |
| 675 | this.updateAttachedStrands() |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | updateAttachedStrands() { |
| 680 | // Update web strands that are connected to this obstacle |
| 681 | for (let strand of webStrands) { |
| 682 | // Check if strand starts at this obstacle |
| 683 | if (dist(strand.start.x, strand.start.y, this.x, this.y) < this.radius + 10) { |
| 684 | strand.start.x = this.x |
| 685 | strand.start.y = this.y |
| 686 | if (strand.path && strand.path.length > 0) { |
| 687 | strand.path[0].x = this.x |
| 688 | strand.path[0].y = this.y |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | // Check if strand ends at this obstacle |
| 693 | if (strand.end && dist(strand.end.x, strand.end.y, this.x, this.y) < this.radius + 10) { |
| 694 | strand.end.x = this.x |
| 695 | strand.end.y = this.y |
| 696 | if (strand.path && strand.path.length > 0) { |
| 697 | strand.path[strand.path.length - 1].x = this.x |
| 698 | strand.path[strand.path.length - 1].y = this.y |
| 699 | } |
| 700 | } |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | breakAttachedStrands() { |
| 705 | // Break any strands attached to this beetle that has drifted too far |
| 706 | for (let strand of webStrands) { |
| 707 | let attachedToStart = dist(strand.start.x, strand.start.y, this.x, this.y) < this.radius + 10 |
| 708 | let attachedToEnd = strand.end && dist(strand.end.x, strand.end.y, this.x, this.y) < this.radius + 10 |
| 709 | |
| 710 | if (attachedToStart || attachedToEnd) { |
| 711 | // Mark strand as broken |
| 712 | strand.broken = true |
| 713 | |
| 714 | // Create dramatic snap particles |
| 715 | let snapX = attachedToStart ? strand.start.x : strand.end.x |
| 716 | let snapY = attachedToStart ? strand.start.y : strand.end.y |
| 717 | |
| 718 | // Red/pink particles for the snap |
| 719 | for (let i = 0; i < 8; i++) { |
| 720 | let p = new Particle(snapX, snapY) |
| 721 | p.color = color(255, random(100, 200), random(100, 150)) |
| 722 | p.vel = createVector(random(-5, 5), random(-5, 2)) |
| 723 | p.size = random(4, 8) |
| 724 | particles.push(p) |
| 725 | } |
| 726 | |
| 727 | // White strand particles |
| 728 | for (let i = 0; i < 4; i++) { |
| 729 | let p = new Particle(snapX, snapY) |
| 730 | p.color = color(255, 255, 255) |
| 731 | p.vel = createVector(random(-3, 3), random(-3, 0)) |
| 732 | p.size = 3 |
| 733 | particles.push(p) |
| 734 | } |
| 735 | |
| 736 | // Reset beetle drift after breaking strands |
| 737 | this.initialX = this.x |
| 738 | this.initialY = this.y |
| 739 | this.driftDistance = 0 |
| 740 | } |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | display () { |
| 745 | push() |
| 746 | translate(this.x, this.y) |
| 747 | |
| 748 | if (this.type === 'balloon') { |
| 749 | // Balloon with ant in basket! |
| 750 | push() |
| 751 | |
| 752 | // String first (behind balloon) |
| 753 | stroke(80, 60, 40) |
| 754 | strokeWeight(1) |
| 755 | noFill() |
| 756 | beginShape() |
| 757 | for (let i = 0; i <= 10; i++) { |
| 758 | let t = i / 10 |
| 759 | let stringX = sin(t * PI * 2 + this.stringWave) * 3 |
| 760 | let stringY = t * 40 + this.radius |
| 761 | curveVertex(stringX, stringY) |
| 762 | } |
| 763 | endShape() |
| 764 | |
| 765 | // Balloon shadow |
| 766 | noStroke() |
| 767 | fill(0, 0, 0, 30) |
| 768 | ellipse(5, 5, this.radius * 2.2, this.radius * 2.5) |
| 769 | |
| 770 | // Main balloon |
| 771 | noStroke() |
| 772 | fill(red(this.balloonColor), green(this.balloonColor), blue(this.balloonColor), 150) |
| 773 | ellipse(0, 0, this.radius * 2.2, this.radius * 2.5) |
| 774 | fill(red(this.balloonColor) + 30, green(this.balloonColor) + 30, blue(this.balloonColor) + 30, 200) |
| 775 | ellipse(-this.radius * 0.3, -this.radius * 0.3, this.radius * 1.2, this.radius * 1.4) |
| 776 | // Highlight |
| 777 | fill(255, 255, 255, 120) |
| 778 | ellipse(-this.radius * 0.4, -this.radius * 0.5, this.radius * 0.5, this.radius * 0.6) |
| 779 | |
| 780 | // Basket |
| 781 | translate(0, this.radius + 10) |
| 782 | fill(139, 90, 43) |
| 783 | stroke(100, 60, 20) |
| 784 | strokeWeight(1) |
| 785 | // Trapezoid basket |
| 786 | beginShape() |
| 787 | vertex(-8, 0) |
| 788 | vertex(8, 0) |
| 789 | vertex(6, 10) |
| 790 | vertex(-6, 10) |
| 791 | endShape(CLOSE) |
| 792 | // Basket weave pattern |
| 793 | stroke(100, 60, 20, 100) |
| 794 | for (let i = -6; i < 6; i += 3) { |
| 795 | line(i, 2, i, 8) |
| 796 | } |
| 797 | for (let i = 2; i < 8; i += 3) { |
| 798 | line(-6, i, 6, i) |
| 799 | } |
| 800 | |
| 801 | // Ant in basket |
| 802 | translate(0, 5) |
| 803 | fill(20) |
| 804 | noStroke() |
| 805 | // Ant body |
| 806 | ellipse(0, 0, 6, 4) // Head |
| 807 | ellipse(0, 3, 5, 6) // Thorax |
| 808 | ellipse(0, 7, 7, 9) // Abdomen |
| 809 | // Ant legs (animated) |
| 810 | stroke(20) |
| 811 | strokeWeight(0.5) |
| 812 | for (let i = 0; i < 3; i++) { |
| 813 | let legAngle = this.antLegPhase + i * 0.5 |
| 814 | let legSpread = 4 + sin(legAngle) * 2 |
| 815 | line(-2, 3 + i * 2, -legSpread, 3 + i * 2) |
| 816 | line(2, 3 + i * 2, legSpread, 3 + i * 2) |
| 817 | } |
| 818 | // Antennae |
| 819 | line(-1, -1, -3, -3) |
| 820 | line(1, -1, 3, -3) |
| 821 | |
| 822 | pop() |
| 823 | |
| 824 | } else if (this.type === 'beetle') { |
| 825 | // Big beetle! |
| 826 | push() |
| 827 | rotate(this.rotation) |
| 828 | |
| 829 | // Shadow |
| 830 | noStroke() |
| 831 | fill(0, 0, 0, 40) |
| 832 | ellipse(3, 3, this.radius * 1.8, this.radius * 2.2) |
| 833 | |
| 834 | // Wings (if flying at night) |
| 835 | if (gamePhase === 'NIGHT') { |
| 836 | push() |
| 837 | fill(255, 255, 255, 100 + sin(this.wingPhase) * 50) |
| 838 | noStroke() |
| 839 | let wingSpread = sin(this.wingPhase) * 15 |
| 840 | ellipse(-wingSpread, 0, 20, 12) |
| 841 | ellipse(wingSpread, 0, 20, 12) |
| 842 | pop() |
| 843 | } |
| 844 | |
| 845 | // Main beetle body |
| 846 | fill(red(this.beetleColor), green(this.beetleColor), blue(this.beetleColor)) |
| 847 | stroke(0) |
| 848 | strokeWeight(2) |
| 849 | ellipse(0, 0, this.radius * 1.6, this.radius * 2) |
| 850 | |
| 851 | // Shell split line |
| 852 | stroke(0) |
| 853 | strokeWeight(1) |
| 854 | line(0, -this.radius, 0, this.radius) |
| 855 | |
| 856 | // Head |
| 857 | fill(10) |
| 858 | ellipse(0, -this.radius * 0.8, this.radius * 0.8, this.radius * 0.6) |
| 859 | |
| 860 | // Spots/pattern |
| 861 | noStroke() |
| 862 | fill(0, 0, 0, 80) |
| 863 | ellipse(-this.radius * 0.3, 0, this.radius * 0.4) |
| 864 | ellipse(this.radius * 0.3, -this.radius * 0.2, this.radius * 0.3) |
| 865 | ellipse(this.radius * 0.2, this.radius * 0.4, this.radius * 0.35) |
| 866 | ellipse(-this.radius * 0.25, this.radius * 0.3, this.radius * 0.25) |
| 867 | |
| 868 | // Legs |
| 869 | stroke(0) |
| 870 | strokeWeight(2) |
| 871 | for (let i = 0; i < 3; i++) { |
| 872 | let legY = -this.radius * 0.3 + i * this.radius * 0.3 |
| 873 | let legMove = sin(this.wingPhase * 2 + i) * 2 |
| 874 | line(-this.radius * 0.8, legY, -this.radius * 1.2 + legMove, legY + 5) |
| 875 | line(this.radius * 0.8, legY, this.radius * 1.2 - legMove, legY + 5) |
| 876 | } |
| 877 | |
| 878 | // Antennae |
| 879 | strokeWeight(1) |
| 880 | line(-3, -this.radius * 1.1, -8, -this.radius * 1.4) |
| 881 | line(3, -this.radius * 1.1, 8, -this.radius * 1.4) |
| 882 | |
| 883 | // Eyes |
| 884 | fill(255, 0, 0) |
| 885 | noStroke() |
| 886 | ellipse(-5, -this.radius * 0.7, 4) |
| 887 | ellipse(5, -this.radius * 0.7, 4) |
| 888 | |
| 889 | pop() |
| 890 | |
| 891 | } else if (this.type === 'leaf') { |
| 892 | // Original leaf code |
| 893 | rotate(this.rotation) |
| 894 | |
| 895 | if (gamePhase === 'NIGHT') { |
| 896 | fill(20, 40, 20) |
| 897 | stroke(10, 20, 10) |
| 898 | } else { |
| 899 | fill(34, 139, 34) |
| 900 | stroke(25, 100, 25) |
| 901 | } |
| 902 | strokeWeight(2) |
| 903 | |
| 904 | beginShape() |
| 905 | for (let point of this.leafPoints) { |
| 906 | let x = cos(point.angle) * point.radius |
| 907 | let y = sin(point.angle) * point.radius |
| 908 | curveVertex(x, y) |
| 909 | } |
| 910 | let firstPoint = this.leafPoints[0] |
| 911 | curveVertex( |
| 912 | cos(firstPoint.angle) * firstPoint.radius, |
| 913 | sin(firstPoint.angle) * firstPoint.radius |
| 914 | ) |
| 915 | let secondPoint = this.leafPoints[1] |
| 916 | curveVertex( |
| 917 | cos(secondPoint.angle) * secondPoint.radius, |
| 918 | sin(secondPoint.angle) * secondPoint.radius |
| 919 | ) |
| 920 | endShape() |
| 921 | |
| 922 | stroke(25, 100, 25, 100) |
| 923 | strokeWeight(1) |
| 924 | line(0, -this.radius, 0, this.radius) |
| 925 | line(0, 0, -this.radius / 2, -this.radius / 2) |
| 926 | line(0, 0, this.radius / 2, -this.radius / 2) |
| 927 | line(0, 0, -this.radius / 2, this.radius / 2) |
| 928 | line(0, 0, this.radius / 2, this.radius / 2) |
| 929 | |
| 930 | } else if (this.type === 'branch') { |
| 931 | // Keep old branch code for backwards compatibility |
| 932 | rotate(this.rotation) |
| 933 | |
| 934 | if (gamePhase === 'NIGHT') { |
| 935 | stroke(40, 20, 0) |
| 936 | fill(50, 25, 5) |
| 937 | } else { |
| 938 | stroke(101, 67, 33) |
| 939 | fill(139, 90, 43) |
| 940 | } |
| 941 | strokeWeight(3) |
| 942 | |
| 943 | push() |
| 944 | strokeWeight(this.radius / 3) |
| 945 | line(-this.radius, 0, this.radius, 0) |
| 946 | |
| 947 | strokeWeight(2) |
| 948 | line(-this.radius / 2, 0, -this.radius / 2 - 10, -10) |
| 949 | line(this.radius / 3, 0, this.radius / 3 + 8, -8) |
| 950 | line(0, 0, 5, -15) |
| 951 | |
| 952 | stroke(80, 50, 20, 100) |
| 953 | strokeWeight(1) |
| 954 | for (let i = -this.radius; i < this.radius; i += 5) { |
| 955 | line(i, -2, i + 2, 2) |
| 956 | } |
| 957 | pop() |
| 958 | |
| 959 | noStroke() |
| 960 | fill(255, 255, 255, 30) |
| 961 | ellipse(0, 0, this.radius * 2) |
| 962 | } |
| 963 | |
| 964 | pop() |
| 965 | } |
| 966 | } |
| 967 | |
| 968 | class FoodBox { |
| 969 | constructor (x, y) { |
| 970 | this.pos = createVector(x, y) |
| 971 | this.radius = 10 |
| 972 | this.collected = false |
| 973 | this.floatOffset = random(TWO_PI) |
| 974 | this.silkValue = random(20, 35) |
| 975 | this.glowPhase = random(TWO_PI) |
| 976 | } |
| 977 | |
| 978 | collect () { |
| 979 | webSilk = min(webSilk + this.silkValue, maxWebSilk) |
| 980 | |
| 981 | for (let i = 0; i < 8; i++) { |
| 982 | particles.push(new Particle(this.pos.x, this.pos.y)) |
| 983 | } |
| 984 | } |
| 985 | |
| 986 | display () { |
| 987 | push() |
| 988 | let floatY = sin(frameCount * 0.05 + this.floatOffset) * 3 |
| 989 | translate(this.pos.x, this.pos.y + floatY) |
| 990 | |
| 991 | let glowIntensity = 100 + sin(frameCount * 0.1 + this.glowPhase) * 50 |
| 992 | noStroke() |
| 993 | fill(255, 200, 100, glowIntensity * 0.3) |
| 994 | ellipse(0, 0, 40) |
| 995 | fill(255, 220, 150, glowIntensity * 0.5) |
| 996 | ellipse(0, 0, 25) |
| 997 | |
| 998 | rectMode(CENTER) |
| 999 | |
| 1000 | fill(0, 0, 0, 50) |
| 1001 | rect(2, 2, this.radius * 2, this.radius * 1.8, 3) |
| 1002 | |
| 1003 | fill(139, 69, 19) |
| 1004 | stroke(100, 50, 0) |
| 1005 | strokeWeight(1) |
| 1006 | rect(0, 0, this.radius * 2, this.radius * 1.8, 3) |
| 1007 | |
| 1008 | stroke(100, 50, 0) |
| 1009 | strokeWeight(1) |
| 1010 | line(-this.radius, 0, this.radius, 0) |
| 1011 | line(0, -this.radius * 0.9, 0, this.radius * 0.9) |
| 1012 | |
| 1013 | noStroke() |
| 1014 | fill(255, 200, 100) |
| 1015 | ellipse(-5, -4, 4) |
| 1016 | ellipse(5, -4, 3) |
| 1017 | ellipse(-4, 5, 3) |
| 1018 | ellipse(4, 4, 4) |
| 1019 | |
| 1020 | pop() |
| 1021 | } |
| 1022 | } |
| 1023 | |
| 1024 | class Particle { |
| 1025 | constructor (x, y) { |
| 1026 | this.pos = createVector(x, y) |
| 1027 | this.vel = createVector(random(-3, 3), random(-5, -2)) |
| 1028 | this.lifetime = 255 |
| 1029 | this.color = color(255, random(200, 255), random(100, 200)) |
| 1030 | this.size = 6 // Default size |
| 1031 | } |
| 1032 | |
| 1033 | update () { |
| 1034 | this.vel.y += 0.2 |
| 1035 | this.pos.add(this.vel) |
| 1036 | this.lifetime -= 8 |
| 1037 | } |
| 1038 | |
| 1039 | display () { |
| 1040 | push() |
| 1041 | noStroke() |
| 1042 | fill(red(this.color), green(this.color), blue(this.color), this.lifetime) |
| 1043 | ellipse(this.pos.x, this.pos.y, this.size) |
| 1044 | pop() |
| 1045 | } |
| 1046 | |
| 1047 | isDead () { |
| 1048 | return this.lifetime <= 0 |
| 1049 | } |
| 1050 | } |