JavaScript · 18038 bytes Raw Blame History
1 // Building placement system for dougk
2 // Constraint-based freeform placement with real-time validation
3
4 import * as THREE from 'three'
5 import { createBuilding, createGhostBuilding } from './buildings.js'
6 import inventory from './shop/inventory.js'
7 import { playPlaceBuilding } from './sounds.js'
8 import { BUILDINGS } from './shop/items.js'
9
10 // Placement states
11 const STATE = {
12 INACTIVE: 'inactive',
13 SELECTING: 'selecting'
14 }
15
16 // Terrain types
17 const TERRAIN = {
18 WATER: 'water',
19 WATER_EDGE: 'waterEdge',
20 SHORE: 'shore',
21 OUT_OF_BOUNDS: 'outOfBounds'
22 }
23
24 // Building terrain requirements
25 const BUILDING_TERRAIN = {
26 dock_wooden: [TERRAIN.WATER_EDGE],
27 fishing_hut: [TERRAIN.WATER_EDGE],
28 lighthouse: [TERRAIN.SHORE],
29 reeds: [TERRAIN.WATER, TERRAIN.WATER_EDGE],
30 fence: [TERRAIN.SHORE],
31 onion_house: [TERRAIN.SHORE],
32 boot_house: [TERRAIN.SHORE]
33 }
34
35 // Building collision radii (for overlap detection)
36 const BUILDING_COLLISION_RADIUS = {
37 dock_wooden: 0.8,
38 fishing_hut: 0.7,
39 lighthouse: 0.5,
40 reeds: 0.3,
41 fence: 0.25,
42 onion_house: 0.5,
43 boot_house: 1.0
44 }
45
46 export class PlacementManager {
47 constructor(scene, pond, camera, gradientMap) {
48 this.scene = scene
49 this.pond = pond
50 this.camera = camera
51 this.gradientMap = gradientMap
52
53 this.state = STATE.INACTIVE
54 this.currentBuildingType = null
55 this.ghostMesh = null
56 this.isValidPosition = false
57 this.currentPosition = new THREE.Vector3()
58 this.currentRotation = 0
59 this.placedBuildings = new THREE.Group()
60
61 this.raycaster = new THREE.Raycaster()
62 this.mouse = new THREE.Vector2()
63
64 // Pond geometry constants
65 this.pondCenter = new THREE.Vector2(0, 0)
66 this.pondRadius = 4.0 // Water radius
67
68 // Terrain zone boundaries (distance from center)
69 this.terrainBounds = {
70 waterInner: 3.2, // Deep water
71 waterEdgeInner: 3.2, // Water edge starts
72 waterEdgeOuter: 4.8, // Water edge ends
73 shoreInner: 4.3, // Shore starts
74 shoreOuter: 7.0, // Shore ends (map bounds)
75 mapBounds: 8.0 // Absolute map edge
76 }
77
78 // Invisible ground plane for raycasting
79 this.groundPlane = new THREE.Mesh(
80 new THREE.PlaneGeometry(50, 50),
81 new THREE.MeshBasicMaterial({ visible: false })
82 )
83 this.groundPlane.rotation.x = -Math.PI / 2
84 this.groundPlane.position.y = 0
85 scene.add(this.groundPlane)
86
87 // Building forbidden radius (for creature emergence)
88 this.buildingRadius = {
89 dock_wooden: 1.0,
90 fishing_hut: 0.9,
91 lighthouse: 0.5,
92 reeds: 0.4,
93 fence: 0.3,
94 onion_house: 0.6,
95 boot_house: 1.2
96 }
97
98 // Placed building positions for collision detection
99 this.placedPositions = []
100
101 // Forbidden zones (rowboat, dock area, etc.)
102 this.forbiddenZones = []
103
104 scene.add(this.placedBuildings)
105
106 // Callbacks
107 this.onPlacementComplete = null
108 this.onPlacementCancel = null
109 }
110
111 // Get terrain type at a position
112 getTerrainType(x, z) {
113 const dx = x - this.pondCenter.x
114 const dz = z - this.pondCenter.y
115 const distance = Math.sqrt(dx * dx + dz * dz)
116
117 if (distance > this.terrainBounds.mapBounds) {
118 return TERRAIN.OUT_OF_BOUNDS
119 }
120
121 if (distance > this.terrainBounds.shoreOuter) {
122 return TERRAIN.OUT_OF_BOUNDS
123 }
124
125 if (distance >= this.terrainBounds.shoreInner) {
126 return TERRAIN.SHORE
127 }
128
129 if (distance >= this.terrainBounds.waterEdgeInner && distance <= this.terrainBounds.waterEdgeOuter) {
130 return TERRAIN.WATER_EDGE
131 }
132
133 if (distance < this.terrainBounds.waterInner) {
134 return TERRAIN.WATER
135 }
136
137 // Transition zone - allow water edge
138 return TERRAIN.WATER_EDGE
139 }
140
141 // Check if position collides with existing buildings
142 checkBuildingCollision(x, z, buildingType) {
143 const newRadius = BUILDING_COLLISION_RADIUS[buildingType] || 0.5
144
145 for (const placed of this.placedPositions) {
146 const dx = x - placed.x
147 const dz = z - placed.z
148 const distance = Math.sqrt(dx * dx + dz * dz)
149 const minDistance = newRadius + placed.radius
150
151 if (distance < minDistance) {
152 return true // Collision!
153 }
154 }
155
156 return false
157 }
158
159 // Check if position is in a forbidden zone
160 checkForbiddenZone(x, z) {
161 for (const zone of this.forbiddenZones) {
162 const dx = x - zone.x
163 const dz = z - zone.z
164 const distance = Math.sqrt(dx * dx + dz * dz)
165
166 if (distance < zone.radius) {
167 return true // In forbidden zone
168 }
169 }
170
171 return false
172 }
173
174 // Validate placement position
175 validatePosition(x, z, buildingType) {
176 // Check map bounds
177 const terrain = this.getTerrainType(x, z)
178 if (terrain === TERRAIN.OUT_OF_BOUNDS) {
179 return { valid: false, reason: 'out_of_bounds' }
180 }
181
182 // Check terrain requirements for building type
183 const allowedTerrain = BUILDING_TERRAIN[buildingType] || []
184 if (!allowedTerrain.includes(terrain)) {
185 return { valid: false, reason: 'wrong_terrain', terrain, required: allowedTerrain }
186 }
187
188 // Check collision with existing buildings
189 if (this.checkBuildingCollision(x, z, buildingType)) {
190 return { valid: false, reason: 'collision' }
191 }
192
193 // Check forbidden zones (rowboat area, etc.)
194 if (this.checkForbiddenZone(x, z)) {
195 return { valid: false, reason: 'forbidden_zone' }
196 }
197
198 return { valid: true, terrain }
199 }
200
201 // Calculate rotation angle to face pond center
202 calculateRotation(x, z) {
203 const dx = this.pondCenter.x - x
204 const dz = this.pondCenter.y - z
205 return Math.atan2(dx, dz)
206 }
207
208 // Start placement mode for a building type
209 startPlacement(buildingType, callbacks = {}) {
210 if (this.state !== STATE.INACTIVE) {
211 this.cancelPlacement()
212 }
213
214 this.currentBuildingType = buildingType
215 this.onPlacementComplete = callbacks.onComplete
216 this.onPlacementCancel = callbacks.onCancel
217
218 // Create ghost mesh (starts as invalid/red)
219 this.ghostMesh = createGhostBuilding(buildingType, this.gradientMap, false)
220 this.ghostMesh.visible = true
221 this.ghostMesh.position.set(0, 0, 0)
222 this.scene.add(this.ghostMesh)
223
224 this.state = STATE.SELECTING
225 this.isValidPosition = false
226
227 return true
228 }
229
230 // Cancel current placement
231 cancelPlacement() {
232 if (this.ghostMesh) {
233 this.scene.remove(this.ghostMesh)
234 this.ghostMesh.traverse((child) => {
235 if (child.isMesh) {
236 child.geometry?.dispose()
237 child.material?.dispose()
238 }
239 })
240 this.ghostMesh = null
241 }
242
243 this.state = STATE.INACTIVE
244 this.currentBuildingType = null
245 this.isValidPosition = false
246
247 if (this.onPlacementCancel) {
248 this.onPlacementCancel()
249 }
250 }
251
252 // Update mouse position and ghost preview
253 onMouseMove(event, containerWidth, containerHeight) {
254 if (this.state !== STATE.SELECTING) return
255 if (!this.ghostMesh) return
256
257 // Update mouse coordinates
258 this.mouse.x = (event.clientX / containerWidth) * 2 - 1
259 this.mouse.y = -(event.clientY / containerHeight) * 2 + 1
260
261 // Raycast to invisible ground plane
262 this.raycaster.setFromCamera(this.mouse, this.camera)
263 const intersects = this.raycaster.intersectObject(this.groundPlane)
264
265 if (intersects.length > 0) {
266 const point = intersects[0].point
267
268 // Ghost always follows cursor
269 this.currentPosition.set(point.x, 0, point.z)
270 this.ghostMesh.position.copy(this.currentPosition)
271
272 // Calculate rotation to face pond center
273 this.currentRotation = this.calculateRotation(point.x, point.z)
274 this.ghostMesh.rotation.y = this.currentRotation
275
276 // Validate position
277 const validation = this.validatePosition(point.x, point.z, this.currentBuildingType)
278 this.isValidPosition = validation.valid
279
280 // Update ghost color
281 this.updateGhostColor(this.isValidPosition)
282
283 // Always visible during placement
284 this.ghostMesh.visible = true
285 }
286 }
287
288 // Update ghost mesh color based on validity
289 updateGhostColor(isValid) {
290 const color = isValid ? 0x44ff44 : 0xff4444
291 this.ghostMesh.traverse((child) => {
292 if (child.isMesh && child.material) {
293 child.material.color.setHex(color)
294 }
295 })
296 }
297
298 // Handle click during placement
299 onClick(event, containerWidth, containerHeight) {
300 if (this.state !== STATE.SELECTING) return false
301
302 // Update position one more time
303 this.onMouseMove(event, containerWidth, containerHeight)
304
305 if (this.isValidPosition) {
306 // Valid position - confirm placement
307 this.confirmPlacement()
308 return true
309 }
310
311 return false
312 }
313
314 // Confirm and place the building
315 confirmPlacement() {
316 const x = this.currentPosition.x
317 const z = this.currentPosition.z
318 const rotation = this.currentRotation
319
320 // Create the real building
321 const building = createBuilding(this.currentBuildingType, this.gradientMap)
322 building.position.set(x, 0, z)
323 building.rotation.y = rotation
324 building.userData.buildingType = this.currentBuildingType
325 building.userData.placementId = `placed_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
326
327 this.placedBuildings.add(building)
328
329 // Play placement sound
330 playPlaceBuilding()
331
332 // Track placed position for collision detection
333 const collisionRadius = BUILDING_COLLISION_RADIUS[this.currentBuildingType] || 0.5
334 this.placedPositions.push({
335 x, z,
336 radius: collisionRadius,
337 buildingType: this.currentBuildingType,
338 placementId: building.userData.placementId
339 })
340
341 // Add forbidden zone for creature emergence
342 const forbiddenRadius = this.buildingRadius[this.currentBuildingType] || 0.5
343 this.pond.addForbiddenZone(x, z, forbiddenRadius)
344
345 // Save to inventory (use coordinates as ID for freeform placement)
346 const placementData = {
347 type: this.currentBuildingType,
348 x, z, rotation,
349 placementId: building.userData.placementId
350 }
351 inventory.placeBuildingFreeform(placementData)
352
353 // Clean up ghost
354 if (this.ghostMesh) {
355 this.scene.remove(this.ghostMesh)
356 this.ghostMesh.traverse((child) => {
357 if (child.isMesh) {
358 child.geometry?.dispose()
359 child.material?.dispose()
360 }
361 })
362 this.ghostMesh = null
363 }
364
365 // Reset state
366 const buildingType = this.currentBuildingType
367 this.state = STATE.INACTIVE
368 this.currentBuildingType = null
369 this.isValidPosition = false
370
371 // Callback
372 if (this.onPlacementComplete) {
373 this.onPlacementComplete(buildingType, { x, z, rotation })
374 }
375
376 return building
377 }
378
379 // Initialize forbidden zones from pond
380 initForbiddenZones(zones) {
381 this.forbiddenZones = zones || []
382 }
383
384 // Add a forbidden zone
385 addForbiddenZone(x, z, radius) {
386 this.forbiddenZones.push({ x, z, radius })
387 }
388
389 // Load saved buildings from inventory
390 loadSavedBuildings() {
391 // Try new freeform format first
392 const savedFreeform = inventory.getPlacedBuildingsFreeform()
393
394 if (savedFreeform && savedFreeform.length > 0) {
395 for (const data of savedFreeform) {
396 this.loadBuilding(data.type, data.x, data.z, data.rotation, data.placementId)
397 }
398 } else {
399 // Fall back to old zone-based format for backwards compatibility
400 const savedBuildings = inventory.getPlacedBuildings()
401
402 for (const { type, zoneId } of savedBuildings) {
403 const zone = this.pond.getZone(zoneId)
404 if (zone && !zone.occupied) {
405 this.loadBuilding(type, zone.x, zone.z, zone.angle, zoneId)
406 this.pond.occupyZone(zoneId)
407 }
408 }
409 }
410 }
411
412 // Load a single building
413 loadBuilding(type, x, z, rotation, placementId) {
414 const building = createBuilding(type, this.gradientMap)
415 building.position.set(x, 0, z)
416 building.rotation.y = rotation
417 building.userData.buildingType = type
418 building.userData.placementId = placementId
419
420 this.placedBuildings.add(building)
421
422 // Track for collision detection
423 const collisionRadius = BUILDING_COLLISION_RADIUS[type] || 0.5
424 this.placedPositions.push({
425 x, z,
426 radius: collisionRadius,
427 buildingType: type,
428 placementId
429 })
430
431 // Add forbidden zone
432 const forbiddenRadius = this.buildingRadius[type] || 0.5
433 this.pond.addForbiddenZone(x, z, forbiddenRadius)
434 }
435
436 // Check if currently placing
437 isPlacing() {
438 return this.state === STATE.SELECTING
439 }
440
441 // Get the placed buildings group for outline pass
442 getPlacedBuildingsGroup() {
443 return this.placedBuildings
444 }
445
446 // Remove a placed building
447 removeBuilding(placementId) {
448 const building = this.placedBuildings.children.find(
449 b => b.userData.placementId === placementId
450 )
451
452 if (building) {
453 this.placedBuildings.remove(building)
454 building.traverse((child) => {
455 if (child.isMesh) {
456 child.geometry?.dispose()
457 child.material?.dispose()
458 }
459 })
460
461 // Remove from collision tracking
462 this.placedPositions = this.placedPositions.filter(
463 p => p.placementId !== placementId
464 )
465
466 // Remove from inventory
467 inventory.removeBuildingFreeform(placementId)
468
469 return true
470 }
471
472 return false
473 }
474
475 // Update building animations
476 update(delta, elapsed) {
477 for (const building of this.placedBuildings.children) {
478 // Lighthouse beam rotation
479 if (building.userData.buildingType === 'lighthouse') {
480 building.traverse((child) => {
481 if (child.userData.isLightBeam) {
482 child.rotation.y = elapsed * 0.8
483 }
484 })
485 }
486
487 // Reeds swaying
488 if (building.userData.buildingType === 'reeds') {
489 building.traverse((child) => {
490 if (child.userData.isReed) {
491 const phase = child.userData.phase
492 const baseX = child.userData.baseRotX
493 const baseZ = child.userData.baseRotZ
494 child.rotation.x = baseX + Math.sin(elapsed * 1.5 + phase) * 0.15
495 child.rotation.z = baseZ + Math.cos(elapsed * 1.2 + phase) * 0.1
496 }
497 })
498 }
499
500 // Onion house smoke puffs
501 if (building.userData.buildingType === 'onion_house') {
502 building.traverse((child) => {
503 if (child.userData.isSmokePuff) {
504 const phase = child.userData.phase
505 const cycleTime = 3.0
506 const t = ((elapsed * 0.5 + phase * cycleTime) % cycleTime) / cycleTime
507
508 child.position.y = t * 0.5
509 const scale = 1 + t * 1.2
510 child.scale.set(scale, scale, scale)
511
512 if (child.material) {
513 child.material.opacity = 0.6 * (1 - t * 0.8)
514 }
515
516 child.position.x = Math.sin(elapsed * 0.8 + phase * 5) * 0.03
517 child.position.z = Math.cos(elapsed * 0.6 + phase * 3) * 0.02
518 }
519 })
520 }
521
522 // Boot house animations
523 if (building.userData.buildingType === 'boot_house') {
524 building.traverse((child) => {
525 // Smoke puffs from chimney
526 if (child.userData.isSmokePuff) {
527 const phase = child.userData.phase
528 const cycleTime = 3.5
529 const t = ((elapsed * 0.4 + phase * cycleTime) % cycleTime) / cycleTime
530
531 child.position.y = t * 0.4
532 const scale = 1 + t * 1.0
533 child.scale.set(scale, scale, scale)
534
535 if (child.material) {
536 child.material.opacity = 0.5 * (1 - t * 0.85)
537 }
538
539 child.position.x += Math.sin(elapsed * 0.7 + phase * 4) * 0.001
540 child.position.z += Math.cos(elapsed * 0.5 + phase * 3) * 0.001
541 }
542
543 // Grass blade swaying
544 if (child.userData.isGrassBlade) {
545 const phase = child.userData.phase
546 const baseX = child.userData.baseRotX
547 const baseZ = child.userData.baseRotZ
548 child.rotation.x = baseX + Math.sin(elapsed * 1.8 + phase) * 0.12
549 child.rotation.z = baseZ + Math.cos(elapsed * 1.4 + phase) * 0.08
550 }
551
552 // Sprite running around the yard
553 if (child.userData.isBootSprite) {
554 const orbitRadius = child.userData.orbitRadius
555 const orbitSpeed = child.userData.orbitSpeed
556 const orbitPhase = child.userData.orbitPhase
557 const bobPhase = child.userData.bobPhase
558 const centerX = child.userData.orbitCenterX
559 const centerZ = child.userData.orbitCenterZ
560
561 // Orbit position
562 const angle = elapsed * orbitSpeed + orbitPhase
563 child.position.x = centerX + Math.cos(angle) * orbitRadius
564 child.position.z = centerZ + Math.sin(angle) * orbitRadius
565
566 // Bobbing up and down while running
567 child.position.y = Math.abs(Math.sin(elapsed * 8 + bobPhase)) * 0.02
568
569 // Face direction of movement
570 child.rotation.y = angle + Math.PI / 2
571
572 // Animate legs
573 child.traverse((part) => {
574 if (part.userData.isLeg) {
575 const legSwing = Math.sin(elapsed * 12 + bobPhase) * 0.4
576 if (part.userData.legSide === 'left') {
577 part.rotation.x = legSwing
578 } else {
579 part.rotation.x = -legSwing
580 }
581 }
582 })
583 }
584 })
585 }
586 }
587 }
588
589 // Dispose of all resources
590 dispose() {
591 this.cancelPlacement()
592
593 while (this.placedBuildings.children.length > 0) {
594 const building = this.placedBuildings.children[0]
595 this.placedBuildings.remove(building)
596 building.traverse((child) => {
597 if (child.isMesh) {
598 child.geometry?.dispose()
599 child.material?.dispose()
600 }
601 })
602 }
603
604 this.scene.remove(this.placedBuildings)
605
606 if (this.groundPlane) {
607 this.scene.remove(this.groundPlane)
608 this.groundPlane.geometry?.dispose()
609 this.groundPlane.material?.dispose()
610 }
611
612 this.placedPositions = []
613 this.forbiddenZones = []
614 }
615 }