JavaScript · 9381 bytes Raw Blame History
1 // Building placement system for dougk
2 // Handles ghost preview, snap zones, and building instantiation
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
9 // Placement states
10 const STATE = {
11 INACTIVE: 'inactive',
12 SELECTING: 'selecting'
13 }
14
15 export class PlacementManager {
16 constructor(scene, pond, camera, gradientMap) {
17 this.scene = scene
18 this.pond = pond
19 this.camera = camera
20 this.gradientMap = gradientMap
21
22 this.state = STATE.INACTIVE
23 this.currentBuildingType = null
24 this.ghostMesh = null
25 this.hoveredZone = null
26 this.placedBuildings = new THREE.Group()
27
28 this.raycaster = new THREE.Raycaster()
29 this.mouse = new THREE.Vector2()
30
31 // Invisible ground plane for raycasting (so ghost is always visible)
32 this.groundPlane = new THREE.Mesh(
33 new THREE.PlaneGeometry(50, 50),
34 new THREE.MeshBasicMaterial({ visible: false })
35 )
36 this.groundPlane.rotation.x = -Math.PI / 2
37 this.groundPlane.position.y = 0
38 scene.add(this.groundPlane)
39
40 // Building forbidden radius (for creature emergence)
41 this.buildingRadius = {
42 dock_wooden: 0.8,
43 fishing_hut: 0.9,
44 lighthouse: 0.5,
45 reeds: 0.4,
46 fence: 0.3,
47 onion_house: 0.6
48 }
49
50 scene.add(this.placedBuildings)
51
52 // Callbacks
53 this.onPlacementComplete = null
54 this.onPlacementCancel = null
55 }
56
57 // Start placement mode for a building type
58 startPlacement(buildingType, callbacks = {}) {
59 if (this.state !== STATE.INACTIVE) {
60 this.cancelPlacement()
61 }
62
63 this.currentBuildingType = buildingType
64 this.onPlacementComplete = callbacks.onComplete
65 this.onPlacementCancel = callbacks.onCancel
66
67 // Create ghost mesh (starts as invalid/red, visible immediately)
68 this.ghostMesh = createGhostBuilding(buildingType, this.gradientMap, false)
69 this.ghostMesh.visible = true
70 this.ghostMesh.position.set(0, 0, 0) // Will be updated on mouse move
71 this.scene.add(this.ghostMesh)
72
73 this.state = STATE.SELECTING
74 this.hoveredZone = null
75
76 return true
77 }
78
79 // Cancel current placement
80 cancelPlacement() {
81 if (this.ghostMesh) {
82 this.scene.remove(this.ghostMesh)
83 this.ghostMesh.traverse((child) => {
84 if (child.isMesh) {
85 child.geometry?.dispose()
86 child.material?.dispose()
87 }
88 })
89 this.ghostMesh = null
90 }
91
92 this.state = STATE.INACTIVE
93 this.currentBuildingType = null
94 this.hoveredZone = null
95
96 if (this.onPlacementCancel) {
97 this.onPlacementCancel()
98 }
99 }
100
101 // Update mouse position and ghost preview
102 onMouseMove(event, containerWidth, containerHeight) {
103 if (this.state !== STATE.SELECTING) return
104 if (!this.ghostMesh) return
105
106 // Update mouse coordinates
107 this.mouse.x = (event.clientX / containerWidth) * 2 - 1
108 this.mouse.y = -(event.clientY / containerHeight) * 2 + 1
109
110 // Raycast to invisible ground plane (always hits)
111 this.raycaster.setFromCamera(this.mouse, this.camera)
112 const intersects = this.raycaster.intersectObject(this.groundPlane)
113
114 if (intersects.length > 0) {
115 const point = intersects[0].point
116
117 // Find nearest valid zone
118 const nearestZone = this.pond.findNearestZone(
119 point.x,
120 point.z,
121 this.currentBuildingType
122 )
123
124 if (nearestZone) {
125 // Snap to zone - show green
126 this.hoveredZone = nearestZone
127 this.ghostMesh.position.set(nearestZone.x, 0, nearestZone.z)
128 this.ghostMesh.rotation.y = nearestZone.angle
129 this.updateGhostColor(true)
130 } else {
131 // No valid zone - follow cursor, show red
132 this.hoveredZone = null
133 this.ghostMesh.position.set(point.x, 0, point.z)
134 this.ghostMesh.rotation.y = 0
135 this.updateGhostColor(false)
136 }
137
138 // Always visible during placement
139 this.ghostMesh.visible = true
140 }
141 }
142
143 // Update ghost mesh color based on validity
144 updateGhostColor(isValid) {
145 const color = isValid ? 0x44ff44 : 0xff4444
146 this.ghostMesh.traverse((child) => {
147 if (child.isMesh && child.material) {
148 child.material.color.setHex(color)
149 }
150 })
151 }
152
153 // Handle click during placement
154 onClick(event, containerWidth, containerHeight) {
155 if (this.state !== STATE.SELECTING) return false
156
157 // Update position one more time
158 this.onMouseMove(event, containerWidth, containerHeight)
159
160 if (this.hoveredZone) {
161 // Valid zone - confirm placement
162 this.confirmPlacement(this.hoveredZone)
163 return true
164 }
165
166 return false
167 }
168
169 // Confirm and place the building
170 confirmPlacement(zone) {
171 // Create the real building
172 const building = createBuilding(this.currentBuildingType, this.gradientMap)
173 building.position.set(zone.x, 0, zone.z)
174 building.rotation.y = zone.angle
175 building.userData.buildingType = this.currentBuildingType
176 building.userData.zoneId = zone.id
177
178 this.placedBuildings.add(building)
179
180 // Play placement sound
181 playPlaceBuilding()
182
183 // Mark zone as occupied
184 this.pond.occupyZone(zone.id)
185
186 // Add forbidden zone for creature emergence
187 const forbiddenRadius = this.buildingRadius[this.currentBuildingType] || 0.5
188 this.pond.addForbiddenZone(zone.x, zone.z, forbiddenRadius)
189
190 // Save to inventory
191 inventory.placeBuilding(this.currentBuildingType, zone.id)
192
193 // Clean up ghost
194 if (this.ghostMesh) {
195 this.scene.remove(this.ghostMesh)
196 this.ghostMesh.traverse((child) => {
197 if (child.isMesh) {
198 child.geometry?.dispose()
199 child.material?.dispose()
200 }
201 })
202 this.ghostMesh = null
203 }
204
205 // Reset state
206 const buildingType = this.currentBuildingType
207 this.state = STATE.INACTIVE
208 this.currentBuildingType = null
209 this.hoveredZone = null
210
211 // Callback
212 if (this.onPlacementComplete) {
213 this.onPlacementComplete(buildingType, zone)
214 }
215
216 return building
217 }
218
219 // Load saved buildings from inventory
220 loadSavedBuildings() {
221 const savedBuildings = inventory.getPlacedBuildings()
222
223 for (const { type, zoneId } of savedBuildings) {
224 const zone = this.pond.getZone(zoneId)
225 if (zone && !zone.occupied) {
226 // Create and place building
227 const building = createBuilding(type, this.gradientMap)
228 building.position.set(zone.x, 0, zone.z)
229 building.rotation.y = zone.angle
230 building.userData.buildingType = type
231 building.userData.zoneId = zone.id
232
233 this.placedBuildings.add(building)
234
235 // Mark zone as occupied
236 this.pond.occupyZone(zone.id)
237
238 // Add forbidden zone
239 const forbiddenRadius = this.buildingRadius[type] || 0.5
240 this.pond.addForbiddenZone(zone.x, zone.z, forbiddenRadius)
241 }
242 }
243 }
244
245 // Check if currently placing
246 isPlacing() {
247 return this.state === STATE.SELECTING
248 }
249
250 // Get the placed buildings group for outline pass
251 getPlacedBuildingsGroup() {
252 return this.placedBuildings
253 }
254
255 // Remove a placed building (for future use)
256 removeBuilding(zoneId) {
257 const building = this.placedBuildings.children.find(
258 b => b.userData.zoneId === zoneId
259 )
260
261 if (building) {
262 this.placedBuildings.remove(building)
263 building.traverse((child) => {
264 if (child.isMesh) {
265 child.geometry?.dispose()
266 child.material?.dispose()
267 }
268 })
269
270 // Free up the zone
271 const zone = this.pond.getZone(zoneId)
272 if (zone) {
273 zone.occupied = false
274 }
275
276 // Remove from inventory
277 inventory.removeBuilding(zoneId)
278
279 return true
280 }
281
282 return false
283 }
284
285 // Update building animations
286 update(delta, elapsed) {
287 for (const building of this.placedBuildings.children) {
288 // Lighthouse beam rotation
289 if (building.userData.buildingType === 'lighthouse') {
290 building.traverse((child) => {
291 if (child.userData.isLightBeam) {
292 child.rotation.y = elapsed * 0.8 // Slow rotation
293 }
294 })
295 }
296
297 // Reeds swaying
298 if (building.userData.buildingType === 'reeds') {
299 building.traverse((child) => {
300 if (child.userData.isReed) {
301 const phase = child.userData.phase
302 const baseX = child.userData.baseRotX
303 const baseZ = child.userData.baseRotZ
304 // Gentle swaying motion
305 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
307 }
308 })
309 }
310 }
311 }
312
313 // Dispose of all resources
314 dispose() {
315 this.cancelPlacement()
316
317 // Clean up placed buildings
318 while (this.placedBuildings.children.length > 0) {
319 const building = this.placedBuildings.children[0]
320 this.placedBuildings.remove(building)
321 building.traverse((child) => {
322 if (child.isMesh) {
323 child.geometry?.dispose()
324 child.material?.dispose()
325 }
326 })
327 }
328
329 this.scene.remove(this.placedBuildings)
330
331 // Clean up ground plane
332 if (this.groundPlane) {
333 this.scene.remove(this.groundPlane)
334 this.groundPlane.geometry?.dispose()
335 this.groundPlane.material?.dispose()
336 }
337 }
338 }