diff --git a/index.html b/index.html index 30e436f..e378e1c 100644 --- a/index.html +++ b/index.html @@ -4,315 +4,7 @@ Zen Koi Pond Screensaver - + @@ -344,6 +36,14 @@

Stats

Kills 0 +
+ Crocodiles + 0 +
+
+ Croc Kills + 0 +
FPS 0 @@ -485,6 +185,7 @@

Performance

Fish: 0 | Ripples: 0
Food: 0 | Frogs: 0
+
Turtles: 0
@@ -579,6 +280,142 @@

Predators

+

Crocodiles 🐊

+
+
Crocodile Count
+ + +
Size -
+
+
+
+ + +
+ +
Base Speed
+ + +
Hunt Success
+ + +
Detection Range
+ + +
Attack Speed
+ + +
Rest Time
+ + +
Body Color
+ + +
Eye Color
+ + +
+
Hunts: 0 | Kills: 0
+
+
+ +

Turtles

+
+
Turtle Count
+ + +
Size -
+
+
+
+ + +
+ +
Swim Speed -
+
+
+
+ + +
+ +
Turn Force
+ + +
Detection Range
+ + +
Flee Force
+ + +
Food Seek Range
+ + +
Tamed Duration
+ + +
Shell Color
+ + +
Pattern Color
+ + +
Head/Limb Color
+ + +
+
Turtles: 0
+
+
+ +

Snails

+
+
Snail Count
+ + +
Size -
+
+
+
+ + +
+ +
Speed -
+
+
+
+ + +
+ +
Detection Range
+ + +
Retract Duration
+ + +
Crawl Speed
+ + +
Swim Chance
+ + +
Swim Duration
+ + +
Shell Color
+ + +
Body Color
+ + +
+
Snails: 0
+
+
+

Advanced Settings (click to expand)

@@ -704,6 +541,95 @@

+ +

Predator Shading

+
+
Body Shade Dark
+ + +
Body Shade Light
+ + +
Body Solid Alpha
+ + +
Specular Outer Alpha
+ + +
Specular Inner Alpha
+ + +
Specular Width
+ + +
Specular Inner Width
+ + +
Fin Shade Light
+ + +
Fin Shade Mid
+ + +
Fin Shade Dark
+ + +
Fin Alpha Base
+ + +
Fin Alpha Mid
+ + +
Fin Alpha Edge
+ + +
Outline Darken
+ + +
Outline Alpha
+ + +
Outline Width
+ + +
Eye Size Ratio
+ + +
Eye Iris Ratio
+ + +
Eye Pupil Ratio
+ + +
Eye Attack Glow
+ +
+ + +

Attack Effects (Anime-Style Eye Trails)

+
+
Eye Glow Outer Radius
+ + +
Eye Glow Pulse Speed
+ + +
Eye Glow Intensity
+ + +
Trail Max Length
+ + +
Trail Fade Time
+ + +
Trail Spacing
+ + +
Trail Alpha
+ +
+

Lily Pads & Flowers

@@ -765,6 +691,39 @@

Eye Size Ratio

+ +
Jump Range
+ + +
Swim Speed Min
+ + +
Swim Speed Max
+ + +
Swim Force
+ + +
Swim Chance
+ + +
Swim Seek Range
+ + +
Float Chance
+ + +
Float Duration
+ + +
Float Drift Speed
+ + +
Float Bob Speed
+ + +
Float Bob Amount
+ @@ -836,6 +795,70 @@

+ +

Fish 3D Shading

+
+
Body Shade Dark
+ + +
Body Shade Light
+ + +
Body Solid Alpha
+ + +
Pattern Edge Alpha
+ + +
Specular Outer Alpha
+ + +
Specular Inner Alpha
+ + +
Specular Width
+ + +
Specular Inner Width
+ + +
Fin Shade Light
+ + +
Fin Shade Mid
+ + +
Fin Shade Dark
+ + +
Fin Alpha Base
+ + +
Fin Alpha Mid
+ + +
Fin Alpha Edge
+ + +
Outline Darken
+ + +
Outline Alpha
+ + +
Outline Width
+ + +
Eye Size Ratio
+ + +
Eye Iris Ratio
+ + +
Eye Pupil Ratio
+ +
+

Rendering

@@ -857,2859 +880,6 @@

Copied!

