| 1 | // Pond environment - 3D water, shore, and fence with Wind Waker styling |
| 2 | import * as THREE from 'three' |
| 3 | |
| 4 | export function createPond(scene, gradientMap) { |
| 5 | const group = new THREE.Group() |
| 6 | const radius = 4 |
| 7 | |
| 8 | // Colors |
| 9 | const waterColor = 0x46a0be // Vibrant teal |
| 10 | const waterDeep = 0x2d7a94 // Darker teal |
| 11 | const shoreColor = 0x78b456 // Bright grass green |
| 12 | const sandColor = 0xc8b080 // Sandy edge |
| 13 | const fenceColor = 0xb4823c // Warm wood |
| 14 | |
| 15 | // Materials |
| 16 | const waterMaterial = new THREE.MeshToonMaterial({ |
| 17 | color: waterColor, |
| 18 | gradientMap: gradientMap, |
| 19 | transparent: true, |
| 20 | opacity: 0.9 |
| 21 | }) |
| 22 | |
| 23 | const shoreMaterial = new THREE.MeshToonMaterial({ |
| 24 | color: shoreColor, |
| 25 | gradientMap: gradientMap |
| 26 | }) |
| 27 | |
| 28 | const sandMaterial = new THREE.MeshToonMaterial({ |
| 29 | color: sandColor, |
| 30 | gradientMap: gradientMap |
| 31 | }) |
| 32 | |
| 33 | const fenceMaterial = new THREE.MeshToonMaterial({ |
| 34 | color: fenceColor, |
| 35 | gradientMap: gradientMap |
| 36 | }) |
| 37 | |
| 38 | // Ground plane (grass) |
| 39 | const groundGeom = new THREE.CircleGeometry(radius + 2.5, 32) |
| 40 | groundGeom.rotateX(-Math.PI / 2) |
| 41 | const ground = new THREE.Mesh(groundGeom, shoreMaterial) |
| 42 | ground.position.y = -0.05 |
| 43 | group.add(ground) |
| 44 | |
| 45 | // Sandy shore ring |
| 46 | const sandGeom = new THREE.RingGeometry(radius - 0.2, radius + 0.5, 32) |
| 47 | sandGeom.rotateX(-Math.PI / 2) |
| 48 | const sand = new THREE.Mesh(sandGeom, sandMaterial) |
| 49 | sand.position.y = -0.02 |
| 50 | group.add(sand) |
| 51 | |
| 52 | // Water surface |
| 53 | const waterGeom = new THREE.CircleGeometry(radius, 32) |
| 54 | waterGeom.rotateX(-Math.PI / 2) |
| 55 | const water = new THREE.Mesh(waterGeom, waterMaterial) |
| 56 | water.position.y = 0 |
| 57 | group.add(water) |
| 58 | |
| 59 | // Water depth visual (darker center with gradient) |
| 60 | const deepGeom = new THREE.CircleGeometry(radius * 0.7, 32) |
| 61 | deepGeom.rotateX(-Math.PI / 2) |
| 62 | const deepMaterial = new THREE.MeshToonMaterial({ |
| 63 | color: waterDeep, |
| 64 | gradientMap: gradientMap, |
| 65 | transparent: true, |
| 66 | opacity: 0.6 |
| 67 | }) |
| 68 | const deep = new THREE.Mesh(deepGeom, deepMaterial) |
| 69 | deep.position.y = -0.02 |
| 70 | group.add(deep) |
| 71 | |
| 72 | // Secondary highlight shimmer |
| 73 | const shimmerGeom = new THREE.CircleGeometry(radius * 0.5, 24) |
| 74 | shimmerGeom.rotateX(-Math.PI / 2) |
| 75 | const shimmerMaterial = new THREE.MeshBasicMaterial({ |
| 76 | color: 0x6bc4d8, |
| 77 | transparent: true, |
| 78 | opacity: 0.25 |
| 79 | }) |
| 80 | const shimmer = new THREE.Mesh(shimmerGeom, shimmerMaterial) |
| 81 | shimmer.position.set(radius * 0.15, 0.01, radius * 0.15) |
| 82 | group.add(shimmer) |
| 83 | |
| 84 | // Main highlight (sun reflection) |
| 85 | const highlightGeom = new THREE.CircleGeometry(radius * 0.25, 16) |
| 86 | highlightGeom.rotateX(-Math.PI / 2) |
| 87 | const highlightMaterial = new THREE.MeshBasicMaterial({ |
| 88 | color: 0xa8e8f8, |
| 89 | transparent: true, |
| 90 | opacity: 0.5 |
| 91 | }) |
| 92 | const highlight = new THREE.Mesh(highlightGeom, highlightMaterial) |
| 93 | highlight.position.set(-radius * 0.35, 0.02, -radius * 0.35) |
| 94 | group.add(highlight) |
| 95 | |
| 96 | // Small sparkle highlights |
| 97 | const sparkles = [] |
| 98 | const sparkleMaterial = new THREE.MeshBasicMaterial({ |
| 99 | color: 0xffffff, |
| 100 | transparent: true, |
| 101 | opacity: 0.7 |
| 102 | }) |
| 103 | for (let i = 0; i < 6; i++) { |
| 104 | const sparkleGeom = new THREE.CircleGeometry(0.08 + Math.random() * 0.06, 6) |
| 105 | sparkleGeom.rotateX(-Math.PI / 2) |
| 106 | const sparkle = new THREE.Mesh(sparkleGeom, sparkleMaterial.clone()) |
| 107 | const angle = Math.random() * Math.PI * 2 |
| 108 | const dist = Math.random() * radius * 0.8 |
| 109 | sparkle.position.set( |
| 110 | Math.cos(angle) * dist, |
| 111 | 0.03, |
| 112 | Math.sin(angle) * dist |
| 113 | ) |
| 114 | sparkle.userData.baseX = sparkle.position.x |
| 115 | sparkle.userData.baseZ = sparkle.position.z |
| 116 | sparkle.userData.phase = Math.random() * Math.PI * 2 |
| 117 | group.add(sparkle) |
| 118 | sparkles.push(sparkle) |
| 119 | } |
| 120 | |
| 121 | // Grass tufts around the pond |
| 122 | const grassTuftGeom = new THREE.ConeGeometry(0.15, 0.3, 4) |
| 123 | const grassMaterial = new THREE.MeshToonMaterial({ |
| 124 | color: 0x4a8530, |
| 125 | gradientMap: gradientMap |
| 126 | }) |
| 127 | |
| 128 | for (let i = 0; i < 30; i++) { |
| 129 | const angle = Math.random() * Math.PI * 2 |
| 130 | const dist = radius + 0.8 + Math.random() * 1.5 |
| 131 | |
| 132 | const tuft = new THREE.Mesh(grassTuftGeom, grassMaterial) |
| 133 | tuft.position.set( |
| 134 | Math.cos(angle) * dist, |
| 135 | 0.1, |
| 136 | Math.sin(angle) * dist |
| 137 | ) |
| 138 | tuft.rotation.x = (Math.random() - 0.5) * 0.3 |
| 139 | tuft.rotation.z = (Math.random() - 0.5) * 0.3 |
| 140 | tuft.scale.setScalar(0.5 + Math.random() * 0.5) |
| 141 | group.add(tuft) |
| 142 | } |
| 143 | |
| 144 | // Rickety fence |
| 145 | const fenceGroup = new THREE.Group() |
| 146 | const fenceX = radius + 1 |
| 147 | const postCount = 5 |
| 148 | const postSpacing = 0.8 |
| 149 | |
| 150 | for (let i = 0; i < postCount; i++) { |
| 151 | const wobble = Math.sin(i * 1.5) * 0.1 |
| 152 | |
| 153 | // Fence post |
| 154 | const postGeom = new THREE.BoxGeometry(0.12, 0.8, 0.12) |
| 155 | const post = new THREE.Mesh(postGeom, fenceMaterial) |
| 156 | post.position.set( |
| 157 | fenceX + wobble, |
| 158 | 0.35, |
| 159 | -1.5 + i * postSpacing |
| 160 | ) |
| 161 | post.rotation.x = wobble * 0.3 |
| 162 | post.rotation.z = wobble * 0.5 |
| 163 | fenceGroup.add(post) |
| 164 | |
| 165 | // Post cap |
| 166 | const capGeom = new THREE.BoxGeometry(0.16, 0.06, 0.16) |
| 167 | const cap = new THREE.Mesh(capGeom, fenceMaterial) |
| 168 | cap.position.set( |
| 169 | fenceX + wobble, |
| 170 | 0.78, |
| 171 | -1.5 + i * postSpacing |
| 172 | ) |
| 173 | cap.rotation.x = wobble * 0.3 |
| 174 | cap.rotation.z = wobble * 0.5 |
| 175 | fenceGroup.add(cap) |
| 176 | } |
| 177 | |
| 178 | // Horizontal rails |
| 179 | const railGeom = new THREE.BoxGeometry(0.08, 0.08, postSpacing * (postCount - 1) + 0.3) |
| 180 | |
| 181 | const topRail = new THREE.Mesh(railGeom, fenceMaterial) |
| 182 | topRail.position.set(fenceX + 0.05, 0.6, -1.5 + (postCount - 1) * postSpacing / 2) |
| 183 | topRail.rotation.y = 0.02 |
| 184 | fenceGroup.add(topRail) |
| 185 | |
| 186 | const bottomRail = new THREE.Mesh(railGeom, fenceMaterial) |
| 187 | bottomRail.position.set(fenceX - 0.03, 0.25, -1.5 + (postCount - 1) * postSpacing / 2) |
| 188 | bottomRail.rotation.y = -0.03 |
| 189 | fenceGroup.add(bottomRail) |
| 190 | |
| 191 | group.add(fenceGroup) |
| 192 | |
| 193 | // ============================================ |
| 194 | // DISTANT SCENERY - Mountains and Village |
| 195 | // Scaled small like game board pieces! |
| 196 | // ============================================ |
| 197 | |
| 198 | // Mountain range materials |
| 199 | const mountainMaterial = new THREE.MeshToonMaterial({ |
| 200 | color: 0x6b8e7a, // Muted green-grey |
| 201 | gradientMap: gradientMap |
| 202 | }) |
| 203 | const mountainSnowMaterial = new THREE.MeshToonMaterial({ |
| 204 | color: 0xe8e8f0, // Snow white with slight blue |
| 205 | gradientMap: gradientMap |
| 206 | }) |
| 207 | const mountainDarkMaterial = new THREE.MeshToonMaterial({ |
| 208 | color: 0x4a6b5a, // Darker mountain |
| 209 | gradientMap: gradientMap |
| 210 | }) |
| 211 | |
| 212 | // Create mountain range (back-left corner) |
| 213 | const mountains = new THREE.Group() |
| 214 | |
| 215 | // Large back mountain |
| 216 | const bigMountainGeom = new THREE.ConeGeometry(0.8, 1.4, 6) |
| 217 | const bigMountain = new THREE.Mesh(bigMountainGeom, mountainMaterial) |
| 218 | bigMountain.position.set(-5.5, 0.7, -5) |
| 219 | mountains.add(bigMountain) |
| 220 | |
| 221 | // Snow cap for big mountain |
| 222 | const snowCapGeom = new THREE.ConeGeometry(0.35, 0.45, 6) |
| 223 | const snowCap = new THREE.Mesh(snowCapGeom, mountainSnowMaterial) |
| 224 | snowCap.position.set(-5.5, 1.35, -5) |
| 225 | mountains.add(snowCap) |
| 226 | |
| 227 | // Medium mountain |
| 228 | const medMountainGeom = new THREE.ConeGeometry(0.6, 1.0, 5) |
| 229 | const medMountain = new THREE.Mesh(medMountainGeom, mountainDarkMaterial) |
| 230 | medMountain.position.set(-4.5, 0.5, -5.8) |
| 231 | mountains.add(medMountain) |
| 232 | |
| 233 | // Small mountain |
| 234 | const smallMountainGeom = new THREE.ConeGeometry(0.5, 0.8, 5) |
| 235 | const smallMountain = new THREE.Mesh(smallMountainGeom, mountainMaterial) |
| 236 | smallMountain.position.set(-6.2, 0.4, -4.2) |
| 237 | mountains.add(smallMountain) |
| 238 | |
| 239 | // Another tiny peak |
| 240 | const tinyMountainGeom = new THREE.ConeGeometry(0.4, 0.6, 5) |
| 241 | const tinyMountain = new THREE.Mesh(tinyMountainGeom, mountainDarkMaterial) |
| 242 | tinyMountain.position.set(-5, 0.3, -4) |
| 243 | mountains.add(tinyMountain) |
| 244 | |
| 245 | group.add(mountains) |
| 246 | |
| 247 | // ============================================ |
| 248 | // VILLAGE - tiny game-board scale! |
| 249 | // ============================================ |
| 250 | |
| 251 | const village = new THREE.Group() |
| 252 | const villageX = 5.5 |
| 253 | const villageZ = -3 |
| 254 | |
| 255 | // House materials |
| 256 | const houseMaterial = new THREE.MeshToonMaterial({ |
| 257 | color: 0xd4a574, // Warm beige/tan |
| 258 | gradientMap: gradientMap |
| 259 | }) |
| 260 | const roofMaterial = new THREE.MeshToonMaterial({ |
| 261 | color: 0x8b4513, // Brown roof |
| 262 | gradientMap: gradientMap |
| 263 | }) |
| 264 | const roofRedMaterial = new THREE.MeshToonMaterial({ |
| 265 | color: 0xb85450, // Red roof |
| 266 | gradientMap: gradientMap |
| 267 | }) |
| 268 | const windowMaterial = new THREE.MeshToonMaterial({ |
| 269 | color: 0x87ceeb, // Light blue windows |
| 270 | gradientMap: gradientMap |
| 271 | }) |
| 272 | |
| 273 | // Helper to create a tiny house |
| 274 | function createHouse(x, z, scale, roofMat) { |
| 275 | const houseGroup = new THREE.Group() |
| 276 | |
| 277 | // House body |
| 278 | const bodyGeom = new THREE.BoxGeometry(0.3, 0.25, 0.25) |
| 279 | const body = new THREE.Mesh(bodyGeom, houseMaterial) |
| 280 | body.position.y = 0.125 |
| 281 | houseGroup.add(body) |
| 282 | |
| 283 | // Roof |
| 284 | const roofGeom = new THREE.ConeGeometry(0.22, 0.2, 4) |
| 285 | const roof = new THREE.Mesh(roofGeom, roofMat) |
| 286 | roof.position.y = 0.32 |
| 287 | roof.rotation.y = Math.PI / 4 |
| 288 | houseGroup.add(roof) |
| 289 | |
| 290 | // Window (tiny dot) |
| 291 | const windowGeom = new THREE.PlaneGeometry(0.06, 0.06) |
| 292 | const windowMesh = new THREE.Mesh(windowGeom, windowMaterial) |
| 293 | windowMesh.position.set(0.151, 0.14, 0) |
| 294 | houseGroup.add(windowMesh) |
| 295 | |
| 296 | houseGroup.position.set(x, 0, z) |
| 297 | houseGroup.scale.setScalar(scale) |
| 298 | houseGroup.rotation.y = Math.random() * 0.5 - 0.25 |
| 299 | |
| 300 | return houseGroup |
| 301 | } |
| 302 | |
| 303 | // Create village houses - clustered together |
| 304 | const house1 = createHouse(villageX, villageZ, 1.0, roofMaterial) |
| 305 | village.add(house1) |
| 306 | |
| 307 | const house2 = createHouse(villageX + 0.5, villageZ + 0.3, 0.8, roofRedMaterial) |
| 308 | village.add(house2) |
| 309 | |
| 310 | const house3 = createHouse(villageX + 0.2, villageZ + 0.6, 0.9, roofMaterial) |
| 311 | village.add(house3) |
| 312 | |
| 313 | const house4 = createHouse(villageX - 0.35, villageZ + 0.25, 0.7, roofRedMaterial) |
| 314 | village.add(house4) |
| 315 | |
| 316 | // Chimney on main house |
| 317 | const chimneyGeom = new THREE.BoxGeometry(0.06, 0.15, 0.06) |
| 318 | const chimney = new THREE.Mesh(chimneyGeom, new THREE.MeshToonMaterial({ |
| 319 | color: 0x8b7355, |
| 320 | gradientMap: gradientMap |
| 321 | })) |
| 322 | chimney.position.set(villageX + 0.08, 0.45, villageZ + 0.04) |
| 323 | village.add(chimney) |
| 324 | |
| 325 | // Smoke particles (tiny puffs) |
| 326 | const smokeParticles = [] |
| 327 | const smokeMaterial = new THREE.MeshBasicMaterial({ |
| 328 | color: 0xdddddd, |
| 329 | transparent: true, |
| 330 | opacity: 0.5 |
| 331 | }) |
| 332 | |
| 333 | function createSmokeParticle() { |
| 334 | const size = 0.03 + Math.random() * 0.03 |
| 335 | const smokeGeom = new THREE.SphereGeometry(size, 5, 4) |
| 336 | const smoke = new THREE.Mesh(smokeGeom, smokeMaterial.clone()) |
| 337 | smoke.position.set( |
| 338 | villageX + 0.08 + (Math.random() - 0.5) * 0.03, |
| 339 | 0.52, |
| 340 | villageZ + 0.04 + (Math.random() - 0.5) * 0.03 |
| 341 | ) |
| 342 | village.add(smoke) |
| 343 | smokeParticles.push({ |
| 344 | mesh: smoke, |
| 345 | age: 0, |
| 346 | maxAge: 2.5 + Math.random() * 1.5, |
| 347 | driftX: (Math.random() - 0.5) * 0.08, |
| 348 | driftZ: (Math.random() - 0.5) * 0.08, |
| 349 | riseSpeed: 0.12 + Math.random() * 0.08 |
| 350 | }) |
| 351 | } |
| 352 | |
| 353 | // Initial smoke |
| 354 | for (let i = 0; i < 4; i++) { |
| 355 | createSmokeParticle() |
| 356 | smokeParticles[i].age = Math.random() * 1.5 |
| 357 | } |
| 358 | |
| 359 | group.add(village) |
| 360 | |
| 361 | // ============================================ |
| 362 | // BOATHOUSE - cozy little structure at pond corner |
| 363 | // ============================================ |
| 364 | |
| 365 | const boathouse = new THREE.Group() |
| 366 | const boathouseX = -3.3 |
| 367 | const boathouseZ = 3.8 |
| 368 | |
| 369 | // Boathouse materials |
| 370 | const boathouseWoodMaterial = new THREE.MeshToonMaterial({ |
| 371 | color: 0x8b6914, // Weathered wood |
| 372 | gradientMap: gradientMap |
| 373 | }) |
| 374 | const boathouseRoofMaterial = new THREE.MeshToonMaterial({ |
| 375 | color: 0x5a4a3a, // Dark wood/slate roof |
| 376 | gradientMap: gradientMap |
| 377 | }) |
| 378 | const boathouseTrimMaterial = new THREE.MeshToonMaterial({ |
| 379 | color: 0x6b5030, // Darker trim |
| 380 | gradientMap: gradientMap |
| 381 | }) |
| 382 | |
| 383 | // Main building - slightly larger than houses to dwarf Doug |
| 384 | const boathouseBodyGeom = new THREE.BoxGeometry(1.2, 0.9, 1.0) |
| 385 | const boathouseBody = new THREE.Mesh(boathouseBodyGeom, boathouseWoodMaterial) |
| 386 | boathouseBody.position.set(0, 0.45, 0) |
| 387 | boathouse.add(boathouseBody) |
| 388 | |
| 389 | // Roof - pitched roof |
| 390 | const roofShape = new THREE.Shape() |
| 391 | roofShape.moveTo(-0.75, 0) |
| 392 | roofShape.lineTo(0, 0.5) |
| 393 | roofShape.lineTo(0.75, 0) |
| 394 | roofShape.lineTo(-0.75, 0) |
| 395 | |
| 396 | const roofExtrudeSettings = { depth: 1.15, bevelEnabled: false } |
| 397 | const boathouseRoofGeom = new THREE.ExtrudeGeometry(roofShape, roofExtrudeSettings) |
| 398 | const boathouseRoof = new THREE.Mesh(boathouseRoofGeom, boathouseRoofMaterial) |
| 399 | boathouseRoof.position.set(0, 0.9, -0.575) |
| 400 | boathouse.add(boathouseRoof) |
| 401 | |
| 402 | // Roof overhang trim |
| 403 | const overhangGeom = new THREE.BoxGeometry(1.5, 0.05, 0.08) |
| 404 | const overhangFront = new THREE.Mesh(overhangGeom, boathouseTrimMaterial) |
| 405 | overhangFront.position.set(0, 0.92, 0.54) |
| 406 | boathouse.add(overhangFront) |
| 407 | const overhangBack = new THREE.Mesh(overhangGeom, boathouseTrimMaterial) |
| 408 | overhangBack.position.set(0, 0.92, -0.54) |
| 409 | boathouse.add(overhangBack) |
| 410 | |
| 411 | // Door opening (dark rectangle facing pond) |
| 412 | const doorGeom = new THREE.PlaneGeometry(0.45, 0.6) |
| 413 | const doorMaterial = new THREE.MeshToonMaterial({ |
| 414 | color: 0x1a1a1a, |
| 415 | gradientMap: gradientMap |
| 416 | }) |
| 417 | const door = new THREE.Mesh(doorGeom, doorMaterial) |
| 418 | door.position.set(0.601, 0.35, 0.15) |
| 419 | door.rotation.y = Math.PI / 2 |
| 420 | boathouse.add(door) |
| 421 | |
| 422 | // Window on side |
| 423 | const boathouseWindowGeom = new THREE.PlaneGeometry(0.25, 0.2) |
| 424 | const boathouseWindowMaterial = new THREE.MeshToonMaterial({ |
| 425 | color: 0x6b9bc3, |
| 426 | gradientMap: gradientMap |
| 427 | }) |
| 428 | const boathouseWindow = new THREE.Mesh(boathouseWindowGeom, boathouseWindowMaterial) |
| 429 | boathouseWindow.position.set(0, 0.55, 0.51) |
| 430 | boathouse.add(boathouseWindow) |
| 431 | |
| 432 | // Window frame |
| 433 | const frameH = new THREE.Mesh( |
| 434 | new THREE.BoxGeometry(0.3, 0.02, 0.02), |
| 435 | boathouseTrimMaterial |
| 436 | ) |
| 437 | frameH.position.set(0, 0.55, 0.52) |
| 438 | boathouse.add(frameH) |
| 439 | const frameV = new THREE.Mesh( |
| 440 | new THREE.BoxGeometry(0.02, 0.25, 0.02), |
| 441 | boathouseTrimMaterial |
| 442 | ) |
| 443 | frameV.position.set(0, 0.55, 0.52) |
| 444 | boathouse.add(frameV) |
| 445 | |
| 446 | // Dock/platform extending toward pond |
| 447 | const dockGeom = new THREE.BoxGeometry(1.0, 0.08, 0.6) |
| 448 | const dock = new THREE.Mesh(dockGeom, boathouseWoodMaterial) |
| 449 | dock.position.set(1.1, 0.04, 0.15) |
| 450 | boathouse.add(dock) |
| 451 | |
| 452 | // Dock support posts |
| 453 | const dockPostGeom = new THREE.CylinderGeometry(0.04, 0.05, 0.3, 6) |
| 454 | const dockPost1 = new THREE.Mesh(dockPostGeom, boathouseTrimMaterial) |
| 455 | dockPost1.position.set(1.5, -0.1, 0.35) |
| 456 | boathouse.add(dockPost1) |
| 457 | const dockPost2 = new THREE.Mesh(dockPostGeom, boathouseTrimMaterial) |
| 458 | dockPost2.position.set(1.5, -0.1, -0.05) |
| 459 | boathouse.add(dockPost2) |
| 460 | |
| 461 | // Position boathouse at corner, angled toward pond |
| 462 | boathouse.position.set(boathouseX, 0, boathouseZ) |
| 463 | boathouse.rotation.y = Math.PI / 4 + 0.3 // Angled toward pond center |
| 464 | |
| 465 | group.add(boathouse) |
| 466 | |
| 467 | // ============================================ |
| 468 | // ROWBOAT - floating by the dock |
| 469 | // ============================================ |
| 470 | |
| 471 | const rowboat = new THREE.Group() |
| 472 | |
| 473 | const rowboatWoodMaterial = new THREE.MeshToonMaterial({ |
| 474 | color: 0x6b4423, // Dark wood |
| 475 | gradientMap: gradientMap |
| 476 | }) |
| 477 | const rowboatTrimMaterial = new THREE.MeshToonMaterial({ |
| 478 | color: 0x8b5a2b, // Lighter trim |
| 479 | gradientMap: gradientMap |
| 480 | }) |
| 481 | |
| 482 | // Boat hull - elongated bowl shape using lathe geometry |
| 483 | const hullPoints = [] |
| 484 | hullPoints.push(new THREE.Vector2(0, 0)) |
| 485 | hullPoints.push(new THREE.Vector2(0.18, 0)) |
| 486 | hullPoints.push(new THREE.Vector2(0.22, 0.03)) |
| 487 | hullPoints.push(new THREE.Vector2(0.22, 0.1)) |
| 488 | hullPoints.push(new THREE.Vector2(0.18, 0.14)) |
| 489 | hullPoints.push(new THREE.Vector2(0, 0.14)) |
| 490 | |
| 491 | const hullGeom = new THREE.LatheGeometry(hullPoints, 8) |
| 492 | hullGeom.scale(1, 1, 2.2) // Stretch into boat shape |
| 493 | const hull = new THREE.Mesh(hullGeom, rowboatWoodMaterial) |
| 494 | hull.rotation.x = Math.PI // Flip right side up |
| 495 | hull.position.y = 0.14 |
| 496 | rowboat.add(hull) |
| 497 | |
| 498 | // Boat seats (thwarts) |
| 499 | const seatGeom = new THREE.BoxGeometry(0.32, 0.02, 0.08) |
| 500 | const seat1 = new THREE.Mesh(seatGeom, rowboatTrimMaterial) |
| 501 | seat1.position.set(0, 0.08, 0.15) |
| 502 | rowboat.add(seat1) |
| 503 | const seat2 = new THREE.Mesh(seatGeom, rowboatTrimMaterial) |
| 504 | seat2.position.set(0, 0.08, -0.15) |
| 505 | rowboat.add(seat2) |
| 506 | |
| 507 | // Oars resting in boat |
| 508 | const oarMaterial = new THREE.MeshToonMaterial({ |
| 509 | color: 0x9b7b4a, |
| 510 | gradientMap: gradientMap |
| 511 | }) |
| 512 | const oarHandleGeom = new THREE.CylinderGeometry(0.012, 0.012, 0.5, 6) |
| 513 | const oarBladeGeom = new THREE.BoxGeometry(0.06, 0.01, 0.15) |
| 514 | |
| 515 | // Left oar |
| 516 | const oar1 = new THREE.Group() |
| 517 | const oarHandle1 = new THREE.Mesh(oarHandleGeom, oarMaterial) |
| 518 | oarHandle1.rotation.z = Math.PI / 2 |
| 519 | oar1.add(oarHandle1) |
| 520 | const oarBlade1 = new THREE.Mesh(oarBladeGeom, oarMaterial) |
| 521 | oarBlade1.position.x = 0.28 |
| 522 | oar1.add(oarBlade1) |
| 523 | oar1.position.set(0.12, 0.1, 0) |
| 524 | oar1.rotation.y = 0.15 |
| 525 | rowboat.add(oar1) |
| 526 | |
| 527 | // Right oar |
| 528 | const oar2 = new THREE.Group() |
| 529 | const oarHandle2 = new THREE.Mesh(oarHandleGeom, oarMaterial) |
| 530 | oarHandle2.rotation.z = Math.PI / 2 |
| 531 | oar2.add(oarHandle2) |
| 532 | const oarBlade2 = new THREE.Mesh(oarBladeGeom, oarMaterial) |
| 533 | oarBlade2.position.x = -0.28 |
| 534 | oar2.add(oarBlade2) |
| 535 | oar2.position.set(-0.12, 0.1, 0) |
| 536 | oar2.rotation.y = -0.15 |
| 537 | rowboat.add(oar2) |
| 538 | |
| 539 | // Position rowboat by the dock (in the water) |
| 540 | // Place it at the pond edge near the dock - inside the water |
| 541 | const rowboatAngle = Math.atan2(boathouseZ, boathouseX) // Angle from center to boathouse |
| 542 | const rowboatDist = 2.7 // Closer to pond center |
| 543 | rowboat.position.set( |
| 544 | Math.cos(rowboatAngle) * rowboatDist, |
| 545 | 0, |
| 546 | Math.sin(rowboatAngle) * rowboatDist |
| 547 | ) |
| 548 | rowboat.rotation.y = rowboatAngle + Math.PI / 2 + 0.2 // Parallel to shore, slightly askew |
| 549 | rowboat.userData.baseY = 0 |
| 550 | rowboat.userData.baseX = rowboat.position.x |
| 551 | rowboat.userData.baseZ = rowboat.position.z |
| 552 | rowboat.userData.phase = Math.random() * Math.PI * 2 |
| 553 | rowboat.userData.lastRipple = 0 |
| 554 | |
| 555 | group.add(rowboat) |
| 556 | |
| 557 | // ============================================ |
| 558 | // TREES - scattered around the edges |
| 559 | // ============================================ |
| 560 | |
| 561 | const treeMaterial = new THREE.MeshToonMaterial({ |
| 562 | color: 0x2d5a3d, |
| 563 | gradientMap: gradientMap |
| 564 | }) |
| 565 | const trunkMaterial = new THREE.MeshToonMaterial({ |
| 566 | color: 0x5c4033, |
| 567 | gradientMap: gradientMap |
| 568 | }) |
| 569 | |
| 570 | // Tree positions - scattered around the scene edges |
| 571 | const treePositions = [ |
| 572 | { x: 5.2, z: -4.5, scale: 0.35 }, |
| 573 | { x: 6.2, z: -2.5, scale: 0.4 }, |
| 574 | { x: 4.8, z: -1.8, scale: 0.3 }, |
| 575 | { x: -5.8, z: -3.5, scale: 0.35 }, |
| 576 | { x: -4.8, z: -2.8, scale: 0.28 }, |
| 577 | { x: -6, z: 2, scale: 0.32 }, |
| 578 | { x: 5.5, z: 3, scale: 0.38 }, |
| 579 | { x: -4, z: 4.5, scale: 0.3 }, |
| 580 | ] |
| 581 | |
| 582 | for (const pos of treePositions) { |
| 583 | const treeGroup = new THREE.Group() |
| 584 | |
| 585 | // Trunk |
| 586 | const trunkGeom = new THREE.CylinderGeometry(0.04, 0.06, 0.25, 5) |
| 587 | const trunk = new THREE.Mesh(trunkGeom, trunkMaterial) |
| 588 | trunk.position.y = 0.125 |
| 589 | treeGroup.add(trunk) |
| 590 | |
| 591 | // Foliage (stacked cones) |
| 592 | const foliage1 = new THREE.Mesh(new THREE.ConeGeometry(0.2, 0.3, 5), treeMaterial) |
| 593 | foliage1.position.y = 0.35 |
| 594 | treeGroup.add(foliage1) |
| 595 | |
| 596 | const foliage2 = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.25, 5), treeMaterial) |
| 597 | foliage2.position.y = 0.55 |
| 598 | treeGroup.add(foliage2) |
| 599 | |
| 600 | treeGroup.position.set(pos.x, 0, pos.z) |
| 601 | treeGroup.scale.setScalar(pos.scale) |
| 602 | |
| 603 | group.add(treeGroup) |
| 604 | } |
| 605 | |
| 606 | // Ripple system |
| 607 | const ripples = [] |
| 608 | const rippleGeom = new THREE.RingGeometry(0.1, 0.15, 16) |
| 609 | rippleGeom.rotateX(-Math.PI / 2) |
| 610 | |
| 611 | function addRipple(x, z) { |
| 612 | const rippleMaterial = new THREE.MeshBasicMaterial({ |
| 613 | color: 0xffffff, |
| 614 | transparent: true, |
| 615 | opacity: 0.6, |
| 616 | side: THREE.DoubleSide |
| 617 | }) |
| 618 | const ripple = new THREE.Mesh(rippleGeom.clone(), rippleMaterial) |
| 619 | ripple.position.set(x, 0.02, z) |
| 620 | |
| 621 | group.add(ripple) |
| 622 | ripples.push({ |
| 623 | mesh: ripple, |
| 624 | age: 0, |
| 625 | maxAge: 1.5 |
| 626 | }) |
| 627 | } |
| 628 | |
| 629 | let smokeSpawnTimer = 0 |
| 630 | |
| 631 | function update(delta, elapsed) { |
| 632 | // Animate water highlight |
| 633 | highlight.position.x = -radius * 0.35 + Math.sin(elapsed * 0.5) * 0.3 |
| 634 | highlight.position.z = -radius * 0.35 + Math.cos(elapsed * 0.5) * 0.3 |
| 635 | highlight.material.opacity = 0.4 + Math.sin(elapsed * 2) * 0.1 |
| 636 | |
| 637 | // Animate shimmer |
| 638 | shimmer.position.x = radius * 0.15 + Math.cos(elapsed * 0.4) * 0.2 |
| 639 | shimmer.position.z = radius * 0.15 + Math.sin(elapsed * 0.3) * 0.2 |
| 640 | shimmer.material.opacity = 0.2 + Math.sin(elapsed * 1.5 + 1) * 0.1 |
| 641 | |
| 642 | // Animate sparkles - twinkle effect |
| 643 | for (const sparkle of sparkles) { |
| 644 | const twinkle = Math.sin(elapsed * 4 + sparkle.userData.phase) |
| 645 | sparkle.material.opacity = twinkle > 0.3 ? 0.8 : 0 |
| 646 | sparkle.position.x = sparkle.userData.baseX + Math.sin(elapsed * 0.5 + sparkle.userData.phase) * 0.1 |
| 647 | sparkle.position.z = sparkle.userData.baseZ + Math.cos(elapsed * 0.5 + sparkle.userData.phase) * 0.1 |
| 648 | } |
| 649 | |
| 650 | // Update ripples |
| 651 | for (let i = ripples.length - 1; i >= 0; i--) { |
| 652 | const ripple = ripples[i] |
| 653 | ripple.age += delta |
| 654 | |
| 655 | const progress = ripple.age / ripple.maxAge |
| 656 | ripple.mesh.scale.setScalar(1 + progress * 3) |
| 657 | ripple.mesh.material.opacity = 0.6 * (1 - progress) |
| 658 | |
| 659 | if (ripple.age >= ripple.maxAge) { |
| 660 | group.remove(ripple.mesh) |
| 661 | ripple.mesh.geometry.dispose() |
| 662 | ripple.mesh.material.dispose() |
| 663 | ripples.splice(i, 1) |
| 664 | } |
| 665 | } |
| 666 | |
| 667 | // Spawn new smoke particles |
| 668 | smokeSpawnTimer += delta |
| 669 | if (smokeSpawnTimer > 0.8) { |
| 670 | smokeSpawnTimer = 0 |
| 671 | createSmokeParticle() |
| 672 | } |
| 673 | |
| 674 | // Animate rowboat - gentle bobbing and rocking |
| 675 | const boatPhase = rowboat.userData.phase |
| 676 | rowboat.position.y = rowboat.userData.baseY + Math.sin(elapsed * 1.2 + boatPhase) * 0.025 |
| 677 | rowboat.rotation.x = Math.sin(elapsed * 0.8 + boatPhase) * 0.03 |
| 678 | rowboat.rotation.z = Math.sin(elapsed * 1.0 + boatPhase + 1) * 0.025 |
| 679 | // Slight drift/tug motion |
| 680 | rowboat.position.x = rowboat.userData.baseX + Math.sin(elapsed * 0.5 + boatPhase) * 0.015 |
| 681 | rowboat.position.z = rowboat.userData.baseZ + Math.cos(elapsed * 0.4 + boatPhase) * 0.015 |
| 682 | |
| 683 | // Occasional ripples from rowboat |
| 684 | rowboat.userData.lastRipple += delta |
| 685 | if (rowboat.userData.lastRipple > 2.5 + Math.random() * 2) { |
| 686 | rowboat.userData.lastRipple = 0 |
| 687 | addRipple( |
| 688 | rowboat.position.x + (Math.random() - 0.5) * 0.3, |
| 689 | rowboat.position.z + (Math.random() - 0.5) * 0.3 |
| 690 | ) |
| 691 | } |
| 692 | |
| 693 | // Update smoke particles |
| 694 | for (let i = smokeParticles.length - 1; i >= 0; i--) { |
| 695 | const smoke = smokeParticles[i] |
| 696 | smoke.age += delta |
| 697 | |
| 698 | // Rise and drift |
| 699 | smoke.mesh.position.y += smoke.riseSpeed * delta |
| 700 | smoke.mesh.position.x += smoke.driftX * delta |
| 701 | smoke.mesh.position.z += smoke.driftZ * delta |
| 702 | |
| 703 | // Grow and fade |
| 704 | const progress = smoke.age / smoke.maxAge |
| 705 | smoke.mesh.scale.setScalar(1 + progress * 2) |
| 706 | smoke.mesh.material.opacity = 0.6 * (1 - progress) |
| 707 | |
| 708 | if (smoke.age >= smoke.maxAge) { |
| 709 | village.remove(smoke.mesh) |
| 710 | smoke.mesh.geometry.dispose() |
| 711 | smoke.mesh.material.dispose() |
| 712 | smokeParticles.splice(i, 1) |
| 713 | } |
| 714 | } |
| 715 | } |
| 716 | |
| 717 | scene.add(group) |
| 718 | |
| 719 | // Forbidden zones for creature emergence (dock and rowboat areas) |
| 720 | const forbiddenZones = [ |
| 721 | { x: rowboat.position.x, z: rowboat.position.z, radius: 0.8 }, // Rowboat |
| 722 | { // Dock area - extends from boathouse toward pond |
| 723 | x: boathouseX + Math.cos(Math.PI / 4 + 0.3) * 1.5, |
| 724 | z: boathouseZ + Math.sin(Math.PI / 4 + 0.3) * 1.5, |
| 725 | radius: 1.2 |
| 726 | } |
| 727 | ] |
| 728 | |
| 729 | function isValidEmergenceSpot(x, z) { |
| 730 | for (const zone of forbiddenZones) { |
| 731 | const dx = x - zone.x |
| 732 | const dz = z - zone.z |
| 733 | const dist = Math.sqrt(dx * dx + dz * dz) |
| 734 | if (dist < zone.radius) { |
| 735 | return false |
| 736 | } |
| 737 | } |
| 738 | return true |
| 739 | } |
| 740 | |
| 741 | // Add a new forbidden zone (for placed buildings) |
| 742 | function addForbiddenZone(x, z, zoneRadius) { |
| 743 | forbiddenZones.push({ x, z, radius: zoneRadius }) |
| 744 | } |
| 745 | |
| 746 | // Get initial forbidden zones (for building placement) |
| 747 | function getInitialForbiddenZones() { |
| 748 | // Return copy of initial zones (rowboat and dock area) |
| 749 | return forbiddenZones.slice(0, 2).map(z => ({ ...z })) |
| 750 | } |
| 751 | |
| 752 | // Snap zones for building placement |
| 753 | // Each zone has: id, position, angle (rotation), type, allowed buildings, occupied status |
| 754 | const snapZones = [ |
| 755 | // Water edge zones (for docks, fishing huts) - all around the pond |
| 756 | { id: 'water_n', x: 0, z: -4.3, angle: Math.PI, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 757 | { id: 'water_ne', x: 3.0, z: -3.0, angle: Math.PI * 0.75, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 758 | { id: 'water_e', x: 4.3, z: 0, angle: Math.PI * 0.5, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 759 | { id: 'water_se', x: 3.0, z: 3.0, angle: Math.PI * 0.25, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 760 | { id: 'water_s', x: 0, z: 4.3, angle: 0, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 761 | { id: 'water_sw', x: -3.0, z: 3.0, angle: -Math.PI * 0.25, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 762 | { id: 'water_w', x: -4.3, z: 0, angle: -Math.PI * 0.5, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 763 | { id: 'water_nw', x: -3.0, z: -3.0, angle: -Math.PI * 0.75, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false }, |
| 764 | |
| 765 | // Shore zones (for lighthouse, fence, onion house) - further from water |
| 766 | { id: 'shore_n', x: 0, z: -5.5, angle: Math.PI, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false }, |
| 767 | { id: 'shore_ne', x: 4.0, z: -4.0, angle: Math.PI * 0.75, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false }, |
| 768 | { id: 'shore_e', x: 5.5, z: 0, angle: Math.PI * 0.5, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false }, |
| 769 | { id: 'shore_se', x: 4.0, z: 4.0, angle: Math.PI * 0.25, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false }, |
| 770 | { id: 'shore_s', x: 0, z: 5.5, angle: 0, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false }, |
| 771 | |
| 772 | // In-water zones (for reeds) |
| 773 | { id: 'water_inner_n', x: 0, z: -2.5, angle: Math.PI, type: 'water', allowedBuildings: ['reeds'], occupied: false }, |
| 774 | { id: 'water_inner_e', x: 2.5, z: 0, angle: Math.PI * 0.5, type: 'water', allowedBuildings: ['reeds'], occupied: false }, |
| 775 | { id: 'water_inner_s', x: 0, z: 2.5, angle: 0, type: 'water', allowedBuildings: ['reeds'], occupied: false }, |
| 776 | { id: 'water_inner_w', x: -2.5, z: 0, angle: -Math.PI * 0.5, type: 'water', allowedBuildings: ['reeds'], occupied: false } |
| 777 | ] |
| 778 | |
| 779 | // Get available snap zones for a building type |
| 780 | function getAvailableZones(buildingType) { |
| 781 | return snapZones.filter(zone => |
| 782 | !zone.occupied && zone.allowedBuildings.includes(buildingType) |
| 783 | ) |
| 784 | } |
| 785 | |
| 786 | // Get zone by ID |
| 787 | function getZone(zoneId) { |
| 788 | return snapZones.find(z => z.id === zoneId) |
| 789 | } |
| 790 | |
| 791 | // Mark a zone as occupied |
| 792 | function occupyZone(zoneId) { |
| 793 | const zone = getZone(zoneId) |
| 794 | if (zone) { |
| 795 | zone.occupied = true |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | // Find nearest valid zone to a point |
| 800 | function findNearestZone(x, z, buildingType, snapDistance = 1.5) { |
| 801 | const available = getAvailableZones(buildingType) |
| 802 | let nearest = null |
| 803 | let nearestDist = snapDistance |
| 804 | |
| 805 | for (const zone of available) { |
| 806 | const dx = x - zone.x |
| 807 | const dz = z - zone.z |
| 808 | const dist = Math.sqrt(dx * dx + dz * dz) |
| 809 | if (dist < nearestDist) { |
| 810 | nearestDist = dist |
| 811 | nearest = zone |
| 812 | } |
| 813 | } |
| 814 | |
| 815 | return nearest |
| 816 | } |
| 817 | |
| 818 | return { |
| 819 | group, |
| 820 | water, |
| 821 | radius, |
| 822 | addRipple, |
| 823 | update, |
| 824 | isValidEmergenceSpot, |
| 825 | addForbiddenZone, |
| 826 | getInitialForbiddenZones, |
| 827 | snapZones, |
| 828 | getAvailableZones, |
| 829 | getZone, |
| 830 | occupyZone, |
| 831 | findNearestZone |
| 832 | } |
| 833 | } |