zeroed-some/dougk / 95a35f3

Browse files

rethink building placement

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
95a35f358ec94109995dd5aba44526ebea34254c
Parents
adc7b26
Tree
988a0b3

4 changed files

StatusFile+-
M src/renderers/three/buildingPlacement.js 262 80
M src/renderers/three/index.js 1 0
M src/renderers/three/pond.js 7 0
M src/renderers/three/shop/inventory.js 33 2
src/renderers/three/buildingPlacement.jsmodified
@@ -1,10 +1,11 @@
1
 // Building placement system for dougk
1
 // Building placement system for dougk
2
-// Handles ghost preview, snap zones, and building instantiation
2
+// Constraint-based freeform placement with real-time validation
3
 
3
 
4
 import * as THREE from 'three'
4
 import * as THREE from 'three'
5
 import { createBuilding, createGhostBuilding } from './buildings.js'
5
 import { createBuilding, createGhostBuilding } from './buildings.js'
6
 import inventory from './shop/inventory.js'
6
 import inventory from './shop/inventory.js'
7
 import { playPlaceBuilding } from './sounds.js'
7
 import { playPlaceBuilding } from './sounds.js'
8
+import { BUILDINGS } from './shop/items.js'
8
 
9
 
9
 // Placement states
10
 // Placement states