- + - \ No newline at end of file + diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..1632eef --- /dev/null +++ b/js/config.js @@ -0,0 +1,297 @@ +// Configuration and Parameters +// All tunable parameters for the Koi Pond + +export const params = { + fishCount: 20, + sizeMin: 5, + sizeMax: 12, + spineCount: 14, + fatness: 1.0, + tailTaper: 1.5, + finScale: 1.0, + speedScale: 1.0, + waveSpeedBase: 0.005, + waveSpeedMult: 0.04, + waveAmpMax: 0.05, + waveAmpGain: 0.01, + distConstraint: 0.35, + turnForce: 0.025, + waterColor1: "#1a2a3a", + waterColor2: "#0f1820", + rainMode: false, + stoneDensity: 100, + stoneSizeMin: 6, + stoneSizeMax: 14, + pebbleColors: ["#5a4d33", "#8c7b60", "#2f2818"], + padDensity: 15, + padFormation: 'random', + padSizeMin: 25, + padSizeMax: 55, + padSplitSize: 0.5, + padVeinCount: 5, + padColor: "#4caf50", + flowerChance: 0.2, + flowerType: 'mixed', + flowerColors: ["#ffccd5", "#ffffff", "#ff69b4"], + frogChance: 0.1, + foodSpawnRate: 0.01, + foodSize: 4, + foodColor: "#eeba7b", + targetFPS: 60, + + // Fish reproduction + birthMode: 'hybrid', // 'proximity', 'random', or 'hybrid' + birthChance: 0.008, + maxFishCount: 50, + birthCooldown: 180, + + // Predator settings + predatorCount: 3, + predatorSizeMin: 12, + predatorSizeMax: 20, + predatorHuntSuccessRate: 0.4, + predatorDetectionRange: 150, + predatorAttackSpeed: 5.0, + predatorRestTime: 300, + predatorColor: "#1a3d2e", + + // Grass settings + grassDensity: 30, + grassHeightMin: 20, + grassHeightMax: 45, + grassColor: "#2d5016", + grassSwaySpeed: 0.006, + grassSwayAmount: 0.08, + + // Turtle settings + turtleCount: 3, + turtleSizeMin: 12, + turtleSizeMax: 18, + turtleBaseSpeedMin: 0.5, + turtleBaseSpeedMax: 0.8, + turtleTurnForce: 0.02, + turtleColor: "#4a7c59", + turtlePatternColor: "#2d4a35", + turtleHeadColor: "#5a8a68", + turtleDetectionRange: 180, + turtleFleeForceMultiplier: 3.5, + turtleFoodSeekRange: 250, + turtleTamedDuration: 600, + + // Snail settings + snailCount: 2, + snailSizeMin: 8, + snailSizeMax: 12, + snailSpeedMin: 0.2, + snailSpeedMax: 0.4, + snailShellColor: "#8B7355", + snailBodyColor: "#C4A57B", + snailDetectionRange: 120, + snailRetractDuration: 180, + snailCrawlSpeed: 0.5, + snailSwimChance: 0.15, + snailSwimDuration: 300, + + // Crocodile settings + crocodileCount: 1, + crocodileSizeMin: 22, + crocodileSizeMax: 30, + crocodileBaseSpeed: 0.4, + crocodileDetectionRange: 200, + crocodileAttackSpeed: 2.5, + crocodileHuntSuccessRate: 0.6, + crocodileRestTime: 400, + crocodileColor: "#3d5a2f", + crocodileBodyColor: "#4a6b38", + crocodileBellyColor: "#8b9b7a", + crocodileEyeColor: "#e8c547", + + // === ADVANCED SETTINGS === + + // Fish Physics Advanced + fishBaseSpeedMin: 1.5, + fishBaseSpeedMax: 2.5, + fishThicknessHead: 0.4, + fishThicknessNeck: 0.35, + fishThicknessTaper: 0.75, + fishThicknessMin: 0.12, + fishThicknessPow: 0.9, + fishWavePhaseOffset: 0.35, + fishInitialBirthCooldown: 60, + fishFleeForceMultiplier: 4, + fishInitialSwimTimer: 100, + + // Food Spawning Advanced + foodSizeVariationMin: 0.9, + foodSizeVariationMax: 1.1, + foodVelocityMin: -0.1, + foodVelocityMax: 0.1, + + // Predator Advanced + predatorBaseSpeed: 0.8, + predatorMaxForceLurking: 0.015, + predatorMaxForceAttacking: 0.15, + predatorMaxForceResting: 0.01, + predatorWanderProbability: 0.02, + predatorWanderMagnitude: 0.3, + predatorDetectingSpeedMult: 0.5, + predatorDetectingSeekMult: 0.3, + predatorDetectionTime: 30, + predatorAttackingSeekMult: 3.0, + predatorCatchDistance: 0.8, + predatorEatingDuration: 60, + predatorEatingSpeed: 0.2, + predatorRestingSpeedMult: 0.3, + predatorRestTimeFailMult: 0.5, + predatorRestTimeGiveUpMult: 0.3, + predatorMaxChaseDistMult: 2.0, + predatorAttackWaveMult: 1.5, + predatorBoundaryMargin: 80, + + // Lily Pad Advanced + padHueVariationMin: -5, + padHueVariationMax: 5, + padNotchAngleMin: 0.5, + padNotchAngleMax: 1.0, + padVeinLengthRatio: 0.85, + flowerMixedLotusProb: 0.5, + flowerMixedLilyProb: 0.8, + lotusOuterPetalCount: 8, + lotusOuterPetalRadius: 12, + lotusOuterPetalWidth: 10, + lotusOuterPetalHeight: 5, + lotusCenterRadius: 5, + lilyPetalCount: 12, + lilyCenterRadius: 3, + + // Frog Advanced + frogDiveDuration: 20, + frogJumpLegLength: 15, + frogEyeSizeRatio: 0.4, + frogSwimSpeedMin: 1.0, + frogSwimSpeedMax: 2.0, + frogSwimForce: 0.03, + frogJumpRange: 200, + frogSwimChance: 0.15, + frogSwimIdleTime: 300, + frogSwimSeekRange: 300, + frogFloatChance: 0.2, + frogFloatDuration: 400, + frogFloatDriftSpeed: 0.3, + frogFloatBobSpeed: 0.02, + frogFloatBobAmount: 2, + + // Environment Advanced + stoneDensityBase: 15000, + padClusterCountMin: 2, + padClusterCountMax: 5, + padRingRadiusMult: 0.35, + padRingVariationMin: -40, + padRingVariationMax: 40, + padClumpedOffsetRange: 180, + + // Ripple Effects + rippleLungeRadius: 5, + rippleLungeMaxRadius: 60, + rippleLungeSpeed: 2, + rippleKillMainRadius: 10, + rippleKillMainMaxRadius: 150, + rippleKillMainSpeed: 3, + rippleKillSecondaryRadius: 5, + rippleKillSecondaryMaxRadius: 100, + rippleKillSecondarySpeed: 4, + rippleFailRadius: 5, + rippleFailMaxRadius: 80, + rippleFailSpeed: 2, + + // Rendering & Performance + shadowUpdateFPS: 30, + shadowOffsetX: 20, + shadowOffsetY: 20, + + // Fish 3D Shading + fishBodyShadeDark: 0.4, + fishBodyShadeLight: 0.2, + fishBodySolidAlpha: 0.45, + fishPatternEdgeAlpha: 0.15, + fishSpecularOuterAlpha: 0.16, + fishSpecularInnerAlpha: 0.3, + fishSpecularWidth: 0.22, + fishSpecularInnerWidth: 0.09, + fishFinShadeLight: 0.25, + fishFinShadeMid: 0.08, + fishFinShadeDark: 0.25, + fishFinAlphaBase: 0.55, + fishFinAlphaMid: 0.35, + fishFinAlphaEdge: 0.15, + fishOutlineDarken: 0.5, + fishOutlineAlpha: 0.3, + fishOutlineWidth: 0.06, + fishEyeSizeRatio: 2.2, + fishEyeIrisRatio: 0.65, + fishEyePupilRatio: 0.45, + + // Predator 3D Shading + predatorBodyShadeDark: 0.4, + predatorBodyShadeLight: 0.15, + predatorBodySolidAlpha: 0.5, + predatorSpecularOuterAlpha: 0.12, + predatorSpecularInnerAlpha: 0.25, + predatorSpecularWidth: 0.2, + predatorSpecularInnerWidth: 0.08, + predatorFinShadeLight: 0.2, + predatorFinShadeMid: 0.05, + predatorFinShadeDark: 0.25, + predatorFinAlphaBase: 0.4, + predatorFinAlphaMid: 0.25, + predatorFinAlphaEdge: 0.1, + predatorOutlineDarken: 0.6, + predatorOutlineAlpha: 0.35, + predatorOutlineWidth: 0.07, + predatorEyeSizeRatio: 2.0, + predatorEyeIrisRatio: 0.6, + predatorEyePupilRatio: 0.5, + predatorEyeAttackGlowAlpha: 0.85, + + // Predator trail settings (anime-style eye after-images) + predatorTrailMaxLength: 12, // Maximum number of trail points + predatorTrailFadeTime: 0.5, // Time in seconds for trail to fade + predatorTrailColor: "#ff4444", // Trail color (red/orange) - not used for eye trails + predatorTrailWidth: 3, // Base trail width - not used for eye trails + predatorTrailSpacing: 10, // Distance between trail points + predatorTrailAlpha: 0.8, // Maximum trail alpha + + // Enhanced eye glow + predatorEyeGlowOuterRadius: 1.8, // Multiplier for outer glow radius + predatorEyeGlowPulseSpeed: 0.15, // Speed of glow pulsing animation + predatorEyeGlowIntensity: 1.2, // Intensity multiplier for glow + + // Crocodile Advanced + crocodileMaxForceLurking: 0.012, + crocodileMaxForceAttacking: 0.12, + crocodileMaxForceResting: 0.008, + crocodileWanderProbability: 0.015, + crocodileWanderMagnitude: 0.25, + crocodileDetectingSpeedMult: 0.6, + crocodileDetectingSeekMult: 0.4, + crocodileDetectionTime: 40, + crocodileAttackingSeekMult: 2.5, + crocodileCatchDistance: 1.0, + crocodileEatingDuration: 80, + crocodileEatingSpeed: 0.15, + crocodileRestingSpeedMult: 0.25, + crocodileRestTimeFailMult: 0.6, + crocodileRestTimeGiveUpMult: 0.4, + crocodileMaxChaseDistMult: 2.5, + crocodileBoundaryMargin: 100, + crocodileBodyShadeDark: 0.35, + crocodileBodyShadeLight: 0.12, + crocodileOutlineDarken: 0.55, + crocodileOutlineAlpha: 0.4, + crocodileOutlineWidth: 0.08, + crocodileEyeSizeRatio: 1.8, + crocodileEyeGlowIntensity: 1.0 +}; + +// Render culling margin +export const CULL_MARGIN = 100; diff --git a/js/entities/Crocodile.js b/js/entities/Crocodile.js new file mode 100644 index 0000000..0c13abf --- /dev/null +++ b/js/entities/Crocodile.js @@ -0,0 +1,801 @@ +// Crocodile class - apex predator that hunts predator fish + +import { Vector } from '../utils/Vector.js'; +import { params, CULL_MARGIN } from '../config.js'; +import { rand, dist, isInView, width, height } from '../utils/helpers.js'; +import { predatorGrid } from '../utils/SpatialGrid.js'; +import { ripplePool } from '../systems/ObjectPool.js'; + +export class Crocodile { + constructor(x, y) { + this.pos = new Vector(x, y); + this.vel = new Vector(rand(-0.3, 0.3), rand(-0.3, 0.3)); + this.acc = new Vector(0, 0); + + this.size = rand(params.crocodileSizeMin, params.crocodileSizeMax); + this.baseSpeed = params.crocodileBaseSpeed; + this.maxSpeed = this.baseSpeed; + this.maxForce = params.crocodileMaxForceLurking; + + // State machine + this.state = 'LURKING'; + this.target = null; + this.stateTimer = 0; + this.restTimer = 0; + this.attackStartPos = null; + + // Animation + this.swimTimer = Math.random() * 100; + this.legPhase = Math.random() * Math.PI * 2; + this.tailPhase = Math.random() * Math.PI * 2; + this.rotation = Math.atan2(this.vel.y, this.vel.x); + + // Stats + this.huntCount = 0; + this.killCount = 0; + } + + applyForce(force) { + this.acc.add(force); + } + + seek(target, speedMult = 1) { + let desired = new Vector(target.x - this.pos.x, target.y - this.pos.y); + desired.normalize(); + desired.mult(this.maxSpeed * speedMult); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * speedMult); + return steer; + } + + findTarget(predatorList) { + const detectionRange = params.crocodileDetectionRange; + let closestDist = detectionRange; + let closestPredator = null; + + const nearbyPredators = predatorGrid.getNearby(this.pos.x, this.pos.y, detectionRange); + + for (const predator of nearbyPredators) { + const dx = predator.pos.x - this.pos.x; + const dy = predator.pos.y - this.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < closestDist) { + closestDist = d; + closestPredator = predator; + } + } + + return closestPredator; + } + + update(predatorList, dt = 1/60) { + const scale = dt * 60; + + // State machine + switch (this.state) { + case 'LURKING': + this.maxSpeed = this.baseSpeed * params.speedScale; + this.maxForce = params.crocodileMaxForceLurking; + + if (Math.random() < params.crocodileWanderProbability) { + let wander = new Vector(rand(-1, 1), rand(-1, 1)); + wander.normalize(); + wander.mult(params.crocodileWanderMagnitude); + this.applyForce(wander); + } + + this.stayInBounds(); + + const target = this.findTarget(predatorList); + if (target) { + this.target = target; + this.state = 'DETECTING'; + this.stateTimer = 0; + } + break; + + case 'DETECTING': + this.maxSpeed = this.baseSpeed * params.crocodileDetectingSpeedMult * params.speedScale; + this.stateTimer += scale; + + if (this.target && predatorList.includes(this.target)) { + let steer = this.seek(this.target.pos, params.crocodileDetectingSeekMult); + this.applyForce(steer); + + if (this.stateTimer > params.crocodileDetectionTime) { + this.state = 'ATTACKING'; + this.attackStartPos = new Vector(this.pos.x, this.pos.y); + this.huntCount++; + ripplePool.acquire(this.pos.x, this.pos.y, 8, 100, 2.5); + } + } else { + this.target = null; + this.state = 'LURKING'; + } + break; + + case 'ATTACKING': + this.maxSpeed = params.crocodileAttackSpeed * params.speedScale; + this.maxForce = params.crocodileMaxForceAttacking; + + if (this.target && predatorList.includes(this.target)) { + let steer = this.seek(this.target.pos, params.crocodileAttackingSeekMult); + this.applyForce(steer); + + const dx = this.target.pos.x - this.pos.x; + const dy = this.target.pos.y - this.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < this.size * params.crocodileCatchDistance) { + if (Math.random() < params.crocodileHuntSuccessRate) { + this.state = 'EATING'; + this.stateTimer = 0; + this.killCount++; + + const idx = predatorList.indexOf(this.target); + if (idx !== -1) { + predatorList.splice(idx, 1); + } + + ripplePool.acquire(this.pos.x, this.pos.y, 15, 200, 3.5); + ripplePool.acquire(this.pos.x, this.pos.y, 8, 140, 4.5); + } else { + this.state = 'RESTING'; + this.restTimer = params.crocodileRestTime * params.crocodileRestTimeFailMult; + ripplePool.acquire(this.pos.x, this.pos.y, 8, 120, 2.5); + } + this.target = null; + } + + const chaseDistance = dist(this.attackStartPos.x, this.attackStartPos.y, this.pos.x, this.pos.y); + if (chaseDistance > params.crocodileDetectionRange * params.crocodileMaxChaseDistMult) { + this.state = 'RESTING'; + this.restTimer = params.crocodileRestTime * params.crocodileRestTimeGiveUpMult; + this.target = null; + } + } else { + this.target = null; + this.state = 'RESTING'; + this.restTimer = params.crocodileRestTime * params.crocodileRestTimeGiveUpMult; + } + break; + + case 'EATING': + this.maxSpeed = params.crocodileEatingSpeed; + this.stateTimer += scale; + + if (this.stateTimer > params.crocodileEatingDuration) { + this.state = 'RESTING'; + this.restTimer = params.crocodileRestTime; + } + break; + + case 'RESTING': + this.maxSpeed = this.baseSpeed * params.crocodileRestingSpeedMult * params.speedScale; + this.maxForce = params.crocodileMaxForceResting; + this.restTimer -= scale; + + this.stayInBounds(); + + if (this.restTimer <= 0) { + this.state = 'LURKING'; + } + break; + } + + // Physics update + this.vel.x += this.acc.x * scale; + this.vel.y += this.acc.y * scale; + this.vel.limit(this.maxSpeed); + + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + this.acc.mult(0); + + // Update rotation to face movement direction + if (this.vel.mag() > 0.1) { + let targetRotation = Math.atan2(this.vel.y, this.vel.x); + let rotDiff = targetRotation - this.rotation; + while (rotDiff > Math.PI) rotDiff -= Math.PI * 2; + while (rotDiff < -Math.PI) rotDiff += Math.PI * 2; + this.rotation += rotDiff * 0.08 * scale; + } + + // Animation + const speed = this.vel.mag(); + const animSpeed = speed > 0.1 ? (0.06 + speed * 0.12) : 0.02; + this.swimTimer += animSpeed * scale; + this.legPhase = this.swimTimer; + this.tailPhase = this.swimTimer * 0.8; + } + + stayInBounds() { + const margin = params.crocodileBoundaryMargin; + let desired = null; + + if (this.pos.x < margin) desired = new Vector(this.maxSpeed, this.vel.y); + else if (this.pos.x > width - margin) desired = new Vector(-this.maxSpeed, this.vel.y); + if (this.pos.y < margin) desired = new Vector(this.vel.x, this.maxSpeed); + else if (this.pos.y > height - margin) desired = new Vector(this.vel.x, -this.maxSpeed); + + if (desired) { + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * 2); + this.applyForce(steer); + } + } + + draw(ctx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 3)) return; + + ctx.save(); + ctx.translate(this.pos.x, this.pos.y); + ctx.rotate(this.rotation); + + // Top-down view proportions + const bodyLength = this.size * 2.2; + const bodyWidth = this.size * 1.0; // Width from above (broader) + const tailLength = this.size * 2.2; + const headLength = this.size * 1.0; + const headWidth = this.size * 0.7; // Head wider from top view + + // Draw tail + this.drawTail(ctx, bodyLength, bodyWidth, tailLength); + + // Draw legs (back) + this.drawLegs(ctx, bodyLength, bodyWidth, false); + + // Draw body + this.drawBody(ctx, bodyLength, bodyWidth); + + // Draw dorsal ridges + this.drawDorsalRidges(ctx, bodyLength, bodyWidth); + + // Draw head + this.drawHead(ctx, headLength, headWidth); + + // Draw legs (front) + this.drawLegs(ctx, bodyLength, bodyWidth, true); + + // Draw eyes + this.drawEyes(ctx, headLength, headWidth); + + // Draw attack indicator + if (this.state === 'DETECTING' || this.state === 'ATTACKING') { + ctx.strokeStyle = this.state === 'ATTACKING' + ? 'rgba(180, 50, 30, 0.15)' + : 'rgba(220, 180, 50, 0.1)'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, params.crocodileDetectionRange, 0, Math.PI * 2); + ctx.stroke(); + } + + ctx.restore(); + } + + drawBody(ctx, length, width) { + // Main body - viewed from above (broader, more rectangular) + ctx.fillStyle = params.crocodileBodyColor; + ctx.beginPath(); + // More rectangular body shape for top view + ctx.ellipse(-length * 0.1, 0, length / 2.5, width, 0, 0, Math.PI * 2); + ctx.ellipse(length * 0.15, 0, length / 3, width * 0.95, 0, 0, Math.PI * 2); + ctx.fill(); + + // Top-down lighting (sun from above, one side) + const gradient = ctx.createRadialGradient(-length * 0.2, -width * 0.3, 0, 0, 0, length); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0.15)'); + gradient.addColorStop(0.3, 'rgba(255, 255, 255, 0.05)'); + gradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.05)'); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.ellipse(-length * 0.1, 0, length / 2.5, width, 0, 0, Math.PI * 2); + ctx.ellipse(length * 0.15, 0, length / 3, width * 0.95, 0, 0, Math.PI * 2); + ctx.fill(); + + // Osteoderms (armor plates) texture - larger and more prominent + ctx.save(); + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)'; + ctx.lineWidth = 0.5; + + for (let x = -length / 2.2; x < length / 2.2; x += this.size * 0.18) { + for (let y = -width * 0.7; y < width * 0.7; y += this.size * 0.16) { + const offset = (Math.floor(x / (this.size * 0.18)) % 2) * this.size * 0.09; + const px = x + offset; + const py = y; + + // Draw rounded rectangular scutes + ctx.beginPath(); + ctx.roundRect(px - this.size * 0.06, py - this.size * 0.065, + this.size * 0.12, this.size * 0.13, this.size * 0.02); + ctx.fill(); + ctx.stroke(); + + // Add ridge detail on each scute + ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; + ctx.beginPath(); + ctx.roundRect(px - this.size * 0.04, py - this.size * 0.045, + this.size * 0.08, this.size * 0.09, this.size * 0.015); + ctx.fill(); + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + } + } + ctx.restore(); + + // Body outline + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 0, length / 2, width, 0, 0, Math.PI * 2); + ctx.stroke(); + } + + drawDorsalRidges(ctx, bodyLength, bodyWidth) { + // Prominent ridges along the back - TOP VIEW (circular bumps) + ctx.fillStyle = 'rgba(35, 55, 25, 0.9)'; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 1; + + const ridgeCount = 8; + const ridgeSize = this.size * 0.18; + const rowSpacing = bodyWidth * 0.35; + + // Draw two rows of circular scutes (viewed from top) + for (let row = 0; row < 2; row++) { + const yOffset = row === 0 ? -rowSpacing : rowSpacing; + + for (let i = 0; i < ridgeCount; i++) { + const x = -bodyLength / 3 + (i * bodyLength / (ridgeCount + 1)); + const y = yOffset; + + // Draw as raised circular bumps + ctx.fillStyle = 'rgba(35, 55, 25, 0.95)'; + ctx.beginPath(); + ctx.arc(x, y, ridgeSize, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Top highlight (light from above) + const highlightGradient = ctx.createRadialGradient( + x - ridgeSize * 0.3, y - ridgeSize * 0.3, 0, + x, y, ridgeSize + ); + highlightGradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); + highlightGradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.08)'); + highlightGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + ctx.fillStyle = highlightGradient; + ctx.beginPath(); + ctx.arc(x, y, ridgeSize, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + drawHead(ctx, length, width) { + const headX = this.size * 1.2; + + ctx.save(); + ctx.translate(headX, 0); + + // Head base - broad from top view + ctx.fillStyle = params.crocodileColor; + ctx.beginPath(); + ctx.ellipse(-length * 0.1, 0, length * 0.4, width * 1.15, 0, 0, Math.PI * 2); + ctx.fill(); + + // Snout - tapered from above (narrower at tip) + ctx.beginPath(); + ctx.ellipse(length * 0.5, 0, length * 1.0, width * 0.5, 0, 0, Math.PI * 2); + ctx.fill(); + + // Top-down lighting on head and snout + const headGradient = ctx.createRadialGradient( + -length * 0.2, -width * 0.4, 0, + length * 0.2, 0, length * 1.2 + ); + headGradient.addColorStop(0, 'rgba(255, 255, 255, 0.2)'); + headGradient.addColorStop(0.4, 'rgba(255, 255, 255, 0.05)'); + headGradient.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)'); + headGradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)'); + ctx.fillStyle = headGradient; + ctx.beginPath(); + ctx.ellipse(-length * 0.1, 0, length * 0.4, width * 1.15, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(length * 0.5, 0, length * 1.0, width * 0.5, 0, 0, Math.PI * 2); + ctx.fill(); + + // Head and snout outline + ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.ellipse(-length * 0.1, 0, length * 0.4, width * 1.15, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(length * 0.5, 0, length * 1.0, width * 0.5, 0, 0, Math.PI * 2); + ctx.stroke(); + + // Nostrils at tip - visible from top + const nostrilX = length * 1.4; + const nostrilSpacing = width * 0.25; + + // Nostril bumps + ctx.fillStyle = 'rgba(50, 70, 40, 0.8)'; + ctx.beginPath(); + ctx.arc(nostrilX, -nostrilSpacing, this.size * 0.12, 0, Math.PI * 2); + ctx.arc(nostrilX, nostrilSpacing, this.size * 0.12, 0, Math.PI * 2); + ctx.fill(); + + // Nostril holes + ctx.fillStyle = 'rgba(0, 0, 0, 0.9)'; + ctx.beginPath(); + ctx.arc(nostrilX, -nostrilSpacing, this.size * 0.07, 0, Math.PI * 2); + ctx.arc(nostrilX, nostrilSpacing, this.size * 0.07, 0, Math.PI * 2); + ctx.fill(); + + // Nostril highlight + ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.beginPath(); + ctx.arc(nostrilX - this.size * 0.03, -nostrilSpacing - this.size * 0.03, this.size * 0.03, 0, Math.PI * 2); + ctx.arc(nostrilX - this.size * 0.03, nostrilSpacing - this.size * 0.03, this.size * 0.03, 0, Math.PI * 2); + ctx.fill(); + + // Jaw texture detail (from above - looks like seams) + ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.lineWidth = 1.5; + + for (let i = 0; i < 8; i++) { + const tx = length * 0.2 + i * length * 0.15; + const indent = this.size * 0.04; + + // Top jaw seam + ctx.beginPath(); + ctx.moveTo(tx, -width * 0.48); + ctx.lineTo(tx + indent, -width * 0.45); + ctx.stroke(); + + // Bottom jaw seam + ctx.beginPath(); + ctx.moveTo(tx + length * 0.05, width * 0.48); + ctx.lineTo(tx + length * 0.05 + indent, width * 0.45); + ctx.stroke(); + } + + ctx.restore(); + } + + drawEyes(ctx, headLength, headWidth) { + const headX = this.size * 1.2; + const eyeX = headX + headLength * 0.1; + const eyeY = headWidth * 0.95; // Eyes on sides of head (visible from top) + const eyeSize = this.size * 0.22; // Slightly larger from top view + + // Left eye + this.drawSingleEye(ctx, eyeX, -eyeY, eyeSize); + + // Right eye + this.drawSingleEye(ctx, eyeX, eyeY, eyeSize); + } + + drawSingleEye(ctx, x, y, size) { + // Eye bulge (raised, visible from top) + const bulgeGradient = ctx.createRadialGradient( + x - size * 0.3, y - size * 0.3, 0, + x, y, size * 1.4 + ); + bulgeGradient.addColorStop(0, 'rgba(255, 255, 255, 0.15)'); + bulgeGradient.addColorStop(0.5, params.crocodileColor); + bulgeGradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)'); + ctx.fillStyle = bulgeGradient; + ctx.beginPath(); + ctx.arc(x, y, size * 1.3, 0, Math.PI * 2); + ctx.fill(); + + // Eye rim + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, size * 1.1, 0, Math.PI * 2); + ctx.stroke(); + + // Eye - golden/yellow iris + const eyeGradient = ctx.createRadialGradient(x, y, 0, x, y, size); + eyeGradient.addColorStop(0, params.crocodileEyeColor); + eyeGradient.addColorStop(0.7, 'rgba(180, 160, 80, 0.9)'); + eyeGradient.addColorStop(1, 'rgba(120, 100, 40, 0.8)'); + ctx.fillStyle = eyeGradient; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + + // Vertical slit pupil (reptilian - rotated for viewing angle) + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.ellipse(x, y, size * 0.15, size * 0.7, 0, 0, Math.PI * 2); + ctx.fill(); + + // Eye shine (from above - sun reflection) + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.beginPath(); + ctx.arc(x - size * 0.25, y - size * 0.25, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.beginPath(); + ctx.arc(x + size * 0.15, y - size * 0.1, size * 0.15, 0, Math.PI * 2); + ctx.fill(); + + // Attack glow + if (this.state === 'ATTACKING' || this.state === 'DETECTING') { + const glowIntensity = (0.5 + Math.sin(this.swimTimer * 0.2) * 0.5); + ctx.save(); + ctx.fillStyle = `rgba(230, 200, 80, ${glowIntensity * 0.3})`; + ctx.beginPath(); + ctx.arc(x, y, size * 1.8, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + drawTail(ctx, bodyLength, bodyWidth, tailLength) { + const tailStartX = -bodyLength / 2; + const tailSway = Math.sin(this.tailPhase) * this.size * 0.5; + + ctx.save(); + ctx.translate(tailStartX, 0); + + // Tail segments (4 segments for smoother taper) + const segments = 4; + for (let i = 0; i < segments; i++) { + const segmentProgress = i / segments; + const nextProgress = (i + 1) / segments; + + const x = -i * tailLength / segments; + const nextX = -(i + 1) * tailLength / segments; + + const widthStart = bodyWidth * (1 - segmentProgress * 0.75); + const widthEnd = bodyWidth * (1 - nextProgress * 0.75); + + const swayStart = tailSway * segmentProgress * segmentProgress; + const swayEnd = tailSway * nextProgress * nextProgress; + + ctx.fillStyle = params.crocodileBodyColor; + + ctx.beginPath(); + ctx.moveTo(x, -widthStart + swayStart); + ctx.lineTo(nextX, -widthEnd + swayEnd); + ctx.lineTo(nextX, widthEnd + swayEnd); + ctx.lineTo(x, widthStart + swayStart); + ctx.closePath(); + ctx.fill(); + + // Gradient on tail + const tailGradient = ctx.createLinearGradient(x, -widthStart, x, widthStart); + tailGradient.addColorStop(0, 'rgba(0, 0, 0, 0.3)'); + tailGradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.05)'); + tailGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + ctx.fillStyle = tailGradient; + ctx.fill(); + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Tail ridges (smaller version of dorsal ridges) + if (i < segments - 1) { + const ridgeX = (x + nextX) / 2; + const ridgeY = swayStart * 0.5; + const ridgeHeight = this.size * 0.15; + const ridgeWidth = this.size * 0.12; + + ctx.fillStyle = 'rgba(35, 55, 25, 0.8)'; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ridgeX - ridgeWidth/2, -widthStart * 0.9 + ridgeY); + ctx.lineTo(ridgeX, -widthStart * 0.9 - ridgeHeight + ridgeY); + ctx.lineTo(ridgeX + ridgeWidth/2, -widthStart * 0.9 + ridgeY); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } + + // Tail fin - more prominent + const finX = -tailLength * 1.15; + const finSway = tailSway * 1.8; + const finHeight = this.size * 1.0; + + ctx.fillStyle = 'rgba(55, 75, 40, 0.85)'; + ctx.beginPath(); + ctx.moveTo(-tailLength * 0.95, finSway); + ctx.bezierCurveTo( + finX * 0.7, -finHeight * 0.8 + finSway, + finX, -finHeight + finSway, + finX, -finHeight + finSway + ); + ctx.lineTo(finX - this.size * 0.2, finSway); + ctx.bezierCurveTo( + finX, finHeight + finSway, + finX * 0.7, finHeight * 0.8 + finSway, + -tailLength * 0.95, finSway + ); + ctx.closePath(); + ctx.fill(); + + // Fin gradient + const finGradient = ctx.createLinearGradient(finX, -finHeight, finX, finHeight); + finGradient.addColorStop(0, 'rgba(0, 0, 0, 0.2)'); + finGradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.08)'); + finGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + ctx.fillStyle = finGradient; + ctx.fill(); + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Fin texture lines + ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.lineWidth = 1; + for (let i = 0; i < 3; i++) { + const lineY = -finHeight * 0.6 + i * finHeight * 0.6; + ctx.beginPath(); + ctx.moveTo(-tailLength * 0.95, finSway); + ctx.lineTo(finX - this.size * 0.1, lineY + finSway); + ctx.stroke(); + } + + ctx.restore(); + } + + drawLegs(ctx, bodyLength, bodyWidth, frontLegs) { + const legColor = 'rgba(70, 90, 55, 0.9)'; + const legKick = Math.sin(this.legPhase); + + if (!frontLegs) { + // Back legs + const backX = -bodyLength * 0.15; + const backY = bodyWidth * 1.05; + const backKick = Math.sin(this.legPhase + Math.PI); + + // Left back leg + this.drawSingleLeg(ctx, backX, -backY, legColor, backKick, true); + + // Right back leg + this.drawSingleLeg(ctx, backX, backY, legColor, -backKick, true); + } else { + // Front legs + const frontX = bodyLength * 0.25; + const frontY = bodyWidth * 0.9; + + // Left front leg + this.drawSingleLeg(ctx, frontX, -frontY, legColor, legKick, false); + + // Right front leg + this.drawSingleLeg(ctx, frontX, frontY, legColor, -legKick, false); + } + } + + drawSingleLeg(ctx, x, y, color, kick, isBack) { + const legLength = this.size * 0.65; + const legWidth = this.size * 0.2; + const footSize = this.size * 0.28; + + ctx.save(); + ctx.translate(x, y); + + // Legs stick out perpendicular (viewed from top) + const baseAngle = y < 0 ? -Math.PI / 2 : Math.PI / 2; + const angle = baseAngle + (y < 0 ? -0.2 : 0.2) + kick * 0.25; + ctx.rotate(angle); + + // Upper leg - visible from top, oval shape + ctx.fillStyle = color; + ctx.beginPath(); + ctx.ellipse(legLength * 0.3, 0, legLength * 0.35, legWidth, 0, 0, Math.PI * 2); + ctx.fill(); + + // Shading on leg + const legGradient = ctx.createRadialGradient( + legLength * 0.2, -legWidth * 0.3, 0, + legLength * 0.3, 0, legLength * 0.5 + ); + legGradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)'); + legGradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.05)'); + legGradient.addColorStop(1, 'rgba(0, 0, 0, 0.25)'); + ctx.fillStyle = legGradient; + ctx.fill(); + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Lower leg/foot + ctx.save(); + ctx.translate(legLength * 0.6, 0); + ctx.rotate(kick * 0.15); + + // Foot - webbed, visible from top + ctx.fillStyle = color; + ctx.beginPath(); + ctx.ellipse(footSize * 0.4, 0, footSize * 0.9, footSize * 0.75, 0, 0, Math.PI * 2); + ctx.fill(); + + // Foot gradient + const footGradient = ctx.createRadialGradient( + footSize * 0.2, -footSize * 0.2, 0, + footSize * 0.4, 0, footSize + ); + footGradient.addColorStop(0, 'rgba(255, 255, 255, 0.08)'); + footGradient.addColorStop(0.7, 'rgba(0, 0, 0, 0.1)'); + footGradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)'); + ctx.fillStyle = footGradient; + ctx.fill(); + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Toe claws (visible from top) + ctx.fillStyle = 'rgba(40, 35, 30, 0.9)'; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.lineWidth = 1; + + for (let c = -1; c <= 1; c++) { + const clawAngle = c * 0.35; + const clawLength = footSize * 0.35; + const clawX = footSize * 1.1 + Math.cos(clawAngle) * clawLength; + const clawY = Math.sin(clawAngle) * clawLength; + + // Draw claw as small triangle + ctx.beginPath(); + ctx.moveTo(footSize * 1.0, Math.sin(clawAngle) * footSize * 0.4); + ctx.lineTo(clawX, clawY); + ctx.lineTo(footSize * 0.95, Math.sin(clawAngle) * footSize * 0.5); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + + // Webbing lines between toes + ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.lineWidth = 1; + for (let c = -1; c <= 1; c++) { + ctx.beginPath(); + ctx.moveTo(footSize * 0.3, 0); + ctx.lineTo(footSize * 0.9, Math.sin(c * 0.35) * footSize * 0.6); + ctx.stroke(); + } + + ctx.restore(); + ctx.restore(); + } + + drawShadow(shadowCtx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 3)) return; + + shadowCtx.save(); + shadowCtx.translate( + this.pos.x + params.shadowOffsetX, + this.pos.y + params.shadowOffsetY + ); + shadowCtx.rotate(this.rotation); + + const bodyLength = this.size * 2.2; + const bodyWidth = this.size * 0.9; + + shadowCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + shadowCtx.beginPath(); + shadowCtx.ellipse(0, 0, bodyLength / 2 + 4, bodyWidth + 4, 0, 0, Math.PI * 2); + shadowCtx.fill(); + + shadowCtx.restore(); + } +} diff --git a/js/entities/Food.js b/js/entities/Food.js new file mode 100644 index 0000000..2782c14 --- /dev/null +++ b/js/entities/Food.js @@ -0,0 +1,34 @@ +// Food pellet class + +import { Vector } from '../utils/Vector.js'; +import { params } from '../config.js'; +import { rand } from '../utils/helpers.js'; + +export class Food { + constructor(x, y) { + this.pos = new Vector(x, y); + this.size = params.foodSize * rand(params.foodSizeVariationMin, params.foodSizeVariationMax); + this.eaten = false; + this.vel = new Vector( + rand(params.foodVelocityMin, params.foodVelocityMax), + rand(params.foodVelocityMin, params.foodVelocityMax) + ); + } + + update(dt = 1/60) { + const scale = dt * 60; // Normalize to 60 FPS + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + } + + draw(ctx) { + if(this.eaten) return; + ctx.save(); + ctx.translate(this.pos.x, this.pos.y); + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fillStyle = params.foodColor; + ctx.fill(); + ctx.restore(); + } +} diff --git a/js/entities/Frog.js b/js/entities/Frog.js new file mode 100644 index 0000000..447fddc --- /dev/null +++ b/js/entities/Frog.js @@ -0,0 +1,453 @@ +// Frog class - sits on lily pads and can jump + +import { Vector } from '../utils/Vector.js'; +import { params } from '../config.js'; +import { rand, dist, lerp, width, height } from '../utils/helpers.js'; +import { ripplePool } from '../systems/ObjectPool.js'; + +// Function to add ripple (will be set by main) +let addRippleFn = null; +export function setAddRippleFunction(fn) { + addRippleFn = fn; +} + +export class Frog { + constructor(pad) { + this.pad = pad; + this.pad.hasFrog = true; + + this.pos = new Vector(pad.x, pad.y); + this.rotation = rand(0, Math.PI * 2); + this.color = `hsl(${rand(80, 110)}, ${rand(60,80)}%, ${rand(40,50)}%)`; + this.size = pad.radius / 3; + + this.state = 'SITTING'; + + // Swimming physics + this.vel = new Vector(0, 0); + this.acc = new Vector(0, 0); + this.swimSpeed = rand(params.frogSwimSpeedMin, params.frogSwimSpeedMax); + this.maxSpeed = this.swimSpeed; + this.maxForce = params.frogSwimForce; + + this.targetPad = null; + this.jumpStartPos = null; + this.jumpT = 0; + this.jumpDuration = 40; + + this.diveT = 0; + + this.idleTime = rand(100, 500); + this.swimTimer = 0; // For swimming animation + this.floatTime = 0; // Time spent floating + this.bobOffset = 0; // Vertical bobbing offset + } + + update(pads, dt = 1/60) { + const scale = dt * 60; // Normalize to 60 FPS + + if (this.state === 'SITTING') { + this.pos.x = this.pad.x; + this.pos.y = this.pad.y; + this.idleTime -= scale; + + if (this.idleTime <= 0) { + const action = Math.random(); + + // Random chance to float, swim, or jump + if (action < params.frogFloatChance) { + // Start floating + this.pad.hasFrog = false; + this.state = 'FLOATING'; + this.vel = new Vector(rand(-0.2, 0.2), rand(-0.2, 0.2)); + this.floatTime = 0; + ripplePool.acquire(this.pos.x, this.pos.y, 3); + } else if (action < params.frogFloatChance + params.frogSwimChance) { + // Start swimming + this.pad.hasFrog = false; + this.state = 'SWIMMING'; + this.vel = new Vector(rand(-1, 1), rand(-1, 1)); + this.vel.normalize(); + this.vel.mult(this.swimSpeed * 0.5); + this.targetPad = null; // Will find one while swimming + ripplePool.acquire(this.pos.x, this.pos.y, 5); + } else { + this.tryJump(pads); + } + this.idleTime = rand(200, 600); + } + } + else if (this.state === 'JUMPING') { + this.jumpT += scale; + const t = this.jumpT / this.jumpDuration; + + if (t >= 1) { + this.state = 'SITTING'; + this.pad = this.targetPad; + this.pos.x = this.pad.x; + this.pos.y = this.pad.y; + ripplePool.acquire(this.pos.x, this.pos.y, 5); + } else { + this.pos.x = lerp(this.jumpStartPos.x, this.targetPad.x, t); + this.pos.y = lerp(this.jumpStartPos.y, this.targetPad.y, t); + + const angle = Math.atan2(this.targetPad.y - this.jumpStartPos.y, this.targetPad.x - this.jumpStartPos.x); + this.rotation = angle + Math.PI/2; + } + } + else if (this.state === 'DIVING') { + this.diveT += scale; + if (this.diveT > params.frogDiveDuration) { + // Transition to swimming or floating + if (Math.random() < 0.4) { + this.state = 'FLOATING'; + this.vel = new Vector(rand(-0.3, 0.3), rand(-0.3, 0.3)); + this.floatTime = 0; + } else { + this.state = 'SWIMMING'; + this.vel = new Vector(rand(-1, 1), rand(-1, 1)); + this.vel.normalize(); + this.vel.mult(this.swimSpeed * 0.5); + } + this.diveT = 0; + } + } + else if (this.state === 'SWIMMING') { + // Update swimming animation timer + this.swimTimer += scale * 0.1; + + // Apply forces for swimming behavior + this.applySwimBehaviors(pads); + + // Update velocity and position + this.vel.x += this.acc.x * scale; + this.vel.y += this.acc.y * scale; + this.vel.limit(this.maxSpeed); + + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + this.acc.mult(0); + + // Update rotation to face movement direction + if (this.vel.mag() > 0.1) { + this.rotation = Math.atan2(this.vel.y, this.vel.x) + Math.PI/2; + } + + // Check if reached a lily pad + for (let pad of pads) { + if (!pad.hasFrog && dist(this.pos.x, this.pos.y, pad.x, pad.y) < pad.radius) { + this.state = 'SITTING'; + this.pad = pad; + this.pad.hasFrog = true; + this.pos.x = pad.x; + this.pos.y = pad.y; + this.vel.mult(0); + ripplePool.acquire(this.pos.x, this.pos.y, 5); + break; + } + } + + // Random chance to stop swimming and start floating + if (Math.random() < 0.002 * scale && !this.targetPad) { + this.state = 'FLOATING'; + this.vel.mult(0.2); // Slow down to drift + this.floatTime = 0; + } + } + else if (this.state === 'FLOATING') { + // Gentle drifting and bobbing on water surface + this.floatTime += scale; + this.swimTimer += scale * params.frogFloatBobSpeed; + + // Slow drift with gentle random movement + this.vel.x += rand(-0.05, 0.05) * scale; + this.vel.y += rand(-0.05, 0.05) * scale; + this.vel.limit(params.frogFloatDriftSpeed); + + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + + // Bobbing motion + this.bobOffset = Math.sin(this.swimTimer) * params.frogFloatBobAmount; + + // Slowly rotate to face drift direction + if (this.vel.mag() > 0.05) { + let targetRotation = Math.atan2(this.vel.y, this.vel.x) + Math.PI/2; + // Smooth rotation + let rotDiff = targetRotation - this.rotation; + // Normalize to -PI to PI + while (rotDiff > Math.PI) rotDiff -= Math.PI * 2; + while (rotDiff < -Math.PI) rotDiff += Math.PI * 2; + this.rotation += rotDiff * 0.02 * scale; + } + + // Boundary avoidance - gentle steering back + const margin = 60; + if (this.pos.x < margin) this.vel.x += 0.05 * scale; + if (this.pos.x > width - margin) this.vel.x -= 0.05 * scale; + if (this.pos.y < margin) this.vel.y += 0.05 * scale; + if (this.pos.y > height - margin) this.vel.y -= 0.05 * scale; + + // Check if floated near a lily pad + for (let pad of pads) { + if (!pad.hasFrog && dist(this.pos.x, this.pos.y, pad.x, pad.y) < pad.radius * 1.5) { + // Decide to hop on or keep floating + if (Math.random() < 0.3 * (dt * 60)) { + this.state = 'SITTING'; + this.pad = pad; + this.pad.hasFrog = true; + this.pos.x = pad.x; + this.pos.y = pad.y; + this.vel.mult(0); + this.bobOffset = 0; + ripplePool.acquire(this.pos.x, this.pos.y, 3); + break; + } + } + } + + // After floating for a while, decide to swim or find a pad + if (this.floatTime > params.frogFloatDuration) { + if (Math.random() < 0.5) { + // Start swimming + this.state = 'SWIMMING'; + this.vel.mult(2); // Boost velocity + this.floatTime = 0; + this.bobOffset = 0; + } else { + // Keep floating longer + this.floatTime = 0; + } + } + } + return true; + } + + tryJump(pads) { + const range = params.frogJumpRange; + const candidates = pads.filter(p => + !p.hasFrog && + p !== this.pad && + dist(this.pos.x, this.pos.y, p.x, p.y) < range + ); + + if (candidates.length > 0) { + this.targetPad = candidates[Math.floor(Math.random() * candidates.length)]; + this.pad.hasFrog = false; + this.targetPad.hasFrog = true; + this.jumpStartPos = new Vector(this.pos.x, this.pos.y); + this.state = 'JUMPING'; + this.jumpT = 0; + } else { + // No pads in jump range, try swimming to a farther pad + const farCandidates = pads.filter(p => + !p.hasFrog && + p !== this.pad && + dist(this.pos.x, this.pos.y, p.x, p.y) < params.frogSwimSeekRange + ); + + if (farCandidates.length > 0) { + this.targetPad = farCandidates[Math.floor(Math.random() * farCandidates.length)]; + this.pad.hasFrog = false; + this.state = 'SWIMMING'; + this.vel = new Vector(rand(-1, 1), rand(-1, 1)); + this.vel.normalize(); + this.vel.mult(this.swimSpeed * 0.5); + } + } + } + + applySwimBehaviors(pads) { + // Boundary avoidance + let desired = null; + const margin = 80; + + if (this.pos.x < margin) desired = new Vector(this.maxSpeed, this.vel.y); + else if (this.pos.x > width - margin) desired = new Vector(-this.maxSpeed, this.vel.y); + if (this.pos.y < margin) desired = new Vector(this.vel.x, this.maxSpeed); + else if (this.pos.y > height - margin) desired = new Vector(this.vel.x, -this.maxSpeed); + + if (desired) { + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * 2); + this.applyForce(steer); + } else { + // Wander slightly + if (Math.random() < 0.03) { + let wander = new Vector(rand(-1, 1), rand(-1, 1)); + wander.normalize(); + wander.mult(0.3); + this.applyForce(wander); + } + } + + // Seek nearest lily pad if we have a target + if (this.targetPad) { + let seekForce = this.seek(this.targetPad.x, this.targetPad.y); + seekForce.mult(1.5); + this.applyForce(seekForce); + } else { + // Look for nearby pads to seek + const nearbyPads = pads.filter(p => + !p.hasFrog && + dist(this.pos.x, this.pos.y, p.x, p.y) < params.frogSwimSeekRange + ); + + if (nearbyPads.length > 0) { + // Find closest + let closest = nearbyPads[0]; + let minDist = dist(this.pos.x, this.pos.y, closest.x, closest.y); + for (let p of nearbyPads) { + let d = dist(this.pos.x, this.pos.y, p.x, p.y); + if (d < minDist) { + minDist = d; + closest = p; + } + } + this.targetPad = closest; + } + } + } + + seek(x, y) { + let desired = new Vector(x - this.pos.x, y - this.pos.y); + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce); + return steer; + } + + applyForce(force) { + this.acc.add(force); + } + + scare() { + if (this.state !== 'DIVING') { + this.state = 'DIVING'; + this.pad.hasFrog = false; + if (addRippleFn) { + addRippleFn(this.pos.x, this.pos.y); + } + } + } + + draw(ctx) { + ctx.save(); + ctx.translate(this.pos.x, this.pos.y + this.bobOffset); + ctx.rotate(this.rotation); + + let scale = 1; + let opacity = 1; + + if (this.state === 'JUMPING') { + const t = this.jumpT / this.jumpDuration; + const height = Math.sin(t * Math.PI); + scale = 1 + height * 0.5; + + ctx.save(); + ctx.scale(1/scale, 1/scale); + ctx.fillStyle = "rgba(0,0,0,0.2)"; + ctx.beginPath(); + ctx.ellipse(0, 20 * height, this.size*0.8, this.size*0.8, 0, 0, Math.PI*2); + ctx.fill(); + ctx.restore(); + } + else if (this.state === 'DIVING') { + scale = 1 - (this.diveT / params.frogDiveDuration); + opacity = 1 - (this.diveT / params.frogDiveDuration); + } + + ctx.globalAlpha = opacity; + ctx.scale(scale, scale); + + // Body + ctx.fillStyle = this.color; + ctx.beginPath(); + ctx.ellipse(0, 0, this.size, this.size * 1.2, 0, 0, Math.PI*2); + ctx.fill(); + + // Legs + if (this.state === 'JUMPING') { + ctx.lineWidth = 3; + ctx.strokeStyle = this.color; + ctx.lineCap = 'round'; + const legLen = params.frogJumpLegLength; + // Hind + ctx.beginPath(); ctx.moveTo(-5, 5); ctx.lineTo(-10, legLen); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(5, 5); ctx.lineTo(10, legLen); ctx.stroke(); + // Front + ctx.beginPath(); ctx.moveTo(-5, -5); ctx.lineTo(-10, -legLen); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(5, -5); ctx.lineTo(10, -legLen); ctx.stroke(); + } else if (this.state === 'SWIMMING') { + // Swimming legs - animated kicking motion + ctx.lineWidth = 2.5; + ctx.strokeStyle = this.color; + ctx.lineCap = 'round'; + const kickCycle = Math.sin(this.swimTimer); + const legExtend = 12 + kickCycle * 5; + const legSpread = 8 + Math.abs(kickCycle) * 4; + + // Hind legs (extended back, kicking) + ctx.beginPath(); ctx.moveTo(-4, 4); ctx.lineTo(-legSpread, legExtend); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(4, 4); ctx.lineTo(legSpread, legExtend); ctx.stroke(); + + // Front legs (smaller, tucked) + const frontKick = Math.sin(this.swimTimer + Math.PI/2); + const frontExtend = 8 + frontKick * 3; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(-3, -3); ctx.lineTo(-6, -frontExtend); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(3, -3); ctx.lineTo(6, -frontExtend); ctx.stroke(); + } else if (this.state === 'FLOATING') { + // Floating legs - spread out, relaxed + ctx.fillStyle = this.color; + ctx.lineWidth = 2; + ctx.strokeStyle = this.color; + ctx.lineCap = 'round'; + + // Gentle floating motion + const floatSway = Math.sin(this.swimTimer * 0.7); + + // Hind legs spread out + ctx.beginPath(); + ctx.ellipse(-this.size * 1.1 + floatSway, this.size*0.6, this.size*0.35, this.size*0.7, -0.3, 0, Math.PI*2); + ctx.ellipse(this.size * 1.1 - floatSway, this.size*0.6, this.size*0.35, this.size*0.7, 0.3, 0, Math.PI*2); + ctx.fill(); + + // Front legs relaxed + ctx.beginPath(); ctx.moveTo(-4, -2); ctx.lineTo(-7 + floatSway*0.5, 4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(7 - floatSway*0.5, 4); ctx.stroke(); + } else { + ctx.fillStyle = this.color; + // Hind legs (thighs) + ctx.beginPath(); + ctx.ellipse(-this.size, this.size*0.5, this.size*0.4, this.size*0.8, -0.5, 0, Math.PI*2); + ctx.ellipse(this.size, this.size*0.5, this.size*0.4, this.size*0.8, 0.5, 0, Math.PI*2); + ctx.fill(); + } + + // Eyes + ctx.fillStyle = '#fff'; + const eyeSize = this.size * params.frogEyeSizeRatio; + const eyeY = -this.size * 0.6; + const eyeX = this.size * 0.5; + + ctx.beginPath(); ctx.arc(-eyeX, eyeY, eyeSize, 0, Math.PI*2); ctx.fill(); + ctx.beginPath(); ctx.arc(eyeX, eyeY, eyeSize, 0, Math.PI*2); ctx.fill(); + + // Pupils + ctx.fillStyle = '#000'; + ctx.beginPath(); ctx.arc(-eyeX, eyeY, eyeSize*0.4, 0, Math.PI*2); ctx.fill(); + ctx.beginPath(); ctx.arc(eyeX, eyeY, eyeSize*0.4, 0, Math.PI*2); ctx.fill(); + + // Back pattern + ctx.fillStyle = 'rgba(0,0,0,0.1)'; + ctx.beginPath(); + ctx.arc(0, 5, this.size*0.3, 0, Math.PI*2); + ctx.fill(); + + ctx.restore(); + } +} diff --git a/js/entities/Grass.js b/js/entities/Grass.js new file mode 100644 index 0000000..8c9bd74 --- /dev/null +++ b/js/entities/Grass.js @@ -0,0 +1,121 @@ +// Underwater grass class with swaying animation + +import { params, CULL_MARGIN } from '../config.js'; +import { rand, hexToHSL, isInView, width, height } from '../utils/helpers.js'; + +export class Grass { + constructor(x, y) { + this.x = x !== undefined ? x : rand(0, width); + this.y = y !== undefined ? y : rand(0, height); + this.size = rand(params.grassHeightMin, params.grassHeightMax); + + // Parse base color and create variations + const baseHsl = hexToHSL(params.grassColor); + this.baseHue = baseHsl.h; + this.baseSat = baseHsl.s; + this.baseLig = baseHsl.l; + + // Phase offset for this clump's sway animation + this.phaseOffset = rand(0, Math.PI * 2); + + // Generate 5-12 leaves radiating from center (top-down view) + this.leafCount = Math.floor(rand(5, 13)); + this.leaves = []; + + for (let i = 0; i < this.leafCount; i++) { + // Distribute leaves around the center + const baseAngle = (i / this.leafCount) * Math.PI * 2 + rand(-0.3, 0.3); + this.leaves.push({ + angle: baseAngle, + length: rand(0.5, 1.0), // Relative to size + width: rand(0.08, 0.15), // Relative to length + curve: rand(-0.3, 0.3), // Curve direction + hueOffset: rand(-15, 15), + satOffset: rand(-10, 10), + ligOffset: rand(-10, 10), + phaseOffset: rand(0, Math.PI * 2) + }); + } + } + + draw(ctx, time) { + // Skip if out of view + if (!isInView(this.x, this.y, CULL_MARGIN + this.size)) return; + + // Gentle rotation sway for the whole clump + const rotationSway = Math.sin(time * params.grassSwaySpeed + this.phaseOffset) * params.grassSwayAmount * 0.5; + + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(rotationSway); + + for (let leaf of this.leaves) { + const leafLength = this.size * leaf.length; + const leafWidth = leafLength * leaf.width; + + // Individual leaf sway + const leafSway = Math.sin(time * params.grassSwaySpeed * 0.8 + leaf.phaseOffset) * params.grassSwayAmount * 0.3; + const currentAngle = leaf.angle + leafSway; + + // Calculate leaf color + const hue = this.baseHue + leaf.hueOffset; + const sat = Math.max(0, Math.min(100, this.baseSat + leaf.satOffset)); + const lig = Math.max(0, Math.min(100, this.baseLig + leaf.ligOffset)); + + ctx.save(); + ctx.rotate(currentAngle); + + // Draw elongated leaf shape (top-down view) + ctx.beginPath(); + + // Start at center + ctx.moveTo(0, 0); + + // Curved leaf shape using bezier + const tipX = leafLength; + const tipY = 0; + const curveOffset = leafLength * leaf.curve; + + // Left edge of leaf + ctx.quadraticCurveTo( + leafLength * 0.5, -leafWidth + curveOffset, + tipX, tipY + ); + + // Right edge of leaf back to center + ctx.quadraticCurveTo( + leafLength * 0.5, leafWidth + curveOffset, + 0, 0 + ); + + ctx.closePath(); + + // Gradient from center (darker) to tip (lighter) + const gradient = ctx.createLinearGradient(0, 0, leafLength, 0); + gradient.addColorStop(0, `hsla(${hue}, ${sat}%, ${lig - 8}%, 0.85)`); + gradient.addColorStop(0.4, `hsla(${hue}, ${sat}%, ${lig}%, 0.8)`); + gradient.addColorStop(1, `hsla(${hue}, ${sat + 5}%, ${lig + 8}%, 0.6)`); + + ctx.fillStyle = gradient; + ctx.fill(); + + // Subtle center vein line + ctx.beginPath(); + ctx.moveTo(2, 0); + ctx.lineTo(leafLength * 0.85, 0); + ctx.strokeStyle = `hsla(${hue}, ${sat - 10}%, ${lig - 15}%, 0.3)`; + ctx.lineWidth = 0.5; + ctx.stroke(); + + ctx.restore(); + } + + // Draw small center cluster + ctx.beginPath(); + ctx.arc(0, 0, this.size * 0.08, 0, Math.PI * 2); + ctx.fillStyle = `hsla(${this.baseHue}, ${this.baseSat}%, ${this.baseLig - 10}%, 0.7)`; + ctx.fill(); + + ctx.restore(); + } +} diff --git a/js/entities/Koi.js b/js/entities/Koi.js new file mode 100644 index 0000000..a5b5da5 --- /dev/null +++ b/js/entities/Koi.js @@ -0,0 +1,810 @@ +// Koi Fish class - main fish entity + +import { Vector } from '../utils/Vector.js'; +import { params, CULL_MARGIN } from '../config.js'; +import { rand, isInView, width, height } from '../utils/helpers.js'; +import { fishGrid, foodGrid, predatorGrid } from '../utils/SpatialGrid.js'; +import { ripplePool } from '../systems/ObjectPool.js'; + +// Reference to main canvas context (will be set externally) +let mainCtx = null; + +export function setMainContext(ctx) { + mainCtx = ctx; +} + +// Function to add ripple (will be set by main) +let addRippleFn = null; +export function setAddRippleFunction(fn) { + addRippleFn = fn; +} + +function clamp01(value) { + return Math.max(0, Math.min(1, value)); +} + +function hexToRgb(hex) { + const clean = hex.replace('#', ''); + const r = parseInt(clean.slice(0, 2), 16); + const g = parseInt(clean.slice(2, 4), 16); + const b = parseInt(clean.slice(4, 6), 16); + return { r, g, b }; +} + +function rgbToHex({ r, g, b }) { + const toHex = (v) => v.toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +function mixColors(a, b, t) { + const ca = hexToRgb(a); + const cb = hexToRgb(b); + const k = clamp01(t); + return rgbToHex({ + r: Math.round(ca.r + (cb.r - ca.r) * k), + g: Math.round(ca.g + (cb.g - ca.g) * k), + b: Math.round(ca.b + (cb.b - ca.b) * k) + }); +} + +function adjustColor(hex, amount) { + if (amount >= 0) { + return mixColors(hex, '#ffffff', clamp01(amount)); + } + return mixColors(hex, '#000000', clamp01(-amount)); +} + +function rgbaFromHex(hex, alpha) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export class Koi { + constructor(x, y) { + this.pos = new Vector(x, y); + this.vel = new Vector(rand(-1, 1), rand(-1, 1)); + this.acc = new Vector(0, 0); + + this.baseSpeed = rand(params.fishBaseSpeedMin, params.fishBaseSpeedMax); + this.maxSpeed = this.baseSpeed * params.speedScale; + + this.maxForce = params.turnForce; + + this.size = rand(params.sizeMin, params.sizeMax); + + this.swimTimer = Math.random() * params.fishInitialSwimTimer; + this.birthTimer = rand(0, params.fishInitialBirthCooldown); + + this.spine = []; + this.spineLength = params.spineCount; + + this.initSpine(x, y); + + const types = ['kohaku', 'showa', 'gold', 'tancho', 'utsuri']; + this.type = types[Math.floor(Math.random() * types.length)]; + + this.baseColor = '#f0f0f0'; + this.spots = []; + + if (this.type === 'kohaku') { + this.baseColor = '#fdfdfd'; + this.generatePatches('#d62828', 'saddle'); + } else if (this.type === 'gold') { + this.baseColor = '#f4a261'; + this.generatePatches('#e76f51', 'large_wash'); + } else if (this.type === 'showa') { + this.baseColor = '#1a1a1a'; + this.generatePatches('#d62828', 'saddle'); + this.generatePatches('#fdfdfd', 'saddle_small'); + } else if (this.type === 'tancho') { + this.baseColor = '#fdfdfd'; + this.spots.push({ color: '#d62828', t: 0.05, offset: 0, radius: this.size * 0.6 }); + } else if (this.type === 'utsuri') { + this.baseColor = '#0f0f0f'; + this.generatePatches('#f4d35e', 'wrapping_bands'); + } + + this.finsAngle = 0; + } + + initSpine(x, y) { + this.spine = []; + const dist = this.size * params.distConstraint; + for (let i = 0; i < this.spineLength; i++) { + this.spine.push({ + pos: new Vector(x - i * dist, y), + size: this.calculateThickness(i) + }); + } + } + + calculateThickness(i) { + const t = i / (this.spineLength - 1); + let thickness = 1.0; + + if (t < 0.25) { + thickness = params.fishThicknessHead + (t / 0.25) * params.fishThicknessNeck; + } else { + let bodyT = (t - 0.25) / 0.75; + thickness = params.fishThicknessTaper * (1 - Math.pow(bodyT, params.tailTaper) * params.fishThicknessPow); + } + thickness = Math.max(params.fishThicknessMin, thickness); + + return this.size * thickness * params.fatness; + } + + generatePatches(color, style) { + if (style === 'saddle') { + let count = Math.floor(rand(2, 3)); + for(let i=0; i width - margin) desired = new Vector(-this.maxSpeed, this.vel.y); + if (this.pos.y < margin) desired = new Vector(this.vel.x, this.maxSpeed); + else if (this.pos.y > height - margin) desired = new Vector(this.vel.x, -this.maxSpeed); + + if (desired) { + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = desired.sub(this.vel); + steer.limit(this.maxForce * 2); + this.applyForce(steer); + } else { + if (Math.random() < 0.05) { + let wander = new Vector(rand(-1, 1), rand(-1, 1)); + wander.normalize(); + wander.mult(0.5); + this.applyForce(wander); + } + } + + // Use spatial grid for efficient food searching + const nearbyFoods = foodGrid.getNearbyFiltered( + this.pos.x, this.pos.y, 300, + food => !food.eaten + ); + + if (nearbyFoods.length > 0) { + // Find closest food from nearby + let closestFood = null; + let minDistSq = 300 * 300; + + for (const { obj: food, distSq } of nearbyFoods) { + if (distSq < minDistSq) { + minDistSq = distSq; + closestFood = food; + } + } + + if (closestFood) { + const targetPos = { x: closestFood.pos.x, y: closestFood.pos.y }; + let seekForce = this.seek(targetPos); + seekForce.mult(2.5); + this.applyForce(seekForce); + + if (minDistSq < 100) { // 10 * 10 + closestFood.eaten = true; + this.swimTimer += 2; + if (addRippleFn) { + addRippleFn(closestFood.pos.x, closestFood.pos.y); + } + } + } + } + + // Use spatial grid for efficient separation (fish avoidance) + const nearbyFish = fishGrid.getNearby(this.pos.x, this.pos.y, 50); + + let separation = new Vector(0, 0); + let count = 0; + for (let other of nearbyFish) { + if (other !== this) { + const dx = this.pos.x - other.pos.x; + const dy = this.pos.y - other.pos.y; + const distSq = dx * dx + dy * dy; + if (distSq < 2500 && distSq > 0) { // 50 * 50 + const d = Math.sqrt(distSq); + separation.x += dx / (d * d); + separation.y += dy / (d * d); + count++; + } + } + } + if (count > 0) { + separation.div(count); + separation.normalize(); + separation.mult(this.maxSpeed); + let steer = separation.sub(this.vel); + steer.limit(this.maxForce * 1.5); + this.applyForce(steer); + } + + // PREDATOR AVOIDANCE - highest priority + const nearbyPredators = predatorGrid.getNearby(this.pos.x, this.pos.y, params.predatorDetectionRange * 1.5); + + for (const predator of nearbyPredators) { + const dx = this.pos.x - predator.pos.x; + const dy = this.pos.y - predator.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < params.predatorDetectionRange * 1.2 && d > 0) { + // Strong flee force - inversely proportional to distance + let fleeForce = new Vector(dx / d, dy / d); + const urgency = 1 - (d / (params.predatorDetectionRange * 1.2)); + fleeForce.mult(this.maxSpeed * 2 * urgency); + + // Extra speed boost when predator is attacking + if (predator.state === 'ATTACKING' && predator.target === this) { + fleeForce.mult(2); + this.maxSpeed = this.baseSpeed * params.speedScale * 1.5; // Burst of speed + } + + let steer = new Vector(fleeForce.x - this.vel.x, fleeForce.y - this.vel.y); + steer.limit(this.maxForce * params.fishFleeForceMultiplier); // High priority flee + this.applyForce(steer); + } + } + } + + applyForce(force) { + this.acc.add(force); + } + + update(dt = 1/60) { + const scale = dt * 60; // Normalize to 60 FPS + + // Scale velocity changes by delta time + this.vel.x += this.acc.x * scale; + this.vel.y += this.acc.y * scale; + this.vel.limit(this.maxSpeed); + + // Scale position changes by delta time + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + this.acc.mult(0); + + let speed = this.vel.mag(); + this.swimTimer += (params.waveSpeedBase + (speed * params.waveSpeedMult)) * scale; + + this.spine[0].pos = new Vector(this.pos.x, this.pos.y); + + for (let i = 1; i < this.spineLength; i++) { + let prev = this.spine[i - 1].pos; + let curr = this.spine[i].pos; + + let dx = curr.x - prev.x; + let dy = curr.y - prev.y; + + let angle = Math.atan2(dy, dx); + + let waveAmt = Math.min(params.waveAmpMax, i * params.waveAmpGain); + let wave = Math.sin(this.swimTimer - i * params.fishWavePhaseOffset) * waveAmt; + + angle += wave; + + const segDist = this.size * params.distConstraint; + + let tx = prev.x + Math.cos(angle) * segDist; + let ty = prev.y + Math.sin(angle) * segDist; + + curr.x = tx; + curr.y = ty; + + curr.size = this.calculateThickness(i); + } + + this.finsAngle = this.swimTimer; + } + + draw(isShadow) { + const ctx = mainCtx; + if (!ctx) return; + + let leftPoints = []; + let rightPoints = []; + + let head = this.spine[0]; + let neck = this.spine[1]; + let headAngle = Math.atan2(head.pos.y - neck.pos.y, head.pos.x - neck.pos.x); + + for (let i = 0; i < this.spineLength; i++) { + let s = this.spine[i]; + + let a; + if (i === 0) { + a = headAngle; + } else if (i === this.spineLength - 1) { + let prev = this.spine[i-1]; + a = Math.atan2(s.pos.y - prev.pos.y, s.pos.x - prev.pos.x); + } else { + let next = this.spine[i+1]; + let prev = this.spine[i-1]; + a = Math.atan2(next.pos.y - prev.pos.y, next.pos.x - prev.pos.x); + } + + let px = Math.cos(a + Math.PI/2); + let py = Math.sin(a + Math.PI/2); + + leftPoints.push({x: s.pos.x + px * s.size, y: s.pos.y + py * s.size}); + rightPoints.push({x: s.pos.x - px * s.size, y: s.pos.y - py * s.size}); + } + + // Shadows are now handled separately via drawShadow method + if (isShadow) return; + + this.drawFinsOnContext(ctx, head, headAngle, false, false); + + ctx.save(); + this.drawBodyPathOnContext(ctx, leftPoints, rightPoints, head, headAngle); + let maxRadius = 0; + for (const s of this.spine) { + if (s.size > maxRadius) maxRadius = s.size; + } + const midIndex = Math.floor(this.spineLength * 0.4); + const midPoint = this.spine[midIndex].pos; + const perpAngle = headAngle + Math.PI / 2; + const gradRadius = maxRadius * 1.25; + const gx0 = midPoint.x + Math.cos(perpAngle) * gradRadius; + const gy0 = midPoint.y + Math.sin(perpAngle) * gradRadius; + const gx1 = midPoint.x - Math.cos(perpAngle) * gradRadius; + const gy1 = midPoint.y - Math.sin(perpAngle) * gradRadius; + + const bodyGradient = ctx.createLinearGradient(gx0, gy0, gx1, gy1); + const bodyDark = params.fishBodyShadeDark; + const bodyLight = params.fishBodyShadeLight; + bodyGradient.addColorStop(0, adjustColor(this.baseColor, -bodyDark)); + bodyGradient.addColorStop(0.45, adjustColor(this.baseColor, bodyLight * 0.5)); + bodyGradient.addColorStop(0.55, adjustColor(this.baseColor, bodyLight)); + bodyGradient.addColorStop(1, adjustColor(this.baseColor, -bodyDark * 0.75)); + ctx.fillStyle = bodyGradient; + ctx.fill(); + + ctx.fillStyle = rgbaFromHex(this.baseColor, params.fishBodySolidAlpha); + ctx.fill(); + + ctx.clip(); + + for(let spot of this.spots) { + let idx = Math.floor(spot.t * (this.spineLength-1)); + idx = Math.max(0, Math.min(idx, this.spineLength-1)); + let s = this.spine[idx]; + const spotX = s.pos.x + spot.offset; + const spotY = s.pos.y + spot.offset; + const spotGradient = ctx.createRadialGradient( + spotX, spotY, 0, + spotX, spotY, spot.radius + ); + spotGradient.addColorStop(0, adjustColor(spot.color, 0.2)); + spotGradient.addColorStop(0.55, spot.color); + spotGradient.addColorStop(1, rgbaFromHex(spot.color, params.fishPatternEdgeAlpha)); + ctx.beginPath(); + ctx.arc(spotX, spotY, spot.radius, 0, Math.PI*2); + ctx.fillStyle = spotGradient; + ctx.fill(); + } + + const lightDirX = 0.4; + const lightDirY = -0.9; + const lightLen = Math.hypot(lightDirX, lightDirY) || 1; + const lx = lightDirX / lightLen; + const ly = lightDirY / lightLen; + const highlightOffset = this.size * 0.18; + + ctx.save(); + ctx.globalCompositeOperation = 'screen'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + for (let i = 0; i < this.spineLength - 1; i++) { + const s = this.spine[i]; + const hx = s.pos.x + lx * highlightOffset * (s.size / maxRadius); + const hy = s.pos.y + ly * highlightOffset * (s.size / maxRadius); + if (i === 0) ctx.moveTo(hx, hy); + else ctx.lineTo(hx, hy); + } + ctx.strokeStyle = `rgba(255, 255, 255, ${params.fishSpecularOuterAlpha})`; + ctx.lineWidth = this.size * params.fishSpecularWidth; + ctx.stroke(); + ctx.strokeStyle = `rgba(255, 255, 255, ${params.fishSpecularInnerAlpha})`; + ctx.lineWidth = this.size * params.fishSpecularInnerWidth; + ctx.stroke(); + ctx.restore(); + + ctx.restore(); + + ctx.save(); + this.drawBodyPathOnContext(ctx, leftPoints, rightPoints, head, headAngle); + ctx.strokeStyle = rgbaFromHex(adjustColor(this.baseColor, -params.fishOutlineDarken), params.fishOutlineAlpha); + ctx.lineWidth = Math.max(1, this.size * params.fishOutlineWidth); + ctx.stroke(); + ctx.restore(); + + this.drawDorsalFinOnContext(ctx, false); + this.drawFinsOnContext(ctx, head, headAngle, true, false); + this.drawEyes(head, headAngle); + } + + drawBodyPathOnContext(c, leftPoints, rightPoints, head, headAngle) { + c.beginPath(); + let noseX = head.pos.x + Math.cos(headAngle) * this.spine[0].size; + let noseY = head.pos.y + Math.sin(headAngle) * this.spine[0].size; + c.moveTo(noseX, noseY); + + c.lineTo(leftPoints[0].x, leftPoints[0].y); + for (let i = 0; i < leftPoints.length - 1; i++) { + const p0 = leftPoints[i]; + const p1 = leftPoints[i + 1]; + const mx = (p0.x + p1.x) * 0.5; + const my = (p0.y + p1.y) * 0.5; + c.quadraticCurveTo(p0.x, p0.y, mx, my); + } + c.lineTo(leftPoints[leftPoints.length-1].x, leftPoints[leftPoints.length-1].y); + + let tail = this.spine[this.spineLength-1]; + c.quadraticCurveTo(tail.pos.x, tail.pos.y, rightPoints[this.spineLength-1].x, rightPoints[this.spineLength-1].y); + + for (let i = rightPoints.length - 1; i > 0; i--) { + const p0 = rightPoints[i]; + const p1 = rightPoints[i - 1]; + const mx = (p0.x + p1.x) * 0.5; + const my = (p0.y + p1.y) * 0.5; + c.quadraticCurveTo(p0.x, p0.y, mx, my); + } + c.lineTo(rightPoints[0].x, rightPoints[0].y); + + c.lineTo(noseX, noseY); + c.closePath(); + } + + drawDorsalFinOnContext(c, isShadow) { + const startIndex = 4; + const endIndex = this.spineLength - 2; + + if (startIndex >= this.spineLength || endIndex >= this.spineLength) return; + + c.beginPath(); + let start = this.spine[startIndex]; + c.moveTo(start.pos.x, start.pos.y); + + for(let i=startIndex + 1; i <= endIndex; i++) { + let s = this.spine[i]; + c.lineTo(s.pos.x, s.pos.y); + } + + c.lineCap = 'round'; + c.lineJoin = 'round'; + + if (isShadow) { + c.lineWidth = 3; + c.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + c.stroke(); + } else { + c.lineWidth = 2; + c.strokeStyle = 'rgba(0, 0, 0, 0.05)'; + c.stroke(); + + c.lineWidth = 1; + c.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + c.stroke(); + } + } + + drawFinsOnContext(c, head, angle, topLayer, isShadow) { + let shoulder = this.spine[4]; + let a = angle; + + c.save(); + c.translate(shoulder.pos.x, shoulder.pos.y); + c.rotate(a); + + const finBase = adjustColor(this.baseColor, params.fishFinShadeLight); + const finMid = adjustColor(this.baseColor, params.fishFinShadeMid); + const finEdge = adjustColor(this.baseColor, -params.fishFinShadeDark); + const makeFinGradient = (x0, y0, x1, y1) => { + const g = c.createLinearGradient(x0, y0, x1, y1); + g.addColorStop(0, rgbaFromHex(finBase, params.fishFinAlphaBase)); + g.addColorStop(0.6, rgbaFromHex(finMid, params.fishFinAlphaMid)); + g.addColorStop(1, rgbaFromHex(finEdge, params.fishFinAlphaEdge)); + return g; + }; + + const s = (this.size / 22) * params.finScale; + + if (!topLayer) { + let finCycle = Math.sin(this.finsAngle); + + if (!isShadow) { + c.fillStyle = makeFinGradient(6*s, 0, 45*s, 0); + } + c.beginPath(); + c.moveTo(4*s, 0); + c.quadraticCurveTo(45*s, -40*s + finCycle*10*s, -15*s, -25*s + finCycle*5*s); + c.quadraticCurveTo(0, -8*s, 4*s, 0); + c.fill(); + if (!isShadow) { + c.strokeStyle = rgbaFromHex(adjustColor(this.baseColor, -0.35), 0.5); + c.lineWidth = Math.max(1, this.size * 0.04); + c.beginPath(); + c.moveTo(4*s, -2*s); + c.lineTo(-6*s, -10*s); + c.stroke(); + } + + if (!isShadow) { + c.fillStyle = makeFinGradient(6*s, 0, 45*s, 0); + } + c.beginPath(); + c.moveTo(4*s, 0); + c.quadraticCurveTo(45*s, 40*s - finCycle*10*s, -15*s, 25*s - finCycle*5*s); + c.quadraticCurveTo(0, 8*s, 4*s, 0); + c.fill(); + if (!isShadow) { + c.strokeStyle = rgbaFromHex(adjustColor(this.baseColor, -0.35), 0.5); + c.lineWidth = Math.max(1, this.size * 0.04); + c.beginPath(); + c.moveTo(4*s, 2*s); + c.lineTo(-6*s, 10*s); + c.stroke(); + } + + c.translate(-22*s, 0); + let pelvicCycle = Math.cos(this.finsAngle); + + if (!isShadow) { + c.fillStyle = makeFinGradient(0, 0, 18*s, 0); + } + c.beginPath(); + c.moveTo(0, 3*s); + c.quadraticCurveTo(15*s, 20*s + pelvicCycle*3*s, -5*s, 15*s); + c.fill(); + if (!isShadow) { + c.strokeStyle = rgbaFromHex(adjustColor(this.baseColor, -0.35), 0.45); + c.lineWidth = Math.max(1, this.size * 0.035); + c.beginPath(); + c.moveTo(0, 3*s); + c.lineTo(-6*s, 10*s); + c.stroke(); + } + + if (!isShadow) { + c.fillStyle = makeFinGradient(0, 0, 18*s, 0); + } + c.beginPath(); + c.moveTo(0, -3*s); + c.quadraticCurveTo(15*s, -20*s - pelvicCycle*3*s, -5*s, -15*s); + c.fill(); + if (!isShadow) { + c.strokeStyle = rgbaFromHex(adjustColor(this.baseColor, -0.35), 0.45); + c.lineWidth = Math.max(1, this.size * 0.035); + c.beginPath(); + c.moveTo(0, -3*s); + c.lineTo(-6*s, -10*s); + c.stroke(); + } + + c.restore(); + } else { + c.restore(); + + let tailIndex = this.spineLength - 1; + let tailPos = this.spine[tailIndex].pos; + let prevPos = this.spine[tailIndex - 1].pos; + let tailAngle = Math.atan2(tailPos.y - prevPos.y, tailPos.x - prevPos.x); + + c.save(); + c.translate(tailPos.x, tailPos.y); + c.rotate(tailAngle); + + if(!isShadow) { + c.fillStyle = makeFinGradient(0, 0, 35*s, 0); + } + + let flutter = Math.sin(this.finsAngle * 1.5); + + c.beginPath(); + c.moveTo(0, 0); + let tipX = 35 * s; + let tipY = -22 * s; + + c.bezierCurveTo(10*s, 0, 20*s, tipY + flutter * 5*s, tipX, tipY + flutter * 10*s); + c.lineTo(tipX - 10*s, 0); + c.bezierCurveTo(tipX, -tipY + flutter * 10*s, 20*s, -tipY + flutter * 5*s, 0, 0); + c.fill(); + + if (!isShadow) { + c.strokeStyle = rgbaFromHex(adjustColor(this.baseColor, -0.4), 0.5); + c.lineWidth = Math.max(1, this.size * 0.04); + c.beginPath(); + c.moveTo(0, 0); + c.lineTo(12*s, 0); + c.stroke(); + } + + if (!isShadow) { + c.strokeStyle = 'rgba(255,255,255,0.1)'; + c.lineWidth = 1; + c.beginPath(); + c.moveTo(0,0); c.lineTo(tipX, tipY + flutter*10*s); + c.moveTo(0,0); c.lineTo(tipX, -tipY + flutter*10*s); + c.moveTo(0,0); c.lineTo(tipX - 10*s, 0); + c.stroke(); + } + + c.restore(); + } + } + + drawEyes(head, angle) { + const ctx = mainCtx; + if (!ctx) return; + + ctx.save(); + ctx.translate(head.pos.x, head.pos.y); + ctx.rotate(angle); + + const s = this.size / 22; + const eyeOffset = 7 * s; + const eyeSize = params.fishEyeSizeRatio * s; + const irisSize = eyeSize * params.fishEyeIrisRatio; + const pupilSize = irisSize * params.fishEyePupilRatio; + const shadowOffset = 0.6 * s; + + const drawSingleEye = (y) => { + const x = eyeOffset; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.beginPath(); + ctx.ellipse(x + shadowOffset, y + shadowOffset, eyeSize * 0.95, eyeSize * 0.75, 0, 0, Math.PI * 2); + ctx.fill(); + + const scleraGradient = ctx.createRadialGradient( + x - eyeSize * 0.2, y - eyeSize * 0.2, eyeSize * 0.2, + x, y, eyeSize + ); + scleraGradient.addColorStop(0, '#ffffff'); + scleraGradient.addColorStop(0.6, '#e2e8ee'); + scleraGradient.addColorStop(1, '#b8c2cc'); + ctx.fillStyle = scleraGradient; + ctx.beginPath(); + ctx.arc(x, y, eyeSize, 0, Math.PI * 2); + ctx.fill(); + + const irisGradient = ctx.createRadialGradient( + x - irisSize * 0.25, y - irisSize * 0.25, irisSize * 0.1, + x, y, irisSize + ); + irisGradient.addColorStop(0, '#7a92a5'); + irisGradient.addColorStop(0.7, '#2c3f52'); + irisGradient.addColorStop(1, '#182431'); + ctx.fillStyle = irisGradient; + ctx.beginPath(); + ctx.arc(x, y, irisSize, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#0b0f14'; + ctx.beginPath(); + ctx.arc(x, y, pupilSize, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.beginPath(); + ctx.arc(x - pupilSize * 0.4, y - pupilSize * 0.4, pupilSize * 0.35, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.beginPath(); + ctx.arc(x + pupilSize * 0.2, y + pupilSize * 0.1, pupilSize * 0.25, 0, Math.PI * 2); + ctx.fill(); + }; + + drawSingleEye(-eyeOffset); + drawSingleEye(eyeOffset); + + ctx.restore(); + } +} diff --git a/js/entities/LilyPad.js b/js/entities/LilyPad.js new file mode 100644 index 0000000..8b360c4 --- /dev/null +++ b/js/entities/LilyPad.js @@ -0,0 +1,223 @@ +// Lily Pad class with optional flowers + +import { params } from '../config.js'; +import { rand, hexToHSL, width, height } from '../utils/helpers.js'; + +export class LilyPad { + constructor(x, y) { + this.x = x !== undefined ? x : rand(0, width); + this.y = y !== undefined ? y : rand(0, height); + this.radius = rand(params.padSizeMin, params.padSizeMax); + this.rotation = rand(0, Math.PI * 2); + + // Parse base color and create variations (like grass) + const baseHsl = hexToHSL(params.padColor); + this.baseHue = baseHsl.h + rand(params.padHueVariationMin, params.padHueVariationMax); + this.baseSat = baseHsl.s + rand(-5, 5); + this.baseLig = baseHsl.l + rand(-8, 8); + + this.notchAngle = rand(params.padNotchAngleMin, params.padNotchAngleMax); + this.hasFlower = Math.random() < params.flowerChance; + this.flowerColor = params.flowerColors[Math.floor(Math.random() * params.flowerColors.length)]; + + this.hasFrog = false; + this.budRotation = rand(0, Math.PI * 2); + + // Generate surface spots for texture variation + this.spotCount = Math.floor(rand(3, 8)); + this.spots = []; + for (let i = 0; i < this.spotCount; i++) { + const angle = rand(0, Math.PI * 2); + const distance = rand(0.2, 0.7) * this.radius; + this.spots.push({ + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: rand(this.radius * 0.08, this.radius * 0.18), + hueOffset: rand(-15, 15), + ligOffset: rand(-12, 8), + opacity: rand(0.15, 0.35) + }); + } + + // Generate vein properties for curved organic veins + this.veinProps = []; + const veinCount = params.padVeinCount; + for (let i = 0; i < veinCount; i++) { + this.veinProps.push({ + curveAmount: rand(-0.15, 0.15), + widthVariation: rand(0.8, 1.3), + lengthVariation: rand(0.9, 1.0) + }); + } + + if (this.hasFlower) { + if (params.flowerType === 'mixed') { + const r = Math.random(); + if (r < params.flowerMixedLotusProb) this.type = 'lotus'; + else if (r < params.flowerMixedLilyProb) this.type = 'lily'; + else this.type = 'bud'; + } else { + this.type = params.flowerType; + } + } + } + + draw(ctx) { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + + const splitSize = params.padSplitSize; + const startAngle = splitSize / 2; + const endAngle = Math.PI * 2 - splitSize / 2; + + // Shadow (keep existing) + ctx.save(); + ctx.translate(5, 5); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, this.radius, startAngle, endAngle); + ctx.lineTo(0, 0); + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.fill(); + ctx.restore(); + + // Main pad shape with radial gradient for depth + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, this.radius, startAngle, endAngle); + ctx.lineTo(0, 0); + + // Radial gradient from center (lighter) to edge (darker) + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, this.radius); + gradient.addColorStop(0, `hsla(${this.baseHue}, ${this.baseSat + 8}%, ${this.baseLig + 12}%, 1)`); + gradient.addColorStop(0.5, `hsla(${this.baseHue}, ${this.baseSat}%, ${this.baseLig}%, 1)`); + gradient.addColorStop(1, `hsla(${this.baseHue}, ${this.baseSat - 5}%, ${this.baseLig - 12}%, 0.95)`); + + ctx.fillStyle = gradient; + ctx.fill(); + + // Surface texture spots/patches + for (let spot of this.spots) { + ctx.beginPath(); + ctx.arc(spot.x, spot.y, spot.radius, 0, Math.PI * 2); + const spotHue = this.baseHue + spot.hueOffset; + const spotLig = Math.max(0, Math.min(100, this.baseLig + spot.ligOffset)); + ctx.fillStyle = `hsla(${spotHue}, ${this.baseSat}%, ${spotLig}%, ${spot.opacity})`; + ctx.fill(); + } + + // Organic curved veins with gradients + const visibleArc = endAngle - startAngle; + const veinCount = params.padVeinCount; + + for(let i = 0; i < veinCount; i++) { + const veinProp = this.veinProps[i]; + const a = startAngle + visibleArc * ((i + 1) / (veinCount + 1)); + const veinLength = this.radius * params.padVeinLengthRatio * veinProp.lengthVariation; + + const endX = Math.cos(a) * veinLength; + const endY = Math.sin(a) * veinLength; + + // Control point for curve (perpendicular offset) + const midX = endX * 0.5; + const midY = endY * 0.5; + const perpX = -Math.sin(a) * this.radius * veinProp.curveAmount; + const perpY = Math.cos(a) * this.radius * veinProp.curveAmount; + + // Draw main vein with gradient stroke + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.quadraticCurveTo(midX + perpX, midY + perpY, endX, endY); + + const veinGradient = ctx.createLinearGradient(0, 0, endX, endY); + veinGradient.addColorStop(0, `hsla(${this.baseHue}, ${this.baseSat}%, ${this.baseLig - 20}%, 0.5)`); + veinGradient.addColorStop(1, `hsla(${this.baseHue}, ${this.baseSat}%, ${this.baseLig - 10}%, 0.3)`); + + ctx.strokeStyle = veinGradient; + ctx.lineWidth = 1.2 * veinProp.widthVariation; + ctx.stroke(); + + // Highlight on one side of vein for dimension + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.quadraticCurveTo(midX + perpX, midY + perpY, endX, endY); + ctx.strokeStyle = `hsla(${this.baseHue}, ${this.baseSat}%, ${this.baseLig + 15}%, 0.25)`; + ctx.lineWidth = 0.5; + ctx.stroke(); + } + + // Edge highlight for depth + ctx.beginPath(); + ctx.arc(0, 0, this.radius * 0.95, startAngle, endAngle); + ctx.strokeStyle = `hsla(${this.baseHue}, ${this.baseSat + 10}%, ${this.baseLig + 18}%, 0.3)`; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Subtle darker edge + ctx.beginPath(); + ctx.arc(0, 0, this.radius * 0.98, startAngle, endAngle); + ctx.strokeStyle = `hsla(${this.baseHue}, ${this.baseSat}%, ${this.baseLig - 15}%, 0.4)`; + ctx.lineWidth = 1; + ctx.stroke(); + + if (this.hasFrog) { + // Frog is drawn by the Frog object + } + else if (this.hasFlower) { + this.drawFlower(ctx); + } + + ctx.restore(); + } + + drawFlower(ctx) { + if (this.type === 'lotus') { + const petalCount = params.lotusOuterPetalCount; + for(let j=0; j v.toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +function mixColors(a, b, t) { + const ca = hexToRgb(a); + const cb = hexToRgb(b); + const k = clamp01(t); + return rgbToHex({ + r: Math.round(ca.r + (cb.r - ca.r) * k), + g: Math.round(ca.g + (cb.g - ca.g) * k), + b: Math.round(ca.b + (cb.b - ca.b) * k) + }); +} + +function adjustColor(hex, amount) { + if (amount >= 0) { + return mixColors(hex, '#ffffff', clamp01(amount)); + } + return mixColors(hex, '#000000', clamp01(-amount)); +} + +function rgbaFromHex(hex, alpha) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export class PredatorFish { + constructor(x, y) { + this.pos = new Vector(x, y); + this.vel = new Vector(rand(-0.5, 0.5), rand(-0.5, 0.5)); + this.acc = new Vector(0, 0); + + this.size = rand(params.predatorSizeMin, params.predatorSizeMax); + this.baseSpeed = params.predatorBaseSpeed; // Slow when lurking + this.maxSpeed = this.baseSpeed; + this.maxForce = params.predatorMaxForceLurking; + + // State machine + this.state = 'LURKING'; + this.target = null; + this.stateTimer = 0; + this.restTimer = 0; + this.attackStartPos = null; + + // Spine system (same as Koi) + this.spine = []; + this.spineLength = params.spineCount; + this.swimTimer = Math.random() * params.fishInitialSwimTimer; + this.initSpine(x, y); + + // Stats + this.huntCount = 0; + this.killCount = 0; + + // Trail system for attack phase + this.trail = []; + this.lastTrailPos = null; + } + + initSpine(x, y) { + this.spine = []; + const d = this.size * params.distConstraint; + for (let i = 0; i < this.spineLength; i++) { + this.spine.push({ + pos: new Vector(x - i * d, y), + size: this.calculateThickness(i) + }); + } + } + + // Use same body shape as Koi + calculateThickness(i) { + const t = i / (this.spineLength - 1); + let thickness = 1.0; + + if (t < 0.25) { + thickness = params.fishThicknessHead + (t / 0.25) * params.fishThicknessNeck; + } else { + let bodyT = (t - 0.25) / 0.75; + thickness = params.fishThicknessTaper * (1 - Math.pow(bodyT, params.tailTaper) * params.fishThicknessPow); + } + thickness = Math.max(params.fishThicknessMin, thickness); + + return this.size * thickness * params.fatness; + } + + applyForce(force) { + this.acc.add(force); + } + + seek(target, speedMult = 1) { + let desired = new Vector(target.x - this.pos.x, target.y - this.pos.y); + desired.normalize(); + desired.mult(this.maxSpeed * speedMult); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * speedMult); + return steer; + } + + findTarget(koiList) { + const detectionRange = params.predatorDetectionRange; + let closestDist = detectionRange; + let closestKoi = null; + + // Use spatial grid for efficient lookup + const nearbyKoi = fishGrid.getNearby(this.pos.x, this.pos.y, detectionRange); + + for (const koi of nearbyKoi) { + const dx = koi.pos.x - this.pos.x; + const dy = koi.pos.y - this.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < closestDist) { + closestDist = d; + closestKoi = koi; + } + } + + return closestKoi; + } + + update(koiList, dt = 1/60) { + const scale = dt * 60; + + // State machine + switch (this.state) { + case 'LURKING': + this.maxSpeed = this.baseSpeed * params.speedScale; + this.maxForce = params.predatorMaxForceLurking; + + // Wander slowly + if (Math.random() < params.predatorWanderProbability) { + let wander = new Vector(rand(-1, 1), rand(-1, 1)); + wander.normalize(); + wander.mult(params.predatorWanderMagnitude); + this.applyForce(wander); + } + + // Stay in bounds + this.stayInBounds(); + + // Look for prey + const target = this.findTarget(koiList); + if (target) { + this.target = target; + this.state = 'DETECTING'; + this.stateTimer = 0; + } + break; + + case 'DETECTING': + this.maxSpeed = this.baseSpeed * params.predatorDetectingSpeedMult * params.speedScale; + this.stateTimer += scale; + + // Slowly turn toward target + if (this.target && koiList.includes(this.target)) { + let steer = this.seek(this.target.pos, params.predatorDetectingSeekMult); + this.applyForce(steer); + + // After brief pause, attack! + if (this.stateTimer > params.predatorDetectionTime) { + this.state = 'ATTACKING'; + this.attackStartPos = new Vector(this.pos.x, this.pos.y); + this.huntCount++; + // Create small ripple as predator lunges + ripplePool.acquire(this.pos.x, this.pos.y, params.rippleLungeRadius, params.rippleLungeMaxRadius, params.rippleLungeSpeed); + } + } else { + this.target = null; + this.state = 'LURKING'; + } + break; + + case 'ATTACKING': + this.maxSpeed = params.predatorAttackSpeed * params.speedScale; + this.maxForce = params.predatorMaxForceAttacking; + + if (this.target && koiList.includes(this.target)) { + let steer = this.seek(this.target.pos, params.predatorAttackingSeekMult); + this.applyForce(steer); + + // Check if caught + const dx = this.target.pos.x - this.pos.x; + const dy = this.target.pos.y - this.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < this.size * params.predatorCatchDistance) { + // Attempt catch + if (Math.random() < params.predatorHuntSuccessRate) { + // Success! Eat the koi + this.state = 'EATING'; + this.stateTimer = 0; + this.killCount++; + + // Remove koi from array + const idx = koiList.indexOf(this.target); + if (idx !== -1) { + koiList.splice(idx, 1); + } + + // Big splash effect + ripplePool.acquire(this.pos.x, this.pos.y, params.rippleKillMainRadius, params.rippleKillMainMaxRadius, params.rippleKillMainSpeed); + ripplePool.acquire(this.pos.x, this.pos.y, params.rippleKillSecondaryRadius, params.rippleKillSecondaryMaxRadius, params.rippleKillSecondarySpeed); + } else { + // Failed - koi escaped + this.state = 'RESTING'; + this.restTimer = params.predatorRestTime * params.predatorRestTimeFailMult; + ripplePool.acquire(this.pos.x, this.pos.y, params.rippleFailRadius, params.rippleFailMaxRadius, params.rippleFailSpeed); + } + this.target = null; + } + + // Give up if chase is too long + const chaseDistance = dist(this.attackStartPos.x, this.attackStartPos.y, this.pos.x, this.pos.y); + if (chaseDistance > params.predatorDetectionRange * params.predatorMaxChaseDistMult) { + this.state = 'RESTING'; + this.restTimer = params.predatorRestTime * params.predatorRestTimeGiveUpMult; + this.target = null; + } + } else { + this.target = null; + this.state = 'RESTING'; + this.restTimer = params.predatorRestTime * params.predatorRestTimeGiveUpMult; + } + break; + + case 'EATING': + this.maxSpeed = params.predatorEatingSpeed; + this.stateTimer += scale; + + // Brief pause while "eating" + if (this.stateTimer > params.predatorEatingDuration) { + this.state = 'RESTING'; + this.restTimer = params.predatorRestTime; + } + break; + + case 'RESTING': + this.maxSpeed = this.baseSpeed * params.predatorRestingSpeedMult * params.speedScale; + this.maxForce = params.predatorMaxForceResting; + this.restTimer -= scale; + + // Slow drift + this.stayInBounds(); + + if (this.restTimer <= 0) { + this.state = 'LURKING'; + } + break; + } + + // Physics update + this.vel.x += this.acc.x * scale; + this.vel.y += this.acc.y * scale; + this.vel.limit(this.maxSpeed); + + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + this.acc.mult(0); + + // Swim animation (same as Koi) + let speed = this.vel.mag(); + this.swimTimer += (params.waveSpeedBase + (speed * params.waveSpeedMult)) * scale; + + // Update spine + this.updateSpine(); + + // Update trail system + this.updateTrail(dt); + } + + stayInBounds() { + const margin = params.predatorBoundaryMargin; + let desired = null; + + if (this.pos.x < margin) desired = new Vector(this.maxSpeed, this.vel.y); + else if (this.pos.x > width - margin) desired = new Vector(-this.maxSpeed, this.vel.y); + if (this.pos.y < margin) desired = new Vector(this.vel.x, this.maxSpeed); + else if (this.pos.y > height - margin) desired = new Vector(this.vel.x, -this.maxSpeed); + + if (desired) { + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * 2); + this.applyForce(steer); + } + } + + updateSpine() { + this.spine[0].pos = new Vector(this.pos.x, this.pos.y); + + for (let i = 1; i < this.spineLength; i++) { + let prev = this.spine[i - 1].pos; + let curr = this.spine[i].pos; + + let dx = curr.x - prev.x; + let dy = curr.y - prev.y; + let angle = Math.atan2(dy, dx); + + // Use same wave parameters as Koi, but more aggressive when attacking + let waveAmt = Math.min(params.waveAmpMax, i * params.waveAmpGain); + if (this.state === 'ATTACKING') waveAmt *= params.predatorAttackWaveMult; + let wave = Math.sin(this.swimTimer - i * params.fishWavePhaseOffset) * waveAmt; + angle += wave; + + const segDist = this.size * params.distConstraint; + curr.x = prev.x + Math.cos(angle) * segDist; + curr.y = prev.y + Math.sin(angle) * segDist; + + this.spine[i].size = this.calculateThickness(i); + } + } + + updateTrail(dt) { + const scale = dt * 60; + + // Update ages of existing trail points + for (let i = this.trail.length - 1; i >= 0; i--) { + const point = this.trail[i]; + point.age += dt; + + // Calculate alpha based on age (exponential fade for anime effect) + const ageFactor = 1 - (point.age / params.predatorTrailFadeTime); + point.alpha = Math.max(0, params.predatorTrailAlpha * Math.pow(ageFactor, 1.5)); + + // Remove expired trail points + if (point.age >= params.predatorTrailFadeTime) { + this.trail.splice(i, 1); + } + } + + // Add new trail points only during ATTACKING state + if (this.state === 'ATTACKING') { + // Calculate eye positions + const head = this.spine[0]; + const neck = this.spine[1]; + const headAngle = Math.atan2(head.pos.y - neck.pos.y, head.pos.x - neck.pos.x); + + const s = this.size / 22; + const eyeOffset = 7 * s; + + // Calculate world positions of both eyes + const leftEyeX = head.pos.x + Math.cos(headAngle) * eyeOffset + Math.cos(headAngle - Math.PI/2) * eyeOffset; + const leftEyeY = head.pos.y + Math.sin(headAngle) * eyeOffset + Math.sin(headAngle - Math.PI/2) * eyeOffset; + + const rightEyeX = head.pos.x + Math.cos(headAngle) * eyeOffset + Math.cos(headAngle + Math.PI/2) * eyeOffset; + const rightEyeY = head.pos.y + Math.sin(headAngle) * eyeOffset + Math.sin(headAngle + Math.PI/2) * eyeOffset; + + // Check if we should add a new trail point based on distance + let shouldAddPoint = false; + + if (!this.lastTrailPos) { + shouldAddPoint = true; + } else { + const dx = head.pos.x - this.lastTrailPos.x; + const dy = head.pos.y - this.lastTrailPos.y; + const distSq = dx * dx + dy * dy; + const spacingSq = params.predatorTrailSpacing * params.predatorTrailSpacing; + + if (distSq >= spacingSq) { + shouldAddPoint = true; + } + } + + if (shouldAddPoint) { + // Add new trail point with eye positions + this.trail.push({ + leftEye: { x: leftEyeX, y: leftEyeY }, + rightEye: { x: rightEyeX, y: rightEyeY }, + angle: headAngle, + age: 0, + alpha: params.predatorTrailAlpha, + eyeSize: params.predatorEyeSizeRatio * s, + pupilSize: params.predatorEyeSizeRatio * s * params.predatorEyeIrisRatio * params.predatorEyePupilRatio + }); + + this.lastTrailPos = { x: head.pos.x, y: head.pos.y }; + + // Limit trail length + if (this.trail.length > params.predatorTrailMaxLength) { + this.trail.shift(); + } + } + } else { + // Clear trail when not attacking + if (this.trail.length > 0) { + // Fade out existing trail naturally rather than clearing abruptly + // Trail will fade out on its own through the age update above + } + this.lastTrailPos = null; + } + } + + drawTrail(ctx) { + if (this.trail.length < 1) return; + + ctx.save(); + + // Draw anime-style blurred eye after-images (oldest to newest for proper layering) + for (let i = 0; i < this.trail.length; i++) { + const point = this.trail[i]; + + if (point.alpha <= 0) continue; + + // Draw each eye's after-image with blur effect + this.drawGhostEye(ctx, point.leftEye.x, point.leftEye.y, point.eyeSize, point.pupilSize, point.alpha); + this.drawGhostEye(ctx, point.rightEye.x, point.rightEye.y, point.eyeSize, point.pupilSize, point.alpha); + } + + ctx.restore(); + } + + drawGhostEye(ctx, x, y, eyeSize, pupilSize, alpha) { + // Anime-style blurred glow effect - multiple layers for soft blur + const blurLayers = 3; + + for (let layer = blurLayers; layer >= 1; layer--) { + const layerScale = 1 + (layer * 0.4); + const layerAlpha = alpha / (layer * 1.5); + + // Outer glow (red/orange halo) + const outerRadius = pupilSize * layerScale * 2.5; + const outerGradient = ctx.createRadialGradient(x, y, pupilSize * 0.3, x, y, outerRadius); + outerGradient.addColorStop(0, `rgba(255, 60, 40, ${layerAlpha * 0.6})`); + outerGradient.addColorStop(0.4, `rgba(255, 80, 50, ${layerAlpha * 0.4})`); + outerGradient.addColorStop(0.7, `rgba(255, 100, 60, ${layerAlpha * 0.2})`); + outerGradient.addColorStop(1, 'rgba(255, 120, 70, 0)'); + + ctx.fillStyle = outerGradient; + ctx.beginPath(); + ctx.arc(x, y, outerRadius, 0, Math.PI * 2); + ctx.fill(); + } + + // Core bright spot (hot center) + const coreGradient = ctx.createRadialGradient(x, y, 0, x, y, pupilSize * 1.2); + coreGradient.addColorStop(0, `rgba(255, 200, 150, ${alpha * 0.9})`); + coreGradient.addColorStop(0.5, `rgba(255, 100, 80, ${alpha * 0.7})`); + coreGradient.addColorStop(1, `rgba(255, 60, 40, ${alpha * 0.3})`); + + ctx.fillStyle = coreGradient; + ctx.beginPath(); + ctx.arc(x, y, pupilSize * 1.2, 0, Math.PI * 2); + ctx.fill(); + + // Add streaky blur effect for motion (anime style) + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = alpha * 0.4; + + const streakLength = pupilSize * 1.5; + const streakGradient = ctx.createRadialGradient(x, y, 0, x, y, streakLength); + streakGradient.addColorStop(0, 'rgba(255, 150, 100, 0.8)'); + streakGradient.addColorStop(0.6, 'rgba(255, 80, 60, 0.4)'); + streakGradient.addColorStop(1, 'rgba(255, 60, 40, 0)'); + + ctx.fillStyle = streakGradient; + ctx.beginPath(); + ctx.arc(x, y, streakLength, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; + } + + draw(ctx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 2)) return; + + // Draw trail first (behind the predator) + this.drawTrail(ctx); + + let leftPoints = []; + let rightPoints = []; + + let head = this.spine[0]; + let neck = this.spine[1]; + let headAngle = Math.atan2(head.pos.y - neck.pos.y, head.pos.x - neck.pos.x); + + for (let i = 0; i < this.spineLength; i++) { + let s = this.spine[i]; + let a; + + if (i === 0) { + a = headAngle; + } else if (i === this.spineLength - 1) { + let prev = this.spine[i-1]; + a = Math.atan2(s.pos.y - prev.pos.y, s.pos.x - prev.pos.x); + } else { + let next = this.spine[i+1]; + let prev = this.spine[i-1]; + a = Math.atan2(next.pos.y - prev.pos.y, next.pos.x - prev.pos.x); + } + + let px = Math.cos(a + Math.PI/2); + let py = Math.sin(a + Math.PI/2); + + leftPoints.push({x: s.pos.x + px * s.size, y: s.pos.y + py * s.size}); + rightPoints.push({x: s.pos.x - px * s.size, y: s.pos.y - py * s.size}); + } + + // Draw fins behind body (same as Koi) + this.drawFins(ctx, head, headAngle, false); + + // Draw body with shaded gradient + ctx.save(); + this.drawBodyPath(ctx, leftPoints, rightPoints, head, headAngle); + + let maxRadius = 0; + for (const s of this.spine) { + if (s.size > maxRadius) maxRadius = s.size; + } + const midIndex = Math.floor(this.spineLength * 0.4); + const midPoint = this.spine[midIndex].pos; + const perpAngle = headAngle + Math.PI / 2; + const gradRadius = maxRadius * 1.25; + const gx0 = midPoint.x + Math.cos(perpAngle) * gradRadius; + const gy0 = midPoint.y + Math.sin(perpAngle) * gradRadius; + const gx1 = midPoint.x - Math.cos(perpAngle) * gradRadius; + const gy1 = midPoint.y - Math.sin(perpAngle) * gradRadius; + + const bodyGradient = ctx.createLinearGradient(gx0, gy0, gx1, gy1); + bodyGradient.addColorStop(0, adjustColor(params.predatorColor, -params.predatorBodyShadeDark)); + bodyGradient.addColorStop(0.45, adjustColor(params.predatorColor, params.predatorBodyShadeLight * 0.5)); + bodyGradient.addColorStop(0.55, adjustColor(params.predatorColor, params.predatorBodyShadeLight)); + bodyGradient.addColorStop(1, adjustColor(params.predatorColor, -params.predatorBodyShadeDark * 0.75)); + ctx.fillStyle = bodyGradient; + ctx.fill(); + + ctx.fillStyle = rgbaFromHex(params.predatorColor, params.predatorBodySolidAlpha); + ctx.fill(); + + const lightDirX = 0.4; + const lightDirY = -0.9; + const lightLen = Math.hypot(lightDirX, lightDirY) || 1; + const lx = lightDirX / lightLen; + const ly = lightDirY / lightLen; + const highlightOffset = this.size * 0.18; + + ctx.save(); + ctx.globalCompositeOperation = 'screen'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + for (let i = 0; i < this.spineLength - 1; i++) { + const s = this.spine[i]; + const hx = s.pos.x + lx * highlightOffset * (s.size / maxRadius); + const hy = s.pos.y + ly * highlightOffset * (s.size / maxRadius); + if (i === 0) ctx.moveTo(hx, hy); + else ctx.lineTo(hx, hy); + } + ctx.strokeStyle = `rgba(255, 255, 255, ${params.predatorSpecularOuterAlpha})`; + ctx.lineWidth = this.size * params.predatorSpecularWidth; + ctx.stroke(); + ctx.strokeStyle = `rgba(255, 255, 255, ${params.predatorSpecularInnerAlpha})`; + ctx.lineWidth = this.size * params.predatorSpecularInnerWidth; + ctx.stroke(); + ctx.restore(); + + ctx.restore(); + + ctx.save(); + this.drawBodyPath(ctx, leftPoints, rightPoints, head, headAngle); + ctx.strokeStyle = rgbaFromHex(adjustColor(params.predatorColor, -params.predatorOutlineDarken), params.predatorOutlineAlpha); + ctx.lineWidth = Math.max(1, this.size * params.predatorOutlineWidth); + ctx.stroke(); + ctx.restore(); + + // Draw dorsal fin + this.drawDorsalFin(ctx); + + // Draw fins in front + this.drawFins(ctx, head, headAngle, true); + + // Draw eyes + this.drawEyes(ctx, head, headAngle); + + // Draw attack indicator when detecting/attacking + if (this.state === 'DETECTING' || this.state === 'ATTACKING') { + ctx.save(); + ctx.beginPath(); + ctx.arc(this.pos.x, this.pos.y, params.predatorDetectionRange, 0, Math.PI * 2); + ctx.strokeStyle = this.state === 'ATTACKING' + ? 'rgba(255, 50, 50, 0.15)' + : 'rgba(255, 200, 50, 0.1)'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + } + } + + // Same body path as Koi + drawBodyPath(ctx, leftPoints, rightPoints, head, headAngle) { + ctx.beginPath(); + let noseX = head.pos.x + Math.cos(headAngle) * this.spine[0].size; + let noseY = head.pos.y + Math.sin(headAngle) * this.spine[0].size; + ctx.moveTo(noseX, noseY); + + ctx.lineTo(leftPoints[0].x, leftPoints[0].y); + for (let i = 0; i < leftPoints.length - 1; i++) { + const p0 = leftPoints[i]; + const p1 = leftPoints[i + 1]; + const mx = (p0.x + p1.x) * 0.5; + const my = (p0.y + p1.y) * 0.5; + ctx.quadraticCurveTo(p0.x, p0.y, mx, my); + } + ctx.lineTo(leftPoints[leftPoints.length-1].x, leftPoints[leftPoints.length-1].y); + + let tail = this.spine[this.spineLength-1]; + ctx.quadraticCurveTo(tail.pos.x, tail.pos.y, rightPoints[this.spineLength-1].x, rightPoints[this.spineLength-1].y); + + for (let i = rightPoints.length - 1; i > 0; i--) { + const p0 = rightPoints[i]; + const p1 = rightPoints[i - 1]; + const mx = (p0.x + p1.x) * 0.5; + const my = (p0.y + p1.y) * 0.5; + ctx.quadraticCurveTo(p0.x, p0.y, mx, my); + } + ctx.lineTo(rightPoints[0].x, rightPoints[0].y); + + ctx.lineTo(noseX, noseY); + ctx.closePath(); + } + + drawDorsalFin(ctx) { + const startIndex = 4; + const endIndex = this.spineLength - 2; + + if (startIndex >= this.spineLength || endIndex >= this.spineLength) return; + + ctx.beginPath(); + let start = this.spine[startIndex]; + ctx.moveTo(start.pos.x, start.pos.y); + + for(let i = startIndex + 1; i <= endIndex; i++) { + let s = this.spine[i]; + ctx.lineTo(s.pos.x, s.pos.y); + } + + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)'; + ctx.stroke(); + + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.stroke(); + } + + // Same fin style as Koi + drawFins(ctx, head, angle, topLayer) { + let shoulder = this.spine[4]; + let a = angle; + + ctx.save(); + ctx.translate(shoulder.pos.x, shoulder.pos.y); + ctx.rotate(a); + + const finBase = adjustColor(params.predatorColor, params.predatorFinShadeLight); + const finMid = adjustColor(params.predatorColor, params.predatorFinShadeMid); + const finEdge = adjustColor(params.predatorColor, -params.predatorFinShadeDark); + const makeFinGradient = (x0, y0, x1, y1) => { + const g = ctx.createLinearGradient(x0, y0, x1, y1); + g.addColorStop(0, rgbaFromHex(finBase, params.predatorFinAlphaBase)); + g.addColorStop(0.6, rgbaFromHex(finMid, params.predatorFinAlphaMid)); + g.addColorStop(1, rgbaFromHex(finEdge, params.predatorFinAlphaEdge)); + return g; + }; + + const s = (this.size / 22) * params.finScale; + + if (!topLayer) { + let finCycle = Math.sin(this.swimTimer); + + ctx.fillStyle = makeFinGradient(6*s, 0, 45*s, 0); + ctx.beginPath(); + ctx.moveTo(4*s, 0); + ctx.quadraticCurveTo(45*s, -40*s + finCycle*10*s, -15*s, -25*s + finCycle*5*s); + ctx.quadraticCurveTo(0, -8*s, 4*s, 0); + ctx.fill(); + ctx.strokeStyle = rgbaFromHex(adjustColor(params.predatorColor, -0.45), 0.55); + ctx.lineWidth = Math.max(1, this.size * 0.04); + ctx.beginPath(); + ctx.moveTo(4*s, -2*s); + ctx.lineTo(-6*s, -10*s); + ctx.stroke(); + + ctx.fillStyle = makeFinGradient(6*s, 0, 45*s, 0); + ctx.beginPath(); + ctx.moveTo(4*s, 0); + ctx.quadraticCurveTo(45*s, 40*s - finCycle*10*s, -15*s, 25*s - finCycle*5*s); + ctx.quadraticCurveTo(0, 8*s, 4*s, 0); + ctx.fill(); + ctx.strokeStyle = rgbaFromHex(adjustColor(params.predatorColor, -0.45), 0.55); + ctx.lineWidth = Math.max(1, this.size * 0.04); + ctx.beginPath(); + ctx.moveTo(4*s, 2*s); + ctx.lineTo(-6*s, 10*s); + ctx.stroke(); + + ctx.translate(-22*s, 0); + let pelvicCycle = Math.cos(this.swimTimer); + + ctx.fillStyle = makeFinGradient(0, 0, 18*s, 0); + ctx.beginPath(); + ctx.moveTo(0, 3*s); + ctx.quadraticCurveTo(15*s, 20*s + pelvicCycle*3*s, -5*s, 15*s); + ctx.fill(); + ctx.strokeStyle = rgbaFromHex(adjustColor(params.predatorColor, -0.45), 0.5); + ctx.lineWidth = Math.max(1, this.size * 0.035); + ctx.beginPath(); + ctx.moveTo(0, 3*s); + ctx.lineTo(-6*s, 10*s); + ctx.stroke(); + + ctx.fillStyle = makeFinGradient(0, 0, 18*s, 0); + ctx.beginPath(); + ctx.moveTo(0, -3*s); + ctx.quadraticCurveTo(15*s, -20*s - pelvicCycle*3*s, -5*s, -15*s); + ctx.fill(); + ctx.strokeStyle = rgbaFromHex(adjustColor(params.predatorColor, -0.45), 0.5); + ctx.lineWidth = Math.max(1, this.size * 0.035); + ctx.beginPath(); + ctx.moveTo(0, -3*s); + ctx.lineTo(-6*s, -10*s); + ctx.stroke(); + + ctx.restore(); + } else { + ctx.restore(); + + let tailIndex = this.spineLength - 1; + let tailPos = this.spine[tailIndex].pos; + let prevPos = this.spine[tailIndex - 1].pos; + let tailAngle = Math.atan2(tailPos.y - prevPos.y, tailPos.x - prevPos.x); + + ctx.save(); + ctx.translate(tailPos.x, tailPos.y); + ctx.rotate(tailAngle); + + ctx.fillStyle = makeFinGradient(0, 0, 35*s, 0); + + let flutter = Math.sin(this.swimTimer * 1.5); + + ctx.beginPath(); + ctx.moveTo(0, 0); + let tipX = 35 * s; + let tipY = -22 * s; + + ctx.bezierCurveTo(10*s, 0, 20*s, tipY + flutter * 5*s, tipX, tipY + flutter * 10*s); + ctx.lineTo(tipX - 10*s, 0); + ctx.bezierCurveTo(tipX, -tipY + flutter * 10*s, 20*s, -tipY + flutter * 5*s, 0, 0); + ctx.fill(); + + ctx.strokeStyle = rgbaFromHex(adjustColor(params.predatorColor, -0.5), 0.55); + ctx.lineWidth = Math.max(1, this.size * 0.04); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(12*s, 0); + ctx.stroke(); + + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0,0); ctx.lineTo(tipX, tipY + flutter*10*s); + ctx.moveTo(0,0); ctx.lineTo(tipX, -tipY + flutter*10*s); + ctx.moveTo(0,0); ctx.lineTo(tipX - 10*s, 0); + ctx.stroke(); + + ctx.restore(); + } + } + + drawEyes(ctx, head, angle) { + ctx.save(); + ctx.translate(head.pos.x, head.pos.y); + ctx.rotate(angle); + + const s = this.size / 22; + const eyeOffset = 7 * s; + const eyeSize = params.predatorEyeSizeRatio * s; + const irisSize = eyeSize * params.predatorEyeIrisRatio; + const pupilSize = irisSize * params.predatorEyePupilRatio; + const shadowOffset = 0.6 * s; + + const drawSingleEye = (y) => { + const x = eyeOffset; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; + ctx.beginPath(); + ctx.ellipse(x + shadowOffset, y + shadowOffset, eyeSize * 0.95, eyeSize * 0.75, 0, 0, Math.PI * 2); + ctx.fill(); + + const scleraGradient = ctx.createRadialGradient( + x - eyeSize * 0.2, y - eyeSize * 0.2, eyeSize * 0.2, + x, y, eyeSize + ); + scleraGradient.addColorStop(0, '#e9edf2'); + scleraGradient.addColorStop(0.6, '#c2c9d1'); + scleraGradient.addColorStop(1, '#8c97a3'); + ctx.fillStyle = scleraGradient; + ctx.beginPath(); + ctx.arc(x, y, eyeSize, 0, Math.PI * 2); + ctx.fill(); + + const irisGradient = ctx.createRadialGradient( + x - irisSize * 0.25, y - irisSize * 0.25, irisSize * 0.1, + x, y, irisSize + ); + irisGradient.addColorStop(0, '#5a6d7a'); + irisGradient.addColorStop(0.7, '#24313c'); + irisGradient.addColorStop(1, '#111820'); + ctx.fillStyle = irisGradient; + ctx.beginPath(); + ctx.arc(x, y, irisSize, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#0a0f14'; + ctx.beginPath(); + ctx.arc(x, y, pupilSize, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'; + ctx.beginPath(); + ctx.arc(x - pupilSize * 0.4, y - pupilSize * 0.4, pupilSize * 0.3, 0, Math.PI * 2); + ctx.fill(); + + if (this.state === 'ATTACKING') { + // Pulsing glow effect + const pulsePhase = Math.sin(this.swimTimer * params.predatorEyeGlowPulseSpeed) * 0.5 + 0.5; + const glowIntensity = (0.6 + pulsePhase * 0.4) * params.predatorEyeGlowIntensity; + + // Outer glow halo (largest) + const outerGlowRadius = pupilSize * params.predatorEyeGlowOuterRadius * 1.5; + const outerGlowGradient = ctx.createRadialGradient(x, y, pupilSize * 0.5, x, y, outerGlowRadius); + outerGlowGradient.addColorStop(0, `rgba(255, 40, 40, ${glowIntensity * 0.4})`); + outerGlowGradient.addColorStop(0.5, `rgba(255, 60, 30, ${glowIntensity * 0.2})`); + outerGlowGradient.addColorStop(1, 'rgba(255, 80, 0, 0)'); + ctx.fillStyle = outerGlowGradient; + ctx.beginPath(); + ctx.arc(x, y, outerGlowRadius, 0, Math.PI * 2); + ctx.fill(); + + // Inner glow ring + const innerGlowRadius = pupilSize * params.predatorEyeGlowOuterRadius; + const innerGlowGradient = ctx.createRadialGradient(x, y, pupilSize * 0.3, x, y, innerGlowRadius); + innerGlowGradient.addColorStop(0, `rgba(255, 50, 50, ${glowIntensity * 0.8})`); + innerGlowGradient.addColorStop(0.6, `rgba(255, 40, 30, ${glowIntensity * 0.5})`); + innerGlowGradient.addColorStop(1, 'rgba(255, 60, 0, 0)'); + ctx.fillStyle = innerGlowGradient; + ctx.beginPath(); + ctx.arc(x, y, innerGlowRadius, 0, Math.PI * 2); + ctx.fill(); + + // Core bright glow + ctx.fillStyle = `rgba(255, 60, 60, ${params.predatorEyeAttackGlowAlpha * glowIntensity})`; + ctx.beginPath(); + ctx.arc(x + pupilSize * 0.1, y + pupilSize * 0.1, pupilSize * 0.5, 0, Math.PI * 2); + ctx.fill(); + + // Bright center point + ctx.fillStyle = `rgba(255, 200, 150, ${glowIntensity * 0.9})`; + ctx.beginPath(); + ctx.arc(x, y, pupilSize * 0.25, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.beginPath(); + ctx.arc(x + pupilSize * 0.2, y + pupilSize * 0.1, pupilSize * 0.2, 0, Math.PI * 2); + ctx.fill(); + } + }; + + drawSingleEye(-eyeOffset); + drawSingleEye(eyeOffset); + + ctx.restore(); + } + + drawShadow(shadowCtx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 2)) return; + + let leftPoints = []; + let rightPoints = []; + + let head = this.spine[0]; + let neck = this.spine[1]; + let headAngle = Math.atan2(head.pos.y - neck.pos.y, head.pos.x - neck.pos.x); + + for (let i = 0; i < this.spineLength; i++) { + let s = this.spine[i]; + let a; + + if (i === 0) { + a = headAngle; + } else if (i === this.spineLength - 1) { + let prev = this.spine[i-1]; + a = Math.atan2(s.pos.y - prev.pos.y, s.pos.x - prev.pos.x); + } else { + let next = this.spine[i+1]; + let prev = this.spine[i-1]; + a = Math.atan2(next.pos.y - prev.pos.y, next.pos.x - prev.pos.x); + } + + let px = Math.cos(a + Math.PI/2); + let py = Math.sin(a + Math.PI/2); + + leftPoints.push({x: s.pos.x + px * s.size, y: s.pos.y + py * s.size}); + rightPoints.push({x: s.pos.x - px * s.size, y: s.pos.y - py * s.size}); + } + + shadowCtx.save(); + shadowCtx.translate(params.shadowOffsetX, params.shadowOffsetY); + shadowCtx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + this.drawBodyPath(shadowCtx, leftPoints, rightPoints, head, headAngle); + shadowCtx.fill(); + shadowCtx.restore(); + } +} diff --git a/js/entities/Ripple.js b/js/entities/Ripple.js new file mode 100644 index 0000000..d3c31a7 --- /dev/null +++ b/js/entities/Ripple.js @@ -0,0 +1,163 @@ +// Ripple water effect class - realistic wave propagation with interference + +export class Ripple { + constructor(x, y) { + this.x = x; + this.y = y; + this.radius = 1; + this.maxRadius = 180; + this.opacity = 0.8; + this.speed = 1.5; + this.life = 120; + this.ringCount = 3; // Multiple concentric rings + this.ringSpacing = 25; // Distance between rings + this.amplitude = 1.0; // Wave height (for interference calculations) + } + + update(dt = 1/60) { + const scale = dt * 60; // Normalize to 60 FPS + this.radius += this.speed * scale; + this.opacity -= 0.008 * scale; + this.life -= scale; + + // Amplitude decreases as wave spreads + this.amplitude = this.opacity; + } + + // Calculate wave amplitude at a given point (for interference) + getAmplitudeAt(x, y) { + const dist = Math.sqrt((x - this.x) ** 2 + (y - this.y) ** 2); + + // Check each ring + for (let i = 0; i < this.ringCount; i++) { + const ringRadius = this.radius - (i * this.ringSpacing); + if (ringRadius <= 0) continue; + + // Distance from this ring + const distFromRing = Math.abs(dist - ringRadius); + + // If point is near this ring (within wave width) + const waveWidth = 8; + if (distFromRing < waveWidth) { + // Wave amplitude falls off with distance from ring + const ringStrength = (1 - distFromRing / waveWidth); + const ringAge = ringRadius / this.maxRadius; + const ringOpacity = this.opacity * (1 - i * 0.25) * (1 - ringAge * 0.5); + + // Sinusoidal wave pattern + const phase = (distFromRing / waveWidth) * Math.PI * 2; + return Math.sin(phase) * ringStrength * ringOpacity * this.amplitude; + } + } + + return 0; + } + + draw(ctx, allRipples = []) { + if (this.opacity <= 0 || this.radius < 0) return; + + ctx.save(); + + // Draw multiple concentric rings with interference + for (let i = 0; i < this.ringCount; i++) { + const ringRadius = this.radius - (i * this.ringSpacing); + + if (ringRadius <= 2) continue; // Skip if too small + + // Each ring fades as it expands and is weaker the further from center + const ringAge = ringRadius / this.maxRadius; + let ringOpacity = this.opacity * (1 - i * 0.25) * (1 - ringAge * 0.5); + + if (ringOpacity <= 0) continue; + + // Check for interference with other ripples + let interferenceBoost = 0; + for (let other of allRipples) { + if (other === this || !other.opacity) continue; + + // Distance between ripple centers + const dx = other.x - this.x; + const dy = other.y - this.y; + const centerDist = Math.sqrt(dx * dx + dy * dy); + + // Check if this ring intersects with any of the other ripple's rings + for (let j = 0; j < other.ringCount; j++) { + const otherRingRadius = other.radius - (j * other.ringSpacing); + if (otherRingRadius <= 0) continue; + + // Check if rings are overlapping + const ringDiff = Math.abs(centerDist - (ringRadius + otherRingRadius)); + const ringSum = Math.abs(centerDist - Math.abs(ringRadius - otherRingRadius)); + + // Rings intersect + if (ringDiff < 15 || ringSum < 15) { + const otherRingAge = otherRingRadius / other.maxRadius; + const otherStrength = other.opacity * (1 - j * 0.25) * (1 - otherRingAge * 0.5); + + // Constructive interference when rings align + interferenceBoost += otherStrength * 0.3; + } + } + } + + // Apply interference (can amplify or slightly modify) + ringOpacity = Math.min(1, ringOpacity + interferenceBoost); + + // Calculate line widths with bounds checking + const mainWidth = Math.max(0.5, 2.5 - (ringAge * 1.5)); + const shadowWidth = Math.max(0.5, 1.5 - (ringAge * 1)); + const blurWidth = Math.max(1, 4 - (ringAge * 2)); + + // Interference creates brighter spots + const interferenceColor = interferenceBoost > 0.1 ? + `rgba(240, 250, 255, ${ringOpacity * 0.4})` : + `rgba(220, 240, 255, ${ringOpacity * 0.35})`; + + // Outer bright highlight (light reflecting off wave crest) + ctx.beginPath(); + ctx.arc(this.x, this.y, ringRadius, 0, Math.PI * 2); + ctx.strokeStyle = interferenceColor; + ctx.lineWidth = mainWidth * (1 + interferenceBoost * 0.5); + ctx.stroke(); + + // Inner shadow (wave trough) - only if radius is large enough + if (ringRadius > 2) { + ctx.beginPath(); + ctx.arc(this.x, this.y, Math.max(1, ringRadius - 1.5), 0, Math.PI * 2); + ctx.strokeStyle = `rgba(0, 40, 80, ${ringOpacity * 0.25})`; + ctx.lineWidth = shadowWidth; + ctx.stroke(); + } + + // Subtle blur effect for the main ring + ctx.beginPath(); + ctx.arc(this.x, this.y, ringRadius + 0.5, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(180, 220, 240, ${ringOpacity * 0.15})`; + ctx.lineWidth = blurWidth; + ctx.stroke(); + } + + // Center disturbance (where ripple originated) + if (this.radius > 1 && this.radius < 30) { + const centerRadius = Math.max(1, this.radius * 0.4); + const centerOpacity = this.opacity * (1 - this.radius / 30); + + if (centerOpacity > 0 && centerRadius > 0) { + const disturbanceGradient = ctx.createRadialGradient( + this.x, this.y, 0, + this.x, this.y, centerRadius + ); + disturbanceGradient.addColorStop(0, `rgba(200, 230, 255, ${centerOpacity * 0.3})`); + disturbanceGradient.addColorStop(0.6, `rgba(180, 220, 245, ${centerOpacity * 0.15})`); + disturbanceGradient.addColorStop(1, `rgba(160, 210, 235, 0)`); + + ctx.fillStyle = disturbanceGradient; + ctx.beginPath(); + ctx.arc(this.x, this.y, centerRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.restore(); + } +} diff --git a/js/entities/Snail.js b/js/entities/Snail.js new file mode 100644 index 0000000..6ceb312 --- /dev/null +++ b/js/entities/Snail.js @@ -0,0 +1,492 @@ +// Snail class - slow creature that crawls on lily pads/stones and retracts from predators + +import { Vector } from '../utils/Vector.js'; +import { params, CULL_MARGIN } from '../config.js'; +import { rand, dist, isInView, width, height } from '../utils/helpers.js'; +import { predatorGrid } from '../utils/SpatialGrid.js'; +import { ripplePool } from '../systems/ObjectPool.js'; + +// Function to add ripple (will be set by main) +let addRippleFn = null; +export function setAddRippleFunction(fn) { + addRippleFn = fn; +} + +export class Snail { + constructor(x, y) { + this.pos = new Vector(x, y); + this.vel = new Vector(rand(-0.1, 0.1), rand(-0.1, 0.1)); + this.acc = new Vector(0, 0); + + this.size = rand(params.snailSizeMin, params.snailSizeMax); + this.baseSpeed = rand(params.snailSpeedMin, params.snailSpeedMax); + this.maxSpeed = this.baseSpeed * params.speedScale; + this.maxForce = 0.01; // Very gentle turning + + // Visual properties + this.shellColor = params.snailShellColor; + this.bodyColor = params.snailBodyColor; + this.rotation = Math.atan2(this.vel.y, this.vel.x); + + // Generate shell spiral pattern + this.spiralSegments = []; + const segments = 8; + for (let i = 0; i < segments; i++) { + this.spiralSegments.push({ + angle: (i / segments) * Math.PI * 2, + radius: 0.3 + (i / segments) * 0.7, + size: rand(0.15, 0.25) + }); + } + + // State + this.state = 'CRAWLING'; // CRAWLING, SWIMMING, RETRACTED + this.retractedTimer = 0; + this.swimTimer = Math.random() * 100; + this.stateTimer = rand(200, 500); // Time until state change + + // Tentacle animation + this.tentaclePhase = Math.random() * Math.PI * 2; + + // Target surface (lily pad or stone) + this.targetSurface = null; + this.onSurface = false; + } + + seek(target) { + let desired = new Vector(target.x - this.pos.x, target.y - this.pos.y); + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce); + return steer; + } + + applyForce(force) { + this.acc.add(force); + } + + behaviors(pads, stones, dt = 1/60) { + const scale = dt * 60; + + // Handle retracted state + if (this.retractedTimer > 0) { + this.retractedTimer -= scale; + if (this.retractedTimer <= 0) { + this.state = 'CRAWLING'; + } + // Stop moving when retracted + this.vel.mult(0.95); + return; + } + + // PREDATOR AVOIDANCE - highest priority + const nearbyPredators = predatorGrid.getNearby( + this.pos.x, + this.pos.y, + params.snailDetectionRange + ); + + let shouldRetract = false; + for (const predator of nearbyPredators) { + const dx = this.pos.x - predator.pos.x; + const dy = this.pos.y - predator.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < params.snailDetectionRange && d > 0) { + // Retract into shell + if (predator.state === 'ATTACKING' || predator.state === 'DETECTING') { + shouldRetract = true; + break; + } + } + } + + if (shouldRetract && this.state !== 'RETRACTED') { + this.state = 'RETRACTED'; + this.retractedTimer = params.snailRetractDuration; + this.vel.mult(0); + if (addRippleFn) { + addRippleFn(this.pos.x, this.pos.y); + } + return; + } + + // Boundary avoidance + let desired = null; + const margin = 80; + + if (this.pos.x < margin) desired = new Vector(this.maxSpeed, this.vel.y); + else if (this.pos.x > width - margin) desired = new Vector(-this.maxSpeed, this.vel.y); + if (this.pos.y < margin) desired = new Vector(this.vel.x, this.maxSpeed); + else if (this.pos.y > height - margin) desired = new Vector(this.vel.x, -this.maxSpeed); + + if (desired) { + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * 2); + this.applyForce(steer); + } else { + // Gentle wander + if (Math.random() < 0.02) { + let wander = new Vector(rand(-1, 1), rand(-1, 1)); + wander.normalize(); + wander.mult(0.1); + this.applyForce(wander); + } + } + + // State-specific behavior + if (this.state === 'CRAWLING') { + // Look for nearby surfaces (lily pads or stones) + if (!this.onSurface || !this.targetSurface) { + let closestSurface = null; + let minDist = 150; + + // Check lily pads + for (const pad of pads) { + const d = dist(this.pos.x, this.pos.y, pad.x, pad.y); + if (d < minDist) { + minDist = d; + closestSurface = { x: pad.x, y: pad.y, type: 'pad', radius: pad.radius }; + } + } + + // Check stones + for (const stone of stones) { + const d = dist(this.pos.x, this.pos.y, stone.x, stone.y); + if (d < minDist) { + minDist = d; + closestSurface = { x: stone.x, y: stone.y, type: 'stone', radius: stone.size }; + } + } + + if (closestSurface) { + this.targetSurface = closestSurface; + + // Seek surface + let seekForce = this.seek(closestSurface); + seekForce.mult(1.5); + this.applyForce(seekForce); + + // Check if reached surface + if (minDist < closestSurface.radius + this.size) { + this.onSurface = true; + } + } else { + // No surface nearby, consider swimming + this.stateTimer -= scale; + if (this.stateTimer <= 0 && Math.random() < params.snailSwimChance) { + this.state = 'SWIMMING'; + this.stateTimer = params.snailSwimDuration; + this.onSurface = false; + this.targetSurface = null; + } else if (this.stateTimer <= 0) { + this.stateTimer = rand(200, 500); + } + } + } else { + // On surface, crawl slowly + this.stateTimer -= scale; + + // Occasionally leave surface + if (this.stateTimer <= 0) { + if (Math.random() < params.snailSwimChance) { + this.state = 'SWIMMING'; + this.stateTimer = params.snailSwimDuration; + this.onSurface = false; + this.targetSurface = null; + } else { + this.stateTimer = rand(200, 500); + this.onSurface = false; + this.targetSurface = null; + } + } + } + } else if (this.state === 'SWIMMING') { + // Swimming in water + this.stateTimer -= scale; + + // Slow swimming motion + if (Math.random() < 0.03) { + let swim = new Vector(rand(-1, 1), rand(-1, 1)); + swim.normalize(); + swim.mult(0.2); + this.applyForce(swim); + } + + // After swimming for a while, look for surface + if (this.stateTimer <= 0) { + this.state = 'CRAWLING'; + this.stateTimer = rand(200, 500); + this.onSurface = false; + this.targetSurface = null; + } + } + } + + update(pads, stones, dt = 1/60) { + const scale = dt * 60; + + this.behaviors(pads, stones, dt); + + // Physics update + this.vel.x += this.acc.x * scale; + this.vel.y += this.acc.y * scale; + + // Slower when crawling + if (this.state === 'CRAWLING' && this.onSurface) { + this.vel.limit(this.maxSpeed * params.snailCrawlSpeed); + } else { + this.vel.limit(this.maxSpeed); + } + + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + this.acc.mult(0); + + // Update rotation to face movement direction + if (this.vel.mag() > 0.05) { + let targetRotation = Math.atan2(this.vel.y, this.vel.x); + // Smooth rotation + let rotDiff = targetRotation - this.rotation; + // Normalize to -PI to PI + while (rotDiff > Math.PI) rotDiff -= Math.PI * 2; + while (rotDiff < -Math.PI) rotDiff += Math.PI * 2; + this.rotation += rotDiff * 0.05 * scale; + } + + // Tentacle animation + this.tentaclePhase += 0.03 * scale; + this.swimTimer += 0.05 * scale; + } + + draw(ctx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 2)) return; + + ctx.save(); + ctx.translate(this.pos.x, this.pos.y); + ctx.rotate(this.rotation); + + const shellSize = this.size * 1.2; + const bodySize = this.size * 0.8; + + // Draw body (behind shell when retracted) + if (this.state !== 'RETRACTED') { + this.drawBody(ctx, bodySize); + } + + // Draw shell + this.drawShell(ctx, shellSize); + + // Draw tentacles with eyes (only when not retracted) + if (this.state !== 'RETRACTED') { + this.drawTentacles(ctx, bodySize); + } + + ctx.restore(); + } + + drawBody(ctx, bodySize) { + // Body extends forward from shell + const bodyLength = bodySize * 2; + const bodyWidth = bodySize * 0.8; + + ctx.save(); + ctx.translate(bodySize * 0.3, 0); + + // Body gradient (soft, slimy appearance) + const bodyGradient = ctx.createRadialGradient( + 0, 0, 0, + 0, 0, bodyLength + ); + bodyGradient.addColorStop(0, this.bodyColor); + bodyGradient.addColorStop(0.5, this.bodyColor); + bodyGradient.addColorStop(1, 'rgba(150, 130, 110, 0.3)'); + + // Body shape + ctx.fillStyle = bodyGradient; + ctx.beginPath(); + ctx.ellipse(bodyLength * 0.3, 0, bodyLength * 0.6, bodyWidth * 0.5, 0, 0, Math.PI * 2); + ctx.fill(); + + // Body texture (subtle slime texture) + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.beginPath(); + ctx.ellipse(bodyLength * 0.4, -bodyWidth * 0.2, bodyLength * 0.3, bodyWidth * 0.2, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + drawShell(ctx, shellSize) { + ctx.save(); + + // Shell base (darker outline) + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + ctx.beginPath(); + ctx.arc(0, 0, shellSize + 1, 0, Math.PI * 2); + ctx.fill(); + + // Main shell + const shellGradient = ctx.createRadialGradient( + -shellSize * 0.3, -shellSize * 0.3, 0, + 0, 0, shellSize + ); + shellGradient.addColorStop(0, this.adjustColor(this.shellColor, 0.2)); + shellGradient.addColorStop(0.7, this.shellColor); + shellGradient.addColorStop(1, this.adjustColor(this.shellColor, -0.3)); + + ctx.fillStyle = shellGradient; + ctx.beginPath(); + ctx.arc(0, 0, shellSize, 0, Math.PI * 2); + ctx.fill(); + + // Spiral pattern + ctx.save(); + ctx.strokeStyle = this.adjustColor(this.shellColor, -0.4); + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + + // Draw spiral + ctx.beginPath(); + const spiralTurns = 2.5; + const points = 50; + for (let i = 0; i < points; i++) { + const t = i / points; + const angle = t * Math.PI * 2 * spiralTurns; + const radius = t * shellSize * 0.7; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Spiral segments (decorative bands) + for (const segment of this.spiralSegments) { + const sx = Math.cos(segment.angle) * segment.radius * shellSize * 0.6; + const sy = Math.sin(segment.angle) * segment.radius * shellSize * 0.6; + + ctx.fillStyle = this.adjustColor(this.shellColor, -0.2); + ctx.beginPath(); + ctx.arc(sx, sy, segment.size * shellSize * 0.15, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + + // Shell highlight + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.beginPath(); + ctx.arc(-shellSize * 0.35, -shellSize * 0.35, shellSize * 0.25, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + drawTentacles(ctx, bodySize) { + ctx.save(); + + const tentacleLength = bodySize * 1.5; + const tentacleWidth = bodySize * 0.15; + const eyeSize = bodySize * 0.25; + + // Gentle wave motion + const wave = Math.sin(this.tentaclePhase); + + // Left tentacle + ctx.save(); + ctx.translate(bodySize * 1.2, -bodySize * 0.3); + ctx.rotate(wave * 0.1); + + // Tentacle stalk + ctx.fillStyle = this.bodyColor; + ctx.beginPath(); + ctx.ellipse(tentacleLength * 0.4, 0, tentacleLength * 0.5, tentacleWidth, 0, 0, Math.PI * 2); + ctx.fill(); + + // Eye bulb + ctx.fillStyle = this.bodyColor; + ctx.beginPath(); + ctx.arc(tentacleLength * 0.8, 0, eyeSize, 0, Math.PI * 2); + ctx.fill(); + + // Eye + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(tentacleLength * 0.85, 0, eyeSize * 0.5, 0, Math.PI * 2); + ctx.fill(); + + // Eye shine + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.beginPath(); + ctx.arc(tentacleLength * 0.88, -eyeSize * 0.2, eyeSize * 0.25, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + // Right tentacle + ctx.save(); + ctx.translate(bodySize * 1.2, bodySize * 0.3); + ctx.rotate(-wave * 0.1); + + // Tentacle stalk + ctx.fillStyle = this.bodyColor; + ctx.beginPath(); + ctx.ellipse(tentacleLength * 0.4, 0, tentacleLength * 0.5, tentacleWidth, 0, 0, Math.PI * 2); + ctx.fill(); + + // Eye bulb + ctx.fillStyle = this.bodyColor; + ctx.beginPath(); + ctx.arc(tentacleLength * 0.8, 0, eyeSize, 0, Math.PI * 2); + ctx.fill(); + + // Eye + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(tentacleLength * 0.85, 0, eyeSize * 0.5, 0, Math.PI * 2); + ctx.fill(); + + // Eye shine + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.beginPath(); + ctx.arc(tentacleLength * 0.88, -eyeSize * 0.2, eyeSize * 0.25, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + ctx.restore(); + } + + drawShadow(shadowCtx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 2)) return; + + shadowCtx.save(); + shadowCtx.translate( + this.pos.x + params.shadowOffsetX, + this.pos.y + params.shadowOffsetY + ); + + const shellSize = this.size * 1.2; + + shadowCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + shadowCtx.beginPath(); + shadowCtx.arc(0, 0, shellSize + 2, 0, Math.PI * 2); + shadowCtx.fill(); + + shadowCtx.restore(); + } + + adjustColor(hex, amount) { + // Simple color adjustment + const num = parseInt(hex.replace('#', ''), 16); + const r = Math.max(0, Math.min(255, ((num >> 16) & 0xff) + amount * 255)); + const g = Math.max(0, Math.min(255, ((num >> 8) & 0xff) + amount * 255)); + const b = Math.max(0, Math.min(255, (num & 0xff) + amount * 255)); + return `rgb(${r}, ${g}, ${b})`; + } +} diff --git a/js/entities/Stone.js b/js/entities/Stone.js new file mode 100644 index 0000000..f77d8a5 --- /dev/null +++ b/js/entities/Stone.js @@ -0,0 +1,277 @@ +// Stone/Pebble class for pond floor decoration + +import { params } from '../config.js'; +import { rand, hexToHSL, width, height } from '../utils/helpers.js'; + +export class Stone { + constructor() { + this.x = rand(0, width); + this.y = rand(0, height); + this.size = rand(params.stoneSizeMin, params.stoneSizeMax); + this.rotation = rand(0, Math.PI); + + // Parse base color with variations (like grass/lily pads) + const color = params.pebbleColors[Math.floor(Math.random() * params.pebbleColors.length)]; + const hsl = hexToHSL(color); + + this.hue = hsl.h + rand(-8, 8); + this.sat = Math.max(0, Math.min(100, hsl.s + rand(-10, 10))); + this.lig = Math.max(0, Math.min(100, hsl.l + rand(-15, 15))); + + // Shape variation for organic look + this.widthRatio = rand(0.65, 0.85); + this.irregularity = rand(0.92, 1.0); // How perfect the shape is + + // Light source position (offset from center top) + this.lightOffsetX = rand(-0.2, 0.2); + this.lightOffsetY = rand(-0.3, -0.15); + + // Multi-colored patches (like real stones have different minerals) + this.patchCount = Math.floor(rand(2, 5)); + this.patches = []; + for (let i = 0; i < this.patchCount; i++) { + const angle = rand(0, Math.PI * 2); + const distance = rand(0, 0.5); + // Each patch can be a different color from palette or variation + const patchColorIndex = Math.floor(Math.random() * params.pebbleColors.length); + const patchHsl = hexToHSL(params.pebbleColors[patchColorIndex]); + this.patches.push({ + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: rand(0.2, 0.45), + hue: patchHsl.h + rand(-15, 15), + sat: Math.max(0, Math.min(100, patchHsl.s + rand(-10, 10))), + lig: Math.max(0, Math.min(100, patchHsl.l + rand(-15, 15))), + opacity: rand(0.4, 0.7) + }); + } + + // Small speckles (like granite texture) + this.speckleCount = Math.floor(rand(15, 40)); + this.speckles = []; + for (let i = 0; i < this.speckleCount; i++) { + const angle = rand(0, Math.PI * 2); + const distance = rand(0, 0.85); + this.speckles.push({ + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: rand(0.02, 0.06), + isLight: Math.random() > 0.5 + }); + } + + // Veins/cracks (mineral veins in stone) + this.veinCount = Math.floor(rand(1, 4)); + this.veins = []; + for (let i = 0; i < this.veinCount; i++) { + const startAngle = rand(0, Math.PI * 2); + const length = rand(0.4, 0.9); + this.veins.push({ + startX: Math.cos(startAngle) * rand(0, 0.3), + startY: Math.sin(startAngle) * rand(0, 0.3), + angle: rand(0, Math.PI * 2), + length: length, + width: rand(0.01, 0.03), + segments: Math.floor(rand(2, 5)), + isLight: Math.random() > 0.6 // 40% light veins, 60% dark + }); + } + + // Natural striping/banding (sedimentary layers, 30% chance) + this.hasStripes = Math.random() < 0.3; + if (this.hasStripes) { + this.stripeCount = Math.floor(rand(2, 5)); + this.stripeAngle = rand(0, Math.PI); + this.stripeSpacing = rand(0.2, 0.35); + } + } + + draw(ctx) { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + + const width = this.size; + const height = this.size * this.widthRatio; + + // Soft underwater shadow (more diffused than in air) + ctx.save(); + ctx.translate(1.5, 2); + ctx.beginPath(); + ctx.ellipse(0, 0, width * 0.9, height * 0.9, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; // Softer shadow underwater + ctx.fill(); + ctx.restore(); + + // Main pebble body with radial gradient for 3D effect + ctx.beginPath(); + ctx.ellipse(0, 0, width, height, 0, 0, Math.PI * 2); + + // Underwater colors: desaturated and blue-shifted + const underwaterSat = this.sat * 0.6; // Reduce saturation by 40% + const underwaterHue = this.hue + (190 - this.hue) * 0.15; // Shift slightly toward blue-green + + // Radial gradient from light point (top) to shadow (bottom) + const lightX = width * this.lightOffsetX; + const lightY = height * this.lightOffsetY; + const gradientRadius = Math.max(width, height) * 1.3; + + const gradient = ctx.createRadialGradient( + lightX, lightY, 0, + 0, height * 0.3, gradientRadius + ); + + // Softer highlights underwater (less contrast) + gradient.addColorStop(0, `hsla(${underwaterHue}, ${Math.max(0, underwaterSat - 5)}%, ${Math.min(100, this.lig + 15)}%, 0.85)`); + // Mid-tone base color + gradient.addColorStop(0.45, `hsla(${underwaterHue}, ${underwaterSat}%, ${this.lig}%, 0.8)`); + // Softer shadows underwater + gradient.addColorStop(1, `hsla(${underwaterHue}, ${Math.min(100, underwaterSat + 5)}%, ${Math.max(0, this.lig - 15)}%, 0.75)`); + + ctx.fillStyle = gradient; + ctx.fill(); + + // Clip to pebble shape for details + ctx.save(); + ctx.clip(); + + // Multi-colored patches (different mineral regions) + for (let patch of this.patches) { + const patchX = patch.x * width; + const patchY = patch.y * height; + const patchRadius = patch.radius * Math.max(width, height); + + // Apply underwater color shift to patches too + const patchUnderwaterSat = patch.sat * 0.6; + const patchUnderwaterHue = patch.hue + (190 - patch.hue) * 0.15; + + const patchGradient = ctx.createRadialGradient( + patchX, patchY, 0, + patchX, patchY, patchRadius + ); + patchGradient.addColorStop(0, `hsla(${patchUnderwaterHue}, ${patchUnderwaterSat}%, ${patch.lig}%, ${patch.opacity})`); + patchGradient.addColorStop(0.7, `hsla(${patchUnderwaterHue}, ${patchUnderwaterSat}%, ${patch.lig - 5}%, ${patch.opacity * 0.6})`); + patchGradient.addColorStop(1, `hsla(${patchUnderwaterHue}, ${patchUnderwaterSat}%, ${patch.lig - 10}%, 0)`); + + ctx.beginPath(); + ctx.arc(patchX, patchY, patchRadius, 0, Math.PI * 2); + ctx.fillStyle = patchGradient; + ctx.fill(); + } + + // Natural banding/striping (sedimentary layers) + if (this.hasStripes) { + ctx.save(); + ctx.rotate(this.stripeAngle); + for (let i = 0; i < this.stripeCount; i++) { + const offset = (i - this.stripeCount / 2) * this.stripeSpacing * width; + ctx.beginPath(); + ctx.rect(-width * 1.5, offset - height * 0.04, width * 3, height * 0.08); + ctx.fillStyle = `hsla(${underwaterHue}, ${underwaterSat}%, ${this.lig - 12}%, 0.25)`; + ctx.fill(); + } + ctx.restore(); + } + + // Veins and cracks (mineral veins) + for (let vein of this.veins) { + ctx.save(); + ctx.translate(vein.startX * width, vein.startY * height); + ctx.rotate(vein.angle); + + ctx.beginPath(); + ctx.moveTo(0, 0); + + // Draw vein as segmented line with slight curves + const segmentLength = (vein.length * Math.max(width, height)) / vein.segments; + let currentX = 0; + let currentY = 0; + + for (let i = 0; i < vein.segments; i++) { + const nextX = currentX + segmentLength; + const nextY = currentY + rand(-segmentLength * 0.2, segmentLength * 0.2); + const controlX = currentX + segmentLength * 0.5; + const controlY = currentY + rand(-segmentLength * 0.15, segmentLength * 0.15); + ctx.quadraticCurveTo(controlX, controlY, nextX, nextY); + currentX = nextX; + currentY = nextY; + } + + const veinLig = vein.isLight ? this.lig + 15 : this.lig - 18; + ctx.strokeStyle = `hsla(${underwaterHue}, ${underwaterSat * 0.7}%, ${veinLig}%, 0.4)`; + ctx.lineWidth = vein.width * this.size; + ctx.stroke(); + ctx.restore(); + } + + // Small speckles (granite-like texture) + for (let speckle of this.speckles) { + const speckleX = speckle.x * width; + const speckleY = speckle.y * height; + const speckleRadius = speckle.radius * this.size; + + ctx.beginPath(); + ctx.arc(speckleX, speckleY, speckleRadius, 0, Math.PI * 2); + + if (speckle.isLight) { + ctx.fillStyle = `hsla(${underwaterHue}, ${underwaterSat * 0.5}%, ${this.lig + 20}%, 0.5)`; + } else { + ctx.fillStyle = `hsla(${underwaterHue}, ${underwaterSat}%, ${this.lig - 25}%, 0.6)`; + } + ctx.fill(); + } + + ctx.restore(); // End clip + + // Underwater caustic light patterns (shimmering effect) + const causticX = lightX + Math.sin(Date.now() * 0.001 + this.x) * width * 0.1; + const causticY = lightY + Math.cos(Date.now() * 0.0015 + this.y) * height * 0.1; + const causticSize = Math.min(width, height) * 0.35; + + const causticGradient = ctx.createRadialGradient( + causticX, causticY, 0, + causticX, causticY, causticSize + ); + causticGradient.addColorStop(0, `rgba(200, 230, 255, 0.25)`); // Soft blue-white + causticGradient.addColorStop(0.5, `rgba(180, 220, 250, 0.1)`); + causticGradient.addColorStop(1, `rgba(180, 220, 250, 0)`); + + ctx.beginPath(); + ctx.ellipse(0, 0, width, height, 0, 0, Math.PI * 2); + ctx.fillStyle = causticGradient; + ctx.fill(); + + // Subtle underwater highlight (less intense than in air) + const highlightX = lightX * 0.7; + const highlightY = lightY * 0.7; + const highlightSize = Math.min(width, height) * 0.2; + + const highlightGradient = ctx.createRadialGradient( + highlightX, highlightY, 0, + highlightX, highlightY, highlightSize + ); + highlightGradient.addColorStop(0, `rgba(220, 240, 255, 0.2)`); // Soft bluish highlight + highlightGradient.addColorStop(0.6, `rgba(220, 240, 255, 0.08)`); + highlightGradient.addColorStop(1, `rgba(220, 240, 255, 0)`); + + ctx.beginPath(); + ctx.ellipse(0, 0, width, height, 0, 0, Math.PI * 2); + ctx.fillStyle = highlightGradient; + ctx.fill(); + + // Very subtle edge definition (softer underwater) + ctx.beginPath(); + ctx.ellipse(0, 0, width, height, 0, 0, Math.PI * 2); + ctx.strokeStyle = `hsla(${underwaterHue}, ${underwaterSat}%, ${this.lig - 20}%, 0.2)`; + ctx.lineWidth = 0.5; + ctx.stroke(); + + // Blue-green water tint overlay + ctx.beginPath(); + ctx.ellipse(0, 0, width, height, 0, 0, Math.PI * 2); + ctx.fillStyle = `rgba(100, 150, 180, 0.15)`; // Subtle water color overlay + ctx.fill(); + + ctx.restore(); + } +} diff --git a/js/entities/Turtle.js b/js/entities/Turtle.js new file mode 100644 index 0000000..5fa819e --- /dev/null +++ b/js/entities/Turtle.js @@ -0,0 +1,712 @@ +// Turtle class - slow swimming creature that eats food and avoids predators + +import { Vector } from '../utils/Vector.js'; +import { params, CULL_MARGIN } from '../config.js'; +import { rand, dist, isInView, width, height } from '../utils/helpers.js'; +import { foodGrid, predatorGrid } from '../utils/SpatialGrid.js'; +import { ripplePool } from '../systems/ObjectPool.js'; + +// Function to add ripple (will be set by main) +let addRippleFn = null; +export function setAddRippleFunction(fn) { + addRippleFn = fn; +} + +export class Turtle { + constructor(x, y) { + this.pos = new Vector(x, y); + this.vel = new Vector(rand(-0.5, 0.5), rand(-0.5, 0.5)); + this.acc = new Vector(0, 0); + + this.size = rand(params.turtleSizeMin, params.turtleSizeMax); + this.baseSpeed = rand(params.turtleBaseSpeedMin, params.turtleBaseSpeedMax); + this.maxSpeed = this.baseSpeed * params.speedScale; + this.maxForce = params.turtleTurnForce; + + // Swimming animation + this.swimTimer = Math.random() * 100; + this.legPhase = Math.random() * Math.PI * 2; + + // Visual properties + this.shellColor = params.turtleColor; + this.patternColor = params.turtlePatternColor; + this.rotation = Math.atan2(this.vel.y, this.vel.x); + + // Generate shell pattern + this.shellSegments = []; + const segments = 13; // Hexagonal pattern on shell + for (let i = 0; i < segments; i++) { + this.shellSegments.push({ + angle: rand(0, Math.PI * 2), + distance: rand(0.3, 0.7), + size: rand(0.15, 0.25) + }); + } + + // State + this.isScared = false; + this.scaredTimer = 0; + + // Taming + this.isTamed = false; + this.tamedTimer = 0; + this.heartScale = 0; + this.heartBeat = 0; + } + + seek(target) { + let desired = new Vector(target.x - this.pos.x, target.y - this.pos.y); + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce); + return steer; + } + + applyForce(force) { + this.acc.add(force); + } + + behaviors(foodList, followTarget, dt = 1/60) { + const scale = dt * 60; + + // Reset scared state + if (this.scaredTimer > 0) { + this.scaredTimer -= scale; + if (this.scaredTimer <= 0) { + this.isScared = false; + } + } + + // Handle tamed state + if (this.isTamed) { + this.tamedTimer -= scale; + if (this.tamedTimer <= 0) { + this.isTamed = false; + this.heartScale = 0; + } + } + + // Boundary avoidance + let desired = null; + const margin = 100; + + if (this.pos.x < margin) desired = new Vector(this.maxSpeed, this.vel.y); + else if (this.pos.x > width - margin) desired = new Vector(-this.maxSpeed, this.vel.y); + if (this.pos.y < margin) desired = new Vector(this.vel.x, this.maxSpeed); + else if (this.pos.y > height - margin) desired = new Vector(this.vel.x, -this.maxSpeed); + + if (desired) { + desired.normalize(); + desired.mult(this.maxSpeed); + let steer = new Vector(desired.x - this.vel.x, desired.y - this.vel.y); + steer.limit(this.maxForce * 2); + this.applyForce(steer); + } else { + // Gentle wander + if (Math.random() < 0.03) { + let wander = new Vector(rand(-1, 1), rand(-1, 1)); + wander.normalize(); + wander.mult(0.3); + this.applyForce(wander); + } + } + + // FOLLOW CURSOR when tamed (highest priority) + let following = false; + if (this.isTamed && followTarget) { + const dx = followTarget.x - this.pos.x; + const dy = followTarget.y - this.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + // Only follow if not too close (to avoid jittering) + if (d > 50) { + const targetPos = { x: followTarget.x, y: followTarget.y }; + let seekForce = this.seek(targetPos); + seekForce.mult(2.0); // Strong follow + this.applyForce(seekForce); + following = true; + } + } + + // PREDATOR AVOIDANCE - high priority (but tamed turtles are braver) + if (!following) { + const detectionRange = this.isTamed ? params.turtleDetectionRange * 0.5 : params.turtleDetectionRange; + const nearbyPredators = predatorGrid.getNearby( + this.pos.x, + this.pos.y, + detectionRange + ); + + let fleeing = false; + for (const predator of nearbyPredators) { + const dx = this.pos.x - predator.pos.x; + const dy = this.pos.y - predator.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < detectionRange && d > 0) { + // Strong flee force (weaker if tamed) + let fleeForce = new Vector(dx / d, dy / d); + const urgency = 1 - (d / detectionRange); + const fleeMultiplier = this.isTamed ? 1.0 : 1.5; + fleeForce.mult(this.maxSpeed * fleeMultiplier * urgency); + + // Extra boost if predator is attacking + if (predator.state === 'ATTACKING' || predator.state === 'DETECTING') { + fleeForce.mult(1.5); + if (!this.isScared && !this.isTamed) { + this.isScared = true; + this.scaredTimer = 60; + // Create ripple when scared + if (addRippleFn) { + addRippleFn(this.pos.x, this.pos.y); + } + } + } + + let steer = new Vector(fleeForce.x - this.vel.x, fleeForce.y - this.vel.y); + steer.limit(this.maxForce * params.turtleFleeForceMultiplier); + this.applyForce(steer); + fleeing = true; + } + } + + // Food seeking (lower priority than fleeing) + if (!fleeing) { + const nearbyFoods = foodGrid.getNearbyFiltered( + this.pos.x, + this.pos.y, + params.turtleFoodSeekRange, + food => !food.eaten + ); + + if (nearbyFoods.length > 0) { + // Find closest food + let closestFood = null; + let minDistSq = params.turtleFoodSeekRange * params.turtleFoodSeekRange; + + for (const { obj: food, distSq } of nearbyFoods) { + if (distSq < minDistSq) { + minDistSq = distSq; + closestFood = food; + } + } + + if (closestFood) { + const targetPos = { x: closestFood.pos.x, y: closestFood.pos.y }; + let seekForce = this.seek(targetPos); + seekForce.mult(1.5); + this.applyForce(seekForce); + + // Eat food if close enough + if (minDistSq < 100) { // 10 * 10 + closestFood.eaten = true; + this.swimTimer += 3; + if (addRippleFn) { + addRippleFn(closestFood.pos.x, closestFood.pos.y); + } + } + } + } + } + } + } + + tame() { + if (!this.isTamed) { + this.isTamed = true; + this.tamedTimer = params.turtleTamedDuration; + this.heartScale = 0; + this.isScared = false; + this.scaredTimer = 0; + + // Create ripple effect when tamed + if (addRippleFn) { + addRippleFn(this.pos.x, this.pos.y); + } + } else { + // Re-tame to extend duration + this.tamedTimer = params.turtleTamedDuration; + } + } + + isPointInside(x, y) { + const dx = x - this.pos.x; + const dy = y - this.pos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + // Use shell size for hit detection + return distance < this.size * 2; + } + + update(foodList, followTarget, dt = 1/60) { + const scale = dt * 60; + + this.behaviors(foodList, followTarget, dt); + + // Physics update + this.vel.x += this.acc.x * scale; + this.vel.y += this.acc.y * scale; + this.vel.limit(this.maxSpeed); + + this.pos.x += this.vel.x * scale; + this.pos.y += this.vel.y * scale; + this.acc.mult(0); + + // Update rotation to face movement direction + if (this.vel.mag() > 0.1) { + let targetRotation = Math.atan2(this.vel.y, this.vel.x); + // Smooth rotation + let rotDiff = targetRotation - this.rotation; + // Normalize to -PI to PI + while (rotDiff > Math.PI) rotDiff -= Math.PI * 2; + while (rotDiff < -Math.PI) rotDiff += Math.PI * 2; + this.rotation += rotDiff * 0.1 * scale; + } + + // Swimming animation - faster leg movement when swimming faster + const swimSpeed = this.vel.mag(); + const animSpeed = swimSpeed > 0.1 ? (0.08 + swimSpeed * 0.15) : 0.03; + this.swimTimer += animSpeed * scale; + this.legPhase = this.swimTimer; + + // Heart beat animation for tamed turtles + if (this.isTamed) { + this.heartBeat += 0.15 * scale; + this.heartScale = Math.min(1.0, this.heartScale + 0.1 * scale); + } else { + this.heartScale = Math.max(0, this.heartScale - 0.15 * scale); + } + } + + draw(ctx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 2)) return; + + ctx.save(); + ctx.translate(this.pos.x, this.pos.y); + ctx.rotate(this.rotation); + + // Top-down view: shell is circular/oval from above + const shellRadius = this.size * 1.3; // Circular shell from top + + // Draw legs (behind shell) + this.drawLegs(ctx, shellRadius, false); + + // Draw shell + ctx.save(); + + // Shell base (darker outline/shadow) + ctx.fillStyle = this.patternColor; + ctx.beginPath(); + ctx.arc(0, 0, shellRadius + 2, 0, Math.PI * 2); + ctx.fill(); + + // Main shell - circular from top + ctx.fillStyle = this.shellColor; + ctx.beginPath(); + ctx.arc(0, 0, shellRadius, 0, Math.PI * 2); + ctx.fill(); + + // Shell pattern (hexagonal segments) - visible from top + ctx.fillStyle = this.patternColor; + for (const segment of this.shellSegments) { + const sx = Math.cos(segment.angle) * segment.distance * shellRadius; + const sy = Math.sin(segment.angle) * segment.distance * shellRadius; + const sSize = segment.size * shellRadius; + + ctx.beginPath(); + // Draw hexagon (scutes visible from above) + for (let i = 0; i < 6; i++) { + const angle = (i / 6) * Math.PI * 2; + const px = sx + Math.cos(angle) * sSize; + const py = sy + Math.sin(angle) * sSize; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.fill(); + + // Add highlight on each scute (top-down lighting) + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.beginPath(); + ctx.arc(sx - sSize * 0.2, sy - sSize * 0.2, sSize * 0.3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = this.patternColor; + } + + // Shell highlight - top-down lighting (sun from above) + const gradient = ctx.createRadialGradient( + -shellRadius * 0.3, -shellRadius * 0.3, 0, + 0, 0, shellRadius * 1.2 + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); + gradient.addColorStop(0.4, 'rgba(255, 255, 255, 0.08)'); + gradient.addColorStop(0.7, 'rgba(0, 0, 0, 0.05)'); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, shellRadius, 0, Math.PI * 2); + ctx.fill(); + + // Shell rim/edge + ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, shellRadius, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); + + // Draw head (from top) + this.drawHead(ctx, shellRadius); + + // Draw legs (in front of shell) + this.drawLegs(ctx, shellRadius, true); + + // Draw tamed indicator (heart) + if (this.heartScale > 0) { + this.drawHeart(ctx, shellRadius); + } + + // Draw glow when tamed + if (this.isTamed) { + ctx.save(); + ctx.globalAlpha = 0.3 * Math.sin(this.heartBeat * 0.5) * 0.5 + 0.3; + ctx.strokeStyle = '#ff69b4'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, shellRadius + 4, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + ctx.restore(); + } + + drawHeart(ctx, shellRadius) { + ctx.save(); + + // Heart position above turtle (from top view) + const heartY = -shellRadius - this.size * 0.8; + const bounce = Math.sin(this.heartBeat) * 3; + ctx.translate(0, heartY + bounce); + + const scale = this.heartScale * (0.9 + Math.sin(this.heartBeat) * 0.1); + ctx.scale(scale, scale); + + const heartSize = this.size * 0.6; + + // Heart shadow + ctx.save(); + ctx.translate(2, 2); + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + this.drawHeartShape(ctx, heartSize); + ctx.fill(); + ctx.restore(); + + // Heart + ctx.fillStyle = '#ff69b4'; + ctx.strokeStyle = '#ff1493'; + ctx.lineWidth = 2; + this.drawHeartShape(ctx, heartSize); + ctx.fill(); + ctx.stroke(); + + // Heart shine + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.beginPath(); + ctx.arc(-heartSize * 0.2, -heartSize * 0.2, heartSize * 0.3, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + drawHeartShape(ctx, size) { + ctx.beginPath(); + ctx.moveTo(0, size * 0.3); + + // Left curve + ctx.bezierCurveTo( + -size, -size * 0.5, + -size, size * 0.3, + 0, size + ); + + // Right curve + ctx.bezierCurveTo( + size, size * 0.3, + size, -size * 0.5, + 0, size * 0.3 + ); + + ctx.closePath(); + } + + drawHead(ctx, shellRadius) { + const headX = shellRadius + this.size * 0.25; + const headSize = this.size * 0.55; + + // Retract head slightly when scared + const retraction = this.isScared ? this.size * 0.25 : 0; + + ctx.save(); + ctx.translate(headX - retraction, 0); + + // Neck (visible from top as connecting oval) + ctx.fillStyle = params.turtleHeadColor; + ctx.beginPath(); + ctx.ellipse(-headSize * 0.2, 0, headSize * 0.35, headSize * 0.25, 0, 0, Math.PI * 2); + ctx.fill(); + + // Head - circular/oval from top view + ctx.beginPath(); + ctx.ellipse(0, 0, headSize, headSize * 0.85, 0, 0, Math.PI * 2); + ctx.fill(); + + // Head shading (top-down lighting) + const headGradient = ctx.createRadialGradient( + -headSize * 0.3, -headSize * 0.3, 0, + 0, 0, headSize + ); + headGradient.addColorStop(0, 'rgba(255, 255, 255, 0.2)'); + headGradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.05)'); + headGradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); + ctx.fillStyle = headGradient; + ctx.beginPath(); + ctx.ellipse(0, 0, headSize, headSize * 0.85, 0, 0, Math.PI * 2); + ctx.fill(); + + // Head outline + ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.ellipse(0, 0, headSize, headSize * 0.85, 0, 0, Math.PI * 2); + ctx.stroke(); + + // Eyes - on sides of head (visible from top) + if (!this.isScared || retraction < this.size * 0.2) { + const eyeSize = headSize * 0.22; + const eyeSpacing = headSize * 0.7; // Eyes on left and right sides + + // Left eye (negative y = left side when facing forward) + this.drawSingleEye(ctx, 0, -eyeSpacing, eyeSize); + + // Right eye (positive y = right side when facing forward) + this.drawSingleEye(ctx, 0, eyeSpacing, eyeSize); + } + + // Nose at front tip (visible from top) + ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.beginPath(); + ctx.arc(headSize * 0.75, 0, headSize * 0.1, 0, Math.PI * 2); + ctx.fill(); + + // Nose highlight + ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.beginPath(); + ctx.arc(headSize * 0.72, -headSize * 0.03, headSize * 0.04, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + drawSingleEye(ctx, x, y, size) { + // Eye bulge (raised, visible from top) + const bulgeGradient = ctx.createRadialGradient( + x - size * 0.3, y - size * 0.3, 0, + x, y, size * 1.2 + ); + bulgeGradient.addColorStop(0, 'rgba(255, 255, 255, 0.12)'); + bulgeGradient.addColorStop(0.6, params.turtleHeadColor); + bulgeGradient.addColorStop(1, 'rgba(0, 0, 0, 0.25)'); + ctx.fillStyle = bulgeGradient; + ctx.beginPath(); + ctx.arc(x, y, size * 1.1, 0, Math.PI * 2); + ctx.fill(); + + // Eye rim + ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.stroke(); + + // Eye white + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + + // Pupil + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(x, y, size * 0.55, 0, Math.PI * 2); + ctx.fill(); + + // Eye shine (from above) + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.beginPath(); + ctx.arc(x - size * 0.2, y - size * 0.2, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + } + + drawLegs(ctx, shellRadius, frontLegs) { + const swimSpeed = this.vel.mag(); + const isSwimming = swimSpeed > 0.1; + + // More pronounced kick when swimming + const kickMultiplier = isSwimming ? 1.5 : 0.5; + const legKick = Math.sin(this.legPhase) * kickMultiplier; + const legColor = params.turtleHeadColor; + + if (!frontLegs) { + // Back legs - stick out from sides (top view) + const backLegX = -shellRadius * 0.25; + const backLegY = shellRadius * 0.9; + const backKick = Math.sin(this.legPhase + Math.PI); + + // Left back leg + this.drawSingleLeg(ctx, backLegX, -backLegY, legColor, backKick, true); + + // Right back leg + this.drawSingleLeg(ctx, backLegX, backLegY, legColor, -backKick, true); + } else { + // Front legs - stick out from sides (top view) + const frontLegX = shellRadius * 0.4; + const frontLegY = shellRadius * 0.75; + + // Left front leg + this.drawSingleLeg(ctx, frontLegX, -frontLegY, legColor, legKick, false); + + // Right front leg + this.drawSingleLeg(ctx, frontLegX, frontLegY, legColor, -legKick, false); + } + } + + drawSingleLeg(ctx, x, y, color, kick, isBack) { + const legLength = this.size * 0.6; + const legWidth = this.size * 0.2; + const footSize = this.size * 0.3; + + ctx.save(); + ctx.translate(x, y); + + // Legs stick out perpendicular from shell (top view) + const baseAngle = y < 0 ? -Math.PI / 2 : Math.PI / 2; + const angle = baseAngle + (y < 0 ? -0.15 : 0.15) + kick * 0.2; + ctx.rotate(angle); + + // Upper leg - oval from top + ctx.fillStyle = color; + ctx.beginPath(); + ctx.ellipse(legLength * 0.25, 0, legLength * 0.3, legWidth, 0, 0, Math.PI * 2); + ctx.fill(); + + // Leg shading + const legGradient = ctx.createRadialGradient( + legLength * 0.15, -legWidth * 0.3, 0, + legLength * 0.25, 0, legLength * 0.4 + ); + legGradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)'); + legGradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.05)'); + legGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + ctx.fillStyle = legGradient; + ctx.fill(); + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Lower leg/foot + ctx.save(); + ctx.translate(legLength * 0.55, 0); + ctx.rotate(kick * 0.1); + + // Webbed foot - visible from top + this.drawWebbedFoot(ctx, footSize, footSize * 0.8, color); + + ctx.restore(); + ctx.restore(); + } + + drawWebbedFoot(ctx, width, height, color) { + // Draw webbed flipper - visible from top + ctx.fillStyle = color; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.lineWidth = 1.5; + + // Main foot pad - oval from top + ctx.beginPath(); + ctx.ellipse(0, 0, width, height, 0, 0, Math.PI * 2); + ctx.fill(); + + // Foot shading (top-down) + const footGradient = ctx.createRadialGradient( + -width * 0.2, -height * 0.2, 0, + 0, 0, width + ); + footGradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)'); + footGradient.addColorStop(0.7, 'rgba(0, 0, 0, 0.08)'); + footGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + ctx.fillStyle = footGradient; + ctx.fill(); + + ctx.stroke(); + + // Webbing detail - visible from top + ctx.fillStyle = 'rgba(0, 0, 0, 0.12)'; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.lineWidth = 1; + + // Draw webbing between toes (3 toes visible) + const toeCount = 3; + for (let i = 0; i < toeCount; i++) { + const angle = (i / (toeCount - 1) - 0.5) * Math.PI * 0.5; + const toeX = Math.sin(angle) * width * 0.75; + const toeY = Math.cos(angle) * height * 0.85; + + // Toe line + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(toeX, toeY); + ctx.stroke(); + + // Toe tip + ctx.beginPath(); + ctx.arc(toeX, toeY, width * 0.08, 0, Math.PI * 2); + ctx.fill(); + } + + // Webbing between toes + ctx.beginPath(); + ctx.moveTo(0, -height * 0.2); + for (let i = 0; i < toeCount; i++) { + const angle = (i / (toeCount - 1) - 0.5) * Math.PI * 0.5; + const toeX = Math.sin(angle) * width * 0.75; + const toeY = Math.cos(angle) * height * 0.85; + ctx.lineTo(toeX, toeY); + } + ctx.lineTo(0, -height * 0.2); + ctx.closePath(); + ctx.fill(); + } + + drawShadow(shadowCtx) { + if (!isInView(this.pos.x, this.pos.y, CULL_MARGIN + this.size * 2)) return; + + shadowCtx.save(); + shadowCtx.translate( + this.pos.x + params.shadowOffsetX, + this.pos.y + params.shadowOffsetY + ); + shadowCtx.rotate(this.rotation); + + const shellRadius = this.size * 1.3; + + shadowCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + shadowCtx.beginPath(); + shadowCtx.arc(0, 0, shellRadius + 4, 0, Math.PI * 2); + shadowCtx.fill(); + + shadowCtx.restore(); + } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..17fa508 --- /dev/null +++ b/js/main.js @@ -0,0 +1,392 @@ +// Main Entry Point - Zen Koi Pond Screensaver +// Coordinates all modules and initializes the application + +import { params } from './config.js'; +import { setDimensions, width, height, rand, dist } from './utils/helpers.js'; +import { Vector } from './utils/Vector.js'; +import { fishGrid } from './utils/SpatialGrid.js'; +import { ripplePool, foodPool } from './systems/ObjectPool.js'; +import { Stone } from './entities/Stone.js'; +import { Grass } from './entities/Grass.js'; +import { LilyPad } from './entities/LilyPad.js'; +import { Frog, setAddRippleFunction as setFrogAddRipple } from './entities/Frog.js'; +import { Koi, setMainContext, setAddRippleFunction as setKoiAddRipple } from './entities/Koi.js'; +import { PredatorFish } from './entities/PredatorFish.js'; +import { Crocodile } from './entities/Crocodile.js'; +import { Turtle, setAddRippleFunction as setTurtleAddRipple } from './entities/Turtle.js'; +import { Snail, setAddRippleFunction as setSnailAddRipple } from './entities/Snail.js'; +import { initCanvases, resize, renderStaticBackground, animate, setEntities } from './managers/RenderManager.js'; +import { initUI } from './ui/UIManager.js'; + +// Entity arrays +let fish = []; +let predators = []; +let crocodiles = []; +let turtles = []; +let snails = []; +let pads = []; +let stones = []; +let frogs = []; +let grass = []; + +// Stats +let birthCount = { count: 0 }; + +// Mouse tracking for tamed turtles +let mousePos = { x: 0, y: 0 }; +let mouseActive = false; + +// Initialize environment (stones, lily pads, grass, frogs) +function initEnvironment() { + const w = window.innerWidth; + const h = window.innerHeight; + + // Rebuild stones and pads based on params + stones = []; + // Calculate density relative to screen size + let area = (w * h); + // Base unit: configurable pixels squared + let stoneCount = Math.floor((area / params.stoneDensityBase) * (params.stoneDensity / 100)); + for(let i = 0; i < stoneCount; i++) stones.push(new Stone()); + + pads = []; + frogs = []; // Reset frogs + let padCount = params.padDensity; + + let clusters = []; + if (params.padFormation === 'clumped') { + for(let j = 0; j < Math.floor(rand(params.padClusterCountMin, params.padClusterCountMax)); j++) { + clusters.push({x: rand(100, w-100), y: rand(100, h-100)}); + } + } + + for(let i = 0; i < padCount; i++){ + let x, y; + if (params.padFormation === 'random') { + x = rand(0, w); + y = rand(0, h); + } else if (params.padFormation === 'ring') { + let angle = rand(0, Math.PI * 2); + let r = Math.min(w, h) * params.padRingRadiusMult + rand(params.padRingVariationMin, params.padRingVariationMax); + x = w/2 + Math.cos(angle) * r; + y = h/2 + Math.sin(angle) * r; + } else if (params.padFormation === 'clumped') { + if (clusters.length > 0) { + let c = clusters[Math.floor(rand(0, clusters.length))]; + let offsetR = rand(0, params.padClumpedOffsetRange); + let offsetA = rand(0, Math.PI * 2); + x = c.x + Math.cos(offsetA) * offsetR; + y = c.y + Math.sin(offsetA) * offsetR; + } else { + x = rand(0, w); y = rand(0, h); + } + } else if (params.padFormation === 'edges') { + if (Math.random() < 0.5) { + x = Math.random() < 0.5 ? rand(0, 150) : rand(w-150, w); + y = rand(0, h); + } else { + x = rand(0, w); + y = Math.random() < 0.5 ? rand(0, 150) : rand(h-150, h); + } + } + pads.push(new LilyPad(x, y)); + } + + // Initialize grass near lily pads + grass = []; + if (pads.length > 0) { + let grassPerPad = Math.ceil(params.grassDensity / pads.length); + pads.forEach(pad => { + for (let i = 0; i < grassPerPad; i++) { + // Spawn grass in a ring around the lily pad + let angle = rand(0, Math.PI * 2); + let distance = pad.radius + rand(10, 60); + let gx = pad.x + Math.cos(angle) * distance; + let gy = pad.y + Math.sin(angle) * distance; + // Keep grass in bounds + gx = Math.max(10, Math.min(w - 10, gx)); + gy = Math.max(10, Math.min(h - 10, gy)); + grass.push(new Grass(gx, gy)); + } + }); + } + + // Spawn initial frogs + pads.forEach(p => { + if (!p.hasFlower && !p.hasFrog && Math.random() < params.frogChance) { + frogs.push(new Frog(p)); + } + }); + + // Update entity references + updateEntityReferences(); + + renderStaticBackground(); +} + +// Initialize fish +function initFish() { + const w = window.innerWidth; + const h = window.innerHeight; + + fish = []; + for (let i = 0; i < params.fishCount; i++) { + fish.push(new Koi(rand(w/2 - 100, w/2 + 100), rand(h/2 - 100, h/2 + 100))); + } + + // Update entity references + updateEntityReferences(); +} + +// Initialize predators +function initPredators() { + const w = window.innerWidth; + const h = window.innerHeight; + + predators = []; + for (let i = 0; i < params.predatorCount; i++) { + // Spawn predators near edges + let x, y; + if (Math.random() < 0.5) { + x = Math.random() < 0.5 ? rand(50, 150) : rand(w - 150, w - 50); + y = rand(100, h - 100); + } else { + x = rand(100, w - 100); + y = Math.random() < 0.5 ? rand(50, 150) : rand(h - 150, h - 50); + } + predators.push(new PredatorFish(x, y)); + } + + // Update entity references + updateEntityReferences(); +} + +// Initialize turtles +function initTurtles() { + const w = window.innerWidth; + const h = window.innerHeight; + + turtles = []; + for (let i = 0; i < params.turtleCount; i++) { + // Spawn turtles in random locations + const x = rand(w * 0.2, w * 0.8); + const y = rand(h * 0.2, h * 0.8); + turtles.push(new Turtle(x, y)); + } + + // Update entity references + updateEntityReferences(); +} + +// Initialize snails +function initSnails() { + const w = window.innerWidth; + const h = window.innerHeight; + + snails = []; + for (let i = 0; i < params.snailCount; i++) { + // Spawn snails near lily pads or stones + if (pads.length > 0 && Math.random() < 0.7) { + // Spawn near a lily pad + const pad = pads[Math.floor(Math.random() * pads.length)]; + const angle = rand(0, Math.PI * 2); + const distance = pad.radius + rand(5, 30); + const x = pad.x + Math.cos(angle) * distance; + const y = pad.y + Math.sin(angle) * distance; + snails.push(new Snail(x, y)); + } else if (stones.length > 0) { + // Spawn near a stone + const stone = stones[Math.floor(Math.random() * stones.length)]; + const angle = rand(0, Math.PI * 2); + const distance = stone.size + rand(5, 20); + const x = stone.x + Math.cos(angle) * distance; + const y = stone.y + Math.sin(angle) * distance; + snails.push(new Snail(x, y)); + } else { + // Random spawn + const x = rand(w * 0.2, w * 0.8); + const y = rand(h * 0.2, h * 0.8); + snails.push(new Snail(x, y)); + } + } + + // Update entity references + updateEntityReferences(); +} + +// Initialize crocodiles +function initCrocodiles() { + const w = window.innerWidth; + const h = window.innerHeight; + + crocodiles = []; + for (let i = 0; i < params.crocodileCount; i++) { + // Spawn crocodiles near edges (apex predators patrol the perimeter) + let x, y; + if (Math.random() < 0.5) { + x = Math.random() < 0.5 ? rand(50, 120) : rand(w - 120, w - 50); + y = rand(150, h - 150); + } else { + x = rand(150, w - 150); + y = Math.random() < 0.5 ? rand(50, 120) : rand(h - 120, h - 50); + } + crocodiles.push(new Crocodile(x, y)); + } + + // Update entity references + updateEntityReferences(); +} + +// Update entity references in RenderManager +function updateEntityReferences() { + setEntities({ fish, predators, crocodiles, turtles, snails, pads, stones, frogs, grass }, birthCount, mousePos, mouseActive); +} + +// Add ripple and scatter nearby fish +function addRipple(x, y) { + ripplePool.acquire(x, y); + // Use spatial grid for efficient fish lookup + const nearbyFish = fishGrid.getNearby(x, y, 200); + for (const f of nearbyFish) { + const dx = f.pos.x - x; + const dy = f.pos.y - y; + const distSq = dx * dx + dy * dy; + if (distSq < 40000 && distSq > 0) { // 200 * 200 + const d = Math.sqrt(distSq); + let flee = new Vector(dx / d, dy / d); + flee.mult(2); + f.applyForce(flee); + } + } +} + +// Handle click/touch interaction +function handleInteraction(x, y) { + let hitSomething = false; + + // Check turtle click (highest priority - taming) + for (let t of turtles) { + if (t.isPointInside(x, y)) { + t.tame(); + hitSomething = true; + break; // One at a time + } + } + + // Check frog click + if (!hitSomething) { + for (let f of frogs) { + if (f.state !== 'DIVING' && dist(x, y, f.pos.x, f.pos.y) < f.size * 2) { + f.scare(); + hitSomething = true; + break; // One at a time + } + } + } + + if (!hitSomething) { + addRipple(x, y); + } +} + +// Initialize everything +function init() { + // Initialize canvases + initCanvases(); + + // Set dimensions + setDimensions(window.innerWidth, window.innerHeight); + + // Set up addRipple functions for entities + setFrogAddRipple(addRipple); + setKoiAddRipple(addRipple); + setTurtleAddRipple(addRipple); + setSnailAddRipple(addRipple); + + // Resize canvases + resize(() => { + initEnvironment(); + renderStaticBackground(); + }); + + // Initialize entities + initFish(); + initPredators(); + initCrocodiles(); + initTurtles(); + initSnails(); + + // Initialize UI + initUI({ + initFish, + initEnvironment, + initPredators, + initCrocodiles, + initTurtles, + initSnails, + renderStaticBackground + }); + + // Start animation + requestAnimationFrame((t) => animate(t, addRipple)); +} + +// Event listeners +window.addEventListener('resize', () => { + resize(() => { + initEnvironment(); + renderStaticBackground(); + }); +}); + +window.addEventListener('click', e => { + if(e.target.closest('#controls') || e.target.closest('#menu-btn')) return; + handleInteraction(e.clientX, e.clientY); +}); + +window.addEventListener('dblclick', e => { + if(e.target.closest('#controls') || e.target.closest('#menu-btn')) return; + for(let i=0; i<3; i++) { + foodPool.acquire(e.clientX + rand(-20, 20), e.clientY + rand(-20, 20)); + } +}); + +window.addEventListener('touchstart', e => { + if(e.target.closest('#controls') || e.target.closest('#menu-btn')) return; + for (let i = 0; i < e.touches.length; i++) { + mousePos.x = e.touches[i].clientX; + mousePos.y = e.touches[i].clientY; + mouseActive = true; + handleInteraction(e.touches[i].clientX, e.touches[i].clientY); + } +}); + +window.addEventListener('touchmove', e => { + if(e.target.closest('#controls') || e.target.closest('#menu-btn')) return; + if (e.touches.length > 0) { + mousePos.x = e.touches[0].clientX; + mousePos.y = e.touches[0].clientY; + mouseActive = true; + } +}); + +window.addEventListener('touchend', () => { + mouseActive = false; +}); + +window.addEventListener('mousemove', e => { + mousePos.x = e.clientX; + mousePos.y = e.clientY; + mouseActive = true; + + if(Math.random() < 0.05) { + // Optional: small trail interaction + } +}); + +window.addEventListener('mouseleave', () => { + mouseActive = false; +}); + +// Start the application +init(); diff --git a/js/managers/RenderManager.js b/js/managers/RenderManager.js new file mode 100644 index 0000000..6b79170 --- /dev/null +++ b/js/managers/RenderManager.js @@ -0,0 +1,534 @@ +// Render Manager - handles all rendering and canvas operations + +import { params, CULL_MARGIN } from '../config.js'; +import { setDimensions, isInView, rand } from '../utils/helpers.js'; +import { fishGrid, foodGrid, predatorGrid, crocodileGrid } from '../utils/SpatialGrid.js'; +import { ripplePool, foodPool, updatePooledRipple, drawPooledRipple, updatePooledFood, drawPooledFood } from '../systems/ObjectPool.js'; +import { performanceManager } from '../systems/PerformanceManager.js'; +import { setMainContext, Koi } from '../entities/Koi.js'; +import { PredatorFish } from '../entities/PredatorFish.js'; +import { Frog } from '../entities/Frog.js'; + +// Canvas references +let canvas, ctx; +let bgCanvas, bgCtx; +let shadowCanvas, shadowCtx; +let lastShadowUpdate = 0; + +// Entity references (set via setEntities) +let entities = null; +let birthCountRef = { count: 0 }; +let mousePosRef = { x: 0, y: 0 }; +let mouseActiveRef = false; + +// Initialize canvases +export function initCanvases() { + canvas = document.getElementById('canvas'); + ctx = canvas.getContext('2d'); + + bgCanvas = document.createElement('canvas'); + bgCtx = bgCanvas.getContext('2d'); + + shadowCanvas = document.createElement('canvas'); + shadowCtx = shadowCanvas.getContext('2d'); + + // Set main context for Koi drawing + setMainContext(ctx); + + return { canvas, ctx, bgCanvas, bgCtx, shadowCanvas, shadowCtx }; +} + +// Set entity references +export function setEntities(entitiesRef, birthCountReference, mousePosition, mouseIsActive) { + entities = entitiesRef; + birthCountRef = birthCountReference; + if (mousePosition) mousePosRef = mousePosition; + if (mouseIsActive !== undefined) mouseActiveRef = mouseIsActive; +} + +// Get canvas context +export function getContext() { + return ctx; +} + +// Resize canvases +export function resize(initEnvironmentCallback) { + const w = window.innerWidth; + const h = window.innerHeight; + + canvas.width = w; + canvas.height = h; + bgCanvas.width = w; + bgCanvas.height = h; + shadowCanvas.width = w; + shadowCanvas.height = h; + + setDimensions(w, h); + + if (initEnvironmentCallback) { + initEnvironmentCallback(); + } +} + +// Render animated water caustics (light patterns on pond floor from above) +function renderAnimatedCaustics(ctx, w, h, time) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = 0.12; + + // Caustic network - light refracting through water surface ripples onto floor + const layers = 2; + for (let layer = 0; layer < layers; layer++) { + const speed = 0.0004 * (layer + 1); + const scale = 180 + layer * 60; + const phase = layer * Math.PI; + + // Organic movement pattern + const offsetX = Math.sin(time * speed + phase) * 25; + const offsetY = Math.cos(time * speed * 0.7 + phase) * 25; + + // Draw caustic network across pond floor + for (let x = -scale; x < w + scale; x += scale) { + for (let y = -scale; y < h + scale; y += scale) { + const cellX = x + offsetX + Math.sin(time * speed * 1.5 + y * 0.008) * 40; + const cellY = y + offsetY + Math.cos(time * speed * 1.5 + x * 0.008) * 40; + + // Distance from center affects intensity (shallower = brighter) + const distFromCenter = Math.sqrt(Math.pow(cellX - w/2, 2) + Math.pow(cellY - h/2, 2)); + const centerFactor = 1 - Math.min(distFromCenter / (Math.max(w, h) * 0.6), 1) * 0.4; + + // Pulsating caustic blob + const intensity = (0.35 + Math.sin(time * speed * 2.5 + x * 0.015 + y * 0.015) * 0.25) * centerFactor; + + const causticGradient = ctx.createRadialGradient( + cellX, cellY, 0, + cellX, cellY, scale * 0.55 + ); + + causticGradient.addColorStop(0, `rgba(230, 245, 255, ${intensity * 0.5})`); + causticGradient.addColorStop(0.35, `rgba(210, 235, 250, ${intensity * 0.3})`); + causticGradient.addColorStop(0.7, `rgba(190, 225, 245, ${intensity * 0.1})`); + causticGradient.addColorStop(1, 'rgba(180, 220, 240, 0)'); + + ctx.fillStyle = causticGradient; + ctx.beginPath(); + ctx.arc(cellX, cellY, scale * 0.5, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + ctx.restore(); +} + +// Render static background (water gradient + stones) - TOP-DOWN VIEW +export function renderStaticBackground() { + const w = canvas.width; + const h = canvas.height; + + // Base water color (uniform since we're looking down) + bgCtx.fillStyle = params.waterColor2; + bgCtx.fillRect(0, 0, w, h); + + // Subtle depth variation from center (shallow) to edges (deeper) + // This represents the natural bowl shape of a pond + let rad = Math.max(w, h); + let depthGradient = bgCtx.createRadialGradient(w/2, h/2, rad * 0.2, w/2, h/2, rad * 0.7); + depthGradient.addColorStop(0, params.waterColor1); // Shallow center + depthGradient.addColorStop(0.6, params.waterColor2); // Transition + depthGradient.addColorStop(1, params.waterColor2); // Deep edges + bgCtx.fillStyle = depthGradient; + bgCtx.fillRect(0, 0, w, h); + + // Suspended particles throughout (uniform distribution from top view) + bgCtx.save(); + for (let i = 0; i < 100; i++) { + const px = rand(0, w); + const py = rand(0, h); + const size = rand(0.5, 1.8); + const opacity = rand(0.03, 0.1); + + bgCtx.beginPath(); + bgCtx.arc(px, py, size, 0, Math.PI * 2); + bgCtx.fillStyle = `rgba(200, 220, 230, ${opacity})`; + bgCtx.fill(); + } + bgCtx.restore(); + + // Draw stones on pond floor + if (entities && entities.stones) { + entities.stones.forEach(s => s.draw(bgCtx)); + } + + // Subtle overall lighting gradient (sun overhead, slightly from one side) + const lightAngle = Math.PI / 6; // Sun slightly off-center + const lightX = w/2 + Math.cos(lightAngle) * w * 0.3; + const lightY = h/2 + Math.sin(lightAngle) * h * 0.3; + + const overheadLight = bgCtx.createRadialGradient(lightX, lightY, 0, lightX, lightY, rad); + overheadLight.addColorStop(0, 'rgba(200, 230, 255, 0.08)'); + overheadLight.addColorStop(0.4, 'rgba(180, 220, 245, 0.04)'); + overheadLight.addColorStop(1, 'rgba(0, 0, 0, 0)'); + bgCtx.fillStyle = overheadLight; + bgCtx.fillRect(0, 0, w, h); + + // Darker edges (pond edges / shadow from banks) + let edgeVignette = bgCtx.createRadialGradient(w/2, h/2, rad * 0.4, w/2, h/2, rad * 0.85); + edgeVignette.addColorStop(0, "rgba(0,0,0,0)"); + edgeVignette.addColorStop(0.7, "rgba(0,0,0,0.1)"); + edgeVignette.addColorStop(1, "rgba(0,0,0,0.45)"); + bgCtx.fillStyle = edgeVignette; + bgCtx.fillRect(0, 0, w, h); +} + +// Main animation frame +export function animate(currentTime, addRippleFn) { + if (!entities) { + requestAnimationFrame((t) => animate(t, addRippleFn)); + return; + } + + const { fish, predators, crocodiles, turtles, snails, pads, frogs, grass } = entities; + + // Frame rate limiting + if (!performanceManager.shouldRender(currentTime, params.targetFPS)) { + requestAnimationFrame((t) => animate(t, addRippleFn)); + return; + } + + // Update FPS counter + if (performanceManager.updateFPS(currentTime)) { + updateStatsDisplay(); + } + + // Delta time in seconds for physics calculations + const dt = performanceManager.getDeltaSeconds(); + const w = canvas.width; + const h = canvas.height; + + ctx.clearRect(0, 0, w, h); + if(bgCanvas.width > 0 && bgCanvas.height > 0) + ctx.drawImage(bgCanvas, 0, 0); + + // Animated water caustic patterns (light refracting through water surface) + renderAnimatedCaustics(ctx, w, h, currentTime); + + // Draw grass (animated, so must be in main loop) + grass.forEach(g => g.draw(ctx, currentTime)); + + // Scale spawn rates by delta time for consistency across frame rates + if (Math.random() < params.foodSpawnRate * dt * 60) { + foodPool.acquire(rand(50, w-50), rand(50, h-50)); + } + + if (params.rainMode && Math.random() < 0.3 * dt * 60) { + ripplePool.acquire(rand(0, w), rand(0, h), 0, rand(30, 80), 3); + } + + // Auto-spawn frogs if population drops low (slow repopulation) + if (Math.random() < 0.005 * dt * 60) { + // Find empty pad + const emptyPad = pads.find(p => !p.hasFlower && !p.hasFrog); + if (emptyPad && frogs.length < pads.length * params.frogChance) { + frogs.push(new Frog(emptyPad)); + } + } + + // Get active pooled objects + const activeRipples = ripplePool.getActive(); + const activeFoods = foodPool.getActive(); + + // Update spatial grids for efficient queries + fishGrid.clear(); + foodGrid.clear(); + predatorGrid.clear(); + crocodileGrid.clear(); + + fish.forEach(f => fishGrid.insert(f, f.pos.x, f.pos.y)); + activeFoods.forEach(f => { + if (!f.eaten) foodGrid.insert(f, f.pos.x, f.pos.y); + }); + predators.forEach(p => predatorGrid.insert(p, p.pos.x, p.pos.y)); + crocodiles.forEach(c => crocodileGrid.insert(c, c.pos.x, c.pos.y)); + + // Update shadow canvas at lower frame rate for performance + const shouldUpdateShadows = currentTime - lastShadowUpdate >= (1000 / params.shadowUpdateFPS); + if (shouldUpdateShadows) { + lastShadowUpdate = currentTime; + shadowCtx.clearRect(0, 0, w, h); + // Only draw shadows for fish in view (with larger margin for shadows) + fish.forEach(f => { + if (isInView(f.pos.x, f.pos.y, CULL_MARGIN + 50)) { + f.drawShadow(shadowCtx); + } + }); + // Predator shadows + predators.forEach(p => { + if (isInView(p.pos.x, p.pos.y, CULL_MARGIN + 50)) { + p.drawShadow(shadowCtx); + } + }); + // Crocodile shadows + crocodiles.forEach(c => { + if (isInView(c.pos.x, c.pos.y, CULL_MARGIN + 100)) { + c.drawShadow(shadowCtx); + } + }); + // Turtle shadows + turtles.forEach(t => { + if (isInView(t.pos.x, t.pos.y, CULL_MARGIN + 50)) { + t.drawShadow(shadowCtx); + } + }); + // Snail shadows + if (snails) { + snails.forEach(s => { + if (isInView(s.pos.x, s.pos.y, CULL_MARGIN + 50)) { + s.drawShadow(shadowCtx); + } + }); + } + } + + // Composite shadow layer + ctx.drawImage(shadowCanvas, 0, 0); + + // Update and draw pooled food (swap-and-pop for removal, with culling) + for (let i = activeFoods.length - 1; i >= 0; i--) { + const food = activeFoods[i]; + updatePooledFood(food, dt); + // Only draw if in view + if (isInView(food.pos.x, food.pos.y)) { + drawPooledFood(food, ctx); + } + if (food.eaten) { + foodPool.release(food); + } + } + + // Update and draw fish BEFORE lily pads (fish swim under pads) + fish.forEach(f => f.run(fish, activeFoods, dt)); + + // Update and draw predators (also under lily pads) + predators.forEach(p => { + p.update(fish, dt); + p.draw(ctx); + }); + + // Update and draw crocodiles (apex predators, also under lily pads) + crocodiles.forEach(c => { + c.update(predators, dt); + c.draw(ctx); + }); + + // Update and draw turtles (also under lily pads) + const followTarget = mouseActiveRef ? mousePosRef : null; + turtles.forEach(t => { + t.update(activeFoods, followTarget, dt); + t.draw(ctx); + }); + + // Update and draw snails (also under lily pads) + if (snails) { + snails.forEach(s => { + s.update(pads, entities.stones, dt); + s.draw(ctx); + }); + } + + // Draw lily pads on top of fish + pads.forEach(pad => pad.draw(ctx)); + + // Update and Draw Frogs (swap-and-pop for removal) + for (let i = frogs.length - 1; i >= 0; i--) { + const f = frogs[i]; + const keep = f.update(pads, dt); + f.draw(ctx); + if (!keep) { + // Swap with last element and pop + frogs[i] = frogs[frogs.length - 1]; + frogs.pop(); + } + } + + // Update and draw pooled ripples (swap-and-pop for removal, with culling) + for (let i = activeRipples.length - 1; i >= 0; i--) { + const ripple = activeRipples[i]; + const alive = updatePooledRipple(ripple, dt); + // Only draw if in view (use ripple radius for margin) + if (isInView(ripple.x, ripple.y, CULL_MARGIN + ripple.radius)) { + // Pass all ripples for interference calculations + drawPooledRipple(ripple, ctx, activeRipples); + } + if (!alive) { + ripplePool.release(ripple); + } + } + + // Fish reproduction + handleReproduction(dt, w, h); + + // Predator respawn (maintain minimum population for crocodile) + handlePredatorRespawn(dt, w, h, predators); + + requestAnimationFrame((t) => animate(t, addRippleFn)); +} + +// Handle fish reproduction +function handleReproduction(dt, w, h) { + if (!entities) return; + const { fish } = entities; + + if (fish.length < params.maxFishCount && params.birthChance > 0) { + let birthHappened = false; + + // Update birth timers + fish.forEach(f => { f.birthTimer -= dt * 60; }); + + // Random birth mode + if ((params.birthMode === 'random' || params.birthMode === 'hybrid') && !birthHappened) { + if (Math.random() < params.birthChance * dt * 60) { + // Pick a random fish as parent + const parent = fish[Math.floor(Math.random() * fish.length)]; + const newX = parent.pos.x + rand(-30, 30); + const newY = parent.pos.y + rand(-30, 30); + + // Keep in bounds + const spawnX = Math.max(50, Math.min(w - 50, newX)); + const spawnY = Math.max(50, Math.min(h - 50, newY)); + + const newFish = new Koi(spawnX, spawnY); + newFish.size = rand(params.sizeMin, (params.sizeMin + params.sizeMax) / 2); + fish.push(newFish); + + ripplePool.acquire(spawnX, spawnY, 3, 40, 1.5); + birthCountRef.count++; + birthHappened = true; + } + } + + // Proximity birth mode + if ((params.birthMode === 'proximity' || params.birthMode === 'hybrid') && !birthHappened) { + for (let i = 0; i < fish.length && !birthHappened; i++) { + const f1 = fish[i]; + if (f1.birthTimer > 0) continue; + + for (let j = i + 1; j < fish.length && !birthHappened; j++) { + const f2 = fish[j]; + if (f2.birthTimer > 0) continue; + + // Check distance + const dx = f1.pos.x - f2.pos.x; + const dy = f1.pos.y - f2.pos.y; + const d = Math.sqrt(dx * dx + dy * dy); + + if (d < 60 && Math.random() < params.birthChance * 2) { + const newX = (f1.pos.x + f2.pos.x) / 2 + rand(-15, 15); + const newY = (f1.pos.y + f2.pos.y) / 2 + rand(-15, 15); + + const newFish = new Koi(newX, newY); + newFish.size = rand(params.sizeMin, (params.sizeMin + params.sizeMax) / 2); + fish.push(newFish); + + f1.birthTimer = params.birthCooldown; + f2.birthTimer = params.birthCooldown; + + ripplePool.acquire(newX, newY, 3, 40, 1.5); + birthCountRef.count++; + birthHappened = true; + } + } + } + } + } +} + +// Handle predator respawn to maintain population +function handlePredatorRespawn(dt, w, h, predators) { + // Only spawn if predator count is low (to ensure crocodile has prey) + if (predators.length <= 1) { + // Spawn chance - not too frequent + if (Math.random() < 0.01 * dt * 60) { + // Spawn near edges like initial spawn + let x, y; + if (Math.random() < 0.5) { + x = Math.random() < 0.5 ? rand(50, 150) : rand(w - 150, w - 50); + y = rand(100, h - 100); + } else { + x = rand(100, w - 100); + y = Math.random() < 0.5 ? rand(50, 150) : rand(h - 150, h - 50); + } + + // Create new predator + const newPredator = new PredatorFish(x, y); + predators.push(newPredator); + + // Create spawn ripple + ripplePool.acquire(x, y, 5, 80, 2); + } + } +} + +// Update stats display +function updateStatsDisplay() { + if (!entities) return; + const { fish, predators, crocodiles, turtles, snails, frogs } = entities; + const actualFPS = performanceManager.getActualFPS(); + + // Update FPS display + const fpsDisplay = document.getElementById('val-currentFPS'); + if (fpsDisplay) fpsDisplay.textContent = actualFPS; + + // Update stats display + const statFish = document.getElementById('stat-fish'); + const statRipples = document.getElementById('stat-ripples'); + const statFood = document.getElementById('stat-food'); + const statFrogs = document.getElementById('stat-frogs'); + const statTurtles = document.getElementById('stat-turtles'); + const statSnails = document.getElementById('stat-snails'); + const statCrocodiles = document.getElementById('stat-crocodiles'); + const statHunts = document.getElementById('stat-hunts'); + const statKills = document.getElementById('stat-kills'); + const statCrocHunts = document.getElementById('stat-croc-hunts'); + const statCrocKills = document.getElementById('stat-croc-kills'); + if (statFish) statFish.textContent = fish.length; + if (statRipples) statRipples.textContent = ripplePool.getActiveCount(); + if (statFood) statFood.textContent = foodPool.getActiveCount(); + if (statFrogs) statFrogs.textContent = frogs.length; + if (statTurtles) statTurtles.textContent = turtles.length; + if (statSnails && snails) statSnails.textContent = snails.length; + if (statCrocodiles && crocodiles) statCrocodiles.textContent = crocodiles.length; + + // Predator stats + let totalHunts = 0, totalKills = 0; + predators.forEach(p => { totalHunts += p.huntCount; totalKills += p.killCount; }); + if (statHunts) statHunts.textContent = totalHunts; + if (statKills) statKills.textContent = totalKills; + + // Crocodile stats + let totalCrocHunts = 0, totalCrocKills = 0; + crocodiles.forEach(c => { totalCrocHunts += c.huntCount; totalCrocKills += c.killCount; }); + if (statCrocHunts) statCrocHunts.textContent = totalCrocHunts; + if (statCrocKills) statCrocKills.textContent = totalCrocKills; + + // Birth stats + const statBirths = document.getElementById('stat-births'); + if (statBirths) statBirths.textContent = birthCountRef.count; + + // Update overlay stats + const overlayFish = document.getElementById('overlay-fish'); + const overlayBirths = document.getElementById('overlay-births'); + const overlayPredators = document.getElementById('overlay-predators'); + const overlayKills = document.getElementById('overlay-kills'); + const overlayCrocodiles = document.getElementById('overlay-crocodiles'); + const overlayCrocKills = document.getElementById('overlay-croc-kills'); + const overlayFps = document.getElementById('overlay-fps'); + + if (overlayFish) overlayFish.textContent = fish.length; + if (overlayBirths) overlayBirths.textContent = birthCountRef.count; + if (overlayPredators) overlayPredators.textContent = predators.length; + if (overlayKills) overlayKills.textContent = totalKills; + if (overlayCrocodiles) overlayCrocodiles.textContent = crocodiles.length; + if (overlayCrocKills) overlayCrocKills.textContent = totalCrocKills; + if (overlayFps) overlayFps.textContent = actualFPS; +} diff --git a/js/systems/ObjectPool.js b/js/systems/ObjectPool.js new file mode 100644 index 0000000..3508bb2 --- /dev/null +++ b/js/systems/ObjectPool.js @@ -0,0 +1,222 @@ +// Object Pooling System for efficient memory management + +import { params } from '../config.js'; + +export class ObjectPool { + constructor(createFn, resetFn, initialSize = 50) { + this.createFn = createFn; + this.resetFn = resetFn; + this.pool = []; + this.active = []; + + // Pre-allocate objects + for (let i = 0; i < initialSize; i++) { + this.pool.push(this.createFn()); + } + } + + acquire(...args) { + let obj = this.pool.pop(); + if (!obj) { + obj = this.createFn(); + } + this.resetFn(obj, ...args); + this.active.push(obj); + return obj; + } + + release(obj) { + const idx = this.active.indexOf(obj); + if (idx !== -1) { + this.active.splice(idx, 1); + this.pool.push(obj); + } + } + + releaseAll() { + while (this.active.length > 0) { + this.pool.push(this.active.pop()); + } + } + + getActive() { + return this.active; + } + + getActiveCount() { + return this.active.length; + } +} + +// Ripple Pool +export const ripplePool = new ObjectPool( + () => ({ + x: 0, y: 0, radius: 1, maxRadius: 180, opacity: 0.8, speed: 1.5, + life: 120, ringCount: 3, ringSpacing: 25, amplitude: 1.0, active: false + }), + (ripple, x, y, radius = 1, maxRadius = 180, speed = 1.5) => { + ripple.x = x; + ripple.y = y; + ripple.radius = radius; + ripple.maxRadius = maxRadius; + ripple.opacity = 0.8; + ripple.speed = speed; + ripple.life = 120; + ripple.ringCount = 3; + ripple.ringSpacing = 25; + ripple.amplitude = 1.0; + ripple.active = true; + }, + 50 +); + +// Food Pool +export const foodPool = new ObjectPool( + () => ({ pos: { x: 0, y: 0 }, size: 4, eaten: false, vel: { x: 0, y: 0 }, active: false }), + (food, x, y) => { + food.pos.x = x; + food.pos.y = y; + food.size = params.foodSize * (params.foodSizeVariationMin + Math.random() * (params.foodSizeVariationMax - params.foodSizeVariationMin)); + food.eaten = false; + food.vel.x = params.foodVelocityMin + Math.random() * (params.foodVelocityMax - params.foodVelocityMin); + food.vel.y = params.foodVelocityMin + Math.random() * (params.foodVelocityMax - params.foodVelocityMin); + food.active = true; + }, + 100 +); + +// Pooled update/draw functions for ripples +export function updatePooledRipple(ripple, dt = 1/60) { + const scale = dt * 60; + ripple.radius += ripple.speed * scale; + ripple.opacity -= 0.008 * scale; + ripple.life -= scale; + ripple.amplitude = ripple.opacity; // Amplitude decreases with opacity + return ripple.life > 0 && ripple.opacity > 0; +} + +export function drawPooledRipple(ripple, ctx, allRipples = []) { + if (ripple.opacity <= 0 || ripple.radius < 0) return; + + ctx.save(); + + // Draw multiple concentric rings with interference + for (let i = 0; i < ripple.ringCount; i++) { + const ringRadius = ripple.radius - (i * ripple.ringSpacing); + + if (ringRadius <= 2) continue; // Skip if too small + + // Each ring fades as it expands and is weaker the further from center + const ringAge = ringRadius / ripple.maxRadius; + let ringOpacity = ripple.opacity * (1 - i * 0.25) * (1 - ringAge * 0.5); + + if (ringOpacity <= 0) continue; + + // Check for interference with other ripples + let interferenceBoost = 0; + for (let other of allRipples) { + if (other === ripple || !other.opacity) continue; + + // Distance between ripple centers + const dx = other.x - ripple.x; + const dy = other.y - ripple.y; + const centerDist = Math.sqrt(dx * dx + dy * dy); + + // Check if this ring intersects with any of the other ripple's rings + for (let j = 0; j < other.ringCount; j++) { + const otherRingRadius = other.radius - (j * other.ringSpacing); + if (otherRingRadius <= 0) continue; + + // Check if rings are overlapping + const ringDiff = Math.abs(centerDist - (ringRadius + otherRingRadius)); + const ringSum = Math.abs(centerDist - Math.abs(ringRadius - otherRingRadius)); + + // Rings intersect + if (ringDiff < 15 || ringSum < 15) { + const otherRingAge = otherRingRadius / other.maxRadius; + const otherStrength = other.opacity * (1 - j * 0.25) * (1 - otherRingAge * 0.5); + + // Constructive interference when rings align + interferenceBoost += otherStrength * 0.3; + } + } + } + + // Apply interference (can amplify or slightly modify) + ringOpacity = Math.min(1, ringOpacity + interferenceBoost); + + // Calculate line widths with bounds checking + const mainWidth = Math.max(0.5, 2.5 - (ringAge * 1.5)); + const shadowWidth = Math.max(0.5, 1.5 - (ringAge * 1)); + const blurWidth = Math.max(1, 4 - (ringAge * 2)); + + // Interference creates brighter spots + const interferenceColor = interferenceBoost > 0.1 ? + `rgba(240, 250, 255, ${ringOpacity * 0.4})` : + `rgba(220, 240, 255, ${ringOpacity * 0.35})`; + + // Outer bright highlight (light reflecting off wave crest) + ctx.beginPath(); + ctx.arc(ripple.x, ripple.y, ringRadius, 0, Math.PI * 2); + ctx.strokeStyle = interferenceColor; + ctx.lineWidth = mainWidth * (1 + interferenceBoost * 0.5); + ctx.stroke(); + + // Inner shadow (wave trough) - only if radius is large enough + if (ringRadius > 2) { + ctx.beginPath(); + ctx.arc(ripple.x, ripple.y, Math.max(1, ringRadius - 1.5), 0, Math.PI * 2); + ctx.strokeStyle = `rgba(0, 40, 80, ${ringOpacity * 0.25})`; + ctx.lineWidth = shadowWidth; + ctx.stroke(); + } + + // Subtle blur effect for the main ring + ctx.beginPath(); + ctx.arc(ripple.x, ripple.y, ringRadius + 0.5, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(180, 220, 240, ${ringOpacity * 0.15})`; + ctx.lineWidth = blurWidth; + ctx.stroke(); + } + + // Center disturbance (where ripple originated) + if (ripple.radius > 1 && ripple.radius < 30) { + const centerRadius = Math.max(1, ripple.radius * 0.4); + const centerOpacity = ripple.opacity * (1 - ripple.radius / 30); + + if (centerOpacity > 0 && centerRadius > 0) { + const disturbanceGradient = ctx.createRadialGradient( + ripple.x, ripple.y, 0, + ripple.x, ripple.y, centerRadius + ); + disturbanceGradient.addColorStop(0, `rgba(200, 230, 255, ${centerOpacity * 0.3})`); + disturbanceGradient.addColorStop(0.6, `rgba(180, 220, 245, ${centerOpacity * 0.15})`); + disturbanceGradient.addColorStop(1, `rgba(160, 210, 235, 0)`); + + ctx.fillStyle = disturbanceGradient; + ctx.beginPath(); + ctx.arc(ripple.x, ripple.y, centerRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.restore(); +} + +// Pooled update/draw functions for food +export function updatePooledFood(food, dt = 1/60) { + const scale = dt * 60; + food.pos.x += food.vel.x * scale; + food.pos.y += food.vel.y * scale; +} + +export function drawPooledFood(food, ctx) { + if (food.eaten) return; + ctx.save(); + ctx.translate(food.pos.x, food.pos.y); + ctx.beginPath(); + ctx.arc(0, 0, food.size, 0, Math.PI * 2); + ctx.fillStyle = params.foodColor; + ctx.fill(); + ctx.restore(); +} diff --git a/js/systems/PerformanceManager.js b/js/systems/PerformanceManager.js new file mode 100644 index 0000000..ecdbaa4 --- /dev/null +++ b/js/systems/PerformanceManager.js @@ -0,0 +1,50 @@ +// Performance Manager - FPS tracking and frame timing + +export class PerformanceManager { + constructor() { + this.lastFrameTime = 0; + this.deltaTime = 0; + this.actualFPS = 0; + this.frameCount = 0; + this.fpsUpdateTime = 0; + } + + // Returns true if we should render this frame, false to skip + shouldRender(currentTime, targetFPS) { + const targetFrameTime = 1000 / targetFPS; + this.deltaTime = currentTime - this.lastFrameTime; + + if (this.deltaTime < targetFrameTime) { + return false; + } + + // Adjust lastFrameTime to avoid drift + this.lastFrameTime = currentTime - (this.deltaTime % targetFrameTime); + return true; + } + + // Update FPS counter (call once per second) + updateFPS(currentTime) { + this.frameCount++; + + if (currentTime - this.fpsUpdateTime >= 1000) { + this.actualFPS = Math.round(this.frameCount * 1000 / (currentTime - this.fpsUpdateTime)); + this.frameCount = 0; + this.fpsUpdateTime = currentTime; + return true; // Signal that FPS was updated + } + return false; + } + + // Get delta time in seconds (capped to prevent huge jumps) + getDeltaSeconds() { + return Math.min(this.deltaTime / 1000, 0.1); + } + + getActualFPS() { + return this.actualFPS; + } +} + +// Singleton instance +export const performanceManager = new PerformanceManager(); diff --git a/js/ui/UIManager.js b/js/ui/UIManager.js new file mode 100644 index 0000000..a9f1d70 --- /dev/null +++ b/js/ui/UIManager.js @@ -0,0 +1,389 @@ +// UI Manager - handles all UI controls and event bindings + +import { params } from '../config.js'; + +// Callbacks for entity management (set via init) +let initFishCallback = null; +let initEnvironmentCallback = null; +let initPredatorsCallback = null; +let initCrocodilesCallback = null; +let initTurtlesCallback = null; +let initSnailsCallback = null; +let renderStaticBackgroundCallback = null; + +// Initialize dual range sliders +function initDualRangeSliders() { + document.querySelectorAll('.dual-range').forEach(container => { + const minInput = container.querySelector('input:first-of-type'); + const maxInput = container.querySelector('input:last-of-type'); + const fill = container.querySelector('.dual-range-fill'); + + if (!minInput || !maxInput || !fill) return; + + function updateFill() { + const min = parseFloat(minInput.min); + const max = parseFloat(minInput.max); + const minVal = parseFloat(minInput.value); + const maxVal = parseFloat(maxInput.value); + + const leftPercent = ((minVal - min) / (max - min)) * 100; + const rightPercent = ((maxVal - min) / (max - min)) * 100; + + fill.style.left = leftPercent + '%'; + fill.style.width = (rightPercent - leftPercent) + '%'; + } + + function enforceMinMax() { + const minVal = parseFloat(minInput.value); + const maxVal = parseFloat(maxInput.value); + + if (minVal > maxVal) { + minInput.value = maxVal; + } + } + + function enforceMaxMin() { + const minVal = parseFloat(minInput.value); + const maxVal = parseFloat(maxInput.value); + + if (maxVal < minVal) { + maxInput.value = minVal; + } + } + + minInput.addEventListener('input', () => { + enforceMinMax(); + updateFill(); + }); + + maxInput.addEventListener('input', () => { + enforceMaxMin(); + updateFill(); + }); + + // Initial update + updateFill(); + }); +} + +// Initialize UI +export function initUI(callbacks) { + initFishCallback = callbacks.initFish; + initEnvironmentCallback = callbacks.initEnvironment; + initPredatorsCallback = callbacks.initPredators; + initCrocodilesCallback = callbacks.initCrocodiles; + initTurtlesCallback = callbacks.initTurtles; + initSnailsCallback = callbacks.initSnails; + renderStaticBackgroundCallback = callbacks.renderStaticBackground; + + // Menu Toggle Logic + const menuBtn = document.getElementById('menu-btn'); + const controls = document.getElementById('controls'); + + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + controls.classList.toggle('visible'); + }); + + const inputs = [ + 'fishCount', 'sizeMin', 'sizeMax', 'fatness', 'finScale', + 'speedScale', 'waveSpeedBase', 'waveAmpMax', 'distConstraint', + 'stoneDensity', 'stoneSizeMin', 'stoneSizeMax', + 'padDensity', 'padSizeMin', 'padSizeMax', 'flowerChance', + 'frogChance', + 'padSplitSize', 'padVeinCount', + 'grassDensity', 'grassHeightMin', 'grassHeightMax', + 'grassSwaySpeed', 'grassSwayAmount', + 'foodSpawnRate', 'foodSize', + 'birthChance', 'maxFishCount', 'birthCooldown', + 'predatorCount', 'predatorSizeMin', 'predatorSizeMax', + 'predatorHuntSuccessRate', 'predatorDetectionRange', + 'predatorAttackSpeed', 'predatorRestTime', + 'crocodileCount', 'crocodileSizeMin', 'crocodileSizeMax', + 'crocodileBaseSpeed', 'crocodileHuntSuccessRate', 'crocodileDetectionRange', + 'crocodileAttackSpeed', 'crocodileRestTime', + 'turtleCount', 'turtleSizeMin', 'turtleSizeMax', + 'turtleBaseSpeedMin', 'turtleBaseSpeedMax', 'turtleTurnForce', + 'turtleDetectionRange', 'turtleFleeForceMultiplier', 'turtleFoodSeekRange', + 'turtleTamedDuration', + 'snailCount', 'snailSizeMin', 'snailSizeMax', + 'snailSpeedMin', 'snailSpeedMax', 'snailDetectionRange', + 'snailRetractDuration', 'snailCrawlSpeed', 'snailSwimChance', 'snailSwimDuration', + // Advanced Settings + 'fishBaseSpeedMin', 'fishBaseSpeedMax', + 'fishThicknessHead', 'fishThicknessNeck', 'fishThicknessTaper', + 'fishThicknessMin', 'fishThicknessPow', 'fishWavePhaseOffset', + 'fishInitialBirthCooldown', 'fishFleeForceMultiplier', 'fishInitialSwimTimer', + 'foodSizeVariationMin', 'foodSizeVariationMax', + 'foodVelocityMin', 'foodVelocityMax', + 'predatorBaseSpeed', 'predatorMaxForceLurking', 'predatorMaxForceAttacking', + 'predatorMaxForceResting', 'predatorWanderProbability', 'predatorWanderMagnitude', + 'predatorDetectingSpeedMult', 'predatorDetectingSeekMult', 'predatorDetectionTime', + 'predatorAttackingSeekMult', 'predatorCatchDistance', 'predatorEatingDuration', + 'predatorEatingSpeed', 'predatorRestingSpeedMult', 'predatorRestTimeFailMult', + 'predatorRestTimeGiveUpMult', 'predatorMaxChaseDistMult', 'predatorAttackWaveMult', + 'predatorBoundaryMargin', + 'padHueVariationMin', 'padHueVariationMax', 'padNotchAngleMin', 'padNotchAngleMax', + 'padVeinLengthRatio', 'flowerMixedLotusProb', 'flowerMixedLilyProb', + 'lotusOuterPetalCount', 'lotusOuterPetalRadius', 'lotusOuterPetalWidth', + 'lotusOuterPetalHeight', 'lotusCenterRadius', 'lilyPetalCount', 'lilyCenterRadius', + 'frogDiveDuration', 'frogJumpLegLength', 'frogEyeSizeRatio', + 'frogJumpRange', 'frogSwimSpeedMin', 'frogSwimSpeedMax', 'frogSwimForce', + 'frogSwimChance', 'frogSwimSeekRange', 'frogFloatChance', 'frogFloatDuration', + 'frogFloatDriftSpeed', 'frogFloatBobSpeed', 'frogFloatBobAmount', + 'stoneDensityBase', 'padClusterCountMin', 'padClusterCountMax', + 'padRingRadiusMult', 'padRingVariationMin', 'padRingVariationMax', + 'padClumpedOffsetRange', + 'rippleLungeRadius', 'rippleLungeMaxRadius', 'rippleLungeSpeed', + 'rippleKillMainRadius', 'rippleKillMainMaxRadius', 'rippleKillMainSpeed', + 'rippleKillSecondaryRadius', 'rippleKillSecondaryMaxRadius', 'rippleKillSecondarySpeed', + 'rippleFailRadius', 'rippleFailMaxRadius', 'rippleFailSpeed', + 'shadowUpdateFPS', 'shadowOffsetX', 'shadowOffsetY', + 'fishBodyShadeDark', 'fishBodyShadeLight', 'fishBodySolidAlpha', + 'fishPatternEdgeAlpha', + 'fishSpecularOuterAlpha', 'fishSpecularInnerAlpha', + 'fishSpecularWidth', 'fishSpecularInnerWidth', + 'fishFinShadeLight', 'fishFinShadeMid', 'fishFinShadeDark', + 'fishFinAlphaBase', 'fishFinAlphaMid', 'fishFinAlphaEdge', + 'fishOutlineDarken', 'fishOutlineAlpha', 'fishOutlineWidth', + 'fishEyeSizeRatio', 'fishEyeIrisRatio', 'fishEyePupilRatio', + 'predatorBodyShadeDark', 'predatorBodyShadeLight', 'predatorBodySolidAlpha', + 'predatorSpecularOuterAlpha', 'predatorSpecularInnerAlpha', + 'predatorSpecularWidth', 'predatorSpecularInnerWidth', + 'predatorFinShadeLight', 'predatorFinShadeMid', 'predatorFinShadeDark', + 'predatorFinAlphaBase', 'predatorFinAlphaMid', 'predatorFinAlphaEdge', + 'predatorOutlineDarken', 'predatorOutlineAlpha', 'predatorOutlineWidth', + 'predatorEyeSizeRatio', 'predatorEyeIrisRatio', 'predatorEyePupilRatio', + 'predatorEyeAttackGlowAlpha', + 'predatorEyeGlowOuterRadius', 'predatorEyeGlowPulseSpeed', 'predatorEyeGlowIntensity', + 'predatorTrailMaxLength', 'predatorTrailFadeTime', 'predatorTrailSpacing', 'predatorTrailAlpha' + ]; + + inputs.forEach(key => { + const inp = document.getElementById('inp-' + key); + const disp = document.getElementById('val-' + key); + + if(inp && disp) { + inp.value = params[key]; + disp.textContent = params[key]; + + inp.addEventListener('input', (e) => { + let val = parseFloat(e.target.value); + params[key] = val; + disp.textContent = val; + + if(key === 'fishCount' || (key.includes('size') && !key.includes('predator') && !key.includes('grass') && !key.includes('turtle'))) { + if (initFishCallback) initFishCallback(); + } + if(key.includes('stone') || key.includes('pad') || key.includes('flower') || key.includes('frog') || key.includes('vein') || key.includes('Split') || key.includes('grass')) { + if (initEnvironmentCallback) initEnvironmentCallback(); + } + if(key === 'predatorCount' || key.includes('predatorSize')) { + if (initPredatorsCallback) initPredatorsCallback(); + } + if(key === 'crocodileCount' || key.includes('crocodileSize')) { + if (initCrocodilesCallback) initCrocodilesCallback(); + } + if(key === 'turtleCount' || key.includes('turtleSize')) { + if (initTurtlesCallback) initTurtlesCallback(); + } + if(key === 'snailCount' || key.includes('snailSize')) { + if (initSnailsCallback) initSnailsCallback(); + } + }); + } + }); + + // Selects + const flowerTypeSelect = document.getElementById('inp-flowerType'); + if (flowerTypeSelect) { + flowerTypeSelect.value = params.flowerType; + flowerTypeSelect.addEventListener('change', e => { + params.flowerType = e.target.value; + if (initEnvironmentCallback) initEnvironmentCallback(); + }); + } + + const padFormSelect = document.getElementById('inp-padFormation'); + if (padFormSelect) { + padFormSelect.value = params.padFormation; + padFormSelect.addEventListener('change', e => { + params.padFormation = e.target.value; + if (initEnvironmentCallback) initEnvironmentCallback(); + }); + } + + const birthModeSelect = document.getElementById('inp-birthMode'); + if (birthModeSelect) { + birthModeSelect.value = params.birthMode; + birthModeSelect.addEventListener('change', e => { + params.birthMode = e.target.value; + }); + } + + // Colors + const bindColor = (id, paramKey, updateFn) => { + const el = document.getElementById(id); + if(!el) return; + el.value = params[paramKey]; + el.addEventListener('input', e => { + params[paramKey] = e.target.value; + if(updateFn) updateFn(); + }); + }; + + const bindPalette = (prefix, paramKey, updateFn) => { + for(let i=0; i<3; i++) { + const el = document.getElementById(prefix + i); + if(el) { + el.value = params[paramKey][i]; + el.addEventListener('input', e => { + params[paramKey][i] = e.target.value; + if(updateFn) updateFn(); + }); + } + } + }; + + bindColor('inp-color1', 'waterColor1', renderStaticBackgroundCallback); + bindColor('inp-color2', 'waterColor2', renderStaticBackgroundCallback); + bindColor('inp-padColor', 'padColor', initEnvironmentCallback); + bindColor('inp-grassColor', 'grassColor', initEnvironmentCallback); + bindColor('inp-foodColor', 'foodColor'); + bindColor('inp-predatorColor', 'predatorColor', initPredatorsCallback); + bindColor('inp-crocodileBodyColor', 'crocodileBodyColor', initCrocodilesCallback); + bindColor('inp-crocodileEyeColor', 'crocodileEyeColor', initCrocodilesCallback); + bindColor('inp-turtleColor', 'turtleColor', initTurtlesCallback); + bindColor('inp-turtlePatternColor', 'turtlePatternColor', initTurtlesCallback); + bindColor('inp-turtleHeadColor', 'turtleHeadColor', initTurtlesCallback); + bindColor('inp-snailShellColor', 'snailShellColor', initSnailsCallback); + bindColor('inp-snailBodyColor', 'snailBodyColor', initSnailsCallback); + + bindPalette('inp-pebbleColor', 'pebbleColors', initEnvironmentCallback); + bindPalette('inp-flowerColor', 'flowerColors', initEnvironmentCallback); + + const rainCheck = document.getElementById('inp-rainMode'); + if (rainCheck) { + rainCheck.checked = params.rainMode; + rainCheck.addEventListener('change', e => { params.rainMode = e.target.checked; }); + } + + // Target FPS selector + const fpsSelect = document.getElementById('inp-targetFPS'); + if (fpsSelect) { + fpsSelect.value = params.targetFPS; + fpsSelect.addEventListener('change', e => { + params.targetFPS = parseInt(e.target.value); + }); + } + + document.getElementById('btn-fullscreen')?.addEventListener('click', () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + }); + + document.getElementById('btn-copy')?.addEventListener('click', async () => { + const data = JSON.stringify(params, null, 2); + const msg = document.getElementById('copy-msg'); + + try { + await navigator.clipboard.writeText(data); + if (msg) { + msg.style.display = 'block'; + setTimeout(() => msg.style.display = 'none', 2000); + } + } catch (err) { + // Fallback for older browsers + const el = document.createElement('textarea'); + el.value = data; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + if (msg) { + msg.style.display = 'block'; + setTimeout(() => msg.style.display = 'none', 2000); + } + } + }); + + document.getElementById('btn-import')?.addEventListener('click', () => { + const input = prompt("Paste your configuration JSON here:"); + if (!input) return; + + try { + const newParams = JSON.parse(input); + Object.assign(params, newParams); + + inputs.forEach(key => { + const el = document.getElementById('inp-' + key); + const disp = document.getElementById('val-' + key); + if(el && params[key] !== undefined) { + el.value = params[key]; + if(disp) disp.textContent = params[key]; + } + }); + + if(document.getElementById('inp-color1')) document.getElementById('inp-color1').value = params.waterColor1; + if(document.getElementById('inp-color2')) document.getElementById('inp-color2').value = params.waterColor2; + if(document.getElementById('inp-padColor')) document.getElementById('inp-padColor').value = params.padColor; + if(document.getElementById('inp-foodColor')) document.getElementById('inp-foodColor').value = params.foodColor; + if(document.getElementById('inp-predatorColor')) document.getElementById('inp-predatorColor').value = params.predatorColor; + if(document.getElementById('inp-crocodileBodyColor')) document.getElementById('inp-crocodileBodyColor').value = params.crocodileBodyColor; + if(document.getElementById('inp-crocodileEyeColor')) document.getElementById('inp-crocodileEyeColor').value = params.crocodileEyeColor; + + for(let i=0; i<3; i++) { + const pEl = document.getElementById('inp-pebbleColor' + i); + if(pEl && params.pebbleColors) pEl.value = params.pebbleColors[i]; + + const fEl = document.getElementById('inp-flowerColor' + i); + if(fEl && params.flowerColors) fEl.value = params.flowerColors[i]; + } + + if(document.getElementById('inp-padFormation')) document.getElementById('inp-padFormation').value = params.padFormation; + if(document.getElementById('inp-flowerType')) document.getElementById('inp-flowerType').value = params.flowerType; + if(document.getElementById('inp-birthMode')) document.getElementById('inp-birthMode').value = params.birthMode; + if(document.getElementById('inp-rainMode')) document.getElementById('inp-rainMode').checked = params.rainMode; + if(document.getElementById('inp-targetFPS') && params.targetFPS) document.getElementById('inp-targetFPS').value = params.targetFPS; + + if (initFishCallback) initFishCallback(); + if (initPredatorsCallback) initPredatorsCallback(); + if (initTurtlesCallback) initTurtlesCallback(); + if (initSnailsCallback) initSnailsCallback(); + if (initEnvironmentCallback) initEnvironmentCallback(); + if (renderStaticBackgroundCallback) renderStaticBackgroundCallback(); + initDualRangeSliders(); // Update dual range fills after import + + alert("Configuration loaded successfully!"); + + } catch(e) { + alert("Error parsing JSON. Please check the format."); + console.error(e); + } + }); + + // Advanced Settings toggle + document.getElementById('toggle-advanced')?.addEventListener('click', () => { + const section = document.getElementById('advanced-settings'); + const toggle = document.getElementById('toggle-advanced'); + if (section && toggle) { + if (section.style.display === 'none') { + section.style.display = 'block'; + const span = toggle.querySelector('span'); + if (span) span.textContent = '(click to collapse)'; + } else { + section.style.display = 'none'; + const span = toggle.querySelector('span'); + if (span) span.textContent = '(click to expand)'; + } + } + }); + + // Initialize dual range sliders after all values are set + initDualRangeSliders(); +} diff --git a/js/utils/SpatialGrid.js b/js/utils/SpatialGrid.js new file mode 100644 index 0000000..c304f33 --- /dev/null +++ b/js/utils/SpatialGrid.js @@ -0,0 +1,86 @@ +// Spatial Grid for Efficient Distance Queries + +export class SpatialGrid { + constructor(cellSize = 100) { + this.cellSize = cellSize; + this.grid = new Map(); + } + + // Get cell key from coordinates + getKey(x, y) { + const cx = Math.floor(x / this.cellSize); + const cy = Math.floor(y / this.cellSize); + return `${cx},${cy}`; + } + + // Clear the grid + clear() { + this.grid.clear(); + } + + // Insert an object into the grid + insert(obj, x, y) { + const key = this.getKey(x, y); + if (!this.grid.has(key)) { + this.grid.set(key, []); + } + this.grid.get(key).push(obj); + } + + // Get all objects within range of a point + getNearby(x, y, range) { + const results = []; + const cellRange = Math.ceil(range / this.cellSize); + const cx = Math.floor(x / this.cellSize); + const cy = Math.floor(y / this.cellSize); + + // Check all cells within range + for (let dx = -cellRange; dx <= cellRange; dx++) { + for (let dy = -cellRange; dy <= cellRange; dy++) { + const key = `${cx + dx},${cy + dy}`; + const cell = this.grid.get(key); + if (cell) { + results.push(...cell); + } + } + } + + return results; + } + + // Get objects within range that pass a filter (more efficient) + getNearbyFiltered(x, y, range, filterFn) { + const results = []; + const cellRange = Math.ceil(range / this.cellSize); + const cx = Math.floor(x / this.cellSize); + const cy = Math.floor(y / this.cellSize); + const rangeSq = range * range; + + for (let dx = -cellRange; dx <= cellRange; dx++) { + for (let dy = -cellRange; dy <= cellRange; dy++) { + const key = `${cx + dx},${cy + dy}`; + const cell = this.grid.get(key); + if (cell) { + for (const obj of cell) { + if (filterFn(obj)) { + const objX = obj.pos ? obj.pos.x : obj.x; + const objY = obj.pos ? obj.pos.y : obj.y; + const distSq = (objX - x) ** 2 + (objY - y) ** 2; + if (distSq <= rangeSq) { + results.push({ obj, distSq }); + } + } + } + } + } + } + + return results; + } +} + +// Create spatial grids for different entity types +export const fishGrid = new SpatialGrid(100); +export const foodGrid = new SpatialGrid(150); +export const predatorGrid = new SpatialGrid(200); +export const crocodileGrid = new SpatialGrid(250); diff --git a/js/utils/Vector.js b/js/utils/Vector.js new file mode 100644 index 0000000..01e1c1b --- /dev/null +++ b/js/utils/Vector.js @@ -0,0 +1,50 @@ +// Vector class for 2D math operations + +export class Vector { + constructor(x, y) { + this.x = x; + this.y = y; + } + + add(v) { + this.x += v.x; + this.y += v.y; + return this; + } + + sub(v) { + this.x -= v.x; + this.y -= v.y; + return this; + } + + mult(n) { + this.x *= n; + this.y *= n; + return this; + } + + div(n) { + this.x /= n; + this.y /= n; + return this; + } + + mag() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + normalize() { + let m = this.mag(); + if (m !== 0) this.mult(1 / m); + return this; + } + + limit(max) { + if (this.mag() > max) { + this.normalize(); + this.mult(max); + } + return this; + } +} diff --git a/js/utils/helpers.js b/js/utils/helpers.js new file mode 100644 index 0000000..1477d02 --- /dev/null +++ b/js/utils/helpers.js @@ -0,0 +1,79 @@ +// Utility helper functions + +import { CULL_MARGIN } from '../config.js'; + +// Canvas dimensions (will be set by RenderManager) +export let width = 0; +export let height = 0; + +export function setDimensions(w, h) { + width = w; + height = h; +} + +// Random number in range +export const rand = (min, max) => Math.random() * (max - min) + min; + +// Distance between two points +export const dist = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); + +// Angle between two points +export const angle = (x1, y1, x2, y2) => Math.atan2(y2 - y1, x2 - x1); + +// Linear interpolation +export const lerp = (a, b, t) => a + (b - a) * t; + +// Convert hex color to HSL +export function hexToHSL(H) { + let r = 0, g = 0, b = 0; + if (H.length == 4) { + r = "0x" + H[1] + H[1]; + g = "0x" + H[2] + H[2]; + b = "0x" + H[3] + H[3]; + } else if (H.length == 7) { + r = "0x" + H[1] + H[2]; + g = "0x" + H[3] + H[4]; + b = "0x" + H[5] + H[6]; + } + r /= 255; + g /= 255; + b /= 255; + let cmin = Math.min(r,g,b), + cmax = Math.max(r,g,b), + delta = cmax - cmin, + h = 0, + s = 0, + l = 0; + + if (delta == 0) + h = 0; + else if (cmax == r) + h = ((g - b) / delta) % 6; + else if (cmax == g) + h = (b - r) / delta + 2; + else + h = (r - g) / delta + 4; + + h = Math.round(h * 60); + if (h < 0) h += 360; + + l = (cmax + cmin) / 2; + s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return { h, s, l }; +} + +// Check if coordinates are in view +export function isInView(x, y, margin = CULL_MARGIN) { + return x >= -margin && x <= width + margin && + y >= -margin && y <= height + margin; +} + +// Check if object is in view (supports both {pos: {x, y}} and {x, y} formats) +export function isObjectInView(obj, margin = CULL_MARGIN) { + const x = obj.pos ? obj.pos.x : obj.x; + const y = obj.pos ? obj.pos.y : obj.y; + return isInView(x, y, margin); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ac404f1 --- /dev/null +++ b/styles.css @@ -0,0 +1,369 @@ +/* Zen Koi Pond Screensaver - Styles */ + +body, html { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + background-color: #000; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +canvas { + display: block; +} + +#ui { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + pointer-events: none; + text-align: center; + text-shadow: 0 1px 2px rgba(0,0,0,0.8); +} + +/* Stats Panel */ +.stats-panel { + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + padding: 10px; + margin-bottom: 15px; +} + +.stats-panel .stat-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.stats-panel .stat-row:last-child { + border-bottom: none; +} + +.stats-panel .stat-label { + color: rgba(255,255,255,0.6); +} + +.stats-panel .stat-value { + font-weight: bold; + font-family: monospace; +} + +.stats-panel .stat-value.fish { color: #81c784; } +.stats-panel .stat-value.predator { color: #ff6b6b; } +.stats-panel .stat-value.birth { color: #64b5f6; } +.stats-panel .stat-value.fps { color: #ffeb3b; } + +/* --- SMART MENU BUTTON --- */ +#menu-btn { + position: fixed; + top: 20px; + right: 20px; + width: 50px; + height: 50px; + cursor: pointer; + z-index: 10000; + filter: drop-shadow(0 4px 6px rgba(0,0,0,0.4)); + animation: spinAndFloat 8s linear infinite; + pointer-events: auto; +} + +#menu-btn:hover { + animation: spinAndFloatHover 6s linear infinite; +} + +/* The Leaf */ +.btn-leaf { + width: 100%; + height: 100%; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #66bb6a, #2e7d32); + clip-path: polygon( + 0% 0%, 100% 0%, 100% 100%, + 55% 55%, + 0% 100% + ); + box-shadow: inset 0 0 10px rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.2); +} + +/* The Flower Icon */ +.btn-flower { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 20px; + height: 20px; + background: radial-gradient(circle, #fff9c4 20%, #f48fb1 90%); + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0,0,0,0.3); +} + +.btn-flower::after { + content: ''; + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 6px; height: 6px; + background: #ffeb3b; + border-radius: 50%; +} + +@keyframes spinAndFloat { + 0% { transform: translateY(0px) rotate(0deg); } + 25% { transform: translateY(-6px) rotate(90deg); } + 50% { transform: translateY(-8px) rotate(180deg); } + 75% { transform: translateY(-6px) rotate(270deg); } + 100% { transform: translateY(0px) rotate(360deg); } +} + +@keyframes spinAndFloatHover { + 0% { transform: translateY(0px) rotate(0deg) scale(1.15); } + 25% { transform: translateY(-6px) rotate(90deg) scale(1.15); } + 50% { transform: translateY(-8px) rotate(180deg) scale(1.15); } + 75% { transform: translateY(-6px) rotate(270deg) scale(1.15); } + 100% { transform: translateY(0px) rotate(360deg) scale(1.15); } +} + +/* --- CONTROL PANEL --- */ +#controls { + position: fixed; + top: 80px; + right: 20px; + width: 280px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(10px); + padding: 15px; + border-radius: 12px; + color: #ddd; + font-size: 12px; + max-height: 80vh; + overflow-y: auto; + border: 1px solid rgba(255,255,255,0.1); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + z-index: 9999; + pointer-events: auto; + + /* Hidden state */ + display: none; + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +#controls.visible { + display: block; + opacity: 1; + transform: translateY(0); +} + +/* Scrollbar styling */ +#controls::-webkit-scrollbar { width: 6px; } +#controls::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); border-radius: 3px;} +#controls::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px;} + +.control-group { + margin-bottom: 15px; + border-bottom: none; + padding: 10px; + border-radius: 8px; + background: rgba(255,255,255,0.03); +} + +.control-group:nth-of-type(even) { + background: rgba(255,255,255,0.06); +} + +.control-group:last-child { + margin-bottom: 0; +} + +.control-label { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + font-weight: 600; + color: #ccc; +} + +.palette-row { + display: flex; + gap: 5px; + margin-bottom: 5px; +} + +input[type=range] { + width: 100%; + cursor: pointer; + accent-color: #4caf50; +} + +/* Dual Range Slider */ +.dual-range { + position: relative; + height: 30px; + margin: 5px 0 10px 0; +} + +.dual-range-track { + position: absolute; + top: 12px; + left: 0; + right: 0; + height: 6px; + background: rgba(255,255,255,0.2); + border-radius: 3px; + z-index: 1; +} + +.dual-range-fill { + position: absolute; + top: 12px; + height: 6px; + background: linear-gradient(to right, #4caf50, #81c784); + border-radius: 3px; + z-index: 2; +} + +.dual-range input[type=range] { + position: absolute; + width: 100%; + height: 6px; + top: 12px; + pointer-events: none; + -webkit-appearance: none; + appearance: none; + background: transparent; + z-index: 3; + margin: 0; + padding: 0; +} + +.dual-range input[type=range]::-webkit-slider-runnable-track { + height: 6px; + background: transparent; +} + +.dual-range input[type=range]::-webkit-slider-thumb { + pointer-events: auto; + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #4caf50; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 2px 6px rgba(0,0,0,0.4); + margin-top: -6px; +} + +.dual-range input[type=range]::-moz-range-track { + height: 6px; + background: transparent; +} + +.dual-range input[type=range]::-moz-range-thumb { + pointer-events: auto; + width: 18px; + height: 18px; + border-radius: 50%; + background: #4caf50; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 2px 6px rgba(0,0,0,0.4); +} + +input[type=color] { + border: none; + height: 25px; + cursor: pointer; + background: none; + padding: 0; + display: block; + border-radius: 4px; +} + +/* Specific rule for single color inputs to be full width */ +.control-group > input[type=color] { + width: 100%; +} + +/* Specific rule for palette inputs to share space */ +.palette-row input[type=color] { + flex: 1; + min-width: 0; +} + +select { + width: 100%; + background: rgba(255,255,255,0.1); + color: #eee; + border: 1px solid rgba(255,255,255,0.2); + padding: 6px; + border-radius: 4px; + cursor: pointer; +} + +select:focus { + outline: none; + border-color: #4caf50; +} + +button { + width: 100%; + padding: 10px; + background: #4caf50; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + margin-top: 8px; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + transition: background 0.2s; +} + +button:hover { + background: #43a047; +} + +#btn-import { + background: #ff9800; +} + +#btn-import:hover { + background: #f57c00; +} + +.val-display { + color: #81c784; + font-family: monospace; +} + +h3 { + margin: 0 0 12px 0; + color: #fff; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid rgba(255,255,255,0.1); + padding: 6px 8px; + border-radius: 6px; + background: rgba(255,255,255,0.06); +} + +h4 { + margin: 0 0 8px 0; + padding: 6px 8px; + border-radius: 6px; + background: rgba(255,255,255,0.05); +}