JavaScript · 54972 bytes Raw Blame History
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 this.attachedObstacle = null; // Track which obstacle spider is on
18 }
19
20 jump(targetX, targetY) {
21 if (!this.canJump || this.isAirborne) return; // Don't jump if already airborne
22
23 let direction = createVector(targetX - this.pos.x, targetY - this.pos.y);
24 let clickDistance = direction.mag();
25 direction.normalize();
26
27 // Scale jump power based on click distance (closer clicks = smaller jumps)
28 let actualJumpPower = map(clickDistance, 0, 200, 3, this.jumpPower);
29 actualJumpPower = constrain(actualJumpPower, 3, this.jumpPower);
30 direction.mult(actualJumpPower);
31
32 this.vel = direction;
33 this.isAirborne = true;
34 this.canJump = false;
35 this.lastAnchorPoint = this.pos.copy();
36 // Record jump time for touch debounce
37 if (typeof window !== 'undefined') {
38 window.lastJumpTime = millis();
39 }
40
41 // Check if we're jumping off a web strand
42 for (let strand of webStrands) {
43 if (strand === currentStrand) continue;
44
45 if (this.checkStrandCollision(strand)) {
46 // Much simpler shimmy detection based on actual jump power used
47 let isShimmy = actualJumpPower < 6; // If we used less than half power, it's a shimmy
48
49 // Apply appropriate recoil based on movement type
50 if (isShimmy) {
51 // Trigger shimmy visual effect
52 this.shimmyEffect = 20;
53
54 // NO recoil at all for shimmying - just tiny vibration
55 strand.vibrate(0.3);
56
57 // Tiny yellow particles
58 let p = new Particle(this.pos.x, this.pos.y);
59 p.color = color(255, 255, 100, 80);
60 p.vel = createVector(random(-0.3, 0.3), random(-0.3, 0.3));
61 p.size = 2;
62 particles.push(p);
63 } else {
64 // Scale recoil based on actual jump power
65 let recoilForce = -(actualJumpPower / this.jumpPower) * 0.08; // Scale by power ratio
66 strand.applyRecoil(recoilForce);
67
68 // Create particles only for real jumps
69 for (let i = 0; i < 2; i++) {
70 let p = new Particle(this.pos.x, this.pos.y);
71 p.color = color(255, 255, 255, 120);
72 p.vel = createVector(random(-0.8, 0.8), random(1, 2));
73 p.size = 3;
74 particles.push(p);
75 }
76 }
77
78 break;
79 }
80 }
81 }
82
83 munch() {
84 if (this.munchCooldown > 0) return;
85
86 isMunching = true;
87 this.munchCooldown = 30;
88
89 for (let i = flies.length - 1; i >= 0; i--) {
90 let fly = flies[i];
91 let d = dist(this.pos.x, this.pos.y, fly.pos.x, fly.pos.y);
92 if (d < this.munchRadius) {
93 fliesMunched++;
94 webSilk = min(webSilk + 15, maxWebSilk);
95
96 for (let j = 0; j < 12; j++) {
97 let p = new Particle(fly.pos.x, fly.pos.y);
98 p.color = color(255, random(100, 255), 0);
99 particles.push(p);
100 }
101
102 flies.splice(i, 1);
103 break;
104 }
105 }
106 }
107
108 update() {
109 // If attached to a moving obstacle, move with it
110 if (this.attachedObstacle && !this.isAirborne) {
111 // Calculate angle from obstacle center to spider
112 let angle = atan2(this.pos.y - this.attachedObstacle.y, this.pos.x - this.attachedObstacle.x);
113 // Keep spider on the surface of the obstacle
114 this.pos.x = this.attachedObstacle.x + cos(angle) * (this.attachedObstacle.radius + this.radius);
115 this.pos.y = this.attachedObstacle.y + sin(angle) * (this.attachedObstacle.radius + this.radius);
116 }
117
118 if (this.isAirborne) {
119 this.acc.add(this.gravity);
120 this.attachedObstacle = null; // Clear attachment when jumping
121 }
122
123 this.vel.add(this.acc);
124 this.vel.limit(this.maxSpeed);
125 this.pos.add(this.vel);
126 this.acc.mult(0);
127
128 if (this.munchCooldown > 0) {
129 this.munchCooldown--;
130 if (this.munchCooldown === 0) {
131 isMunching = false;
132 }
133 }
134
135 // Check ground collision
136 if (this.pos.y >= height - this.radius) {
137 this.pos.y = height - this.radius;
138 this.land();
139 this.attachedObstacle = null;
140 }
141
142 // Check wall collisions
143 if (this.pos.x <= this.radius || this.pos.x >= width - this.radius) {
144 this.pos.x = constrain(this.pos.x, this.radius, width - this.radius);
145 this.vel.x *= -0.5;
146 }
147
148 // Check ceiling
149 if (this.pos.y <= this.radius) {
150 this.pos.y = this.radius;
151 this.vel.y *= -0.5; // Bounce off ceiling, don't land
152 }
153
154 // Check home branch collision (one-way platform)
155 if (window.homeBranch && this.isAirborne && this.vel.y > 0.1) {
156 // Only when actually falling
157 let branch = window.homeBranch;
158
159 // Check if spider is within branch X range
160 let branchStart = Math.min(branch.startX, branch.endX);
161 let branchEnd = Math.max(branch.startX, branch.endX);
162
163 // Since the branch angle is very small (0.05 radians ≈ 3 degrees),
164 // we can use a simpler approximation
165 if (this.pos.x >= branchStart - 10 && this.pos.x <= branchEnd + 10) {
166 // Calculate position along branch (0 to 1)
167 let t = (this.pos.x - branchStart) / (branchEnd - branchStart);
168 t = constrain(t, 0, 1);
169
170 // Branch visual thickness tapers from full at start to 35% at end
171 // This matches exactly how it's drawn in the bezier curves
172 let branchTopThickness = lerp(
173 branch.thickness * 0.9,
174 branch.thickness * 0.35,
175 t
176 );
177
178 // The branch is drawn centered at branch.y
179 // With small angle approximation: the top of the branch is at
180 let branchSurfaceY = branch.y - branchTopThickness;
181
182 // Add slight angle correction (for small angles, tan ≈ sin ≈ angle in radians)
183 let angleCorrection = (this.pos.x - branchStart) * branch.angle;
184 branchSurfaceY += angleCorrection;
185
186 // Check if spider is crossing the branch from above
187 let prevY = this.pos.y - this.vel.y;
188
189 if (
190 prevY <= branchSurfaceY && // Was above
191 this.pos.y + this.radius >= branchSurfaceY && // Now at or below
192 this.pos.y < branch.y + branch.thickness
193 ) {
194 // Not too far below
195
196 // Place spider on the branch surface
197 this.pos.y = branchSurfaceY - this.radius;
198 this.land();
199 this.attachedObstacle = null;
200 }
201 }
202 }
203
204 // Check obstacle collisions
205 for (let obstacle of obstacles) {
206 if (this.checkObstacleCollision(obstacle)) {
207 this.landOnObstacle(obstacle);
208 }
209 }
210
211 // Check web strand collisions
212 for (let strand of webStrands) {
213 if (strand === currentStrand) continue;
214
215 if (this.isAirborne && this.checkStrandCollision(strand)) {
216 this.landOnStrand(strand);
217 }
218 }
219
220 // Check food box collisions
221 for (let i = foodBoxes.length - 1; i >= 0; i--) {
222 let box = foodBoxes[i];
223 if (
224 dist(this.pos.x, this.pos.y, box.pos.x, box.pos.y) <
225 this.radius + box.radius
226 ) {
227 box.collect();
228 foodBoxes.splice(i, 1);
229 }
230 }
231 }
232
233 checkObstacleCollision(obstacle) {
234 let d = dist(this.pos.x, this.pos.y, obstacle.x, obstacle.y);
235 return d < this.radius + obstacle.radius;
236 }
237
238 checkStrandCollision(strand) {
239 if (!strand || !strand.start || !strand.end) return false;
240 let d = this.pointToLineDistance(this.pos, strand.start, strand.end);
241 return d < this.radius + 2;
242 }
243
244 pointToLineDistance(point, lineStart, lineEnd) {
245 // Guard nulls
246 if (!lineStart || !lineEnd) {
247 return Infinity;
248 }
249 let line = p5.Vector.sub(lineEnd, lineStart);
250 let lineLength = line.mag();
251 // If start and end coincide, distance is to the single point
252 if (lineLength === 0) {
253 return p5.Vector.dist(point, lineStart);
254 }
255 line.normalize();
256 let pointToStart = p5.Vector.sub(point, lineStart);
257 let projLength = constrain(pointToStart.dot(line), 0, lineLength);
258 let closestPoint = p5.Vector.add(lineStart, p5.Vector.mult(line, projLength));
259 return p5.Vector.dist(point, closestPoint);
260 }
261
262 landOnObstacle(obstacle) {
263 // Only land if we're actually airborne
264 if (!this.isAirborne) return;
265
266 let angle = atan2(this.pos.y - obstacle.y, this.pos.x - obstacle.x);
267 this.pos.x = obstacle.x + cos(angle) * (obstacle.radius + this.radius);
268 this.pos.y = obstacle.y + sin(angle) * (obstacle.radius + this.radius);
269 this.attachedObstacle = obstacle; // Track which obstacle we're on
270 this.land();
271 }
272
273 landOnStrand(strand) {
274 // Only land if we're actually airborne
275 if (!this.isAirborne) return;
276 if (!strand || !strand.start || !strand.end) return;
277 let line = p5.Vector.sub(strand.end, strand.start);
278 let lineLength = line.mag();
279 if (lineLength === 0) {
280 // Degenerate strand; snap to start
281 this.pos = strand.start.copy ? strand.start.copy() : createVector(strand.start.x, strand.start.y);
282 } else {
283 line.normalize();
284 let pointToStart = p5.Vector.sub(this.pos, strand.start);
285 let projLength = constrain(pointToStart.dot(line), 0, lineLength);
286 let closestPoint = p5.Vector.add(strand.start, p5.Vector.mult(line, projLength));
287 this.pos = closestPoint;
288 }
289 this.attachedObstacle = null; // Not on an obstacle
290 this.land();
291 }
292
293 land() {
294 this.vel.mult(0);
295 this.isAirborne = false;
296 this.canJump = true;
297
298 if (currentStrand && isDeployingWeb && (spacePressed || touchHolding)) {
299 // Ensure the strand has a valid end and a final node on landing
300 currentStrand.end = this.pos.copy();
301 if (!currentStrand.path || currentStrand.path.length === 0) {
302 currentStrand.path = [this.pos.copy()];
303 } else {
304 currentStrand.path.push(this.pos.copy());
305 }
306 webNodes.push(new WebNode(this.pos.x, this.pos.y));
307 }
308
309 currentStrand = null;
310 isDeployingWeb = false;
311 }
312
313 display() {
314 push();
315 translate(this.pos.x, this.pos.y);
316
317 if (isMunching && this.munchCooldown > 15) {
318 push();
319 fill(255, 100, 100, 150);
320 noStroke();
321 let munchSize = 15 + sin(frameCount * 0.5) * 5;
322 arc(0, 0, munchSize, munchSize, 0, PI + HALF_PI, PIE);
323 pop();
324 }
325
326 fill(20);
327 stroke(0);
328 strokeWeight(1);
329 ellipse(0, 0, this.radius * 2);
330
331 fill(40);
332 noStroke();
333 ellipse(0, -2, this.radius * 1.2, this.radius * 1.5);
334
335 if (gamePhase === 'NIGHT') {
336 fill(255, 100, 100);
337 } else {
338 fill(255, 0, 0);
339 }
340 ellipse(-3, -3, 3);
341 ellipse(3, -3, 3);
342
343 stroke(0);
344 strokeWeight(1.5);
345 for (let i = 0; i < 4; i++) {
346 let angle = PI / 6 + (i * PI) / 8;
347 line(0, 0, cos(angle) * 12, sin(angle) * 8);
348 line(0, 0, -cos(angle) * 12, sin(angle) * 8);
349 }
350
351 if (webSilk < 20) {
352 fill(255, 100, 100, 150 + sin(frameCount * 0.2) * 50);
353 noStroke();
354 ellipse(0, -15, 8);
355 }
356
357 pop();
358 }
359 }
360
361 class Fly {
362 constructor() {
363 if (random() < 0.5) {
364 this.pos = createVector(
365 random() < 0.5 ? -20 : width + 20,
366 random(50, height - 100)
367 );
368 } else {
369 this.pos = createVector(random(width), random() < 0.5 ? -20 : height + 20);
370 }
371
372 this.vel = createVector(random(-2, 2), random(-1, 1));
373 this.acc = createVector(0, 0);
374 this.radius = 4;
375 this.caught = false;
376 this.stuck = false;
377 this.wingPhase = random(TWO_PI);
378 this.wanderAngle = random(TWO_PI);
379 this.glowIntensity = random(150, 255);
380 this.touchedStrands = new Set();
381 this.slowedBy = new Set(); // Track which strands are slowing us
382 this.baseSpeed = 3;
383 this.currentSpeed = this.baseSpeed;
384 }
385
386 update() {
387 if (this.stuck) {
388 // If stuck, check if we need to move with a drifting web
389 this.updatePositionOnWeb();
390 return;
391 }
392
393 if (this.caught) {
394 this.vel.mult(0.95);
395 if (this.vel.mag() < 0.1) {
396 this.stuck = true;
397 fliesCaught++;
398 webSilk = min(webSilk + 5, maxWebSilk);
399 }
400 // While caught but not yet stuck, also follow the web
401 this.updatePositionOnWeb();
402 return;
403 }
404
405 this.wanderAngle += random(-0.3, 0.3);
406 let wanderForce = createVector(cos(this.wanderAngle), sin(this.wanderAngle));
407 wanderForce.mult(0.1);
408 this.acc.add(wanderForce);
409
410 // Apply current speed (which may be slowed)
411 this.vel.add(this.acc);
412 this.vel.limit(this.currentSpeed);
413 this.pos.add(this.vel);
414 this.acc.mult(0);
415
416 if (this.pos.x < -30) this.pos.x = width + 30;
417 if (this.pos.x > width + 30) this.pos.x = -30;
418 if (this.pos.y < -30) this.pos.y = height + 30;
419 if (this.pos.y > height + 30) this.pos.y = -30;
420
421 // Check web collisions
422 this.checkWebCollisions();
423 }
424
425 updatePositionOnWeb() {
426 // Find the web strand(s) this fly is attached to
427 for (let strand of webStrands) {
428 if (strand.broken) continue;
429
430 // Check if fly is on this strand
431 let closestPoint = null;
432 let closestDistance = Infinity;
433
434 if (strand.path && strand.path.length > 1) {
435 for (let i = 0; i < strand.path.length - 1; i++) {
436 let p1 = strand.path[i];
437 let p2 = strand.path[i + 1];
438
439 // Find closest point on this segment
440 let line = p5.Vector.sub(p2, p1);
441 let lineLength = line.mag();
442 if (lineLength === 0) continue;
443 line.normalize();
444
445 let pointToStart = p5.Vector.sub(this.pos, p1);
446 let projLength = constrain(pointToStart.dot(line), 0, lineLength);
447
448 let projPoint = p5.Vector.add(p1, p5.Vector.mult(line, projLength));
449 let d = p5.Vector.dist(this.pos, projPoint);
450
451 if (d < closestDistance && d < this.radius + 5) {
452 closestDistance = d;
453 closestPoint = projPoint;
454 }
455 }
456 }
457
458 // If we found a close point on this strand, stick to it
459 if (closestPoint) {
460 // Move fly to follow the strand's movement
461 this.pos.x = closestPoint.x;
462 this.pos.y = closestPoint.y;
463
464 // Add small vibration when on a moving web
465 if (strand.vibration > 0) {
466 this.pos.x += random(-1, 1) * strand.vibration * 0.1;
467 this.pos.y += random(-1, 1) * strand.vibration * 0.1;
468 }
469 }
470 }
471 }
472
473 checkWebCollisions() {
474 let currentlyTouching = new Set();
475
476 for (let strand of webStrands) {
477 let touching = false;
478
479 // Check collision with strand path
480 if (strand.path && strand.path.length > 1) {
481 for (let i = 0; i < strand.path.length - 1; i++) {
482 let p1 = strand.path[i];
483 let p2 = strand.path[i + 1];
484 let d = this.pointToLineDistance(this.pos, p1, p2);
485 if (d < this.radius + 3) {
486 touching = true;
487 break;
488 }
489 }
490 } else if (strand.start && strand.end) {
491 // Fallback for strands without path
492 let d = this.pointToLineDistance(this.pos, strand.start, strand.end);
493 if (d < this.radius + 3) {
494 touching = true;
495 }
496 }
497
498 if (touching) {
499 currentlyTouching.add(strand);
500
501 // If this is a new strand we're touching
502 if (!this.touchedStrands.has(strand)) {
503 this.touchedStrands.add(strand);
504
505 // Vibrate the web when first touching
506 strand.vibrate(3);
507
508 // First strand slows us down
509 if (this.touchedStrands.size === 1) {
510 this.currentSpeed = this.baseSpeed * 0.4; // Slow to 40% speed
511 this.slowedBy.add(strand);
512
513 // Visual feedback - yellow particles for slowing
514 for (let j = 0; j < 3; j++) {
515 let p = new Particle(this.pos.x, this.pos.y);
516 p.color = color(255, 255, 0, 150);
517 p.vel = createVector(random(-1, 1), random(-1, 1));
518 p.size = 3;
519 particles.push(p);
520 }
521 }
522 // Second strand catches us
523 else if (this.touchedStrands.size >= 2 && !this.caught) {
524 this.caught = true;
525 this.currentSpeed = 0;
526
527 // Stronger vibration when caught
528 strand.vibrate(8);
529
530 // Also vibrate nearby strands
531 for (let otherStrand of webStrands) {
532 if (otherStrand !== strand) {
533 for (let touchedStrand of this.touchedStrands) {
534 let d1 = dist(
535 otherStrand.start.x,
536 otherStrand.start.y,
537 touchedStrand.start.x,
538 touchedStrand.start.y
539 );
540 let d2 = dist(
541 otherStrand.start.x,
542 otherStrand.start.y,
543 touchedStrand.end.x,
544 touchedStrand.end.y
545 );
546 let d3 = dist(
547 otherStrand.end.x,
548 otherStrand.end.y,
549 touchedStrand.start.x,
550 touchedStrand.start.y
551 );
552 let d4 = dist(
553 otherStrand.end.x,
554 otherStrand.end.y,
555 touchedStrand.end.x,
556 touchedStrand.end.y
557 );
558 if (min(d1, d2, d3, d4) < 50) {
559 otherStrand.vibrate(2);
560 break;
561 }
562 }
563 }
564 }
565
566 // Create caught particles
567 for (let j = 0; j < 6; j++) {
568 let p = new Particle(this.pos.x, this.pos.y);
569 p.color = color(255, 200, 0, 200);
570 p.vel = createVector(random(-2, 2), random(-2, 2));
571 particles.push(p);
572 }
573 }
574 }
575 }
576 }
577
578 // If we're no longer touching strands we were slowed by, speed back up
579 if (this.slowedBy.size > 0 && currentlyTouching.size === 0) {
580 this.currentSpeed = this.baseSpeed;
581 this.slowedBy.clear();
582 }
583 }
584
585 pointToLineDistance(point, lineStart, lineEnd) {
586 let line = p5.Vector.sub(lineEnd, lineStart);
587 let lineLength = line.mag();
588 line.normalize();
589
590 let pointToStart = p5.Vector.sub(point, lineStart);
591 let projLength = constrain(pointToStart.dot(line), 0, lineLength);
592
593 let closestPoint = p5.Vector.add(
594 lineStart,
595 p5.Vector.mult(line, projLength)
596 );
597 return p5.Vector.dist(point, closestPoint);
598 }
599
600 display() {
601 push();
602 translate(this.pos.x, this.pos.y);
603
604 // Show slowdown effect
605 if (this.slowedBy.size > 0 && !this.caught) {
606 stroke(255, 255, 0, 100);
607 strokeWeight(1);
608 noFill();
609 ellipse(0, 0, 20);
610 }
611
612 if (gamePhase === 'NIGHT') {
613 noStroke();
614 fill(255, 255, 150, this.glowIntensity * 0.3);
615 ellipse(0, 0, 30);
616 fill(255, 255, 100, this.glowIntensity * 0.5);
617 ellipse(0, 0, 20);
618 }
619
620 fill(30);
621 stroke(0);
622 strokeWeight(0.5);
623 ellipse(0, 0, this.radius * 2);
624
625 if (!this.stuck) {
626 // Wing animation slows down when slowed
627 let wingSpeed = this.slowedBy.size > 0 ? 0.25 : 0.5;
628 this.wingPhase += wingSpeed;
629 let wingSpread = sin(this.wingPhase) * 5;
630
631 fill(255, 255, 255, 150);
632 noStroke();
633 ellipse(-wingSpread, 0, 6, 4);
634 ellipse(wingSpread, 0, 6, 4);
635 }
636
637 if (gamePhase === 'NIGHT') {
638 fill(255, 255, 100, this.glowIntensity);
639 noStroke();
640 ellipse(0, 2, 3);
641 }
642
643 pop();
644 }
645 }
646
647 class Obstacle {
648 constructor(x, y, radius, type) {
649 // Store original position for drift tracking
650 this.originalX = x;
651 this.originalY = y;
652 this.x = x;
653 this.y = y;
654 this.radius = radius;
655 this.type = type || 'leaf';
656 this.rotation = random(TWO_PI);
657 this.leafPoints = [];
658
659 // Movement properties for all types
660 this.bobOffset = random(TWO_PI);
661 this.bobSpeed = random(0.02, 0.04);
662 this.bobAmount = 0;
663
664 // Type-specific initialization
665 if (this.type === 'balloon') {
666 this.bobAmount = 8; // Balloons bob more
667 this.balloonColors = [
668 color(255, 100, 100), // Red
669 color(100, 200, 255), // Blue
670 color(255, 200, 100) // Yellow
671 ];
672 this.balloonColor = random(this.balloonColors);
673 this.stringWave = 0;
674 this.antLegPhase = random(TWO_PI);
675
676 } else if (this.type === 'beetle') {
677 this.bobAmount = 4;
678 this.driftSpeed = random(0.15, 0.35);
679 this.driftAngle = random(TWO_PI);
680 this.driftChangeRate = random(0.005, 0.015);
681 this.wingPhase = random(TWO_PI);
682 this.beetleColor = random() < 0.5 ?
683 color(20, 60, 20) : // Dark green
684 color(40, 20, 60); // Purple
685 this.driftDistance = 0; // Track total drift
686
687 } else if (this.type === 'leaf') {
688 this.bobAmount = 2; // Leaves bob slightly
689 let numPoints = 8;
690 for (let i = 0; i < numPoints; i++) {
691 let angle = (TWO_PI / numPoints) * i;
692 let r = radius * random(0.7, 1.2);
693 if (i === 0 || i === numPoints / 2) r = radius * 1.3;
694 this.leafPoints.push({ angle: angle, radius: r });
695 }
696 } else if (this.type === 'branch') {
697 // Keep for backwards compatibility
698 this.bobAmount = 0;
699 }
700 }
701
702 update() {
703 // Bobbing motion for all types
704 let bob = sin(frameCount * this.bobSpeed + this.bobOffset) * this.bobAmount;
705 this.y = this.originalY + bob;
706
707 // Beetle-specific drift
708 if (this.type === 'beetle') {
709 // Store initial position if not set
710 if (!this.initialX) {
711 this.initialX = this.x;
712 this.initialY = this.y;
713 }
714
715 // Slowly change drift direction using Perlin noise
716 this.driftAngle += (noise(frameCount * this.driftChangeRate, this.originalX * 0.01) - 0.5) * 0.1;
717
718 // Apply drift to original position
719 this.originalX += cos(this.driftAngle) * this.driftSpeed;
720 this.originalY += sin(this.driftAngle) * this.driftSpeed * 0.5;
721
722 // Calculate total drift distance from initial position
723 this.driftDistance = dist(this.originalX, this.originalY, this.initialX, this.initialY);
724
725 // Keep beetles on screen with soft boundaries
726 if (this.originalX < 80) {
727 this.driftAngle = random(-PI/4, PI/4);
728 this.originalX = 80;
729 }
730 if (this.originalX > width - 80) {
731 this.driftAngle = random(3*PI/4, 5*PI/4);
732 this.originalX = width - 80;
733 }
734 if (this.originalY < 80) {
735 this.driftAngle = random(-3*PI/4, -PI/4);
736 this.originalY = 80;
737 }
738 if (this.originalY > height - 150) {
739 this.driftAngle = random(PI/4, 3*PI/4);
740 this.originalY = height - 150;
741 }
742
743 // Update actual position (with bob already applied to y)
744 this.x = this.originalX;
745
746 // Check if beetle has drifted too far and break attached strands
747 if (this.driftDistance > 100) {
748 this.breakAttachedStrands();
749 }
750 }
751
752 // Update animation phases
753 if (this.type === 'balloon') {
754 this.stringWave = sin(frameCount * 0.05 + this.bobOffset) * 0.1;
755 this.antLegPhase += 0.1;
756 } else if (this.type === 'beetle') {
757 this.wingPhase += 0.15;
758 }
759
760 // For all moving obstacles, update any attached web strands
761 if (this.bobAmount > 0 || this.type === 'beetle') {
762 this.updateAttachedStrands();
763 }
764 }
765
766 updateAttachedStrands() {
767 // Update web strands that are connected to this obstacle
768 for (let strand of webStrands) {
769 // Check if strand starts at this obstacle
770 if (dist(strand.start.x, strand.start.y, this.x, this.y) < this.radius + 10) {
771 strand.start.x = this.x;
772 strand.start.y = this.y;
773 if (strand.path && strand.path.length > 0) {
774 strand.path[0].x = this.x;
775 strand.path[0].y = this.y;
776 }
777 }
778
779 // Check if strand ends at this obstacle
780 if (strand.end && dist(strand.end.x, strand.end.y, this.x, this.y) < this.radius + 10) {
781 strand.end.x = this.x;
782 strand.end.y = this.y;
783 if (strand.path && strand.path.length > 0) {
784 strand.path[strand.path.length - 1].x = this.x;
785 strand.path[strand.path.length - 1].y = this.y;
786 }
787 }
788 }
789 }
790
791 breakAttachedStrands() {
792 // Break any strands attached to this beetle that has drifted too far
793 for (let strand of webStrands) {
794 let attachedToStart = dist(strand.start.x, strand.start.y, this.x, this.y) < this.radius + 10;
795 let attachedToEnd = strand.end && dist(strand.end.x, strand.end.y, this.x, this.y) < this.radius + 10;
796
797 if (attachedToStart || attachedToEnd) {
798 // Mark strand as broken
799 strand.broken = true;
800
801 // Release any flies stuck to this strand
802 for (let fly of flies) {
803 if (fly.stuck || fly.caught) {
804 // Check if fly is touching this breaking strand
805 let touchingStrand = false;
806 if (strand.path && strand.path.length > 1) {
807 for (let k = 0; k < strand.path.length - 1; k++) {
808 let p1 = strand.path[k];
809 let p2 = strand.path[k + 1];
810 let d = fly.pointToLineDistance(fly.pos, p1, p2);
811 if (d < fly.radius + 5) {
812 touchingStrand = true;
813 break;
814 }
815 }
816 }
817
818 // If fly was on this strand, release it
819 if (touchingStrand) {
820 fly.stuck = false;
821 fly.caught = false;
822 fly.currentSpeed = fly.baseSpeed;
823 fly.touchedStrands.clear();
824 fly.slowedBy.clear();
825 // Give it a little downward velocity to start falling
826 fly.vel = createVector(random(-0.5, 0.5), 2);
827
828 // Create release particles
829 for (let j = 0; j < 3; j++) {
830 let p = new Particle(fly.pos.x, fly.pos.y);
831 p.color = color(255, 255, 100, 150);
832 p.vel = createVector(random(-1, 1), random(0, 2));
833 p.size = 2;
834 particles.push(p);
835 }
836 }
837 }
838 }
839
840 // Create dramatic snap particles
841 let snapX = attachedToStart ? strand.start.x : strand.end.x;
842 let snapY = attachedToStart ? strand.start.y : strand.end.y;
843
844 // Red/pink particles for the snap
845 for (let i = 0; i < 8; i++) {
846 let p = new Particle(snapX, snapY);
847 p.color = color(255, random(100, 200), random(100, 150));
848 p.vel = createVector(random(-5, 5), random(-5, 2));
849 p.size = random(4, 8);
850 particles.push(p);
851 }
852
853 // White strand particles
854 for (let i = 0; i < 4; i++) {
855 let p = new Particle(snapX, snapY);
856 p.color = color(255, 255, 255);
857 p.vel = createVector(random(-3, 3), random(-3, 0));
858 p.size = 3;
859 particles.push(p);
860 }
861
862 // Reset beetle drift after breaking strands
863 this.initialX = this.x;
864 this.initialY = this.y;
865 this.driftDistance = 0;
866 }
867 }
868 }
869
870 display() {
871 push();
872 translate(this.x, this.y);
873
874 if (this.type === 'balloon') {
875 // Hot air balloon with canvas texture!
876 push();
877
878 // String/rope first (behind balloon)
879 stroke(80, 60, 40);
880 strokeWeight(1.5);
881 noFill();
882 beginShape();
883 for (let i = 0; i <= 10; i++) {
884 let t = i / 10;
885 let stringX = sin(t * PI * 2 + this.stringWave) * 3;
886 let stringY = t * 40 + this.radius;
887 curveVertex(stringX, stringY);
888 }
889 endShape();
890
891 // Balloon shadow
892 noStroke();
893 fill(0, 0, 0, 30);
894 ellipse(5, 5, this.radius * 2.2, this.radius * 2.5);
895
896 // Main balloon with canvas panels
897 push();
898 // Draw vertical panels for that classic hot air balloon look
899 let numPanels = 8;
900 for (let i = 0; i < numPanels; i++) {
901 let angle1 = (TWO_PI / numPanels) * i;
902 let angle2 = (TWO_PI / numPanels) * (i + 1);
903
904 // Alternate panel colors for striped effect
905 if (i % 2 === 0) {
906 fill(red(this.balloonColor), green(this.balloonColor), blue(this.balloonColor), 200);
907 } else {
908 fill(
909 red(this.balloonColor) - 30,
910 green(this.balloonColor) - 30,
911 blue(this.balloonColor) - 30,
912 200
913 );
914 }
915
916 // Draw tapered panel (wider at middle, narrow at top/bottom)
917 beginShape();
918 // Top point
919 vertex(0, -this.radius * 1.2);
920 // Upper curve
921 bezierVertex(
922 cos(angle1) * this.radius * 0.3, -this.radius * 0.9,
923 cos(angle1) * this.radius * 0.8, -this.radius * 0.3,
924 cos(angle1) * this.radius * 1.1, 0
925 );
926 // Lower curve to bottom
927 bezierVertex(
928 cos(angle1) * this.radius * 0.9, this.radius * 0.5,
929 cos(angle1) * this.radius * 0.4, this.radius * 0.9,
930 0, this.radius * 1.1
931 );
932 // Back up the other side
933 bezierVertex(
934 cos(angle2) * this.radius * 0.4, this.radius * 0.9,
935 cos(angle2) * this.radius * 0.9, this.radius * 0.5,
936 cos(angle2) * this.radius * 1.1, 0
937 );
938 bezierVertex(
939 cos(angle2) * this.radius * 0.8, -this.radius * 0.3,
940 cos(angle2) * this.radius * 0.3, -this.radius * 0.9,
941 0, -this.radius * 1.2
942 );
943 endShape(CLOSE);
944 }
945
946 // Panel seams/ropes
947 stroke(60, 40, 20, 100);
948 strokeWeight(0.5);
949 for (let i = 0; i < numPanels; i++) {
950 let angle = (TWO_PI / numPanels) * i;
951 // Vertical seam lines
952 beginShape();
953 noFill();
954 vertex(0, -this.radius * 1.2);
955 bezierVertex(
956 cos(angle) * this.radius * 0.3, -this.radius * 0.9,
957 cos(angle) * this.radius * 0.8, -this.radius * 0.3,
958 cos(angle) * this.radius * 1.1, 0
959 );
960 bezierVertex(
961 cos(angle) * this.radius * 0.9, this.radius * 0.5,
962 cos(angle) * this.radius * 0.4, this.radius * 0.9,
963 0, this.radius * 1.1
964 );
965 endShape();
966 }
967
968 // Highlight on balloon
969 noStroke();
970 fill(255, 255, 255, 80);
971 ellipse(-this.radius * 0.3, -this.radius * 0.5, this.radius * 0.6, this.radius * 0.7);
972 pop();
973
974 // FLAME EFFECT!
975 push();
976 translate(0, this.radius - 5);
977 // Flame glow
978 noStroke();
979 fill(255, 200, 0, 40 + sin(frameCount * 0.3) * 20);
980 ellipse(0, 0, 25, 25);
981 fill(255, 150, 0, 60 + sin(frameCount * 0.4) * 30);
982 ellipse(0, 0, 15, 18);
983 // Flame itself
984 fill(255, 200, 0);
985 push();
986 let flameHeight = 8 + sin(frameCount * 0.5) * 3;
987 translate(0, -2);
988 beginShape();
989 vertex(-3, 0);
990 bezierVertex(-3, -flameHeight * 0.7, -1, -flameHeight, 0, -flameHeight * 1.2);
991 bezierVertex(1, -flameHeight, 3, -flameHeight * 0.7, 3, 0);
992 endShape(CLOSE);
993 fill(255, 255, 200);
994 ellipse(0, -flameHeight * 0.5, 3, 4);
995 pop();
996 pop();
997
998 // Basket
999 push();
1000 translate(0, this.radius + 10);
1001 fill(101, 67, 33);
1002 stroke(80, 50, 20);
1003 strokeWeight(1);
1004 // Woven basket shape
1005 beginShape();
1006 vertex(-8, 0);
1007 vertex(8, 0);
1008 vertex(6, 10);
1009 vertex(-6, 10);
1010 endShape(CLOSE);
1011 // Basket weave pattern
1012 stroke(80, 50, 20, 150);
1013 for (let i = -6; i < 6; i += 2) {
1014 line(i, 1, i, 9);
1015 }
1016 for (let i = 2; i < 9; i += 2) {
1017 line(-6, i, 6, i);
1018 }
1019 // Basket rim
1020 stroke(60, 40, 20);
1021 strokeWeight(1.5);
1022 line(-8, 0, 8, 0);
1023 pop();
1024
1025 // Ant in basket (peeking over edge)
1026 push();
1027 translate(0, this.radius + 12);
1028 fill(20);
1029 noStroke();
1030 // Just ant head and antennae visible
1031 ellipse(0, -2, 6, 4); // Head peeking up
1032 // Antennae
1033 stroke(20);
1034 strokeWeight(0.5);
1035 line(-1, -3, -3, -6);
1036 line(1, -3, 3, -6);
1037 // Tiny ant arms gripping basket edge
1038 strokeWeight(1);
1039 line(-3, 0, -4, 2);
1040 line(3, 0, 4, 2);
1041 pop();
1042
1043 pop();
1044
1045 } else if (this.type === 'beetle') {
1046 // Big floating beetle!
1047 push();
1048 rotate(this.rotation);
1049
1050 // Shadow
1051 noStroke();
1052 fill(0, 0, 0, 40);
1053 ellipse(3, 3, this.radius * 1.8, this.radius * 2.2);
1054
1055 // Wings - always visible and flapping since they're floating
1056 push();
1057 // Wing flap animation
1058 let wingAngle = sin(this.wingPhase) * 0.3;
1059 let wingSpread = 15 + sin(this.wingPhase) * 10;
1060
1061 // Left wing
1062 push();
1063 translate(-this.radius * 0.4, 0);
1064 rotate(-wingAngle);
1065 fill(255, 255, 255, 120);
1066 stroke(0, 0, 0, 100);
1067 strokeWeight(0.5);
1068 ellipse(-wingSpread * 0.7, 0, wingSpread * 1.2, 15);
1069 // Wing details
1070 noStroke();
1071 fill(200, 200, 200, 80);
1072 ellipse(-wingSpread * 0.6, 0, wingSpread * 0.8, 10);
1073 pop();
1074
1075 // Right wing
1076 push();
1077 translate(this.radius * 0.4, 0);
1078 rotate(wingAngle);
1079 fill(255, 255, 255, 120);
1080 stroke(0, 0, 0, 100);
1081 strokeWeight(0.5);
1082 ellipse(wingSpread * 0.7, 0, wingSpread * 1.2, 15);
1083 // Wing details
1084 noStroke();
1085 fill(200, 200, 200, 80);
1086 ellipse(wingSpread * 0.6, 0, wingSpread * 0.8, 10);
1087 pop();
1088
1089 // Extra glow at night
1090 if (gamePhase === 'NIGHT') {
1091 noStroke();
1092 fill(255, 255, 200, 30 + sin(this.wingPhase * 2) * 20);
1093 ellipse(0, 0, this.radius * 3, this.radius * 2);
1094 }
1095 pop();
1096
1097 // Main beetle body (on top of wings)
1098 fill(red(this.beetleColor), green(this.beetleColor), blue(this.beetleColor));
1099 stroke(0);
1100 strokeWeight(2);
1101 ellipse(0, 0, this.radius * 1.6, this.radius * 2);
1102
1103 // Shell split line
1104 stroke(0);
1105 strokeWeight(1);
1106 line(0, -this.radius, 0, this.radius);
1107
1108 // Head
1109 fill(10);
1110 ellipse(0, -this.radius * 0.8, this.radius * 0.8, this.radius * 0.6);
1111
1112 // Spots/pattern
1113 noStroke();
1114 fill(0, 0, 0, 80);
1115 ellipse(-this.radius * 0.3, 0, this.radius * 0.4);
1116 ellipse(this.radius * 0.3, -this.radius * 0.2, this.radius * 0.3);
1117 ellipse(this.radius * 0.2, this.radius * 0.4, this.radius * 0.35);
1118 ellipse(-this.radius * 0.25, this.radius * 0.3, this.radius * 0.25);
1119
1120 // No legs - they're flying!
1121 // Just small leg stubs tucked under the body
1122 stroke(0);
1123 strokeWeight(1);
1124 // Tiny tucked legs
1125 line(-this.radius * 0.5, -this.radius * 0.2, -this.radius * 0.6, -this.radius * 0.1);
1126 line(this.radius * 0.5, -this.radius * 0.2, this.radius * 0.6, -this.radius * 0.1);
1127 line(-this.radius * 0.5, this.radius * 0.2, -this.radius * 0.6, this.radius * 0.1);
1128 line(this.radius * 0.5, this.radius * 0.2, this.radius * 0.6, this.radius * 0.1);
1129
1130 // Antennae
1131 strokeWeight(1);
1132 line(-3, -this.radius * 1.1, -8, -this.radius * 1.4);
1133 line(3, -this.radius * 1.1, 8, -this.radius * 1.4);
1134
1135 // Eyes (bigger and more prominent)
1136 fill(255, 0, 0);
1137 noStroke();
1138 ellipse(-5, -this.radius * 0.7, 5);
1139 ellipse(5, -this.radius * 0.7, 5);
1140 // Eye shine
1141 fill(255, 150, 150);
1142 ellipse(-4, -this.radius * 0.72, 2);
1143 ellipse(6, -this.radius * 0.72, 2);
1144
1145 pop();
1146
1147 } else if (this.type === 'leaf') {
1148 // Original leaf code
1149 rotate(this.rotation);
1150
1151 if (gamePhase === 'NIGHT') {
1152 fill(20, 40, 20);
1153 stroke(10, 20, 10);
1154 } else {
1155 fill(34, 139, 34);
1156 stroke(25, 100, 25);
1157 }
1158 strokeWeight(2);
1159
1160 beginShape();
1161 for (let point of this.leafPoints) {
1162 let x = cos(point.angle) * point.radius;
1163 let y = sin(point.angle) * point.radius;
1164 curveVertex(x, y);
1165 }
1166 let firstPoint = this.leafPoints[0];
1167 curveVertex(
1168 cos(firstPoint.angle) * firstPoint.radius,
1169 sin(firstPoint.angle) * firstPoint.radius
1170 );
1171 let secondPoint = this.leafPoints[1];
1172 curveVertex(
1173 cos(secondPoint.angle) * secondPoint.radius,
1174 sin(secondPoint.angle) * secondPoint.radius
1175 );
1176 endShape();
1177
1178 stroke(25, 100, 25, 100);
1179 strokeWeight(1);
1180 line(0, -this.radius, 0, this.radius);
1181 line(0, 0, -this.radius / 2, -this.radius / 2);
1182 line(0, 0, this.radius / 2, -this.radius / 2);
1183 line(0, 0, -this.radius / 2, this.radius / 2);
1184 line(0, 0, this.radius / 2, this.radius / 2);
1185
1186 } else if (this.type === 'branch') {
1187 // Keep old branch code for backwards compatibility
1188 rotate(this.rotation);
1189
1190 if (gamePhase === 'NIGHT') {
1191 stroke(40, 20, 0);
1192 fill(50, 25, 5);
1193 } else {
1194 stroke(101, 67, 33);
1195 fill(139, 90, 43);
1196 }
1197 strokeWeight(3);
1198
1199 push();
1200 strokeWeight(this.radius / 3);
1201 line(-this.radius, 0, this.radius, 0);
1202
1203 strokeWeight(2);
1204 line(-this.radius / 2, 0, -this.radius / 2 - 10, -10);
1205 line(this.radius / 3, 0, this.radius / 3 + 8, -8);
1206 line(0, 0, 5, -15);
1207
1208 stroke(80, 50, 20, 100);
1209 strokeWeight(1);
1210 for (let i = -this.radius; i < this.radius; i += 5) {
1211 line(i, -2, i + 2, 2);
1212 }
1213 pop();
1214
1215 noStroke();
1216 fill(255, 255, 255, 30);
1217 ellipse(0, 0, this.radius * 2);
1218 }
1219
1220 pop();
1221 }
1222 }
1223
1224 class FoodBox {
1225 constructor(x, y) {
1226 this.pos = createVector(x, y);
1227 this.radius = 10;
1228 this.collected = false;
1229 this.floatOffset = random(TWO_PI);
1230 this.silkValue = random(20, 35);
1231 this.glowPhase = random(TWO_PI);
1232 }
1233
1234 collect() {
1235 webSilk = min(webSilk + this.silkValue, maxWebSilk);
1236
1237 for (let i = 0; i < 8; i++) {
1238 particles.push(new Particle(this.pos.x, this.pos.y));
1239 }
1240 }
1241
1242 display() {
1243 push();
1244 let floatY = sin(frameCount * 0.05 + this.floatOffset) * 3;
1245 translate(this.pos.x, this.pos.y + floatY);
1246
1247 let glowIntensity = 100 + sin(frameCount * 0.1 + this.glowPhase) * 50;
1248 noStroke();
1249 fill(255, 200, 100, glowIntensity * 0.3);
1250 ellipse(0, 0, 40);
1251 fill(255, 220, 150, glowIntensity * 0.5);
1252 ellipse(0, 0, 25);
1253
1254 rectMode(CENTER);
1255
1256 fill(0, 0, 0, 50);
1257 rect(2, 2, this.radius * 2, this.radius * 1.8, 3);
1258
1259 fill(139, 69, 19);
1260 stroke(100, 50, 0);
1261 strokeWeight(1);
1262 rect(0, 0, this.radius * 2, this.radius * 1.8, 3);
1263
1264 stroke(100, 50, 0);
1265 strokeWeight(1);
1266 line(-this.radius, 0, this.radius, 0);
1267 line(0, -this.radius * 0.9, 0, this.radius * 0.9);
1268
1269 noStroke();
1270 fill(255, 200, 100);
1271 ellipse(-5, -4, 4);
1272 ellipse(5, -4, 3);
1273 ellipse(-4, 5, 3);
1274 ellipse(4, 4, 4);
1275
1276 pop();
1277 }
1278 }
1279
1280 class Bird {
1281 constructor(pattern, isThief = false) {
1282 this.pattern = pattern; // 'dive', 'swoop', 'glide', 'circle'
1283 this.isThief = isThief;
1284 this.active = false;
1285 this.attacking = false;
1286 this.attackDelay = 120; // Frames before first attack
1287
1288 // Position and movement
1289 this.x = random(width);
1290 this.y = -50; // Start above screen
1291 this.vx = 0;
1292 this.vy = 0;
1293 this.targetX = 0;
1294 this.targetY = 0;
1295 this.speed = 3;
1296 this.angle = 0;
1297 this.wingPhase = random(TWO_PI);
1298
1299 // Visual properties
1300 this.size = isThief ? 25 : 20;
1301 this.color = isThief ? color(100, 50, 150) : color(50, 50, 50);
1302
1303 // Pattern-specific properties
1304 if (pattern === 'circle') {
1305 this.circleRadius = 150;
1306 this.circleAngle = 0;
1307 this.circleCenter = createVector(width/2, height/2);
1308 }
1309
1310 // Attack properties
1311 this.diveSpeed = 8;
1312 this.retreatSpeed = 4;
1313 this.state = 'waiting'; // 'waiting', 'approaching', 'attacking', 'retreating'
1314 }
1315
1316 update() {
1317 // Update wing animation
1318 this.wingPhase += 0.2;
1319
1320 // Countdown to attack
1321 if (this.attackDelay > 0) {
1322 this.attackDelay--;
1323 // Hover while waiting
1324 this.y = -30 + sin(frameCount * 0.05) * 10;
1325 this.x += sin(frameCount * 0.03) * 2;
1326 return;
1327 }
1328
1329 // Activate after delay
1330 if (!this.active) {
1331 this.active = true;
1332 this.state = 'approaching';
1333 // Set initial target
1334 if (this.isThief) {
1335 // Target caught flies
1336 let caughtFlies = flies.filter(f => f.stuck || f.caught);
1337 if (caughtFlies.length > 0) {
1338 let target = random(caughtFlies);
1339 this.targetX = target.pos.x;
1340 this.targetY = target.pos.y;
1341 } else {
1342 this.active = false; // No targets, deactivate
1343 return;
1344 }
1345 } else {
1346 // Target spider or web strands
1347 if (random() < 0.7) {
1348 // Target spider
1349 this.targetX = spider.pos.x;
1350 this.targetY = spider.pos.y;
1351 } else {
1352 // Target a web strand
1353 if (webStrands.length > 0) {
1354 let strand = random(webStrands.filter(s => !s.broken));
1355 if (strand && strand.path && strand.path.length > 0) {
1356 let point = random(strand.path);
1357 this.targetX = point.x;
1358 this.targetY = point.y;
1359 }
1360 }
1361 }
1362 }
1363 }
1364
1365 // Execute movement pattern
1366 switch(this.pattern) {
1367 case 'dive':
1368 this.executeDivePattern();
1369 break;
1370 case 'swoop':
1371 this.executeSwoopPattern();
1372 break;
1373 case 'glide':
1374 this.executeGlidePattern();
1375 break;
1376 case 'circle':
1377 this.executeCirclePattern();
1378 break;
1379 }
1380
1381 // Check collisions
1382 this.checkCollisions();
1383
1384 // Keep on screen during approach
1385 if (this.state === 'approaching') {
1386 this.x = constrain(this.x, 20, width - 20);
1387 }
1388 }
1389
1390 executeDivePattern() {
1391 if (this.state === 'approaching') {
1392 // Move into position above target
1393 let dx = this.targetX - this.x;
1394 let dy = 100 - this.y; // Position above screen
1395
1396 this.x += dx * 0.05;
1397 this.y += dy * 0.05;
1398
1399 // When in position, start diving
1400 if (abs(dx) < 30 && abs(dy) < 20) {
1401 this.state = 'attacking';
1402 this.attacking = true;
1403 }
1404 } else if (this.state === 'attacking') {
1405 // Dive straight down
1406 this.vy = this.diveSpeed;
1407 this.y += this.vy;
1408
1409 // Hit ground or target
1410 if (this.y > height - 50) {
1411 this.state = 'retreating';
1412 this.attacking = false;
1413 }
1414 } else if (this.state === 'retreating') {
1415 // Fly back up
1416 this.vy = -this.retreatSpeed;
1417 this.y += this.vy;
1418
1419 // Reset when off screen
1420 if (this.y < -50) {
1421 this.state = 'approaching';
1422 this.attackDelay = random(180, 300);
1423 this.x = random(width);
1424 }
1425 }
1426 }
1427
1428 executeSwoopPattern() {
1429 if (this.state === 'approaching') {
1430 // Come from the side
1431 if (this.x < 0) {
1432 this.x += 5;
1433 this.y = height * 0.3 + sin(this.x * 0.02) * 50;
1434 } else {
1435 this.state = 'attacking';
1436 this.attacking = true;
1437 }
1438 } else if (this.state === 'attacking') {
1439 // Swoop across screen following sine wave
1440 this.x += 6;
1441 this.y = height * 0.3 + sin(this.x * 0.02) * 100;
1442
1443 // Check if passed target
1444 if (abs(this.x - this.targetX) < 50) {
1445 // Attempt to grab/hit
1446 let swoopY = height * 0.3 + sin(this.targetX * 0.02) * 100;
1447 this.y = lerp(this.y, swoopY, 0.3);
1448 }
1449
1450 // Exit screen
1451 if (this.x > width + 50) {
1452 this.state = 'retreating';
1453 this.attacking = false;
1454 }
1455 } else if (this.state === 'retreating') {
1456 // Reset
1457 this.state = 'approaching';
1458 this.attackDelay = random(240, 360);
1459 this.x = -50;
1460 }
1461 }
1462
1463 executeGlidePattern() {
1464 if (this.state === 'approaching') {
1465 // Glide in from top corner
1466 this.x += 3;
1467 this.y += 1.5;
1468
1469 if (this.y > height * 0.2) {
1470 this.state = 'attacking';
1471 this.attacking = true;
1472 }
1473 } else if (this.state === 'attacking') {
1474 // Glide toward target
1475 let dx = this.targetX - this.x;
1476 let dy = this.targetY - this.y;
1477 let dist = sqrt(dx * dx + dy * dy);
1478
1479 if (dist > 10) {
1480 this.x += (dx / dist) * 4;
1481 this.y += (dy / dist) * 4;
1482 }
1483
1484 // Pass through and continue
1485 if (this.y > height - 100 || this.x < -50 || this.x > width + 50) {
1486 this.state = 'retreating';
1487 this.attacking = false;
1488 }
1489 } else if (this.state === 'retreating') {
1490 // Continue off screen
1491 this.x += this.vx;
1492 this.y += this.vy;
1493
1494 // Reset
1495 if (this.y > height + 50 || this.x < -100 || this.x > width + 100) {
1496 this.state = 'approaching';
1497 this.attackDelay = random(300, 420);
1498 this.x = random() < 0.5 ? -50 : width + 50;
1499 this.y = random(50, 150);
1500 this.vx = this.x < width/2 ? 3 : -3;
1501 this.vy = 1.5;
1502 }
1503 }
1504 }
1505
1506 executeCirclePattern() {
1507 if (this.state === 'approaching') {
1508 // Move to circle start position
1509 let startX = this.circleCenter.x + cos(0) * this.circleRadius;
1510 let startY = this.circleCenter.y + sin(0) * this.circleRadius;
1511
1512 let dx = startX - this.x;
1513 let dy = startY - this.y;
1514
1515 this.x += dx * 0.05;
1516 this.y += dy * 0.05;
1517
1518 if (abs(dx) < 20 && abs(dy) < 20) {
1519 this.state = 'attacking';
1520 this.attacking = true;
1521 this.circleAngle = 0;
1522 }
1523 } else if (this.state === 'attacking') {
1524 // Circle around center
1525 this.circleAngle += 0.05;
1526 this.x = this.circleCenter.x + cos(this.circleAngle) * this.circleRadius;
1527 this.y = this.circleCenter.y + sin(this.circleAngle) * this.circleRadius;
1528
1529 // Occasionally dive toward center
1530 if (frameCount % 120 === 0) {
1531 this.circleRadius = max(50, this.circleRadius - 30);
1532 } else {
1533 this.circleRadius = min(150, this.circleRadius + 1);
1534 }
1535
1536 // Complete circle
1537 if (this.circleAngle > TWO_PI * 2) {
1538 this.state = 'retreating';
1539 this.attacking = false;
1540 }
1541 } else if (this.state === 'retreating') {
1542 // Fly away
1543 this.y -= 5;
1544
1545 if (this.y < -50) {
1546 this.state = 'approaching';
1547 this.attackDelay = random(300, 480);
1548 this.x = random(width);
1549 }
1550 }
1551 }
1552
1553 checkCollisions() {
1554 // Check collision with spider
1555 if (this.attacking && dist(this.x, this.y, spider.pos.x, spider.pos.y) < this.size + spider.radius) {
1556 // Hit spider!
1557 if (gamePhase === 'DAWN') {
1558 // During dawn, hitting spider costs stamina
1559 jumpStamina = max(0, jumpStamina - 15);
1560 stats.birdHitsTaken++;
1561
1562 // Knockback
1563 spider.vel.x = (spider.pos.x - this.x) * 0.3;
1564 spider.vel.y = -3;
1565
1566 // Particles
1567 for (let i = 0; i < 8; i++) {
1568 let p = new Particle(spider.pos.x, spider.pos.y);
1569 p.color = color(255, 100, 100);
1570 p.vel = createVector(random(-3, 3), random(-3, 1));
1571 particles.push(p);
1572 }
1573 }
1574
1575 // Bird bounces off
1576 this.state = 'retreating';
1577 this.attacking = false;
1578 }
1579
1580 // Check collision with web strands
1581 if (this.attacking) {
1582 for (let strand of webStrands) {
1583 if (!strand.broken && strand.path) {
1584 for (let point of strand.path) {
1585 if (dist(this.x, this.y, point.x, point.y) < this.size) {
1586 // Bird breaks the strand!
1587 strand.broken = true;
1588 stats.strandsLostInNight++;
1589
1590 // Particles
1591 for (let i = 0; i < 5; i++) {
1592 let p = new Particle(point.x, point.y);
1593 p.color = color(255, 255, 255);
1594 p.vel = createVector(random(-2, 2), random(-2, 2));
1595 particles.push(p);
1596 }
1597 break;
1598 }
1599 }
1600 }
1601 }
1602 }
1603
1604 // Thief bird steals flies
1605 if (this.isThief && this.attacking) {
1606 for (let i = flies.length - 1; i >= 0; i--) {
1607 let fly = flies[i];
1608 if ((fly.stuck || fly.caught) && dist(this.x, this.y, fly.pos.x, fly.pos.y) < this.size + 10) {
1609 // Steal the fly!
1610 flies.splice(i, 1);
1611
1612 // Purple particles for theft
1613 for (let j = 0; j < 6; j++) {
1614 let p = new Particle(fly.pos.x, fly.pos.y);
1615 p.color = color(200, 100, 255);
1616 p.vel = createVector(random(-2, 2), random(-2, 2));
1617 particles.push(p);
1618 }
1619
1620 // Thief escapes after stealing
1621 this.state = 'retreating';
1622 this.attacking = false;
1623 this.active = false; // Deactivate thief after successful theft
1624 break;
1625 }
1626 }
1627 }
1628 }
1629
1630 display() {
1631 push();
1632 translate(this.x, this.y);
1633
1634 // Rotate based on movement
1635 if (this.state === 'attacking' && this.pattern === 'dive') {
1636 rotate(PI/2); // Point down when diving
1637 } else if (this.vx !== 0) {
1638 rotate(atan2(this.vy, this.vx));
1639 }
1640
1641 // Shadow
1642 push();
1643 noStroke();
1644 fill(0, 0, 0, 30);
1645 ellipse(5, 5, this.size * 2);
1646 pop();
1647
1648 // Wings
1649 let wingSpread = sin(this.wingPhase) * this.size * 0.8;
1650
1651 // Wing shadows
1652 noStroke();
1653 fill(0, 0, 0, 40);
1654 ellipse(-wingSpread + 2, 2, this.size * 1.5, this.size * 0.5);
1655 ellipse(wingSpread + 2, 2, this.size * 1.5, this.size * 0.5);
1656
1657 // Wings
1658 fill(this.isThief ? color(120, 70, 180) : color(80, 80, 80));
1659 ellipse(-wingSpread, 0, this.size * 1.5, this.size * 0.5);
1660 ellipse(wingSpread, 0, this.size * 1.5, this.size * 0.5);
1661
1662 // Body
1663 fill(this.isThief ? color(100, 50, 150) : color(50, 50, 50));
1664 ellipse(0, 0, this.size * 0.8, this.size);
1665
1666 // Head
1667 fill(this.isThief ? color(80, 40, 120) : color(30, 30, 30));
1668 ellipse(0, -this.size * 0.4, this.size * 0.5);
1669
1670 // Eye
1671 fill(this.isThief ? color(255, 100, 255) : color(255, 100, 100));
1672 noStroke();
1673 ellipse(3, -this.size * 0.4, 4);
1674
1675 // Beak
1676 fill(this.isThief ? color(200, 150, 50) : color(200, 150, 0));
1677 triangle(
1678 this.size * 0.25, -this.size * 0.4,
1679 this.size * 0.45, -this.size * 0.35,
1680 this.size * 0.25, -this.size * 0.3
1681 );
1682
1683 // Tail feathers
1684 fill(this.isThief ? color(120, 70, 180) : color(80, 80, 80));
1685 for (let i = -1; i <= 1; i++) {
1686 push();
1687 translate(-this.size * 0.3, this.size * 0.3);
1688 rotate(i * 0.2);
1689 ellipse(0, 0, this.size * 0.3, this.size * 0.8);
1690 pop();
1691 }
1692
1693 // Warning indicator if attacking
1694 if (this.attacking && frameCount % 20 < 10) {
1695 noFill();
1696 stroke(255, 100, 100, 150);
1697 strokeWeight(2);
1698 ellipse(0, 0, this.size * 2.5);
1699 }
1700
1701 pop();
1702 }
1703 }
1704
1705 class Particle {
1706 constructor(x, y) {
1707 this.pos = createVector(x, y);
1708 this.vel = createVector(random(-3, 3), random(-5, -2));
1709 this.lifetime = 255;
1710 this.color = color(255, random(200, 255), random(100, 200));
1711 this.size = 6; // Default size
1712 }
1713
1714 update() {
1715 this.vel.y += 0.2;
1716 this.pos.add(this.vel);
1717 this.lifetime -= 8;
1718 }
1719
1720 display() {
1721 push();
1722 noStroke();
1723 fill(red(this.color), green(this.color), blue(this.color), this.lifetime);
1724 ellipse(this.pos.x, this.pos.y, this.size);
1725 pop();
1726 }
1727
1728 isDead() {
1729 return this.lifetime <= 0;
1730 }
1731 }