10
 const STATE = {
11
 const STATE = {
@@ -12,6 +13,34 @@ const STATE = {
12
   SELECTING: 'selecting'
13
   SELECTING: 'selecting'
13
 }
14
 }
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
+}
33
+
34
+// Building collision radii (for overlap detection)
35
+const BUILDING_COLLISION_RADIUS = {
36
+  dock_wooden: 0.8,
37
+  fishing_hut: 0.7,
38
+  lighthouse: 0.5,
39
+  reeds: 0.3,
40
+  fence: 0.25,
41
+  onion_house: 0.5
42
+}
43
+
15
 export class PlacementManager {
44
 export class PlacementManager {
16
   constructor(scene, pond, camera, gradientMap) {
45
   constructor(scene, pond, camera, gradientMap) {
17
     this.scene = scene
46
     this.scene = scene
@@ -22,13 +51,29 @@ export class PlacementManager {
22
     this.state = STATE.INACTIVE
51
     this.state = STATE.INACTIVE
23
     this.currentBuildingType = null
52
     this.currentBuildingType = null
24
     this.ghostMesh = null
53
     this.ghostMesh = null
25
-    this.hoveredZone = null
54
+    this.isValidPosition = false
55
+    this.currentPosition = new THREE.Vector3()
56
+    this.currentRotation = 0
26
     this.placedBuildings = new THREE.Group()
57
     this.placedBuildings = new THREE.Group()
27
 
58
 
28
     this.raycaster = new THREE.Raycaster()
59
     this.raycaster = new THREE.Raycaster()
29
     this.mouse = new THREE.Vector2()
60
     this.mouse = new THREE.Vector2()
30
 
61
 
31
-    // Invisible ground plane for raycasting (so ghost is always visible)
62
+    // Pond geometry constants
63
+    this.pondCenter = new THREE.Vector2(0, 0)
64
+    this.pondRadius = 4.0  // Water radius
65
+
66
+    // Terrain zone boundaries (distance from center)
67
+    this.terrainBounds = {
68
+      waterInner: 3.2,      // Deep water
69
+      waterEdgeInner: 3.2,  // Water edge starts
70
+      waterEdgeOuter: 4.8,  // Water edge ends
71
+      shoreInner: 4.3,      // Shore starts
72
+      shoreOuter: 7.0,      // Shore ends (map bounds)
73
+      mapBounds: 8.0        // Absolute map edge
74
+    }
75
+
76
+    // Invisible ground plane for raycasting
32
     this.groundPlane = new THREE.Mesh(
77
     this.groundPlane = new THREE.Mesh(
33
       new THREE.PlaneGeometry(50, 50),
78
       new THREE.PlaneGeometry(50, 50),
34
       new THREE.MeshBasicMaterial({ visible: false })
79
       new THREE.MeshBasicMaterial({ visible: false })
@@ -47,6 +92,12 @@ export class PlacementManager {
47
       onion_house: 0.6
92
       onion_house: 0.6
48
     }
93
     }
49
 
94
 
95
+    // Placed building positions for collision detection
96
+    this.placedPositions = []
97
+
98
+    // Forbidden zones (rowboat, dock area, etc.)
99
+    this.forbiddenZones = []
100
+
50
     scene.add(this.placedBuildings)
101
     scene.add(this.placedBuildings)
51
 
102
 
52
     // Callbacks
103
     // Callbacks
@@ -54,6 +105,103 @@ export class PlacementManager {
54
     this.onPlacementCancel = null
105
     this.onPlacementCancel = null
55
   }
106
   }
56
 
107
 
108
+  // Get terrain type at a position
109
+  getTerrainType(x, z) {
110
+    const dx = x - this.pondCenter.x
111
+    const dz = z - this.pondCenter.y
112
+    const distance = Math.sqrt(dx * dx + dz * dz)
113
+
114
+    if (distance > this.terrainBounds.mapBounds) {
115
+      return TERRAIN.OUT_OF_BOUNDS
116
+    }
117
+
118
+    if (distance > this.terrainBounds.shoreOuter) {
119
+      return TERRAIN.OUT_OF_BOUNDS
120
+    }
121
+
122
+    if (distance >= this.terrainBounds.shoreInner) {
123
+      return TERRAIN.SHORE
124
+    }
125
+
126
+    if (distance >= this.terrainBounds.waterEdgeInner && distance <= this.terrainBounds.waterEdgeOuter) {
127
+      return TERRAIN.WATER_EDGE
128
+    }
129
+
130
+    if (distance < this.terrainBounds.waterInner) {
131
+      return TERRAIN.WATER
132
+    }
133
+
134
+    // Transition zone - allow water edge
135
+    return TERRAIN.WATER_EDGE
136
+  }
137
+
138
+  // Check if position collides with existing buildings
139
+  checkBuildingCollision(x, z, buildingType) {
140
+    const newRadius = BUILDING_COLLISION_RADIUS[buildingType] || 0.5
141
+
142
+    for (const placed of this.placedPositions) {
143
+      const dx = x - placed.x
144
+      const dz = z - placed.z
145
+      const distance = Math.sqrt(dx * dx + dz * dz)
146
+      const minDistance = newRadius + placed.radius
147
+
148
+      if (distance < minDistance) {
149
+        return true // Collision!
150
+      }
151
+    }
152
+
153
+    return false
154
+  }
155
+
156
+  // Check if position is in a forbidden zone
157
+  checkForbiddenZone(x, z) {
158
+    for (const zone of this.forbiddenZones) {
159
+      const dx = x - zone.x
160
+      const dz = z - zone.z
161
+      const distance = Math.sqrt(dx * dx + dz * dz)
162
+
163
+      if (distance < zone.radius) {
164
+        return true // In forbidden zone
165
+      }
166
+    }
167
+
168
+    return false
169
+  }
170
+
171
+  // Validate placement position
172
+  validatePosition(x, z, buildingType) {
173
+    // Check map bounds
174
+    const terrain = this.getTerrainType(x, z)
175
+    if (terrain === TERRAIN.OUT_OF_BOUNDS) {
176
+      return { valid: false, reason: 'out_of_bounds' }
177
+    }
178
+
179
+    // Check terrain requirements for building type
180
+    const allowedTerrain = BUILDING_TERRAIN[buildingType] || []
181
+    if (!allowedTerrain.includes(terrain)) {
182
+      return { valid: false, reason: 'wrong_terrain', terrain, required: allowedTerrain }
183
+    }
184
+
185
+    // Check collision with existing buildings
186
+    if (this.checkBuildingCollision(x, z, buildingType)) {
187
+      return { valid: false, reason: 'collision' }
188
+    }
189
+
190
+    // Check forbidden zones (rowboat area, etc.)
191
+    if (this.checkForbiddenZone(x, z)) {
192
+      return { valid: false, reason: 'forbidden_zone' }
193
+    }
194
+
195
+    return { valid: true, terrain }
196
+  }
197
+
198
+  // Calculate rotation angle to face pond center
199
+  calculateRotation(x, z) {
200
+    const dx = this.pondCenter.x - x
201
+    const dz = this.pondCenter.y - z
202
+    return Math.atan2(dx, dz)
203
+  }
204
+
57
   // Start placement mode for a building type
205
   // Start placement mode for a building type
58
   startPlacement(buildingType, callbacks = {}) {
206
   startPlacement(buildingType, callbacks = {}) {
59
     if (this.state !== STATE.INACTIVE) {
207
     if (this.state !== STATE.INACTIVE) {
@@ -64,14 +212,14 @@ export class PlacementManager {
64
     this.onPlacementComplete = callbacks.onComplete
212
     this.onPlacementComplete = callbacks.onComplete
65
     this.onPlacementCancel = callbacks.onCancel
213
     this.onPlacementCancel = callbacks.onCancel
66
 
214
 
67
-    // Create ghost mesh (starts as invalid/red, visible immediately)
215
+    // Create ghost mesh (starts as invalid/red)
68
     this.ghostMesh = createGhostBuilding(buildingType, this.gradientMap, false)
216
     this.ghostMesh = createGhostBuilding(buildingType, this.gradientMap, false)
69
     this.ghostMesh.visible = true
217
     this.ghostMesh.visible = true
70
-    this.ghostMesh.position.set(0, 0, 0) // Will be updated on mouse move
218
+    this.ghostMesh.position.set(0, 0, 0)
71
     this.scene.add(this.ghostMesh)
219
     this.scene.add(this.ghostMesh)
72
 
220
 
73
     this.state = STATE.SELECTING
221
     this.state = STATE.SELECTING
74
-    this.hoveredZone = null
222
+    this.isValidPosition = false
75
 
223
 
76
     return true
224
     return true
77
   }
225
   }
@@ -91,7 +239,7 @@ export class PlacementManager {
91
 
239
 
92
     this.state = STATE.INACTIVE
240
     this.state = STATE.INACTIVE
93
     this.currentBuildingType = null
241
     this.currentBuildingType = null
94
-    this.hoveredZone = null
242
+    this.isValidPosition = false
95
 
243
 
96
     if (this.onPlacementCancel) {
244
     if (this.onPlacementCancel) {
97
       this.onPlacementCancel()
245
       this.onPlacementCancel()
@@ -107,33 +255,27 @@ export class PlacementManager {
107
     this.mouse.x = (event.clientX / containerWidth) * 2 - 1
255
     this.mouse.x = (event.clientX / containerWidth) * 2 - 1
108
     this.mouse.y = -(event.clientY / containerHeight) * 2 + 1
256
     this.mouse.y = -(event.clientY / containerHeight) * 2 + 1
109
 
257
 
110
-    // Raycast to invisible ground plane (always hits)
258
+    // Raycast to invisible ground plane
111
     this.raycaster.setFromCamera(this.mouse, this.camera)
259
     this.raycaster.setFromCamera(this.mouse, this.camera)
112
     const intersects = this.raycaster.intersectObject(this.groundPlane)
260
     const intersects = this.raycaster.intersectObject(this.groundPlane)
113
 
261
 
114
     if (intersects.length > 0) {
262
     if (intersects.length > 0) {
115
       const point = intersects[0].point
263
       const point = intersects[0].point
116
 
264
 
117
-      // Find nearest valid zone
265
+      // Ghost always follows cursor
118
-      const nearestZone = this.pond.findNearestZone(
266
+      this.currentPosition.set(point.x, 0, point.z)
119
-        point.x,
267
+      this.ghostMesh.position.copy(this.currentPosition)
120
-        point.z,
121
-        this.currentBuildingType
122
-      )
123
 
268
 
124
-      if (nearestZone) {
269
+      // Calculate rotation to face pond center
125
-        // Snap to zone - show green
270
+      this.currentRotation = this.calculateRotation(point.x, point.z)
126
-        this.hoveredZone = nearestZone
271
+      this.ghostMesh.rotation.y = this.currentRotation
127
-        this.ghostMesh.position.set(nearestZone.x, 0, nearestZone.z)
272
+
128
-        this.ghostMesh.rotation.y = nearestZone.angle
273
+      // Validate position
129
-        this.updateGhostColor(true)
274
+      const validation = this.validatePosition(point.x, point.z, this.currentBuildingType)
130
-      } else {
275
+      this.isValidPosition = validation.valid
131
-        // No valid zone - follow cursor, show red
276
+
132
-        this.hoveredZone = null
277
+      // Update ghost color
133
-        this.ghostMesh.position.set(point.x, 0, point.z)
278
+      this.updateGhostColor(this.isValidPosition)
134
-        this.ghostMesh.rotation.y = 0
135
-        this.updateGhostColor(false)
136
-      }
137
 
279
 
138
       // Always visible during placement
280
       // Always visible during placement
139
       this.ghostMesh.visible = true
281
       this.ghostMesh.visible = true
@@ -157,9 +299,9 @@ export class PlacementManager {
157
     // Update position one more time
299
     // Update position one more time
158
     this.onMouseMove(event, containerWidth, containerHeight)
300
     this.onMouseMove(event, containerWidth, containerHeight)
159
 
301
 
160
-    if (this.hoveredZone) {
302
+    if (this.isValidPosition) {
161
-      // Valid zone - confirm placement
303
+      // Valid position - confirm placement
162
-      this.confirmPlacement(this.hoveredZone)
304
+      this.confirmPlacement()
163
       return true
305
       return true
164
     }
306
     }
165
 
307
 
@@ -167,28 +309,43 @@ export class PlacementManager {
167
   }
309
   }
168
 
310
 
169
   // Confirm and place the building
311
   // Confirm and place the building
170
-  confirmPlacement(zone) {
312
+  confirmPlacement() {
313
+    const x = this.currentPosition.x
314
+    const z = this.currentPosition.z
315
+    const rotation = this.currentRotation
316
+
171
     // Create the real building
317
     // Create the real building
172
     const building = createBuilding(this.currentBuildingType, this.gradientMap)
318
     const building = createBuilding(this.currentBuildingType, this.gradientMap)
173
-    building.position.set(zone.x, 0, zone.z)
319
+    building.position.set(x, 0, z)
174
-    building.rotation.y = zone.angle
320
+    building.rotation.y = rotation
175
     building.userData.buildingType = this.currentBuildingType
321
     building.userData.buildingType = this.currentBuildingType
176
-    building.userData.zoneId = zone.id
322
+    building.userData.placementId = `placed_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
177
 
323
 
178
     this.placedBuildings.add(building)
324
     this.placedBuildings.add(building)
179
 
325
 
180
     // Play placement sound
326
     // Play placement sound
181
     playPlaceBuilding()
327
     playPlaceBuilding()
182
 
328
 
183
-    // Mark zone as occupied
329
+    // Track placed position for collision detection
184
-    this.pond.occupyZone(zone.id)
330
+    const collisionRadius = BUILDING_COLLISION_RADIUS[this.currentBuildingType] || 0.5
331
+    this.placedPositions.push({
332
+      x, z,
333
+      radius: collisionRadius,
334
+      buildingType: this.currentBuildingType,
335
+      placementId: building.userData.placementId
336
+    })
185
 
337
 
186
     // Add forbidden zone for creature emergence
338
     // Add forbidden zone for creature emergence
187
     const forbiddenRadius = this.buildingRadius[this.currentBuildingType] || 0.5
339
     const forbiddenRadius = this.buildingRadius[this.currentBuildingType] || 0.5
188
-    this.pond.addForbiddenZone(zone.x, zone.z, forbiddenRadius)
340
+    this.pond.addForbiddenZone(x, z, forbiddenRadius)
189
 
341
 
190
-    // Save to inventory
342
+    // Save to inventory (use coordinates as ID for freeform placement)
191
-    inventory.placeBuilding(this.currentBuildingType, zone.id)
343
+    const placementData = {
344
+      type: this.currentBuildingType,
345
+      x, z, rotation,
346
+      placementId: building.userData.placementId
347
+    }
348
+    inventory.placeBuildingFreeform(placementData)
192
 
349
 
193
     // Clean up ghost
350
     // Clean up ghost
194
     if (this.ghostMesh) {
351
     if (this.ghostMesh) {
@@ -206,40 +363,71 @@ export class PlacementManager {
206
     const buildingType = this.currentBuildingType
363
     const buildingType = this.currentBuildingType
207
     this.state = STATE.INACTIVE
364
     this.state = STATE.INACTIVE
208
     this.currentBuildingType = null
365
     this.currentBuildingType = null
209
-    this.hoveredZone = null
366
+    this.isValidPosition = false
210
 
367
 
211
     // Callback
368
     // Callback
212
     if (this.onPlacementComplete) {
369
     if (this.onPlacementComplete) {
213
-      this.onPlacementComplete(buildingType, zone)
370
+      this.onPlacementComplete(buildingType, { x, z, rotation })
214
     }
371
     }
215
 
372
 
216
     return building
373
     return building
217
   }
374
   }
218
 
375
 
376
+  // Initialize forbidden zones from pond
377
+  initForbiddenZones(zones) {
378
+    this.forbiddenZones = zones || []
379
+  }
380
+
381
+  // Add a forbidden zone
382
+  addForbiddenZone(x, z, radius) {
383
+    this.forbiddenZones.push({ x, z, radius })
384
+  }
385
+
219
   // Load saved buildings from inventory
386
   // Load saved buildings from inventory
220
   loadSavedBuildings() {
387
   loadSavedBuildings() {
388
+    // Try new freeform format first
389
+    const savedFreeform = inventory.getPlacedBuildingsFreeform()
390
+
391
+    if (savedFreeform && savedFreeform.length > 0) {
392
+      for (const data of savedFreeform) {
393
+        this.loadBuilding(data.type, data.x, data.z, data.rotation, data.placementId)
394
+      }
395
+    } else {
396
+      // Fall back to old zone-based format for backwards compatibility
221
       const savedBuildings = inventory.getPlacedBuildings()
397
       const savedBuildings = inventory.getPlacedBuildings()
222
 
398
 
223
       for (const { type, zoneId } of savedBuildings) {
399
       for (const { type, zoneId } of savedBuildings) {
224
         const zone = this.pond.getZone(zoneId)
400
         const zone = this.pond.getZone(zoneId)
225
         if (zone && !zone.occupied) {
401
         if (zone && !zone.occupied) {
226
-        // Create and place building
402
+          this.loadBuilding(type, zone.x, zone.z, zone.angle, zoneId)
403
+          this.pond.occupyZone(zoneId)
404
+        }
405
+      }
406
+    }
407
+  }
408
+
409
+  // Load a single building
410
+  loadBuilding(type, x, z, rotation, placementId) {
227
     const building = createBuilding(type, this.gradientMap)
411
     const building = createBuilding(type, this.gradientMap)
228
-        building.position.set(zone.x, 0, zone.z)
412
+    building.position.set(x, 0, z)
229
-        building.rotation.y = zone.angle
413
+    building.rotation.y = rotation
230
     building.userData.buildingType = type
414
     building.userData.buildingType = type
231
-        building.userData.zoneId = zone.id
415
+    building.userData.placementId = placementId
232
 
416
 
233
     this.placedBuildings.add(building)
417
     this.placedBuildings.add(building)
234
 
418
 
235
-        // Mark zone as occupied
419
+    // Track for collision detection
236
-        this.pond.occupyZone(zone.id)
420
+    const collisionRadius = BUILDING_COLLISION_RADIUS[type] || 0.5
421
+    this.placedPositions.push({
422
+      x, z,
423
+      radius: collisionRadius,
424
+      buildingType: type,
425
+      placementId
426
+    })
237
 
427
 
238
     // Add forbidden zone
428
     // Add forbidden zone
239
     const forbiddenRadius = this.buildingRadius[type] || 0.5
429
     const forbiddenRadius = this.buildingRadius[type] || 0.5
240
-        this.pond.addForbiddenZone(zone.x, zone.z, forbiddenRadius)
430
+    this.pond.addForbiddenZone(x, z, forbiddenRadius)
241
-      }
242
-    }
243
   }
431
   }
244
 
432
 
245
   // Check if currently placing
433
   // Check if currently placing
@@ -252,10 +440,10 @@ export class PlacementManager {
252
     return this.placedBuildings
440
     return this.placedBuildings
253
   }
441
   }
254
 
442
 
255
-  // Remove a placed building (for future use)
443
+  // Remove a placed building
256
-  removeBuilding(zoneId) {
444
+  removeBuilding(placementId) {
257
     const building = this.placedBuildings.children.find(
445
     const building = this.placedBuildings.children.find(
258
-      b => b.userData.zoneId === zoneId
446
+      b => b.userData.placementId === placementId
259
     )
447
     )
260
 
448
 
261
     if (building) {
449
     if (building) {
@@ -267,14 +455,13 @@ export class PlacementManager {
267
         }
455
         }
268
       })
456
       })
269
 
457
 
270
-      // Free up the zone
458
+      // Remove from collision tracking
271
-      const zone = this.pond.getZone(zoneId)
459
+      this.placedPositions = this.placedPositions.filter(
272
-      if (zone) {
460
+        p => p.placementId !== placementId
273
-        zone.occupied = false
461
+      )
274
-      }
275
 
462
 
276
       // Remove from inventory
463
       // Remove from inventory
277
-      inventory.removeBuilding(zoneId)
464
+      inventory.removeBuildingFreeform(placementId)
278
 
465
 
279
       return true
466
       return true
280
     }
467
     }
@@ -289,7 +476,7 @@ export class PlacementManager {
289
       if (building.userData.buildingType === 'lighthouse') {
476
       if (building.userData.buildingType === 'lighthouse') {
290
         building.traverse((child) => {
477
         building.traverse((child) => {
291
           if (child.userData.isLightBeam) {
478
           if (child.userData.isLightBeam) {
292
-            child.rotation.y = elapsed * 0.8 // Slow rotation
479
+            child.rotation.y = elapsed * 0.8
293
           }
480
           }
294
         })
481
         })
295
       }
482
       }
@@ -301,7 +488,6 @@ export class PlacementManager {
301
             const phase = child.userData.phase
488
             const phase = child.userData.phase
302
             const baseX = child.userData.baseRotX
489
             const baseX = child.userData.baseRotX
303
             const baseZ = child.userData.baseRotZ
490
             const baseZ = child.userData.baseRotZ
304
-            // Gentle swaying motion
305
             child.rotation.x = baseX + Math.sin(elapsed * 1.5 + phase) * 0.15
491
             child.rotation.x = baseX + Math.sin(elapsed * 1.5 + phase) * 0.15
306
             child.rotation.z = baseZ + Math.cos(elapsed * 1.2 + phase) * 0.1
492
             child.rotation.z = baseZ + Math.cos(elapsed * 1.2 + phase) * 0.1
307
           }
493
           }
@@ -313,22 +499,17 @@ export class PlacementManager {
313
         building.traverse((child) => {
499
         building.traverse((child) => {
314
           if (child.userData.isSmokePuff) {
500
           if (child.userData.isSmokePuff) {
315
             const phase = child.userData.phase
501
             const phase = child.userData.phase
316
-            const cycleTime = 3.0  // Seconds for full cycle
502
+            const cycleTime = 3.0
317
             const t = ((elapsed * 0.5 + phase * cycleTime) % cycleTime) / cycleTime
503
             const t = ((elapsed * 0.5 + phase * cycleTime) % cycleTime) / cycleTime
318
 
504
 
319
-            // Rise from 0 to 0.5 units
320
             child.position.y = t * 0.5
505
             child.position.y = t * 0.5
321
-
322
-            // Expand as it rises (scale from 1 to 2)
323
             const scale = 1 + t * 1.2
506
             const scale = 1 + t * 1.2
324
             child.scale.set(scale, scale, scale)
507
             child.scale.set(scale, scale, scale)
325
 
508
 
326
-            // Fade out as it rises
327
             if (child.material) {
509
             if (child.material) {
328
               child.material.opacity = 0.6 * (1 - t * 0.8)
510
               child.material.opacity = 0.6 * (1 - t * 0.8)
329
             }
511
             }
330
 
512
 
331
-            // Gentle horizontal drift
332
             child.position.x = Math.sin(elapsed * 0.8 + phase * 5) * 0.03
513
             child.position.x = Math.sin(elapsed * 0.8 + phase * 5) * 0.03
333
             child.position.z = Math.cos(elapsed * 0.6 + phase * 3) * 0.02
514
             child.position.z = Math.cos(elapsed * 0.6 + phase * 3) * 0.02
334
           }
515
           }
@@ -341,7 +522,6 @@ export class PlacementManager {
341
   dispose() {
522
   dispose() {
342
     this.cancelPlacement()
523
     this.cancelPlacement()
343
 
524
 
344
-    // Clean up placed buildings
345
     while (this.placedBuildings.children.length > 0) {
525
     while (this.placedBuildings.children.length > 0) {
346
       const building = this.placedBuildings.children[0]
526
       const building = this.placedBuildings.children[0]
347
       this.placedBuildings.remove(building)
527
       this.placedBuildings.remove(building)
@@ -355,11 +535,13 @@ export class PlacementManager {
355
 
535
 
356
     this.scene.remove(this.placedBuildings)
536
     this.scene.remove(this.placedBuildings)
357
 
537
 
358
-    // Clean up ground plane
359
     if (this.groundPlane) {
538
     if (this.groundPlane) {
360
       this.scene.remove(this.groundPlane)
539
       this.scene.remove(this.groundPlane)
361
       this.groundPlane.geometry?.dispose()
540
       this.groundPlane.geometry?.dispose()
362
       this.groundPlane.material?.dispose()
541
       this.groundPlane.material?.dispose()
363
     }
542
     }
543
+
544
+    this.placedPositions = []
545
+    this.forbiddenZones = []
364
   }
546
   }
365
 }
547
 }
src/renderers/three/index.jsmodified
@@ -158,6 +158,7 @@ export function start(container) {
158
   koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
158
   koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
159
   ollie = createOllie(scene, toonGradient)
159
   ollie = createOllie(scene, toonGradient)
160
   placementManager = new PlacementManager(scene, pond, camera, toonGradient)
160
   placementManager = new PlacementManager(scene, pond, camera, toonGradient)
161
+  placementManager.initForbiddenZones(pond.getInitialForbiddenZones())
161
 
162
 
162
   // Post-processing
163
   // Post-processing
163
   composer = new EffectComposer(renderer)
164
   composer = new EffectComposer(renderer)
src/renderers/three/pond.jsmodified
@@ -743,6 +743,12 @@ export function createPond(scene, gradientMap) {
743
     forbiddenZones.push({ x, z, radius: zoneRadius })
743
     forbiddenZones.push({ x, z, radius: zoneRadius })
744
   }
744
   }
745
 
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
+
746
   // Snap zones for building placement
752
   // Snap zones for building placement
747
   // Each zone has: id, position, angle (rotation), type, allowed buildings, occupied status
753
   // Each zone has: id, position, angle (rotation), type, allowed buildings, occupied status
748
   const snapZones = [
754
   const snapZones = [
@@ -817,6 +823,7 @@ export function createPond(scene, gradientMap) {
817
     update,
823
     update,
818
     isValidEmergenceSpot,
824
     isValidEmergenceSpot,
819
     addForbiddenZone,
825
     addForbiddenZone,
826
+    getInitialForbiddenZones,
820
     snapZones,
827
     snapZones,
821
     getAvailableZones,
828
     getAvailableZones,
822
     getZone,
829
     getZone,
src/renderers/three/shop/inventory.jsmodified
@@ -6,7 +6,8 @@ import { getItem } from './items.js'
6
 const STORAGE_KEYS = {
6
 const STORAGE_KEYS = {
7
   OWNED: 'dougk-owned-items',
7
   OWNED: 'dougk-owned-items',
8
   EQUIPPED: 'dougk-equipped',
8
   EQUIPPED: 'dougk-equipped',
9
-  BUILDINGS: 'dougk-buildings'
9
+  BUILDINGS: 'dougk-buildings',
10
+  BUILDINGS_FREEFORM: 'dougk-buildings-freeform'
10
 }
11
 }
11
 
12
 
12
 function loadJSON(key, defaultValue) {
13
 function loadJSON(key, defaultValue) {
@@ -33,9 +34,12 @@ const inventory = {
33
     ollie: []
34
     ollie: []
34
   }),
35
   }),
35
 
36
 
36
-  // Placed buildings
37
+  // Placed buildings (legacy zone-based format)
37
   buildings: loadJSON(STORAGE_KEYS.BUILDINGS, []),
38
   buildings: loadJSON(STORAGE_KEYS.BUILDINGS, []),
38
 
39
 
40
+  // Placed buildings (new freeform format with coordinates)
41
+  buildingsFreeform: loadJSON(STORAGE_KEYS.BUILDINGS_FREEFORM, []),
42
+
39
   // Check if an item is owned
43
   // Check if an item is owned
40
   owns(itemId) {
44
   owns(itemId) {
41
     return this.ownedItems.includes(itemId)
45
     return this.ownedItems.includes(itemId)
@@ -133,11 +137,37 @@ const inventory = {
133
     return false
137
     return false
134
   },
138
   },
135
 
139
 
140
+  // === Freeform placement methods (new system) ===
141
+
142
+  // Place a building with freeform coordinates
143
+  placeBuildingFreeform(data) {
144
+    // data: { type, x, z, rotation, placementId }
145
+    this.buildingsFreeform.push(data)
146
+    this.save()
147
+  },
148
+
149
+  // Get all freeform placed buildings
150
+  getPlacedBuildingsFreeform() {
151
+    return [...this.buildingsFreeform]
152
+  },
153
+
154
+  // Remove a freeform building by placement ID
155
+  removeBuildingFreeform(placementId) {
156
+    const index = this.buildingsFreeform.findIndex(b => b.placementId === placementId)
157
+    if (index !== -1) {
158
+      this.buildingsFreeform.splice(index, 1)
159
+      this.save()
160
+      return true
161
+    }
162
+    return false
163
+  },
164
+
136
   // Save all state
165
   // Save all state
137
   save() {
166
   save() {
138
     saveJSON(STORAGE_KEYS.OWNED, this.ownedItems)
167
     saveJSON(STORAGE_KEYS.OWNED, this.ownedItems)
139
     saveJSON(STORAGE_KEYS.EQUIPPED, this.equipped)
168
     saveJSON(STORAGE_KEYS.EQUIPPED, this.equipped)
140
     saveJSON(STORAGE_KEYS.BUILDINGS, this.buildings)
169
     saveJSON(STORAGE_KEYS.BUILDINGS, this.buildings)
170
+    saveJSON(STORAGE_KEYS.BUILDINGS_FREEFORM, this.buildingsFreeform)
141
   },
171
   },
142
 
172
 
143
   // Clear all data (for testing)
173
   // Clear all data (for testing)
@@ -145,6 +175,7 @@ const inventory = {
145
     this.ownedItems = []
175
     this.ownedItems = []
146
     this.equipped = { doug: [], donny: [], ollie: [] }
176
     this.equipped = { doug: [], donny: [], ollie: [] }
147
     this.buildings = []
177
     this.buildings = []
178
+    this.buildingsFreeform = []
148
     this.save()
179
     this.save()
149
   }
180
   }
150
 }
181
 }