zeroed-some/dougk / befc931

Browse files

lost track, updates

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
befc9316cdf80fec49409e4a1c729f0a355b5e6c
Parents
3fef8d6
Tree
8b133bc

15 changed files

StatusFile+-
A src/renderers/three/buildingPlacement.js 338 0
A src/renderers/three/buildings.js 484 0
M src/renderers/three/duck.js 151 12
A src/renderers/three/gameState.js 51 0
M src/renderers/three/index.js 362 8
M src/renderers/three/koi.js 261 32
M src/renderers/three/narwhal.js 302 20
M src/renderers/three/octopus.js 323 18
M src/renderers/three/pond.js 209 3
A src/renderers/three/shop/dialogScripts.js 270 0
A src/renderers/three/shop/dialogUI.js 298 0
A src/renderers/three/shop/inventory.js 152 0
A src/renderers/three/shop/items.js 307 0
A src/renderers/three/shop/shopUI.js 524 0
M src/renderers/three/sounds.js 239 0
src/renderers/three/buildingPlacement.jsadded
@@ -0,0 +1,338 @@
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
+}
src/renderers/three/buildings.jsadded
@@ -0,0 +1,484 @@
1
+// Building mesh factories for dougk
2
+// Creates 3D meshes for placeable buildings
3
+
4
+import * as THREE from 'three'
5
+
6
+// Create a wooden dock
7
+export function createDock(gradientMap) {
8
+  const group = new THREE.Group()
9
+
10
+  const woodMaterial = new THREE.MeshToonMaterial({
11
+    color: 0x8b6914,
12
+    gradientMap
13
+  })
14
+
15
+  const darkWoodMaterial = new THREE.MeshToonMaterial({
16
+    color: 0x5c4a1a,
17
+    gradientMap
18
+  })
19
+
20
+  // Main platform
21
+  const platformGeom = new THREE.BoxGeometry(1.2, 0.08, 0.6)
22
+  const platform = new THREE.Mesh(platformGeom, woodMaterial)
23
+  platform.position.y = 0.04
24
+  group.add(platform)
25
+
26
+  // Planks (detail lines)
27
+  for (let i = 0; i < 5; i++) {
28
+    const plankGeom = new THREE.BoxGeometry(0.02, 0.09, 0.58)
29
+    const plank = new THREE.Mesh(plankGeom, darkWoodMaterial)
30
+    plank.position.set(-0.5 + i * 0.25, 0.045, 0)
31
+    group.add(plank)
32
+  }
33
+
34
+  // Support posts
35
+  const postGeom = new THREE.CylinderGeometry(0.04, 0.05, 0.4, 6)
36
+  const postPositions = [
37
+    { x: -0.5, z: 0.25 },
38
+    { x: -0.5, z: -0.25 },
39
+    { x: 0.5, z: 0.25 },
40
+    { x: 0.5, z: -0.25 }
41
+  ]
42
+
43
+  for (const pos of postPositions) {
44
+    const post = new THREE.Mesh(postGeom, darkWoodMaterial)
45
+    post.position.set(pos.x, -0.15, pos.z)
46
+    group.add(post)
47
+  }
48
+
49
+  return group
50
+}
51
+
52
+// Create a fishing hut
53
+export function createFishingHut(gradientMap) {
54
+  const group = new THREE.Group()
55
+
56
+  const woodMaterial = new THREE.MeshToonMaterial({
57
+    color: 0x9b7653,
58
+    gradientMap
59
+  })
60
+
61
+  const roofMaterial = new THREE.MeshToonMaterial({
62
+    color: 0x654321,
63
+    gradientMap
64
+  })
65
+
66
+  const windowMaterial = new THREE.MeshBasicMaterial({
67
+    color: 0x87ceeb,
68
+    transparent: true,
69
+    opacity: 0.6
70
+  })
71
+
72
+  // Base/floor
73
+  const baseGeom = new THREE.BoxGeometry(0.9, 0.06, 0.7)
74
+  const base = new THREE.Mesh(baseGeom, woodMaterial)
75
+  base.position.y = 0.03
76
+  group.add(base)
77
+
78
+  // Walls
79
+  const wallGeom = new THREE.BoxGeometry(0.85, 0.5, 0.65)
80
+  const walls = new THREE.Mesh(wallGeom, woodMaterial)
81
+  walls.position.y = 0.31
82
+  group.add(walls)
83
+
84
+  // Roof
85
+  const roofGeom = new THREE.ConeGeometry(0.55, 0.35, 4)
86
+  const roof = new THREE.Mesh(roofGeom, roofMaterial)
87
+  roof.position.y = 0.73
88
+  roof.rotation.y = Math.PI / 4
89
+  group.add(roof)
90
+
91
+  // Window
92
+  const windowGeom = new THREE.PlaneGeometry(0.15, 0.15)
93
+  const window1 = new THREE.Mesh(windowGeom, windowMaterial)
94
+  window1.position.set(0.43, 0.35, 0)
95
+  window1.rotation.y = Math.PI / 2
96
+  group.add(window1)
97
+
98
+  // Door frame
99
+  const doorGeom = new THREE.BoxGeometry(0.02, 0.35, 0.2)
100
+  const door = new THREE.Mesh(doorGeom, roofMaterial)
101
+  door.position.set(0.43, 0.24, 0)
102
+  group.add(door)
103
+
104
+  return group
105
+}
106
+
107
+// Create a mini lighthouse
108
+export function createLighthouse(gradientMap) {
109
+  const group = new THREE.Group()
110
+
111
+  const whiteMaterial = new THREE.MeshToonMaterial({
112
+    color: 0xf5f5f5,
113
+    gradientMap
114
+  })
115
+
116
+  const redMaterial = new THREE.MeshToonMaterial({
117
+    color: 0xcc3333,
118
+    gradientMap
119
+  })
120
+
121
+  const glassMaterial = new THREE.MeshBasicMaterial({
122
+    color: 0xffffaa,
123
+    transparent: true,
124
+    opacity: 0.8
125
+  })
126
+
127
+  // Base
128
+  const baseGeom = new THREE.CylinderGeometry(0.25, 0.3, 0.15, 8)
129
+  const base = new THREE.Mesh(baseGeom, whiteMaterial)
130
+  base.position.y = 0.075
131
+  group.add(base)
132
+
133
+  // Tower - alternating stripes
134
+  const stripeHeight = 0.2
135
+  for (let i = 0; i < 4; i++) {
136
+    const stripeGeom = new THREE.CylinderGeometry(
137
+      0.18 - i * 0.02,
138
+      0.2 - i * 0.02,
139
+      stripeHeight,
140
+      8
141
+    )
142
+    const stripe = new THREE.Mesh(stripeGeom, i % 2 === 0 ? whiteMaterial : redMaterial)
143
+    stripe.position.y = 0.25 + i * stripeHeight
144
+    group.add(stripe)
145
+  }
146
+
147
+  // Lamp housing
148
+  const housingGeom = new THREE.CylinderGeometry(0.12, 0.1, 0.15, 8)
149
+  const housing = new THREE.Mesh(housingGeom, redMaterial)
150
+  housing.position.y = 1.02
151
+  group.add(housing)
152
+
153
+  // Glass/light
154
+  const glassGeom = new THREE.SphereGeometry(0.08, 8, 6)
155
+  const glass = new THREE.Mesh(glassGeom, glassMaterial)
156
+  glass.position.y = 1.0
157
+  group.add(glass)
158
+
159
+  // Light beam (animated)
160
+  const beamGroup = new THREE.Group()
161
+  beamGroup.position.y = 1.0
162
+
163
+  const beamMaterial = new THREE.MeshBasicMaterial({
164
+    color: 0xffffaa,
165
+    transparent: true,
166
+    opacity: 0.3,
167
+    side: THREE.DoubleSide
168
+  })
169
+
170
+  // Create a cone-shaped beam - tip at lamp, wide end extending outward
171
+  const beamGeom = new THREE.ConeGeometry(0.8, 2.5, 8, 1, true)
172
+  beamGeom.rotateX(-Math.PI / 2) // Tip toward -Z, base toward +Z
173
+  beamGeom.translate(0, 0, 1.25) // Move so tip is at origin (lamp), base extends outward
174
+  const beam = new THREE.Mesh(beamGeom, beamMaterial)
175
+  beamGroup.add(beam)
176
+
177
+  // Mark for animation
178
+  beamGroup.userData.isLightBeam = true
179
+  group.add(beamGroup)
180
+
181
+  // Roof cap
182
+  const capGeom = new THREE.ConeGeometry(0.14, 0.12, 8)
183
+  const cap = new THREE.Mesh(capGeom, redMaterial)
184
+  cap.position.y = 1.15
185
+  group.add(cap)
186
+
187
+  // Mark group as lighthouse for animation
188
+  group.userData.isLighthouse = true
189
+
190
+  return group
191
+}
192
+
193
+// Create reed cluster
194
+export function createReeds(gradientMap) {
195
+  const group = new THREE.Group()
196
+
197
+  const reedMaterial = new THREE.MeshToonMaterial({
198
+    color: 0x4a7c3f,
199
+    gradientMap
200
+  })
201
+
202
+  const tipMaterial = new THREE.MeshToonMaterial({
203
+    color: 0x8b7355,
204
+    gradientMap
205
+  })
206
+
207
+  // Create 5-7 reeds
208
+  const reedCount = 5 + Math.floor(Math.random() * 3)
209
+  const reeds = []
210
+
211
+  for (let i = 0; i < reedCount; i++) {
212
+    const height = 0.4 + Math.random() * 0.3
213
+    const angle = (i / reedCount) * Math.PI * 2 + Math.random() * 0.5
214
+    const dist = Math.random() * 0.15
215
+
216
+    // Reed group (stalk + tip together for swaying)
217
+    const reedGroup = new THREE.Group()
218
+    reedGroup.position.set(
219
+      Math.cos(angle) * dist,
220
+      0,
221
+      Math.sin(angle) * dist
222
+    )
223
+
224
+    // Reed stalk
225
+    const stalkGeom = new THREE.CylinderGeometry(0.015, 0.02, height, 4)
226
+    const stalk = new THREE.Mesh(stalkGeom, reedMaterial)
227
+    stalk.position.y = height / 2 - 0.1
228
+    reedGroup.add(stalk)
229
+
230
+    // Cattail tip
231
+    const tipGeom = new THREE.CylinderGeometry(0.03, 0.025, 0.1, 6)
232
+    const tip = new THREE.Mesh(tipGeom, tipMaterial)
233
+    tip.position.y = height - 0.05
234
+    reedGroup.add(tip)
235
+
236
+    // Mark for animation with random phase
237
+    reedGroup.userData.isReed = true
238
+    reedGroup.userData.phase = Math.random() * Math.PI * 2
239
+    reedGroup.userData.baseRotX = (Math.random() - 0.5) * 0.2
240
+    reedGroup.userData.baseRotZ = (Math.random() - 0.5) * 0.2
241
+
242
+    reeds.push(reedGroup)
243
+    group.add(reedGroup)
244
+  }
245
+
246
+  // Mark group as reeds cluster for animation
247
+  group.userData.isReeds = true
248
+  group.userData.reedChildren = reeds
249
+
250
+  return group
251
+}
252
+
253
+// Create fence segment
254
+export function createFence(gradientMap) {
255
+  const group = new THREE.Group()
256
+
257
+  const woodMaterial = new THREE.MeshToonMaterial({
258
+    color: 0xa0826d,
259
+    gradientMap
260
+  })
261
+
262
+  // Two posts
263
+  const postGeom = new THREE.BoxGeometry(0.06, 0.4, 0.06)
264
+
265
+  const post1 = new THREE.Mesh(postGeom, woodMaterial)
266
+  post1.position.set(-0.25, 0.15, 0)
267
+  group.add(post1)
268
+
269
+  const post2 = new THREE.Mesh(postGeom, woodMaterial)
270
+  post2.position.set(0.25, 0.15, 0)
271
+  group.add(post2)
272
+
273
+  // Pointed tops
274
+  const pointGeom = new THREE.ConeGeometry(0.04, 0.08, 4)
275
+
276
+  const point1 = new THREE.Mesh(pointGeom, woodMaterial)
277
+  point1.position.set(-0.25, 0.39, 0)
278
+  group.add(point1)
279
+
280
+  const point2 = new THREE.Mesh(pointGeom, woodMaterial)
281
+  point2.position.set(0.25, 0.39, 0)
282
+  group.add(point2)
283
+
284
+  // Cross beams
285
+  const beamGeom = new THREE.BoxGeometry(0.5, 0.04, 0.03)
286
+
287
+  const beam1 = new THREE.Mesh(beamGeom, woodMaterial)
288
+  beam1.position.set(0, 0.25, 0)
289
+  group.add(beam1)
290
+
291
+  const beam2 = new THREE.Mesh(beamGeom, woodMaterial)
292
+  beam2.position.set(0, 0.1, 0)
293
+  group.add(beam2)
294
+
295
+  return group
296
+}
297
+
298
+// Create a giant onion house
299
+export function createOnionHouse(gradientMap) {
300
+  const group = new THREE.Group()
301
+
302
+  // Onion colors - layered purples and whites
303
+  const outerSkinMaterial = new THREE.MeshToonMaterial({
304
+    color: 0x8b668b, // Dusty purple outer skin
305
+    gradientMap
306
+  })
307
+
308
+  const innerSkinMaterial = new THREE.MeshToonMaterial({
309
+    color: 0xdda0dd, // Lighter purple inner layer peeking through
310
+    gradientMap
311
+  })
312
+
313
+  const rootMaterial = new THREE.MeshToonMaterial({
314
+    color: 0xd2b48c, // Tan roots
315
+    gradientMap
316
+  })
317
+
318
+  const doorMaterial = new THREE.MeshToonMaterial({
319
+    color: 0x4a3728, // Dark wood door
320
+    gradientMap
321
+  })
322
+
323
+  const chimneyMaterial = new THREE.MeshToonMaterial({
324
+    color: 0x8b7355, // Stone chimney
325
+    gradientMap
326
+  })
327
+
328
+  // Main onion body - bulbous bottom
329
+  const bulbGeom = new THREE.SphereGeometry(0.5, 10, 8)
330
+  bulbGeom.scale(1, 0.85, 1)
331
+  const bulb = new THREE.Mesh(bulbGeom, outerSkinMaterial)
332
+  bulb.position.y = 0.4
333
+  group.add(bulb)
334
+
335
+  // Onion top/neck tapering up
336
+  const neckGeom = new THREE.CylinderGeometry(0.15, 0.35, 0.4, 8)
337
+  const neck = new THREE.Mesh(neckGeom, outerSkinMaterial)
338
+  neck.position.y = 0.95
339
+  group.add(neck)
340
+
341
+  // Dried top sprout/tip
342
+  const tipGeom = new THREE.ConeGeometry(0.08, 0.25, 6)
343
+  const tip = new THREE.Mesh(tipGeom, rootMaterial)
344
+  tip.position.y = 1.27
345
+  tip.rotation.z = 0.15 // Slight lean for whimsy
346
+  group.add(tip)
347
+
348
+  // Peeling skin detail (decorative flaps)
349
+  const peelGeom = new THREE.PlaneGeometry(0.2, 0.35)
350
+  const peel1 = new THREE.Mesh(peelGeom, innerSkinMaterial)
351
+  peel1.position.set(0.45, 0.5, 0.15)
352
+  peel1.rotation.y = -0.4
353
+  peel1.rotation.z = 0.3
354
+  group.add(peel1)
355
+
356
+  const peel2 = new THREE.Mesh(peelGeom, innerSkinMaterial)
357
+  peel2.position.set(-0.35, 0.6, 0.3)
358
+  peel2.rotation.y = 0.6
359
+  peel2.rotation.z = -0.2
360
+  group.add(peel2)
361
+
362
+  // Root tendrils at the bottom
363
+  for (let i = 0; i < 5; i++) {
364
+    const angle = (i / 5) * Math.PI * 2 + Math.random() * 0.3
365
+    const rootGeom = new THREE.CylinderGeometry(0.02, 0.01, 0.15 + Math.random() * 0.1, 4)
366
+    const root = new THREE.Mesh(rootGeom, rootMaterial)
367
+    root.position.set(
368
+      Math.cos(angle) * 0.15,
369
+      -0.02,
370
+      Math.sin(angle) * 0.15
371
+    )
372
+    root.rotation.x = (Math.random() - 0.5) * 0.4
373
+    root.rotation.z = (Math.random() - 0.5) * 0.4
374
+    group.add(root)
375
+  }
376
+
377
+  // Door - cute rounded top
378
+  const doorGroup = new THREE.Group()
379
+
380
+  // Door frame (arch)
381
+  const doorFrameGeom = new THREE.BoxGeometry(0.22, 0.35, 0.05)
382
+  const doorFrame = new THREE.Mesh(doorFrameGeom, doorMaterial)
383
+  doorFrame.position.y = 0.175
384
+  doorGroup.add(doorFrame)
385
+
386
+  // Door arch top
387
+  const archGeom = new THREE.SphereGeometry(0.11, 8, 4, 0, Math.PI * 2, 0, Math.PI / 2)
388
+  const arch = new THREE.Mesh(archGeom, doorMaterial)
389
+  arch.position.y = 0.35
390
+  arch.rotation.x = Math.PI
391
+  doorGroup.add(arch)
392
+
393
+  // Door knob
394
+  const knobGeom = new THREE.SphereGeometry(0.02, 6, 4)
395
+  const knobMaterial = new THREE.MeshToonMaterial({ color: 0xffd700, gradientMap })
396
+  const knob = new THREE.Mesh(knobGeom, knobMaterial)
397
+  knob.position.set(0.07, 0.2, 0.03)
398
+  doorGroup.add(knob)
399
+
400
+  doorGroup.position.set(0.48, 0.02, 0)
401
+  doorGroup.rotation.y = Math.PI / 2
402
+  group.add(doorGroup)
403
+
404
+  // Chimney - whimsically placed on the side/top
405
+  const chimneyGroup = new THREE.Group()
406
+
407
+  const chimneyBaseGeom = new THREE.CylinderGeometry(0.06, 0.07, 0.3, 6)
408
+  const chimneyBase = new THREE.Mesh(chimneyBaseGeom, chimneyMaterial)
409
+  chimneyBase.position.y = 0.15
410
+  chimneyGroup.add(chimneyBase)
411
+
412
+  // Chimney cap
413
+  const capGeom = new THREE.CylinderGeometry(0.08, 0.06, 0.04, 6)
414
+  const cap = new THREE.Mesh(capGeom, chimneyMaterial)
415
+  cap.position.y = 0.32
416
+  chimneyGroup.add(cap)
417
+
418
+  // Position chimney at a jaunty angle on the onion
419
+  chimneyGroup.position.set(-0.25, 0.75, 0.2)
420
+  chimneyGroup.rotation.z = 0.3 // Tilted for whimsy
421
+  chimneyGroup.rotation.x = -0.15
422
+  group.add(chimneyGroup)
423
+
424
+  // Little window
425
+  const windowGeom = new THREE.CircleGeometry(0.08, 8)
426
+  const windowMaterial = new THREE.MeshBasicMaterial({
427
+    color: 0xffffcc,
428
+    transparent: true,
429
+    opacity: 0.7
430
+  })
431
+  const windowMesh = new THREE.Mesh(windowGeom, windowMaterial)
432
+  windowMesh.position.set(0.1, 0.55, 0.49)
433
+  group.add(windowMesh)
434
+
435
+  // Window frame
436
+  const windowFrameGeom = new THREE.TorusGeometry(0.08, 0.012, 4, 12)
437
+  const windowFrame = new THREE.Mesh(windowFrameGeom, doorMaterial)
438
+  windowFrame.position.set(0.1, 0.55, 0.485)
439
+  group.add(windowFrame)
440
+
441
+  return group
442
+}
443
+
444
+// Factory function to create building by type
445
+export function createBuilding(type, gradientMap) {
446
+  switch (type) {
447
+    case 'dock_wooden':
448
+      return createDock(gradientMap)
449
+    case 'fishing_hut':
450
+      return createFishingHut(gradientMap)
451
+    case 'lighthouse':
452
+      return createLighthouse(gradientMap)
453
+    case 'reeds':
454
+      return createReeds(gradientMap)
455
+    case 'fence':
456
+      return createFence(gradientMap)
457
+    case 'onion_house':
458
+      return createOnionHouse(gradientMap)
459
+    default:
460
+      console.warn('Unknown building type:', type)
461
+      return new THREE.Group()
462
+  }
463
+}
464
+
465
+// Create ghost (preview) version of a building
466
+export function createGhostBuilding(type, gradientMap, isValid) {
467
+  const building = createBuilding(type, gradientMap)
468
+
469
+  // Make all materials transparent and tinted
470
+  const color = isValid ? 0x44ff44 : 0xff4444
471
+  const opacity = 0.5
472
+
473
+  building.traverse((child) => {
474
+    if (child.isMesh) {
475
+      child.material = new THREE.MeshBasicMaterial({
476
+        color,
477
+        transparent: true,
478
+        opacity
479
+      })
480
+    }
481
+  })
482
+
483
+  return building
484
+}
src/renderers/three/duck.jsmodified
@@ -5,29 +5,47 @@ import { playMonch } from './sounds.js'
55
 export function createDoug(scene, gradientMap) {
66
   const group = new THREE.Group()
77
 
8
-  // Color palette - vibrant Wind Waker yellows
9
-  const bodyColor = 0xffdc50 // Warm yellow
10
-  const bodyHighlight = 0xfff0a0 // Light yellow
11
-  const beakColor = 0xff9020 // Bright orange
12
-  const eyeWhite = 0xffffff
13
-  const eyePupil = 0x191410
8
+  // Store gradientMap for accessory creation
9
+  const storedGradientMap = gradientMap
10
+
11
+  // Default colors - vibrant Wind Waker yellows
12
+  const defaultColors = {
13
+    body: 0xffdc50,
14
+    highlight: 0xfff0a0,
15
+    beak: 0xff9020
16
+  }
1417
 
15
-  // Toon materials
18
+  // Toon materials (stored for outfit swapping)
1619
   const bodyMaterial = new THREE.MeshToonMaterial({
17
-    color: bodyColor,
20
+    color: defaultColors.body,
1821
     gradientMap: gradientMap
1922
   })
2023
 
2124
   const highlightMaterial = new THREE.MeshToonMaterial({
22
-    color: bodyHighlight,
25
+    color: defaultColors.highlight,
2326
     gradientMap: gradientMap
2427
   })
2528
 
2629
   const beakMaterial = new THREE.MeshToonMaterial({
27
-    color: beakColor,
30
+    color: defaultColors.beak,
2831
     gradientMap: gradientMap
2932
   })
3033
 
34
+  // Accessory tracking
35
+  const accessories = {
36
+    head: null,
37
+    face: null
38
+  }
39
+
40
+  // Mount points for accessories
41
+  const mountPoints = {
42
+    head: new THREE.Vector3(0.38, 0.98, 0),
43
+    face: new THREE.Vector3(0.72, 0.78, 0)
44
+  }
45
+
46
+  const eyeWhite = 0xffffff
47
+  const eyePupil = 0x191410
48
+
3149
   const eyeWhiteMaterial = new THREE.MeshToonMaterial({
3250
     color: eyeWhite,
3351
     gradientMap: gradientMap
@@ -246,7 +264,47 @@ export function createDoug(scene, gradientMap) {
246264
     )
247265
   }
248266
 
249
-  function update(delta, elapsed, breadBits, pond) {
267
+  function update(delta, elapsed, breadBits, pond, options = {}) {
268
+    const { paused = false, focusTarget = null } = options
269
+
270
+    // When paused (during dialog), stop movement but keep animations
271
+    if (paused) {
272
+      state.isMoving = false
273
+
274
+      // If there's a focus target, slowly turn to face it
275
+      if (focusTarget) {
276
+        const dx = focusTarget.x - state.position.x
277
+        const dz = focusTarget.z - state.position.z
278
+        const targetAngle = Math.atan2(dx, dz)
279
+        state.rotation = lerpAngle(state.rotation, targetAngle, delta * 2)
280
+      }
281
+
282
+      // Apply position (no movement, just stay in place)
283
+      group.position.x = state.position.x
284
+      group.position.z = state.position.z
285
+
286
+      // Gentle bobbing
287
+      const bobAmount = Math.sin(elapsed * 2)
288
+      group.position.y = bobAmount * 0.03
289
+
290
+      // Rotation
291
+      group.rotation.y = state.rotation - Math.PI / 2
292
+
293
+      // Subtle idle animations
294
+      leftWing.rotation.z = Math.sin(elapsed * 2) * 0.05
295
+      rightWing.rotation.z = -Math.sin(elapsed * 2) * 0.05
296
+      head.position.y = 0.7 + Math.sin(elapsed * 1.5) * 0.02
297
+
298
+      // Occasional idle ripple
299
+      state.rippleTimer += delta
300
+      if (state.rippleTimer > 2 && bobAmount < -0.9) {
301
+        state.rippleTimer = 0
302
+        pond.addRipple(state.position.x, state.position.z)
303
+      }
304
+
305
+      return
306
+    }
307
+
250308
     // Find closest bread
251309
     let closestBread = null
252310
     let closestDist = Infinity
@@ -397,9 +455,90 @@ export function createDoug(scene, gradientMap) {
397455
     }
398456
   }
399457
 
458
+  // Apply an outfit to Doug
459
+  function applyOutfit(outfit) {
460
+    if (!outfit) return
461
+
462
+    switch (outfit.type) {
463
+      case 'color_body':
464
+        if (outfit.colors) {
465
+          if (outfit.colors.body) bodyMaterial.color.setHex(outfit.colors.body)
466
+          if (outfit.colors.highlight) highlightMaterial.color.setHex(outfit.colors.highlight)
467
+        }
468
+        break
469
+
470
+      case 'color_accent':
471
+        if (outfit.colors && outfit.colors.beak) {
472
+          beakMaterial.color.setHex(outfit.colors.beak)
473
+        }
474
+        break
475
+
476
+      case 'accessory_head':
477
+        // Remove existing head accessory
478
+        if (accessories.head) {
479
+          group.remove(accessories.head)
480
+          accessories.head = null
481
+        }
482
+        // Add new accessory
483
+        if (outfit.meshFactory) {
484
+          accessories.head = outfit.meshFactory(storedGradientMap)
485
+          accessories.head.position.copy(mountPoints.head)
486
+          group.add(accessories.head)
487
+        }
488
+        break
489
+
490
+      case 'accessory_face':
491
+        // Remove existing face accessory
492
+        if (accessories.face) {
493
+          group.remove(accessories.face)
494
+          accessories.face = null
495
+        }
496
+        // Add new accessory
497
+        if (outfit.meshFactory) {
498
+          accessories.face = outfit.meshFactory(storedGradientMap)
499
+          accessories.face.position.copy(mountPoints.face)
500
+          accessories.face.rotation.y = -Math.PI / 2 // Face forward
501
+          group.add(accessories.face)
502
+        }
503
+        break
504
+    }
505
+  }
506
+
507
+  // Remove an outfit from Doug
508
+  function removeOutfit(outfit) {
509
+    if (!outfit) return
510
+
511
+    switch (outfit.type) {
512
+      case 'color_body':
513
+        bodyMaterial.color.setHex(defaultColors.body)
514
+        highlightMaterial.color.setHex(defaultColors.highlight)
515
+        break
516
+
517
+      case 'color_accent':
518
+        beakMaterial.color.setHex(defaultColors.beak)
519
+        break
520
+
521
+      case 'accessory_head':
522
+        if (accessories.head) {
523
+          group.remove(accessories.head)
524
+          accessories.head = null
525
+        }
526
+        break
527
+
528
+      case 'accessory_face':
529
+        if (accessories.face) {
530
+          group.remove(accessories.face)
531
+          accessories.face = null
532
+        }
533
+        break
534
+    }
535
+  }
536
+
400537
   return {
401538
     group,
402539
     update,
403
-    getPosition: () => state.position.clone()
540
+    getPosition: () => state.position.clone(),
541
+    applyOutfit,
542
+    removeOutfit
404543
   }
405544
 }
src/renderers/three/gameState.jsadded
@@ -0,0 +1,51 @@
1
+// Game state singleton for dougk gamification
2
+// Tracks currency (captured koi) and other persistent state
3
+
4
+const STORAGE_KEY = 'dougk-koi'
5
+
6
+const gameState = {
7
+  capturedKoi: parseInt(localStorage.getItem(STORAGE_KEY) || '0'),
8
+
9
+  addKoi(n = 1) {
10
+    this.capturedKoi += n
11
+    this.save()
12
+    this.notifyListeners()
13
+  },
14
+
15
+  spendKoi(n) {
16
+    if (this.capturedKoi >= n) {
17
+      this.capturedKoi -= n
18
+      this.save()
19
+      this.notifyListeners()
20
+      return true
21
+    }
22
+    return false
23
+  },
24
+
25
+  getKoi() {
26
+    return this.capturedKoi
27
+  },
28
+
29
+  save() {
30
+    localStorage.setItem(STORAGE_KEY, this.capturedKoi.toString())
31
+  },
32
+
33
+  // Listener system for UI updates
34
+  listeners: [],
35
+
36
+  addListener(callback) {
37
+    this.listeners.push(callback)
38
+  },
39
+
40
+  removeListener(callback) {
41
+    this.listeners = this.listeners.filter(l => l !== callback)
42
+  },
43
+
44
+  notifyListeners() {
45
+    for (const listener of this.listeners) {
46
+      listener(this.capturedKoi)
47
+    }
48
+  }
49
+}
50
+
51
+export default gameState
src/renderers/three/index.jsmodified
@@ -10,12 +10,74 @@ import { BreadManager } from './bread.js'
1010
 import { createDonny } from './narwhal.js'
1111
 import { createKoiSchool } from './koi.js'
1212
 import { createOllie } from './octopus.js'
13
+import { PlacementManager } from './buildingPlacement.js'
1314
 import { unlockAudio } from './sounds.js'
15
+import gameState from './gameState.js'
16
+import { openShop, closeShop, isShopOpen } from './shop/shopUI.js'
17
+import { showDialog, closeDialog, isDialogOpen } from './shop/dialogUI.js'
18
+import { getDialogForCharacter, getReturnDialog } from './shop/dialogScripts.js'
19
+import inventory from './shop/inventory.js'
20
+import { getItem, CHARACTERS } from './shop/items.js'
1421
 
1522
 let scene, camera, renderer, composer, outlinePass
16
-let doug, pond, breadManager, donny, koiSchool, ollie
23
+let doug, pond, breadManager, donny, koiSchool, ollie, placementManager
1724
 let clock
1825
 let animationId = null
26
+let koiCounterEl = null
27
+
28
+// Capture state
29
+let captureState = {
30
+  isHolding: false,
31
+  targetKoi: null,
32
+  clickPos: null  // Where bread would spawn if not capturing
33
+}
34
+
35
+// Auto-capture state (when Doug hovers over koi)
36
+let autoCapture = {
37
+  targetKoi: null,
38
+  hoverTime: 0,
39
+  CAPTURE_DELAY: 0.5 // seconds Doug must be over koi to auto-capture
40
+}
41
+
42
+// Camera zoom state for dialog focus effect
43
+const cameraZoom = {
44
+  defaultFrustum: 12,
45
+  dialogFrustum: 8, // Zoomed in during dialog
46
+  currentFrustum: 12,
47
+  targetFrustum: 12
48
+}
49
+
50
+function createKoiCounter(container) {
51
+  const counter = document.createElement('div')
52
+  counter.id = 'koi-counter'
53
+  counter.style.cssText = `
54
+    position: fixed;
55
+    top: 16px;
56
+    left: 16px;
57
+    background: rgba(0, 0, 0, 0.6);
58
+    color: #ffd700;
59
+    padding: 8px 16px;
60
+    border-radius: 8px;
61
+    font-family: 'Courier New', monospace;
62
+    font-size: 18px;
63
+    font-weight: bold;
64
+    z-index: 1000;
65
+    pointer-events: none;
66
+    display: flex;
67
+    align-items: center;
68
+    gap: 8px;
69
+  `
70
+  counter.innerHTML = `<span style="font-size: 24px;">🐟</span> <span id="koi-count">${gameState.getKoi()}</span>`
71
+  container.appendChild(counter)
72
+
73
+  // Listen for game state changes
74
+  gameState.addListener((count) => {
75
+    const countEl = document.getElementById('koi-count')
76
+    if (countEl) countEl.textContent = count
77
+  })
78
+
79
+  return counter
80
+}
1981
 
2082
 function createToonGradient() {
2183
   const canvas = document.createElement('canvas')
@@ -95,6 +157,7 @@ export function start(container) {
95157
   donny = createDonny(scene, toonGradient)
96158
   koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
97159
   ollie = createOllie(scene, toonGradient)
160
+  placementManager = new PlacementManager(scene, pond, camera, toonGradient)
98161
 
99162
   // Post-processing
100163
   composer = new EffectComposer(renderer)
@@ -110,7 +173,7 @@ export function start(container) {
110173
   outlinePass.edgeThickness = 1.5
111174
   outlinePass.visibleEdgeColor.set(0x191410)
112175
   outlinePass.hiddenEdgeColor.set(0x191410)
113
-  outlinePass.selectedObjects = [doug.group, pond.group, donny.group, koiSchool.group, ollie.group]
176
+  outlinePass.selectedObjects = [doug.group, pond.group, donny.group, koiSchool.group, ollie.group, placementManager.getPlacedBuildingsGroup()]
114177
   composer.addPass(outlinePass)
115178
   composer.addPass(new OutputPass())
116179
 
@@ -121,25 +184,206 @@ export function start(container) {
121184
   // Unlock audio on first touch (for mobile)
122185
   renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true })
123186
 
124
-  renderer.domElement.addEventListener('click', (event) => {
125
-    // Unlock audio on first interaction (required for mobile)
187
+  // Mouse move - handle placement preview
188
+  renderer.domElement.addEventListener('pointermove', (event) => {
189
+    if (placementManager.isPlacing()) {
190
+      placementManager.onMouseMove(event, window.innerWidth, window.innerHeight)
191
+    }
192
+  })
193
+
194
+  // Helper to handle tap-to-shop for a creature
195
+  const handleCreatureTap = (creature, characterName) => {
196
+    if (!creature.isTappable()) return false
197
+    if (isDialogOpen() || isShopOpen()) return false
198
+
199
+    // Show return dialog, then open shop
200
+    const dialogLines = getReturnDialog(characterName)
201
+    showDialog(characterName, dialogLines, container, () => {
202
+      openShop(characterName, container, {
203
+        onClose: () => {
204
+          creature.dismissShop()
205
+        },
206
+        onPurchase: (item, action) => {
207
+          if (item.character) {
208
+            handleOutfitChange(item, action)
209
+          }
210
+          if (action === 'place' && item.buildingType) {
211
+            placementManager.startPlacement(item.buildingType, {
212
+              onComplete: () => {},
213
+              onCancel: () => {}
214
+            })
215
+          }
216
+        }
217
+      })
218
+    })
219
+
220
+    // Transition creature to shop mode if surfaced
221
+    creature.triggerShopFromTap(pond, doug)
222
+    return true
223
+  }
224
+
225
+  // Pointer down - handle placement, capture, or bread spawn
226
+  renderer.domElement.addEventListener('pointerdown', (event) => {
126227
     unlockAudio()
127228
 
229
+    // If placing a building, try to place it
230
+    if (placementManager.isPlacing()) {
231
+      if (placementManager.onClick(event, window.innerWidth, window.innerHeight)) {
232
+        // Building placed successfully
233
+        return
234
+      }
235
+      // Clicked in invalid spot - cancel placement
236
+      placementManager.cancelPlacement()
237
+      return
238
+    }
239
+
128240
     mouse.x = (event.clientX / window.innerWidth) * 2 - 1
129241
     mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
130242
 
131243
     raycaster.setFromCamera(mouse, camera)
244
+
245
+    // Check if clicked on Donny or Ollie (tap to reopen shop)
246
+    if (donny.isTappable()) {
247
+      const donnyIntersects = raycaster.intersectObject(donny.group, true)
248
+      if (donnyIntersects.length > 0) {
249
+        if (handleCreatureTap(donny, 'donny')) return
250
+      }
251
+    }
252
+    if (ollie.isTappable()) {
253
+      const ollieIntersects = raycaster.intersectObject(ollie.group, true)
254
+      if (ollieIntersects.length > 0) {
255
+        if (handleCreatureTap(ollie, 'ollie')) return
256
+      }
257
+    }
258
+
132259
     const intersects = raycaster.intersectObject(pond.water)
133260
 
134261
     if (intersects.length > 0) {
135262
       const point = intersects[0].point
136
-      breadManager.spawnBread(point.x, point.z)
137
-      pond.addRipple(point.x, point.z)
138
-      koiSchool.triggerPanic(point.x, point.z)
263
+      captureState.clickPos = { x: point.x, z: point.z }
264
+      captureState.isHolding = true
265
+
266
+      // Check if Doug is over a koi
267
+      const dougPos = doug.getPosition()
268
+      const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.4)
269
+
270
+      if (koiUnderDoug) {
271
+        // Start capturing
272
+        koiSchool.startCapture(koiUnderDoug)
273
+        captureState.targetKoi = koiUnderDoug
274
+      }
275
+    }
276
+  })
277
+
278
+  // Pointer up - complete capture or spawn bread
279
+  renderer.domElement.addEventListener('pointerup', () => {
280
+    if (!captureState.isHolding) return
281
+
282
+    if (captureState.targetKoi) {
283
+      // If still capturing (not completed), cancel it
284
+      if (captureState.targetKoi.state.beingCaptured) {
285
+        koiSchool.cancelCapture(captureState.targetKoi)
286
+      }
287
+    } else if (captureState.clickPos) {
288
+      // No capture was started - spawn bread
289
+      breadManager.spawnBread(captureState.clickPos.x, captureState.clickPos.z)
290
+      pond.addRipple(captureState.clickPos.x, captureState.clickPos.z)
291
+      koiSchool.triggerPanic(captureState.clickPos.x, captureState.clickPos.z)
139292
       outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()]
140293
     }
294
+
295
+    // Reset capture state
296
+    captureState.isHolding = false
297
+    captureState.targetKoi = null
298
+    captureState.clickPos = null
141299
   })
142300
 
301
+  // Create koi counter UI
302
+  koiCounterEl = createKoiCounter(container)
303
+
304
+  // Helper to get character object by name
305
+  const getCharacter = (charName) => {
306
+    switch (charName) {
307
+      case 'doug': return doug
308
+      case 'donny': return donny
309
+      case 'ollie': return ollie
310
+      default: return null
311
+    }
312
+  }
313
+
314
+  // Handle outfit equip/unequip
315
+  const handleOutfitChange = (item, action) => {
316
+    const character = getCharacter(item.character)
317
+    if (!character) return
318
+
319
+    if (action === 'equip') {
320
+      character.applyOutfit(item)
321
+    } else if (action === 'unequip') {
322
+      character.removeOutfit(item)
323
+    }
324
+  }
325
+
326
+  // Set up shop callbacks
327
+  const handleShopReady = (shopkeeper) => {
328
+    // Get dialog lines for this character
329
+    const dialogLines = getDialogForCharacter(shopkeeper)
330
+
331
+    // Show dialog first, then open shop when complete
332
+    showDialog(shopkeeper, dialogLines, container, () => {
333
+      // Dialog complete - now open the shop
334
+      openShop(shopkeeper, container, {
335
+        onClose: () => {
336
+          // Dismiss the appropriate shopkeeper
337
+          if (shopkeeper === 'donny') {
338
+            donny.dismissShop()
339
+          } else if (shopkeeper === 'ollie') {
340
+            ollie.dismissShop()
341
+          }
342
+        },
343
+        onPurchase: (item, action) => {
344
+          // Handle outfit equip/unequip
345
+          if (item.character) {
346
+            handleOutfitChange(item, action)
347
+          }
348
+          // Handle building placement
349
+          if (action === 'place' && item.buildingType) {
350
+            placementManager.startPlacement(item.buildingType, {
351
+              onComplete: (buildingType, zone) => {
352
+                // Building placed successfully
353
+              },
354
+              onCancel: () => {
355
+                // Placement cancelled
356
+              }
357
+            })
358
+          }
359
+        }
360
+      })
361
+    })
362
+  }
363
+
364
+  donny.setShopReadyCallback(handleShopReady)
365
+  ollie.setShopReadyCallback(handleShopReady)
366
+
367
+  // Load saved equipped outfits
368
+  const loadEquippedOutfits = () => {
369
+    for (const charName of Object.values(CHARACTERS)) {
370
+      const equipped = inventory.getEquipped(charName)
371
+      const character = getCharacter(charName)
372
+      if (character) {
373
+        for (const itemId of equipped) {
374
+          const item = getItem(itemId)
375
+          if (item) {
376
+            character.applyOutfit(item)
377
+          }
378
+        }
379
+      }
380
+    }
381
+  }
382
+  loadEquippedOutfits()
383
+
384
+  // Load saved placed buildings
385
+  placementManager.loadSavedBuildings()
386
+
143387
   // Resize handler
144388
   window.addEventListener('resize', onResize)
145389
 
@@ -166,12 +410,99 @@ function animate() {
166410
   const delta = clock.getDelta()
167411
   const elapsed = clock.getElapsedTime()
168412
 
169
-  doug.update(delta, elapsed, breadManager.getActiveBits(), pond)
413
+  // Check if in dialog/shop mode for camera zoom and movement pause
414
+  const inConversation = isDialogOpen() || isShopOpen()
415
+
416
+  // Animate camera zoom
417
+  cameraZoom.targetFrustum = inConversation ? cameraZoom.dialogFrustum : cameraZoom.defaultFrustum
418
+  if (Math.abs(cameraZoom.currentFrustum - cameraZoom.targetFrustum) > 0.01) {
419
+    cameraZoom.currentFrustum += (cameraZoom.targetFrustum - cameraZoom.currentFrustum) * delta * 3
420
+    const aspect = window.innerWidth / window.innerHeight
421
+    camera.left = -cameraZoom.currentFrustum * aspect / 2
422
+    camera.right = cameraZoom.currentFrustum * aspect / 2
423
+    camera.top = cameraZoom.currentFrustum / 2
424
+    camera.bottom = -cameraZoom.currentFrustum / 2
425
+    camera.updateProjectionMatrix()
426
+  }
427
+
428
+  // Get active bread positions for koi attraction
429
+  const activeBread = breadManager.getActiveBits()
430
+  const breadPositions = activeBread.map(b => ({ x: b.position.x, z: b.position.z }))
431
+
432
+  // Update koi attraction to bread
433
+  koiSchool.attractToBread(breadPositions)
434
+
435
+  // Handle capture in progress (manual click-hold)
436
+  if (captureState.isHolding && captureState.targetKoi) {
437
+    const completed = koiSchool.updateCapture(captureState.targetKoi, delta)
438
+    if (completed) {
439
+      // Capture completed
440
+      captureState.targetKoi = null
441
+      captureState.isHolding = false
442
+      captureState.clickPos = null
443
+    }
444
+  }
445
+
446
+  // Auto-capture: when Doug hovers over a koi for a moment
447
+  if (!captureState.isHolding) {
448
+    const dougPos = doug.getPosition()
449
+    const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.5)
450
+
451
+    if (koiUnderDoug) {
452
+      if (autoCapture.targetKoi === koiUnderDoug) {
453
+        // Same koi - accumulate hover time
454
+        autoCapture.hoverTime += delta
455
+        if (autoCapture.hoverTime >= autoCapture.CAPTURE_DELAY) {
456
+          // Start and immediately complete capture
457
+          koiSchool.startCapture(koiUnderDoug)
458
+          // Fast-forward capture to completion
459
+          while (!koiSchool.updateCapture(koiUnderDoug, 0.2)) {}
460
+          autoCapture.targetKoi = null
461
+          autoCapture.hoverTime = 0
462
+        }
463
+      } else {
464
+        // New koi - reset timer
465
+        autoCapture.targetKoi = koiUnderDoug
466
+        autoCapture.hoverTime = 0
467
+      }
468
+    } else {
469
+      // No koi under Doug - reset
470
+      autoCapture.targetKoi = null
471
+      autoCapture.hoverTime = 0
472
+    }
473
+  }
474
+
475
+  // Determine focus target for Doug during conversation
476
+  let focusTarget = null
477
+  if (inConversation) {
478
+    // Focus on whoever is in shop mode
479
+    if (donny.isInShopMode()) {
480
+      focusTarget = { x: donny.group.position.x, z: donny.group.position.z }
481
+    } else if (ollie.isInShopMode()) {
482
+      focusTarget = { x: ollie.group.position.x, z: ollie.group.position.z }
483
+    }
484
+  }
485
+
486
+  doug.update(delta, elapsed, activeBread, pond, {
487
+    paused: inConversation,
488
+    focusTarget: focusTarget
489
+  })
170490
   breadManager.update(delta, elapsed)
171491
   pond.update(delta, elapsed)
172492
   donny.update(delta, elapsed, pond, doug)
173493
   koiSchool.update(delta, elapsed)
174494
   ollie.update(delta, elapsed, pond, doug)
495
+  placementManager.update(delta, elapsed)
496
+
497
+  // Try to trigger shop if player has enough koi
498
+  // Only try if no dialog/shop is currently open and no creature is in shop mode
499
+  if (!isDialogOpen() && !isShopOpen() && !donny.isInShopMode() && !ollie.isInShopMode()) {
500
+    const koiCount = gameState.getKoi()
501
+    // Try Donny first (lower threshold), then Ollie
502
+    if (!donny.tryTriggerShop(koiCount, pond, doug)) {
503
+      ollie.tryTriggerShop(koiCount, pond, doug)
504
+    }
505
+  }
175506
 
176507
   composer.render()
177508
 }
@@ -184,6 +515,14 @@ export function stop() {
184515
 
185516
   window.removeEventListener('resize', onResize)
186517
 
518
+  // Close dialog and shop if open
519
+  if (isDialogOpen()) {
520
+    closeDialog()
521
+  }
522
+  if (isShopOpen()) {
523
+    closeShop()
524
+  }
525
+
187526
   if (renderer) {
188527
     renderer.domElement.remove()
189528
     renderer.dispose()
@@ -193,6 +532,20 @@ export function stop() {
193532
     composer.dispose()
194533
   }
195534
 
535
+  if (koiCounterEl) {
536
+    koiCounterEl.remove()
537
+    koiCounterEl = null
538
+  }
539
+
540
+  // Reset capture state
541
+  captureState.isHolding = false
542
+  captureState.targetKoi = null
543
+  captureState.clickPos = null
544
+
545
+  if (placementManager) {
546
+    placementManager.dispose()
547
+  }
548
+
196549
   scene = null
197550
   camera = null
198551
   renderer = null
@@ -203,6 +556,7 @@ export function stop() {
203556
   koiSchool = null
204557
   ollie = null
205558
   breadManager = null
559
+  placementManager = null
206560
 }
207561
 
208562
 export const name = '3D'
src/renderers/three/koi.jsmodified
@@ -1,11 +1,80 @@
1
-// Koi fish - natural swimmers that react to bread
1
+// Koi fish - natural swimmers that are attracted to bread and can be captured
22
 import * as THREE from 'three'
3
+import gameState from './gameState.js'
4
+import { playCapture } from './sounds.js'
35
 
46
 export function createKoiSchool(scene, gradientMap, pondRadius) {
57
   const group = new THREE.Group()
68
   const kois = []
79
   const koiCount = 5
810
 
11
+  // Sparkle particles for capture effect
12
+  const sparkles = []
13
+  const sparkleMaterial = new THREE.MeshBasicMaterial({
14
+    color: 0xffd700,
15
+    transparent: true
16
+  })
17
+
18
+  function createCaptureSparkles(x, y, z) {
19
+    const particleCount = 12
20
+    for (let i = 0; i < particleCount; i++) {
21
+      const angle = (i / particleCount) * Math.PI * 2
22
+      const upAngle = Math.random() * Math.PI * 0.5
23
+
24
+      const sparkleGeom = new THREE.OctahedronGeometry(0.06)
25
+      const sparkle = new THREE.Mesh(sparkleGeom, sparkleMaterial.clone())
26
+      sparkle.position.set(x, y + 0.1, z)
27
+
28
+      // Random velocity outward and upward
29
+      const speed = 1.5 + Math.random() * 1
30
+      sparkle.userData = {
31
+        vx: Math.cos(angle) * Math.cos(upAngle) * speed,
32
+        vy: Math.sin(upAngle) * speed + 1,
33
+        vz: Math.sin(angle) * Math.cos(upAngle) * speed,
34
+        age: 0,
35
+        maxAge: 0.6 + Math.random() * 0.3,
36
+        rotSpeed: (Math.random() - 0.5) * 10
37
+      }
38
+
39
+      group.add(sparkle)
40
+      sparkles.push(sparkle)
41
+    }
42
+  }
43
+
44
+  function updateSparkles(delta) {
45
+    for (let i = sparkles.length - 1; i >= 0; i--) {
46
+      const sparkle = sparkles[i]
47
+      const d = sparkle.userData
48
+
49
+      d.age += delta
50
+      const progress = d.age / d.maxAge
51
+
52
+      // Move
53
+      sparkle.position.x += d.vx * delta
54
+      sparkle.position.y += d.vy * delta
55
+      sparkle.position.z += d.vz * delta
56
+
57
+      // Gravity
58
+      d.vy -= 4 * delta
59
+
60
+      // Spin
61
+      sparkle.rotation.x += d.rotSpeed * delta
62
+      sparkle.rotation.y += d.rotSpeed * delta
63
+
64
+      // Fade and shrink
65
+      sparkle.material.opacity = 1 - progress
66
+      sparkle.scale.setScalar(1 - progress * 0.5)
67
+
68
+      // Remove when done
69
+      if (d.age >= d.maxAge) {
70
+        group.remove(sparkle)
71
+        sparkle.geometry.dispose()
72
+        sparkle.material.dispose()
73
+        sparkles.splice(i, 1)
74
+      }
75
+    }
76
+  }
77
+
978
   // Koi color variations
1079
   const koiColors = [
1180
     { body: 0xff6b35, spots: 0xffffff },  // Orange with white
@@ -33,11 +102,18 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
33102
 
34103
     koi.state = {
35104
       speed: 0.3 + Math.random() * 0.3,
36
-      turnRate: 0,  // Current turning rate
105
+      turnRate: 0,
37106
       targetTurnRate: 0,
38107
       turnTimer: Math.random() * 3,
39
-      panicTimer: 0,
40
-      flickerPhase: Math.random() * Math.PI * 2
108
+      flickerPhase: Math.random() * Math.PI * 2,
109
+      // Attraction state
110
+      attractedTo: null, // { x, z } of bread attracting this koi
111
+      // Capture state
112
+      captured: false,
113
+      beingCaptured: false,
114
+      captureProgress: 0,
115
+      respawnTimer: 0,
116
+      originalScale: 0.9
41117
     }
42118
 
43119
     group.add(koi.group)
@@ -105,59 +181,208 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
105181
     return { group: koiGroup, tail }
106182
   }
107183
 
184
+  // Attract koi to nearby bread - replaces panic behavior
185
+  function attractToBread(breadBits) {
186
+    for (const koi of kois) {
187
+      if (koi.state.captured || koi.state.beingCaptured) continue
188
+
189
+      // Find nearest bread
190
+      let nearestBread = null
191
+      let nearestDist = 2.5 // Attraction radius
192
+
193
+      for (const bread of breadBits) {
194
+        const dist = Math.hypot(
195
+          koi.group.position.x - bread.x,
196
+          koi.group.position.z - bread.z
197
+        )
198
+        if (dist < nearestDist) {
199
+          nearestDist = dist
200
+          nearestBread = bread
201
+        }
202
+      }
203
+
204
+      koi.state.attractedTo = nearestBread
205
+    }
206
+  }
207
+
208
+  // Legacy panic trigger - keep for ripple effects but make koi scatter briefly
108209
   function triggerPanic(x, z) {
210
+    // Now just a brief scatter, not sustained panic
109211
     for (const koi of kois) {
212
+      if (koi.state.captured || koi.state.beingCaptured) continue
213
+
110214
       const dist = Math.hypot(
111215
         koi.group.position.x - x,
112216
         koi.group.position.z - z
113217
       )
114218
 
115
-      if (dist < 1.5) {
116
-        koi.state.panicTimer = 1 + Math.random() * 0.5
117
-
118
-        // Turn away from the disturbance
219
+      if (dist < 0.8) {
220
+        // Brief scatter only when bread lands very close
221
+        koi.state.attractedTo = null
222
+        // Turn slightly away then resume
119223
         const awayAngle = Math.atan2(
120224
           koi.group.position.x - x,
121225
           koi.group.position.z - z
122226
         )
123
-        // Set a strong turn toward the away direction
124227
         let turnNeeded = awayAngle - koi.group.rotation.y
125228
         while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
126229
         while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
127
-        koi.state.targetTurnRate = Math.sign(turnNeeded) * 3
230
+        koi.state.targetTurnRate = Math.sign(turnNeeded) * 2
231
+        koi.state.turnTimer = 0.3 // Brief scatter
128232
       }
129233
     }
130234
   }
131235
 
236
+  // Get koi under a given position (for capture detection)
237
+  function getKoiUnderPosition(x, z, radius = 0.3) {
238
+    for (const koi of kois) {
239
+      if (koi.state.captured || koi.state.beingCaptured) continue
240
+
241
+      const dist = Math.hypot(
242
+        koi.group.position.x - x,
243
+        koi.group.position.z - z
244
+      )
245
+
246
+      if (dist < radius) {
247
+        return koi
248
+      }
249
+    }
250
+    return null
251
+  }
252
+
253
+  // Start capturing a koi
254
+  function startCapture(koi) {
255
+    if (!koi || koi.state.captured || koi.state.beingCaptured) return false
256
+    koi.state.beingCaptured = true
257
+    koi.state.captureProgress = 0
258
+    koi.state.attractedTo = null
259
+    return true
260
+  }
261
+
262
+  // Update capture progress - returns true if capture completes
263
+  function updateCapture(koi, delta) {
264
+    if (!koi || !koi.state.beingCaptured) return false
265
+
266
+    koi.state.captureProgress += delta / 0.8 // 0.8 seconds to capture
267
+
268
+    // Scale down and wiggle during capture
269
+    const scale = koi.state.originalScale * (1 - koi.state.captureProgress * 0.3)
270
+    koi.group.scale.setScalar(Math.max(scale, 0.4))
271
+
272
+    // Wiggle/struggle effect
273
+    koi.group.rotation.z = Math.sin(koi.state.captureProgress * 20) * 0.3
274
+
275
+    if (koi.state.captureProgress >= 1) {
276
+      completeCapture(koi)
277
+      return true
278
+    }
279
+    return false
280
+  }
281
+
282
+  // Cancel capture in progress
283
+  function cancelCapture(koi) {
284
+    if (!koi || !koi.state.beingCaptured) return
285
+    koi.state.beingCaptured = false
286
+    koi.state.captureProgress = 0
287
+    koi.group.scale.setScalar(koi.state.originalScale)
288
+    koi.group.rotation.z = 0
289
+  }
290
+
291
+  // Complete capture - hide koi and schedule respawn
292
+  function completeCapture(koi) {
293
+    // Spawn sparkles at koi position before hiding
294
+    const pos = koi.group.position
295
+    createCaptureSparkles(pos.x, pos.y, pos.z)
296
+
297
+    koi.state.captured = true
298
+    koi.state.beingCaptured = false
299
+    koi.state.captureProgress = 0
300
+    koi.group.visible = false
301
+
302
+    // Schedule respawn
303
+    koi.state.respawnTimer = 8 + Math.random() * 12 // 8-20 seconds
304
+
305
+    // Add to game state and play sound
306
+    gameState.addKoi(1)
307
+    playCapture()
308
+  }
309
+
310
+  // Respawn a captured koi
311
+  function respawnKoi(koi) {
312
+    koi.state.captured = false
313
+    koi.group.visible = true
314
+    koi.group.scale.setScalar(koi.state.originalScale)
315
+    koi.group.rotation.z = 0
316
+
317
+    // Random new position
318
+    const angle = Math.random() * Math.PI * 2
319
+    const dist = Math.random() * pondRadius * 0.6 + pondRadius * 0.1
320
+    koi.group.position.set(
321
+      Math.cos(angle) * dist,
322
+      -0.08,
323
+      Math.sin(angle) * dist
324
+    )
325
+    koi.group.rotation.y = Math.random() * Math.PI * 2
326
+  }
327
+
132328
   function update(delta, elapsed) {
133329
     for (const koi of kois) {
134330
       const s = koi.state
135331
       const pos = koi.group.position
136332
 
137
-      // Update panic
138
-      const isPanicked = s.panicTimer > 0
139
-      if (isPanicked) {
140
-        s.panicTimer -= delta
333
+      // Handle respawn timer
334
+      if (s.captured) {
335
+        s.respawnTimer -= delta
336
+        if (s.respawnTimer <= 0) {
337
+          respawnKoi(koi)
338
+        }
339
+        continue
340
+      }
341
+
342
+      // Skip movement if being captured
343
+      if (s.beingCaptured) {
344
+        continue
141345
       }
142346
 
143347
       // Decide turning behavior
144348
       s.turnTimer -= delta
145
-      if (s.turnTimer <= 0 && !isPanicked) {
146
-        // Occasionally change turn rate for natural wandering
147
-        s.targetTurnRate = (Math.random() - 0.5) * 1.5
148
-        s.turnTimer = 1 + Math.random() * 3
349
+
350
+      // If attracted to bread, swim toward it
351
+      if (s.attractedTo) {
352
+        const toBreakX = s.attractedTo.x - pos.x
353
+        const toBreadZ = s.attractedTo.z - pos.z
354
+        const distToBread = Math.hypot(toBreakX, toBreadZ)
355
+
356
+        if (distToBread < 0.15) {
357
+          // Very close to bread - slow down and circle
358
+          s.targetTurnRate = 0.3
359
+          s.speed = 0.1
360
+        } else {
361
+          // Swim toward bread
362
+          const toBreadAngle = Math.atan2(toBreakX, toBreadZ)
363
+          let turnNeeded = toBreadAngle - koi.group.rotation.y
364
+          while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
365
+          while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
366
+
367
+          s.targetTurnRate = Math.sign(turnNeeded) * Math.min(Math.abs(turnNeeded) * 2, 2)
368
+          s.speed = 0.4 + Math.min(distToBread * 0.2, 0.3) // Faster when far
369
+        }
370
+      } else {
371
+        // Natural wandering behavior
372
+        if (s.turnTimer <= 0) {
373
+          s.targetTurnRate = (Math.random() - 0.5) * 1.5
374
+          s.turnTimer = 1 + Math.random() * 3
375
+          s.speed = 0.3 + Math.random() * 0.3
376
+        }
149377
       }
150378
 
151379
       // Check if heading toward pond edge
152380
       const distFromCenter = Math.hypot(pos.x, pos.z)
153381
       if (distFromCenter > pondRadius * 0.75) {
154
-        // Calculate angle to center
155382
         const toCenter = Math.atan2(-pos.x, -pos.z)
156383
         let turnNeeded = toCenter - koi.group.rotation.y
157384
         while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
158385
         while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
159
-
160
-        // Steer back toward center
161386
         s.targetTurnRate = Math.sign(turnNeeded) * 1.5
162387
       }
163388
 
@@ -167,19 +392,15 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
167392
       // Apply rotation
168393
       koi.group.rotation.y += s.turnRate * delta
169394
 
170
-      // Move forward (in the direction the fish is facing, which is +Z in local space)
171
-      const speed = isPanicked ? s.speed * 2.5 : s.speed
172
-
173
-      // Get forward direction from rotation
395
+      // Move forward
174396
       const forwardX = Math.sin(koi.group.rotation.y)
175397
       const forwardZ = Math.cos(koi.group.rotation.y)
398
+      pos.x += forwardX * s.speed * delta
399
+      pos.z += forwardZ * s.speed * delta
176400
 
177
-      pos.x += forwardX * speed * delta
178
-      pos.z += forwardZ * speed * delta
179
-
180
-      // Tail wiggle - faster when moving fast
181
-      const wiggleSpeed = isPanicked ? 18 : 10
182
-      const wiggleAmount = isPanicked ? 0.4 : 0.25
401
+      // Tail wiggle
402
+      const wiggleSpeed = s.attractedTo ? 14 : 10
403
+      const wiggleAmount = s.attractedTo ? 0.35 : 0.25
183404
       koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount
184405
 
185406
       // Gentle vertical bob
@@ -193,12 +414,20 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
193414
         pos.z *= scale
194415
       }
195416
     }
417
+
418
+    // Update sparkle particles
419
+    updateSparkles(delta)
196420
   }
197421
 
198422
   return {
199423
     group,
200424
     update,
201425
     triggerPanic,
202
-    getKois: () => kois.map(k => k.group)
426
+    attractToBread,
427
+    getKoiUnderPosition,
428
+    startCapture,
429
+    updateCapture,
430
+    cancelCapture,
431
+    getKois: () => kois.filter(k => !k.state.captured).map(k => k.group)
203432
   }
204433
 }
src/renderers/three/narwhal.jsmodified
@@ -4,39 +4,54 @@ import * as THREE from 'three'
44
 export function createDonny(scene, gradientMap) {
55
   const group = new THREE.Group()
66
 
7
-  // Color palette
8
-  const bodyColor = 0x7a9eb8 // Dusty blue-grey
9
-  const bellyColor = 0xc8d8e4 // Pale belly
10
-  const tuskColor = 0xf5f0e6 // Ivory
11
-  const monocleColor = 0xd4af37 // Gold
7
+  // Store gradientMap for accessory creation
8
+  const storedGradientMap = gradientMap
9
+
10
+  // Default colors
11
+  const defaultColors = {
12
+    body: 0x7a9eb8,
13
+    belly: 0xc8d8e4,
14
+    monocleRim: 0xd4af37,
15
+    monocleGlass: 0x88ccff
16
+  }
1217
 
13
-  // Materials
18
+  // Materials (stored for outfit swapping)
1419
   const bodyMaterial = new THREE.MeshToonMaterial({
15
-    color: bodyColor,
20
+    color: defaultColors.body,
1621
     gradientMap: gradientMap
1722
   })
1823
 
1924
   const bellyMaterial = new THREE.MeshToonMaterial({
20
-    color: bellyColor,
25
+    color: defaultColors.belly,
2126
     gradientMap: gradientMap
2227
   })
2328
 
2429
   const tuskMaterial = new THREE.MeshToonMaterial({
25
-    color: tuskColor,
30
+    color: 0xf5f0e6,
2631
     gradientMap: gradientMap
2732
   })
2833
 
2934
   const monocleMaterial = new THREE.MeshToonMaterial({
30
-    color: monocleColor,
35
+    color: defaultColors.monocleRim,
3136
     gradientMap: gradientMap
3237
   })
3338
 
3439
   const glassMaterial = new THREE.MeshBasicMaterial({
35
-    color: 0x88ccff,
40
+    color: defaultColors.monocleGlass,
3641
     transparent: true,
3742
     opacity: 0.3
3843
   })
3944
 
45
+  // Accessory tracking
46
+  const accessories = {
47
+    head: null
48
+  }
49
+
50
+  // Mount points
51
+  const mountPoints = {
52
+    head: new THREE.Vector3(1.0, 0.55, 0)
53
+  }
54
+
4055
   // Main body - elongated oval
4156
   const bodyGeom = new THREE.SphereGeometry(0.5, 8, 6)
4257
   bodyGeom.scale(2.2, 0.7, 0.8)
@@ -115,7 +130,7 @@ export function createDonny(scene, gradientMap) {
115130
 
116131
   // Chain (simple dangling segments)
117132
   const chainMaterial = new THREE.MeshToonMaterial({
118
-    color: monocleColor,
133
+    color: defaultColors.monocleRim,
119134
     gradientMap: gradientMap
120135
   })
121136
   for (let i = 0; i < 4; i++) {
@@ -168,28 +183,68 @@ export function createDonny(scene, gradientMap) {
168183
 
169184
   // State
170185
   const state = {
171
-    mode: 'waiting', // 'waiting', 'rumbling', 'emerging', 'surfaced', 'submerging'
186
+    mode: 'waiting', // 'waiting', 'rumbling', 'emerging', 'surfaced', 'submerging', 'shop_approaching', 'shop_ready', 'shop_departing'
172187
     timer: 30 + Math.random() * 30, // First appearance in 30-60 seconds
173188
     emergeX: 0,
174189
     emergeZ: 0,
175
-    surfaceTime: 0
190
+    surfaceTime: 0,
191
+    // Shop state
192
+    shopMode: false,
193
+    shopCooldown: 0,
194
+    onShopReady: null
176195
   }
177196
 
197
+  // Shop trigger thresholds
198
+  const SHOP_KOI_THRESHOLD = 5 // Lower threshold for first shop experience
199
+  const SHOP_COOLDOWN = 45 // seconds between shop approaches
200
+
178201
   function startRumble(pond) {
179202
     state.mode = 'rumbling'
180203
     state.timer = 0
181204
 
182
-    // Pick random spot in the pond
183
-    const angle = Math.random() * Math.PI * 2
184
-    const dist = Math.random() * pond.radius * 0.5 + pond.radius * 0.2
185
-    state.emergeX = Math.cos(angle) * dist
186
-    state.emergeZ = Math.sin(angle) * dist
205
+    // Pick random spot in the pond, avoiding forbidden zones (dock, boat)
206
+    let attempts = 0
207
+    let angle, dist
208
+    do {
209
+      angle = Math.random() * Math.PI * 2
210
+      dist = Math.random() * pond.radius * 0.5 + pond.radius * 0.2
211
+      state.emergeX = Math.cos(angle) * dist
212
+      state.emergeZ = Math.sin(angle) * dist
213
+      attempts++
214
+    } while (!pond.isValidEmergenceSpot(state.emergeX, state.emergeZ) && attempts < 20)
187215
 
188216
     group.position.x = state.emergeX
189217
     group.position.z = state.emergeZ
190218
     group.rotation.y = angle + Math.PI / 2 // Face outward-ish
191219
   }
192220
 
221
+  function startShopApproach(pond, doug) {
222
+    state.mode = 'shop_approaching'
223
+    state.shopMode = true
224
+    state.timer = 0
225
+
226
+    // Emerge near Doug
227
+    const dougPos = doug.getPosition()
228
+    const approachAngle = Math.random() * Math.PI * 2
229
+    const approachDist = 1.5 // Close to Doug
230
+
231
+    state.emergeX = dougPos.x + Math.cos(approachAngle) * approachDist
232
+    state.emergeZ = dougPos.z + Math.sin(approachAngle) * approachDist
233
+
234
+    // Clamp to pond bounds
235
+    const distFromCenter = Math.hypot(state.emergeX, state.emergeZ)
236
+    if (distFromCenter > pond.radius * 0.85) {
237
+      const scale = (pond.radius * 0.85) / distFromCenter
238
+      state.emergeX *= scale
239
+      state.emergeZ *= scale
240
+    }
241
+
242
+    group.position.x = state.emergeX
243
+    group.position.z = state.emergeZ
244
+    group.visible = true
245
+    group.position.y = -2
246
+  }
247
+
193248
   // Helper to smoothly interpolate angles
194249
   function lerpAngle(from, to, t) {
195250
     let diff = to - from
@@ -315,11 +370,238 @@ export function createDonny(scene, gradientMap) {
315370
           group.rotation.z = 0
316371
         }
317372
         break
373
+
374
+      case 'shop_approaching':
375
+        // Rise from water for shop
376
+        const shopEmergeProgress = Math.min(state.timer / 1.2, 1)
377
+        const shopEaseOut = 1 - Math.pow(1 - shopEmergeProgress, 3)
378
+        group.position.y = -2 + shopEaseOut * 2.25
379
+
380
+        // Face Doug
381
+        group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.8)
382
+        group.rotation.z = 0.87 * shopEaseOut
383
+
384
+        if (shopEmergeProgress >= 1) {
385
+          state.mode = 'shop_ready'
386
+          state.timer = 0
387
+          // Trigger shop UI callback
388
+          if (state.onShopReady) {
389
+            state.onShopReady('donny')
390
+          }
391
+        }
392
+        break
393
+
394
+      case 'shop_ready':
395
+        // Bob gently while shop is open
396
+        group.position.y = 0.25 + Math.sin(elapsed * 2) * 0.06
397
+        group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.3)
398
+        group.rotation.z = 0.87 + Math.sin(elapsed * 1.5) * 0.04
399
+
400
+        // Follow Doug while talking - swim toward him
401
+        if (doug) {
402
+          const dougPos = doug.getPosition()
403
+          const dx = dougPos.x - group.position.x
404
+          const dz = dougPos.z - group.position.z
405
+          const distToDoug = Math.hypot(dx, dz)
406
+          const minDist = 1.0 // Keep some distance from Doug
407
+
408
+          if (distToDoug > minDist) {
409
+            // Swim toward Doug - faster when farther away
410
+            const approachSpeed = Math.min(distToDoug * 0.5, 1.5) * delta
411
+            group.position.x += (dx / distToDoug) * approachSpeed
412
+            group.position.z += (dz / distToDoug) * approachSpeed
413
+
414
+            // Clamp to pond bounds
415
+            const distFromCenter = Math.hypot(group.position.x, group.position.z)
416
+            if (distFromCenter > pond.radius * 0.85) {
417
+              const scale = (pond.radius * 0.85) / distFromCenter
418
+              group.position.x *= scale
419
+              group.position.z *= scale
420
+            }
421
+          }
422
+        }
423
+
424
+        // Flipper animation
425
+        leftFlipper.rotation.z = 2.2 + Math.sin(elapsed * 3) * 0.15
426
+        rightFlipper.rotation.z = 2.2 + Math.sin(elapsed * 3 + 0.5) * 0.15
427
+
428
+        // Occasional ripples
429
+        if (Math.random() < delta * 0.5) {
430
+          pond.addRipple(
431
+            group.position.x + (Math.random() - 0.5) * 0.5,
432
+            group.position.z + (Math.random() - 0.5) * 0.5
433
+          )
434
+        }
435
+        // Stay in this state until dismissShop() is called
436
+        break
437
+
438
+      case 'shop_departing':
439
+        // Sink back down after shop closes
440
+        const shopSubmergeProgress = Math.min(state.timer / 1.2, 1)
441
+        const shopEaseIn = Math.pow(shopSubmergeProgress, 2)
442
+        group.position.y = 0.25 - shopEaseIn * 2.5
443
+        group.rotation.z = 0.87 - shopEaseIn * 1.1
444
+
445
+        if (Math.random() < delta * 4) {
446
+          pond.addRipple(
447
+            group.position.x + (Math.random() - 0.5) * 0.6,
448
+            group.position.z + (Math.random() - 0.5) * 0.6
449
+          )
450
+        }
451
+
452
+        if (shopSubmergeProgress >= 1) {
453
+          state.mode = 'waiting'
454
+          state.timer = 0
455
+          state.shopMode = false
456
+          state.shopCooldown = SHOP_COOLDOWN
457
+          group.visible = false
458
+          group.position.y = -3
459
+          group.rotation.x = 0
460
+          group.rotation.z = 0
461
+        }
462
+        break
463
+    }
464
+
465
+    // Decrement shop cooldown
466
+    if (state.shopCooldown > 0) {
467
+      state.shopCooldown -= delta
468
+    }
469
+  }
470
+
471
+  // Try to trigger shop approach (called from main loop)
472
+  function tryTriggerShop(koiCount, pond, doug) {
473
+    if (state.shopCooldown > 0) return false
474
+    if (koiCount < SHOP_KOI_THRESHOLD) return false
475
+
476
+    // If waiting, do full approach sequence
477
+    if (state.mode === 'waiting') {
478
+      startShopApproach(pond, doug)
479
+      return true
480
+    }
481
+
482
+    // If already surfaced, transition directly to shop mode
483
+    if (state.mode === 'surfaced') {
484
+      state.mode = 'shop_ready'
485
+      state.shopMode = true
486
+      state.timer = 0
487
+      if (state.onShopReady) {
488
+        state.onShopReady('donny')
489
+      }
490
+      return true
491
+    }
492
+
493
+    return false
494
+  }
495
+
496
+  // Dismiss shop and start departing
497
+  function dismissShop() {
498
+    if (state.mode === 'shop_ready') {
499
+      state.mode = 'shop_departing'
500
+      state.timer = 0
501
+    }
502
+  }
503
+
504
+  // Set callback for when shop is ready
505
+  function setShopReadyCallback(callback) {
506
+    state.onShopReady = callback
507
+  }
508
+
509
+  // Check if in shop mode
510
+  function isInShopMode() {
511
+    return state.shopMode
512
+  }
513
+
514
+  // Apply an outfit to Donny
515
+  function applyOutfit(outfit) {
516
+    if (!outfit) return
517
+
518
+    switch (outfit.type) {
519
+      case 'color_body':
520
+        if (outfit.colors) {
521
+          if (outfit.colors.body) bodyMaterial.color.setHex(outfit.colors.body)
522
+          if (outfit.colors.belly) bellyMaterial.color.setHex(outfit.colors.belly)
523
+        }
524
+        break
525
+
526
+      case 'accessory_face':
527
+        // Monocle color swap
528
+        if (outfit.colors) {
529
+          if (outfit.colors.rim) monocleMaterial.color.setHex(outfit.colors.rim)
530
+          if (outfit.colors.glass) glassMaterial.color.setHex(outfit.colors.glass)
531
+        }
532
+        break
533
+
534
+      case 'accessory_head':
535
+        // Remove existing head accessory
536
+        if (accessories.head) {
537
+          group.remove(accessories.head)
538
+          accessories.head = null
539
+        }
540
+        // Add new accessory
541
+        if (outfit.meshFactory) {
542
+          accessories.head = outfit.meshFactory(storedGradientMap)
543
+          accessories.head.position.copy(mountPoints.head)
544
+          group.add(accessories.head)
545
+        }
546
+        break
547
+    }
548
+  }
549
+
550
+  // Remove an outfit from Donny
551
+  function removeOutfit(outfit) {
552
+    if (!outfit) return
553
+
554
+    switch (outfit.type) {
555
+      case 'color_body':
556
+        bodyMaterial.color.setHex(defaultColors.body)
557
+        bellyMaterial.color.setHex(defaultColors.belly)
558
+        break
559
+
560
+      case 'accessory_face':
561
+        monocleMaterial.color.setHex(defaultColors.monocleRim)
562
+        glassMaterial.color.setHex(defaultColors.monocleGlass)
563
+        break
564
+
565
+      case 'accessory_head':
566
+        if (accessories.head) {
567
+          group.remove(accessories.head)
568
+          accessories.head = null
569
+        }
570
+        break
571
+    }
572
+  }
573
+
574
+  // Check if Donny can be tapped to open shop
575
+  function isTappable() {
576
+    // Tappable when visible and surfaced (not emerging/submerging)
577
+    return group.visible && (state.mode === 'surfaced' || state.mode === 'shop_ready')
578
+  }
579
+
580
+  // Manually trigger shop (for tap-to-shop feature)
581
+  function triggerShopFromTap(pond, doug) {
582
+    if (state.mode === 'surfaced') {
583
+      // Already surfaced - transition to shop mode
584
+      state.mode = 'shop_ready'
585
+      state.shopMode = true
586
+      state.timer = 0
587
+      if (state.onShopReady) {
588
+        state.onShopReady('donny')
589
+      }
590
+      return true
318591
     }
592
+    return false
319593
   }
320594
 
321595
   return {
322596
     group,
323
-    update
597
+    update,
598
+    tryTriggerShop,
599
+    dismissShop,
600
+    setShopReadyCallback,
601
+    isInShopMode,
602
+    isTappable,
603
+    triggerShopFromTap,
604
+    applyOutfit,
605
+    removeOutfit
324606
   }
325607
 }
src/renderers/three/octopus.jsmodified
@@ -4,39 +4,55 @@ import * as THREE from 'three'
44
 export function createOllie(scene, gradientMap) {
55
   const group = new THREE.Group()
66
 
7
-  // Color palette - purple theme
8
-  const bodyColor = 0x7b4b94 // Deep purple
9
-  const bellyColor = 0xb89bc9 // Lighter lavender
10
-  const suckerColor = 0xd4a5c9 // Pink-ish
11
-  const glassRimColor = 0xd4af37 // Gold
7
+  // Store gradientMap for accessory creation
8
+  const storedGradientMap = gradientMap
9
+
10
+  // Default colors - purple theme
11
+  const defaultColors = {
12
+    body: 0x7b4b94,
13
+    belly: 0xb89bc9,
14
+    suckers: 0xd4a5c9,
15
+    magRim: 0xd4af37,
16
+    magGlass: 0x88ccff
17
+  }
1218
 
13
-  // Materials
19
+  // Materials (stored for outfit swapping)
1420
   const bodyMaterial = new THREE.MeshToonMaterial({
15
-    color: bodyColor,
21
+    color: defaultColors.body,
1622
     gradientMap: gradientMap
1723
   })
1824
 
1925
   const bellyMaterial = new THREE.MeshToonMaterial({
20
-    color: bellyColor,
26
+    color: defaultColors.belly,
2127
     gradientMap: gradientMap
2228
   })
2329
 
2430
   const suckerMaterial = new THREE.MeshToonMaterial({
25
-    color: suckerColor,
31
+    color: defaultColors.suckers,
2632
     gradientMap: gradientMap
2733
   })
2834
 
2935
   const glassRimMaterial = new THREE.MeshToonMaterial({
30
-    color: glassRimColor,
36
+    color: defaultColors.magRim,
3137
     gradientMap: gradientMap
3238
   })
3339
 
3440
   const glassMaterial = new THREE.MeshBasicMaterial({
35
-    color: 0x88ccff,
41
+    color: defaultColors.magGlass,
3642
     transparent: true,
3743
     opacity: 0.3
3844
   })
3945
 
46
+  // Accessory tracking
47
+  const accessories = {
48
+    head: null
49
+  }
50
+
51
+  // Mount points
52
+  const mountPoints = {
53
+    head: new THREE.Vector3(0, 0.95, 0)
54
+  }
55
+
4056
   // Head/Mantle - bulbous dome
4157
   const mantleGeom = new THREE.SphereGeometry(0.5, 10, 8)
4258
   mantleGeom.scale(1.2, 1.4, 1.0)
@@ -169,9 +185,17 @@ export function createOllie(scene, gradientMap) {
169185
     timer: 45 + Math.random() * 30, // First appearance in 45-75 seconds (after narwhal)
170186
     emergeX: 0,
171187
     emergeZ: 0,
172
-    surfaceTime: 0
188
+    surfaceTime: 0,
189
+    // Shop state
190
+    shopMode: false,
191
+    shopCooldown: 0,
192
+    onShopReady: null
173193
   }
174194
 
195
+  // Shop trigger thresholds
196
+  const SHOP_KOI_THRESHOLD = 10 // Second shop unlocks after Donny
197
+  const SHOP_COOLDOWN = 45 // seconds between shop approaches
198
+
175199
   function createTentacle(bodyMat, suckerMat, gradient) {
176200
     // Build tentacle as a chain of segments, each one a child of the previous
177201
     // This creates a smooth curve by rotating each joint
@@ -229,17 +253,49 @@ export function createOllie(scene, gradientMap) {
229253
     state.mode = 'rumbling'
230254
     state.timer = 0
231255
 
232
-    // Pick random spot in outer zone of pond (70-90% radius)
233
-    const angle = Math.random() * Math.PI * 2
234
-    const dist = Math.random() * pond.radius * 0.2 + pond.radius * 0.7
235
-    state.emergeX = Math.cos(angle) * dist
236
-    state.emergeZ = Math.sin(angle) * dist
256
+    // Pick random spot in outer zone of pond (70-90% radius), avoiding forbidden zones
257
+    let attempts = 0
258
+    let angle, dist
259
+    do {
260
+      angle = Math.random() * Math.PI * 2
261
+      dist = Math.random() * pond.radius * 0.2 + pond.radius * 0.7
262
+      state.emergeX = Math.cos(angle) * dist
263
+      state.emergeZ = Math.sin(angle) * dist
264
+      attempts++
265
+    } while (!pond.isValidEmergenceSpot(state.emergeX, state.emergeZ) && attempts < 20)
237266
 
238267
     group.position.x = state.emergeX
239268
     group.position.z = state.emergeZ
240269
     group.rotation.y = angle + Math.PI / 2
241270
   }
242271
 
272
+  function startShopApproach(pond, doug) {
273
+    state.mode = 'shop_approaching'
274
+    state.shopMode = true
275
+    state.timer = 0
276
+
277
+    // Emerge near Doug
278
+    const dougPos = doug.getPosition()
279
+    const approachAngle = Math.random() * Math.PI * 2
280
+    const approachDist = 1.5
281
+
282
+    state.emergeX = dougPos.x + Math.cos(approachAngle) * approachDist
283
+    state.emergeZ = dougPos.z + Math.sin(approachAngle) * approachDist
284
+
285
+    // Clamp to pond bounds
286
+    const distFromCenter = Math.hypot(state.emergeX, state.emergeZ)
287
+    if (distFromCenter > pond.radius * 0.85) {
288
+      const scale = (pond.radius * 0.85) / distFromCenter
289
+      state.emergeX *= scale
290
+      state.emergeZ *= scale
291
+    }
292
+
293
+    group.position.x = state.emergeX
294
+    group.position.z = state.emergeZ
295
+    group.visible = true
296
+    group.position.y = -2
297
+  }
298
+
243299
   // Helper to smoothly interpolate angles
244300
   function lerpAngle(from, to, t) {
245301
     let diff = to - from
@@ -378,11 +434,260 @@ export function createOllie(scene, gradientMap) {
378434
           group.rotation.z = 0
379435
         }
380436
         break
437
+
438
+      case 'shop_approaching':
439
+        // Rise from water for shop
440
+        const shopEmergeProgress = Math.min(state.timer / 1.5, 1)
441
+        const shopEaseOut = 1 - Math.pow(1 - shopEmergeProgress, 3)
442
+        group.position.y = -2 + shopEaseOut * 2.2
443
+
444
+        // Face Doug
445
+        group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.8)
446
+
447
+        // Animate tentacles during emergence
448
+        tentacles.forEach((t, i) => {
449
+          const baseAngle = (i / 8) * Math.PI * 2
450
+          const phase = i * (Math.PI / 4)
451
+          t.rotation.y = baseAngle + Math.sin(elapsed * 1.5 + phase) * 0.12
452
+          t.rotation.x = Math.sin(elapsed * 2 + phase) * 0.08
453
+        })
454
+
455
+        if (shopEmergeProgress >= 1) {
456
+          state.mode = 'shop_ready'
457
+          state.timer = 0
458
+          if (state.onShopReady) {
459
+            state.onShopReady('ollie')
460
+          }
461
+        }
462
+        break
463
+
464
+      case 'shop_ready':
465
+        // Bob gently while shop is open
466
+        group.position.y = 0.2 + Math.sin(elapsed * 2) * 0.05
467
+        group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.4)
468
+        group.rotation.x = Math.sin(elapsed * 1.2) * 0.03
469
+        group.rotation.z = Math.cos(elapsed * 1.0) * 0.02
470
+
471
+        // Follow Doug while talking - Ollie is eager and curious!
472
+        if (doug) {
473
+          const dougPos = doug.getPosition()
474
+          const dx = dougPos.x - group.position.x
475
+          const dz = dougPos.z - group.position.z
476
+          const distToDoug = Math.hypot(dx, dz)
477
+          const minDist = 0.8 // Ollie gets closer (curious!)
478
+
479
+          if (distToDoug > minDist) {
480
+            // Swim toward Doug - faster when farther, Ollie is eager!
481
+            const approachSpeed = Math.min(distToDoug * 0.6, 1.8) * delta
482
+            group.position.x += (dx / distToDoug) * approachSpeed
483
+            group.position.z += (dz / distToDoug) * approachSpeed
484
+
485
+            // Clamp to pond bounds
486
+            const distFromCenter = Math.hypot(group.position.x, group.position.z)
487
+            if (distFromCenter > pond.radius * 0.85) {
488
+              const scale = (pond.radius * 0.85) / distFromCenter
489
+              group.position.x *= scale
490
+              group.position.z *= scale
491
+            }
492
+          }
493
+        }
494
+
495
+        // Tentacle animation
496
+        tentacles.forEach((t, i) => {
497
+          const baseAngle = (i / 8) * Math.PI * 2
498
+          const phase = i * (Math.PI / 4)
499
+          t.rotation.y = baseAngle + Math.sin(elapsed * 1.5 + phase) * 0.12
500
+          t.rotation.x = Math.sin(elapsed * 2 + phase) * 0.08
501
+        })
502
+
503
+        // Magnifying glass wobble
504
+        magGlassGroup.rotation.z = Math.sin(elapsed * 3) * 0.1
505
+        magGlassGroup.rotation.y = Math.sin(elapsed * 2) * 0.15
506
+
507
+        // Occasional ripples
508
+        if (Math.random() < delta * 0.4) {
509
+          pond.addRipple(
510
+            group.position.x + (Math.random() - 0.5) * 0.6,
511
+            group.position.z + (Math.random() - 0.5) * 0.6
512
+          )
513
+        }
514
+        break
515
+
516
+      case 'shop_departing':
517
+        const shopSubmergeProgress = Math.min(state.timer / 1.5, 1)
518
+        const shopEaseIn = Math.pow(shopSubmergeProgress, 2)
519
+        group.position.y = 0.2 - shopEaseIn * 2.5
520
+
521
+        // Tentacles curl as departing
522
+        tentacles.forEach((t, i) => {
523
+          const baseAngle = (i / 8) * Math.PI * 2
524
+          const phase = i * (Math.PI / 4)
525
+          t.rotation.y = baseAngle + Math.sin(elapsed * 2 + phase) * 0.05
526
+          t.rotation.x = shopEaseIn * 0.4 + Math.sin(elapsed * 2 + phase) * 0.05
527
+        })
528
+
529
+        if (Math.random() < delta * 5) {
530
+          pond.addRipple(
531
+            group.position.x + (Math.random() - 0.5) * 0.8,
532
+            group.position.z + (Math.random() - 0.5) * 0.8
533
+          )
534
+        }
535
+
536
+        if (shopSubmergeProgress >= 1) {
537
+          state.mode = 'waiting'
538
+          state.timer = 0
539
+          state.shopMode = false
540
+          state.shopCooldown = SHOP_COOLDOWN
541
+          group.visible = false
542
+          group.position.y = -3
543
+          group.rotation.x = 0
544
+          group.rotation.z = 0
545
+        }
546
+        break
547
+    }
548
+
549
+    // Decrement shop cooldown
550
+    if (state.shopCooldown > 0) {
551
+      state.shopCooldown -= delta
552
+    }
553
+  }
554
+
555
+  // Try to trigger shop approach
556
+  function tryTriggerShop(koiCount, pond, doug) {
557
+    if (state.shopCooldown > 0) return false
558
+    if (koiCount < SHOP_KOI_THRESHOLD) return false
559
+
560
+    // If waiting, do full approach sequence
561
+    if (state.mode === 'waiting') {
562
+      startShopApproach(pond, doug)
563
+      return true
564
+    }
565
+
566
+    // If already surfaced, transition directly to shop mode
567
+    if (state.mode === 'surfaced') {
568
+      state.mode = 'shop_ready'
569
+      state.shopMode = true
570
+      state.timer = 0
571
+      if (state.onShopReady) {
572
+        state.onShopReady('ollie')
573
+      }
574
+      return true
575
+    }
576
+
577
+    return false
578
+  }
579
+
580
+  // Dismiss shop and start departing
581
+  function dismissShop() {
582
+    if (state.mode === 'shop_ready') {
583
+      state.mode = 'shop_departing'
584
+      state.timer = 0
585
+    }
586
+  }
587
+
588
+  // Set callback for when shop is ready
589
+  function setShopReadyCallback(callback) {
590
+    state.onShopReady = callback
591
+  }
592
+
593
+  // Check if in shop mode
594
+  function isInShopMode() {
595
+    return state.shopMode
596
+  }
597
+
598
+  // Apply an outfit to Ollie
599
+  function applyOutfit(outfit) {
600
+    if (!outfit) return
601
+
602
+    switch (outfit.type) {
603
+      case 'color_body':
604
+        if (outfit.colors) {
605
+          if (outfit.colors.body) bodyMaterial.color.setHex(outfit.colors.body)
606
+          if (outfit.colors.belly) bellyMaterial.color.setHex(outfit.colors.belly)
607
+          if (outfit.colors.suckers) suckerMaterial.color.setHex(outfit.colors.suckers)
608
+        }
609
+        break
610
+
611
+      case 'accessory_held':
612
+        // Magnifying glass color swap
613
+        if (outfit.colors) {
614
+          if (outfit.colors.rim) glassRimMaterial.color.setHex(outfit.colors.rim)
615
+          if (outfit.colors.glass) glassMaterial.color.setHex(outfit.colors.glass)
616
+        }
617
+        break
618
+
619
+      case 'accessory_head':
620
+        // Remove existing head accessory
621
+        if (accessories.head) {
622
+          group.remove(accessories.head)
623
+          accessories.head = null
624
+        }
625
+        // Add new accessory
626
+        if (outfit.meshFactory) {
627
+          accessories.head = outfit.meshFactory(storedGradientMap)
628
+          accessories.head.position.copy(mountPoints.head)
629
+          group.add(accessories.head)
630
+        }
631
+        break
632
+    }
633
+  }
634
+
635
+  // Remove an outfit from Ollie
636
+  function removeOutfit(outfit) {
637
+    if (!outfit) return
638
+
639
+    switch (outfit.type) {
640
+      case 'color_body':
641
+        bodyMaterial.color.setHex(defaultColors.body)
642
+        bellyMaterial.color.setHex(defaultColors.belly)
643
+        suckerMaterial.color.setHex(defaultColors.suckers)
644
+        break
645
+
646
+      case 'accessory_held':
647
+        glassRimMaterial.color.setHex(defaultColors.magRim)
648
+        glassMaterial.color.setHex(defaultColors.magGlass)
649
+        break
650
+
651
+      case 'accessory_head':
652
+        if (accessories.head) {
653
+          group.remove(accessories.head)
654
+          accessories.head = null
655
+        }
656
+        break
657
+    }
658
+  }
659
+
660
+  // Check if Ollie can be tapped to open shop
661
+  function isTappable() {
662
+    // Tappable when visible and surfaced (not emerging/submerging)
663
+    return group.visible && (state.mode === 'surfaced' || state.mode === 'shop_ready')
664
+  }
665
+
666
+  // Manually trigger shop (for tap-to-shop feature)
667
+  function triggerShopFromTap(pond, doug) {
668
+    if (state.mode === 'surfaced') {
669
+      // Already surfaced - transition to shop mode
670
+      state.mode = 'shop_ready'
671
+      state.shopMode = true
672
+      state.timer = 0
673
+      if (state.onShopReady) {
674
+        state.onShopReady('ollie')
675
+      }
676
+      return true
381677
     }
678
+    return false
382679
   }
383680
 
384681
   return {
385682
     group,
386
-    update
683
+    update,
684
+    tryTriggerShop,
685
+    dismissShop,
686
+    setShopReadyCallback,
687
+    isInShopMode,
688
+    isTappable,
689
+    triggerShopFromTap,
690
+    applyOutfit,
691
+    removeOutfit
387692
   }
388693
 }
src/renderers/three/pond.jsmodified
@@ -363,8 +363,8 @@ export function createPond(scene, gradientMap) {
363363
   // ============================================
364364
 
365365
   const boathouse = new THREE.Group()
366
-  const boathouseX = -4.2
367
-  const boathouseZ = 3.2
366
+  const boathouseX = -3.3
367
+  const boathouseZ = 3.8
368368
 
369369
   // Boathouse materials
370370
   const boathouseWoodMaterial = new THREE.MeshToonMaterial({
@@ -464,6 +464,96 @@ export function createPond(scene, gradientMap) {
464464
 
465465
   group.add(boathouse)
466466
 
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
+
467557
   // ============================================
468558
   // TREES - scattered around the edges
469559
   // ============================================
@@ -581,6 +671,25 @@ export function createPond(scene, gradientMap) {
581671
       createSmokeParticle()
582672
     }
583673
 
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
+
584693
     // Update smoke particles
585694
     for (let i = smokeParticles.length - 1; i >= 0; i--) {
586695
       const smoke = smokeParticles[i]
@@ -607,11 +716,108 @@ export function createPond(scene, gradientMap) {
607716
 
608717
   scene.add(group)
609718
 
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
+  // Snap zones for building placement
747
+  // Each zone has: id, position, angle (rotation), type, allowed buildings, occupied status
748
+  const snapZones = [
749
+    // Water edge zones (for docks, fishing huts)
750
+    { id: 'water_n', x: 0, z: -4.3, angle: Math.PI, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false },
751
+    { id: 'water_ne', x: 3.0, z: -3.0, angle: Math.PI * 0.75, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false },
752
+    { id: 'water_e', x: 4.3, z: 0, angle: Math.PI * 0.5, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false },
753
+    { id: 'water_se', x: 3.0, z: 3.0, angle: Math.PI * 0.25, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false },
754
+    { id: 'water_s', x: 0, z: 4.3, angle: 0, type: 'waterEdge', allowedBuildings: ['dock_wooden', 'fishing_hut', 'reeds'], occupied: false },
755
+
756
+    // Shore zones (for lighthouse, fence, onion house) - further from water
757
+    { id: 'shore_n', x: 0, z: -5.5, angle: Math.PI, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false },
758
+    { id: 'shore_ne', x: 4.0, z: -4.0, angle: Math.PI * 0.75, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false },
759
+    { id: 'shore_e', x: 5.5, z: 0, angle: Math.PI * 0.5, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false },
760
+    { id: 'shore_se', x: 4.0, z: 4.0, angle: Math.PI * 0.25, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false },
761
+    { id: 'shore_s', x: 0, z: 5.5, angle: 0, type: 'shore', allowedBuildings: ['lighthouse', 'fence', 'onion_house'], occupied: false },
762
+
763
+    // In-water zones (for reeds)
764
+    { id: 'water_inner_n', x: 0, z: -2.5, angle: Math.PI, type: 'water', allowedBuildings: ['reeds'], occupied: false },
765
+    { id: 'water_inner_e', x: 2.5, z: 0, angle: Math.PI * 0.5, type: 'water', allowedBuildings: ['reeds'], occupied: false },
766
+    { id: 'water_inner_s', x: 0, z: 2.5, angle: 0, type: 'water', allowedBuildings: ['reeds'], occupied: false },
767
+    { id: 'water_inner_w', x: -2.5, z: 0, angle: -Math.PI * 0.5, type: 'water', allowedBuildings: ['reeds'], occupied: false }
768
+  ]
769
+
770
+  // Get available snap zones for a building type
771
+  function getAvailableZones(buildingType) {
772
+    return snapZones.filter(zone =>
773
+      !zone.occupied && zone.allowedBuildings.includes(buildingType)
774
+    )
775
+  }
776
+
777
+  // Get zone by ID
778
+  function getZone(zoneId) {
779
+    return snapZones.find(z => z.id === zoneId)
780
+  }
781
+
782
+  // Mark a zone as occupied
783
+  function occupyZone(zoneId) {
784
+    const zone = getZone(zoneId)
785
+    if (zone) {
786
+      zone.occupied = true
787
+    }
788
+  }
789
+
790
+  // Find nearest valid zone to a point
791
+  function findNearestZone(x, z, buildingType, snapDistance = 1.5) {
792
+    const available = getAvailableZones(buildingType)
793
+    let nearest = null
794
+    let nearestDist = snapDistance
795
+
796
+    for (const zone of available) {
797
+      const dx = x - zone.x
798
+      const dz = z - zone.z
799
+      const dist = Math.sqrt(dx * dx + dz * dz)
800
+      if (dist < nearestDist) {
801
+        nearestDist = dist
802
+        nearest = zone
803
+      }
804
+    }
805
+
806
+    return nearest
807
+  }
808
+
610809
   return {
611810
     group,
612811
     water,
613812
     radius,
614813
     addRipple,
615
-    update
814
+    update,
815
+    isValidEmergenceSpot,
816
+    addForbiddenZone,
817
+    snapZones,
818
+    getAvailableZones,
819
+    getZone,
820
+    occupyZone,
821
+    findNearestZone
616822
   }
617823
 }
src/renderers/three/shop/dialogScripts.jsadded
@@ -0,0 +1,270 @@
1
+// Dialog scripts for Donny and Ollie
2
+// Context-aware greetings and personality-driven lines
3
+
4
+import gameState from '../gameState.js'
5
+import inventory from './inventory.js'
6
+
7
+// Track if this is first meeting
8
+const STORAGE_KEY_MET = 'dougk-met-characters'
9
+
10
+function getMetCharacters() {
11
+  try {
12
+    return JSON.parse(localStorage.getItem(STORAGE_KEY_MET) || '{}')
13
+  } catch {
14
+    return {}
15
+  }
16
+}
17
+
18
+function markCharacterMet(character) {
19
+  const met = getMetCharacters()
20
+  met[character] = true
21
+  localStorage.setItem(STORAGE_KEY_MET, JSON.stringify(met))
22
+}
23
+
24
+function hasMetCharacter(character) {
25
+  return getMetCharacters()[character] || false
26
+}
27
+
28
+// Donny - Distinguished, formal, slightly pompous but endearing
29
+const DONNY_FIRST_MEETING = [
30
+  "Ah, a fellow connoisseur of the finer things...",
31
+  "I am Donny. Distinguished purveyor of exquisite wares.",
32
+  "I have procured some rather exceptional items...",
33
+  "Care to take a look?"
34
+]
35
+
36
+const DONNY_GREETINGS = [
37
+  [
38
+    "Ah, we meet again, my discerning friend...",
39
+    "I have acquired some new curiosities.",
40
+    "Shall we browse?"
41
+  ],
42
+  [
43
+    "How fortuitous! I was just thinking of you.",
44
+    "My collection has grown quite splendidly...",
45
+    "Care to peruse?"
46
+  ],
47
+  [
48
+    "Ah yes, the distinguished duck keeper returns!",
49
+    "I trust your pond prospers?",
50
+    "Perhaps some new finery is in order..."
51
+  ],
52
+  [
53
+    "Splendid to see you again!",
54
+    "I've been polishing the merchandise...",
55
+    "Only the finest for my favorite customer."
56
+  ]
57
+]
58
+
59
+const DONNY_HIGH_KOI = [
60
+  [
61
+    "My word! That is quite the koi fortune!",
62
+    "A collector of your caliber deserves only the best.",
63
+    "Allow me to show you my premium selection..."
64
+  ],
65
+  [
66
+    "Impressive! Your koi-catching prowess is legendary.",
67
+    "With wealth like that, anything is possible...",
68
+    "Shall we see what catches your eye?"
69
+  ]
70
+]
71
+
72
+const DONNY_REPEAT_VISITOR = [
73
+  [
74
+    "Back so soon? Excellent taste, as always.",
75
+    "I've kept the good stuff in the back...",
76
+    "Just for you, of course."
77
+  ],
78
+  [
79
+    "Ah, my most loyal patron returns!",
80
+    "I do so enjoy our little exchanges.",
81
+    "What shall it be today?"
82
+  ]
83
+]
84
+
85
+// Ollie - Curious, excitable, slightly scatterbrained
86
+const OLLIE_FIRST_MEETING = [
87
+  "Ooooh! Hello hello hello!",
88
+  "I'm Ollie! I've been watching you through my glass...",
89
+  "You catch the fishies! So clever!",
90
+  "I found some treasures! Wanna see wanna see?"
91
+]
92
+
93
+const OLLIE_GREETINGS = [
94
+  [
95
+    "Oh oh oh! It's you again!",
96
+    "I found MORE things! So many things!",
97
+    "Look look look!"
98
+  ],
99
+  [
100
+    "Hiii! I was hoping you'd come back!",
101
+    "I've been organizing my collection...",
102
+    "Well, TRYING to organize it anyway..."
103
+  ],
104
+  [
105
+    "There you are! I've been looking everywhere!",
106
+    "Wait, no, I was looking at stuff with my magnifier.",
107
+    "BUT NOW YOU'RE HERE! Perfect timing!"
108
+  ],
109
+  [
110
+    "*excited tentacle wiggles*",
111
+    "Friend friend friend! Welcome back!",
112
+    "I have oddities! Curiosities! Thingamabobs!"
113
+  ]
114
+]
115
+
116
+const OLLIE_HIGH_KOI = [
117
+  [
118
+    "WOW! Look at all those fishies!",
119
+    "You're like... a koi WIZARD!",
120
+    "I have special special things for special fishers!"
121
+  ],
122
+  [
123
+    "So many koi! How do you DO that?!",
124
+    "I tried counting them but I lost track at... um...",
125
+    "Anyway! TREASURES! I have them!"
126
+  ]
127
+]
128
+
129
+const OLLIE_REPEAT_VISITOR = [
130
+  [
131
+    "You came back! You came BACK!",
132
+    "I was worried you forgot about me...",
133
+    "Just kidding! I was busy looking at a rock. Wanna see?"
134
+  ],
135
+  [
136
+    "FRIEND! *happy bubbles*",
137
+    "I reorganized everything! Again!",
138
+    "It's a LITTLE messier but also better somehow?"
139
+  ]
140
+]
141
+
142
+function pickRandom(array) {
143
+  return array[Math.floor(Math.random() * array.length)]
144
+}
145
+
146
+export function getDialogForCharacter(character) {
147
+  const koiCount = gameState.getKoi()
148
+  const hasMet = hasMetCharacter(character)
149
+  const ownedCount = inventory.ownedItems.length
150
+
151
+  // Mark as met for next time
152
+  if (!hasMet) {
153
+    markCharacterMet(character)
154
+  }
155
+
156
+  if (character === 'donny') {
157
+    // First meeting
158
+    if (!hasMet) {
159
+      return DONNY_FIRST_MEETING
160
+    }
161
+
162
+    // High koi count (50+)
163
+    if (koiCount >= 50 && Math.random() < 0.5) {
164
+      return pickRandom(DONNY_HIGH_KOI)
165
+    }
166
+
167
+    // Repeat visitor with purchases
168
+    if (ownedCount >= 3 && Math.random() < 0.4) {
169
+      return pickRandom(DONNY_REPEAT_VISITOR)
170
+    }
171
+
172
+    // Standard greeting
173
+    return pickRandom(DONNY_GREETINGS)
174
+  }
175
+
176
+  if (character === 'ollie') {
177
+    // First meeting
178
+    if (!hasMet) {
179
+      return OLLIE_FIRST_MEETING
180
+    }
181
+
182
+    // High koi count (50+)
183
+    if (koiCount >= 50 && Math.random() < 0.5) {
184
+      return pickRandom(OLLIE_HIGH_KOI)
185
+    }
186
+
187
+    // Repeat visitor
188
+    if (ownedCount >= 3 && Math.random() < 0.4) {
189
+      return pickRandom(OLLIE_REPEAT_VISITOR)
190
+    }
191
+
192
+    // Standard greeting
193
+    return pickRandom(OLLIE_GREETINGS)
194
+  }
195
+
196
+  return ["Hello!"]
197
+}
198
+
199
+// Quick return lines - when player taps to summon them back
200
+const DONNY_TAP_RETURN = [
201
+  [
202
+    "Ah, you're back for more, eh?",
203
+    "I knew you would be.",
204
+    "Let's see what catches your fancy..."
205
+  ],
206
+  [
207
+    "Changed your mind? Excellent judgment.",
208
+    "The best customers always return.",
209
+    "Now then, shall we?"
210
+  ],
211
+  [
212
+    "Back so soon? Marvelous!",
213
+    "I was just about to polish my finest wares.",
214
+    "Your timing is impeccable."
215
+  ],
216
+  [
217
+    "Couldn't stay away, could you?",
218
+    "I completely understand.",
219
+    "Quality is irresistible, after all."
220
+  ]
221
+]
222
+
223
+const OLLIE_TAP_RETURN = [
224
+  [
225
+    "You tapped me! You TAPPED me!",
226
+    "I love taps! They're like underwater high-fives!",
227
+    "Okay okay, let's look at stuff!"
228
+  ],
229
+  [
230
+    "OOH! Hello again!",
231
+    "Did you forget something? I forget stuff ALL the time!",
232
+    "Let's see what I have..."
233
+  ],
234
+  [
235
+    "Back for more shinies?",
236
+    "I KNEW you liked the shinies!",
237
+    "Everyone likes shinies!"
238
+  ],
239
+  [
240
+    "*surprised wiggle*",
241
+    "Oh hi! I was just counting my tentacles...",
242
+    "Still eight! Anyway, SHOPPING TIME!"
243
+  ]
244
+]
245
+
246
+export function getReturnDialog(character) {
247
+  if (character === 'donny') {
248
+    return pickRandom(DONNY_TAP_RETURN)
249
+  }
250
+  if (character === 'ollie') {
251
+    return pickRandom(OLLIE_TAP_RETURN)
252
+  }
253
+  return ["Welcome back!"]
254
+}
255
+
256
+// Farewell lines (for future use when closing shop)
257
+export const FAREWELLS = {
258
+  donny: [
259
+    "Until next time, my friend.",
260
+    "Do come again. I'll save the best for you.",
261
+    "Farewell! May your pond flourish.",
262
+    "A pleasure, as always."
263
+  ],
264
+  ollie: [
265
+    "Bye bye bye! Come back soon!",
266
+    "See you later! I'll find more stuff!",
267
+    "*waves all tentacles* BYEEE!",
268
+    "Don't be a stranger! I'll be here! Looking at things!"
269
+  ]
270
+}
src/renderers/three/shop/dialogUI.jsadded
@@ -0,0 +1,298 @@
1
+// Animal Crossing-style dialog system for dougk
2
+// Typewriter text effect with character portraits
3
+
4
+import { playDialogBlip } from '../sounds.js'
5
+
6
+let dialogOverlay = null
7
+let currentDialog = null
8
+let currentLineIndex = 0
9
+let currentCharIndex = 0
10
+let isTyping = false
11
+let typeInterval = null
12
+let onCompleteCallback = null
13
+
14
+const TYPE_SPEED = 35 // ms per character
15
+const FAST_TYPE_SPEED = 10 // when holding/clicking during type
16
+
17
+const STYLES = `
18
+  .dialog-overlay {
19
+    position: fixed;
20
+    bottom: 0;
21
+    left: 0;
22
+    right: 0;
23
+    display: flex;
24
+    justify-content: center;
25
+    padding: 20px;
26
+    z-index: 1500;
27
+    pointer-events: none;
28
+  }
29
+
30
+  .dialog-box {
31
+    background: linear-gradient(180deg, #2a2218 0%, #1a1510 100%);
32
+    border: 4px solid #8b6914;
33
+    border-radius: 16px;
34
+    padding: 16px 20px;
35
+    max-width: 600px;
36
+    width: 90%;
37
+    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
38
+    pointer-events: auto;
39
+    cursor: pointer;
40
+    position: relative;
41
+  }
42
+
43
+  .dialog-header {
44
+    display: flex;
45
+    align-items: center;
46
+    gap: 12px;
47
+    margin-bottom: 12px;
48
+  }
49
+
50
+  .dialog-portrait {
51
+    width: 48px;
52
+    height: 48px;
53
+    border-radius: 50%;
54
+    display: flex;
55
+    align-items: center;
56
+    justify-content: center;
57
+    font-size: 28px;
58
+    flex-shrink: 0;
59
+  }
60
+
61
+  .dialog-portrait.donny {
62
+    background: linear-gradient(135deg, #7a9eb8 0%, #5a7e98 100%);
63
+    border: 3px solid #d4af37;
64
+  }
65
+
66
+  .dialog-portrait.ollie {
67
+    background: linear-gradient(135deg, #7b4b94 0%, #5b2b74 100%);
68
+    border: 3px solid #d4af37;
69
+  }
70
+
71
+  .dialog-name {
72
+    font-family: 'Courier New', monospace;
73
+    font-size: 18px;
74
+    font-weight: bold;
75
+    color: #ffd700;
76
+    text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
77
+  }
78
+
79
+  .dialog-text {
80
+    font-family: 'Courier New', monospace;
81
+    font-size: 16px;
82
+    color: #e8d8c8;
83
+    line-height: 1.5;
84
+    min-height: 48px;
85
+  }
86
+
87
+  .dialog-continue {
88
+    position: absolute;
89
+    bottom: 12px;
90
+    right: 16px;
91
+    color: #ffd700;
92
+    font-size: 14px;
93
+    opacity: 0;
94
+    transition: opacity 0.3s;
95
+    animation: bounce 0.6s ease-in-out infinite;
96
+  }
97
+
98
+  .dialog-continue.visible {
99
+    opacity: 1;
100
+  }
101
+
102
+  @keyframes bounce {
103
+    0%, 100% { transform: translateY(0); }
104
+    50% { transform: translateY(-4px); }
105
+  }
106
+
107
+  .dialog-box.shake {
108
+    animation: dialogShake 0.1s ease-in-out;
109
+  }
110
+
111
+  @keyframes dialogShake {
112
+    0%, 100% { transform: translateX(0); }
113
+    25% { transform: translateX(-2px); }
114
+    75% { transform: translateX(2px); }
115
+  }
116
+`
117
+
118
+const CHARACTER_INFO = {
119
+  donny: {
120
+    name: 'Donny',
121
+    emoji: '🦄',
122
+    portrait: 'donny'
123
+  },
124
+  ollie: {
125
+    name: 'Ollie',
126
+    emoji: '🐙',
127
+    portrait: 'ollie'
128
+  }
129
+}
130
+
131
+function injectStyles() {
132
+  if (document.getElementById('dialog-styles')) return
133
+  const style = document.createElement('style')
134
+  style.id = 'dialog-styles'
135
+  style.textContent = STYLES
136
+  document.head.appendChild(style)
137
+}
138
+
139
+function createDialogOverlay(character) {
140
+  injectStyles()
141
+
142
+  const charInfo = CHARACTER_INFO[character]
143
+
144
+  const overlay = document.createElement('div')
145
+  overlay.className = 'dialog-overlay'
146
+  overlay.innerHTML = `
147
+    <div class="dialog-box">
148
+      <div class="dialog-header">
149
+        <div class="dialog-portrait ${charInfo.portrait}">${charInfo.emoji}</div>
150
+        <div class="dialog-name">${charInfo.name}</div>
151
+      </div>
152
+      <div class="dialog-text"></div>
153
+      <div class="dialog-continue">▼</div>
154
+    </div>
155
+  `
156
+
157
+  const box = overlay.querySelector('.dialog-box')
158
+  box.addEventListener('click', handleAdvance)
159
+
160
+  // Also handle keyboard
161
+  document.addEventListener('keydown', handleKeyDown)
162
+
163
+  return overlay
164
+}
165
+
166
+function handleKeyDown(e) {
167
+  if (e.key === ' ' || e.key === 'Enter') {
168
+    e.preventDefault()
169
+    handleAdvance()
170
+  }
171
+}
172
+
173
+function handleAdvance() {
174
+  if (!currentDialog) return
175
+
176
+  if (isTyping) {
177
+    // Skip to end of current line
178
+    finishCurrentLine()
179
+  } else {
180
+    // Advance to next line
181
+    advanceDialog()
182
+  }
183
+}
184
+
185
+function finishCurrentLine() {
186
+  if (typeInterval) {
187
+    clearInterval(typeInterval)
188
+    typeInterval = null
189
+  }
190
+
191
+  const textEl = dialogOverlay.querySelector('.dialog-text')
192
+  textEl.textContent = currentDialog.lines[currentLineIndex]
193
+  isTyping = false
194
+
195
+  // Show continue indicator
196
+  const continueEl = dialogOverlay.querySelector('.dialog-continue')
197
+  continueEl.classList.add('visible')
198
+}
199
+
200
+function advanceDialog() {
201
+  currentLineIndex++
202
+
203
+  if (currentLineIndex >= currentDialog.lines.length) {
204
+    // Dialog complete - save callback before closing (closeDialog clears it)
205
+    const callback = onCompleteCallback
206
+    closeDialog()
207
+    if (callback) {
208
+      callback()
209
+    }
210
+    return
211
+  }
212
+
213
+  // Hide continue indicator
214
+  const continueEl = dialogOverlay.querySelector('.dialog-continue')
215
+  continueEl.classList.remove('visible')
216
+
217
+  // Start typing next line
218
+  startTypingLine()
219
+}
220
+
221
+function startTypingLine() {
222
+  const textEl = dialogOverlay.querySelector('.dialog-text')
223
+  const line = currentDialog.lines[currentLineIndex]
224
+
225
+  textEl.textContent = ''
226
+  currentCharIndex = 0
227
+  isTyping = true
228
+
229
+  typeInterval = setInterval(() => {
230
+    if (currentCharIndex < line.length) {
231
+      const char = line[currentCharIndex]
232
+      textEl.textContent += char
233
+      currentCharIndex++
234
+
235
+      // Play blip sound for letters (skip spaces/punctuation, play every 2 chars)
236
+      if (char.match(/[a-zA-Z]/) && currentCharIndex % 2 === 0) {
237
+        playDialogBlip()
238
+      }
239
+
240
+      // Add slight shake on punctuation for emphasis
241
+      if (['.', '!', '?', '...'].some(p => line.substring(0, currentCharIndex).endsWith(p))) {
242
+        const box = dialogOverlay.querySelector('.dialog-box')
243
+        box.classList.add('shake')
244
+        setTimeout(() => box.classList.remove('shake'), 100)
245
+      }
246
+    } else {
247
+      // Line complete
248
+      clearInterval(typeInterval)
249
+      typeInterval = null
250
+      isTyping = false
251
+
252
+      // Show continue indicator
253
+      const continueEl = dialogOverlay.querySelector('.dialog-continue')
254
+      continueEl.classList.add('visible')
255
+    }
256
+  }, TYPE_SPEED)
257
+}
258
+
259
+export function showDialog(character, lines, container, onComplete) {
260
+  if (dialogOverlay) {
261
+    closeDialog()
262
+  }
263
+
264
+  currentDialog = { character, lines }
265
+  currentLineIndex = 0
266
+  currentCharIndex = 0
267
+  onCompleteCallback = onComplete
268
+
269
+  dialogOverlay = createDialogOverlay(character)
270
+  container.appendChild(dialogOverlay)
271
+
272
+  // Start typing first line
273
+  startTypingLine()
274
+}
275
+
276
+export function closeDialog() {
277
+  if (typeInterval) {
278
+    clearInterval(typeInterval)
279
+    typeInterval = null
280
+  }
281
+
282
+  document.removeEventListener('keydown', handleKeyDown)
283
+
284
+  if (dialogOverlay) {
285
+    dialogOverlay.remove()
286
+    dialogOverlay = null
287
+  }
288
+
289
+  currentDialog = null
290
+  currentLineIndex = 0
291
+  currentCharIndex = 0
292
+  isTyping = false
293
+  onCompleteCallback = null
294
+}
295
+
296
+export function isDialogOpen() {
297
+  return dialogOverlay !== null
298
+}
src/renderers/three/shop/inventory.jsadded
@@ -0,0 +1,152 @@
1
+// Inventory persistence for dougk shop system
2
+// Tracks owned items, equipped outfits, and placed buildings
3
+
4
+import { getItem } from './items.js'
5
+
6
+const STORAGE_KEYS = {
7
+  OWNED: 'dougk-owned-items',
8
+  EQUIPPED: 'dougk-equipped',
9
+  BUILDINGS: 'dougk-buildings'
10
+}
11
+
12
+function loadJSON(key, defaultValue) {
13
+  try {
14
+    const data = localStorage.getItem(key)
15
+    return data ? JSON.parse(data) : defaultValue
16
+  } catch {
17
+    return defaultValue
18
+  }
19
+}
20
+
21
+function saveJSON(key, value) {
22
+  localStorage.setItem(key, JSON.stringify(value))
23
+}
24
+
25
+const inventory = {
26
+  // Owned items (array of item IDs)
27
+  ownedItems: loadJSON(STORAGE_KEYS.OWNED, []),
28
+
29
+  // Equipped outfits per character
30
+  equipped: loadJSON(STORAGE_KEYS.EQUIPPED, {
31
+    doug: [],
32
+    donny: [],
33
+    ollie: []
34
+  }),
35
+
36
+  // Placed buildings
37
+  buildings: loadJSON(STORAGE_KEYS.BUILDINGS, []),
38
+
39
+  // Check if an item is owned
40
+  owns(itemId) {
41
+    return this.ownedItems.includes(itemId)
42
+  },
43
+
44
+  // Purchase an item (add to owned)
45
+  purchase(itemId) {
46
+    if (!this.owns(itemId)) {
47
+      this.ownedItems.push(itemId)
48
+      this.save()
49
+      return true
50
+    }
51
+    return false
52
+  },
53
+
54
+  // Equip an outfit to a character
55
+  // Automatically unequips any other outfit of the same type
56
+  equip(character, itemId) {
57
+    if (!this.equipped[character]) {
58
+      this.equipped[character] = []
59
+    }
60
+
61
+    // Get the type of the item being equipped
62
+    const newItem = getItem(itemId)
63
+    if (!newItem) return
64
+
65
+    // Find and unequip any item of the same type
66
+    const sameTypeItems = this.equipped[character].filter(equippedId => {
67
+      const equippedItem = getItem(equippedId)
68
+      return equippedItem && equippedItem.type === newItem.type
69
+    })
70
+
71
+    // Remove items of the same type
72
+    for (const oldId of sameTypeItems) {
73
+      this.equipped[character] = this.equipped[character].filter(id => id !== oldId)
74
+    }
75
+
76
+    // Now equip the new item
77
+    if (!this.equipped[character].includes(itemId)) {
78
+      this.equipped[character].push(itemId)
79
+    }
80
+    this.save()
81
+
82
+    // Return the unequipped items so the caller can update visuals
83
+    return sameTypeItems
84
+  },
85
+
86
+  // Unequip an outfit from a character
87
+  unequip(character, itemId) {
88
+    if (this.equipped[character]) {
89
+      this.equipped[character] = this.equipped[character].filter(id => id !== itemId)
90
+      this.save()
91
+    }
92
+  },
93
+
94
+  // Check if an outfit is equipped on a character
95
+  isEquipped(character, itemId) {
96
+    return this.equipped[character]?.includes(itemId) || false
97
+  },
98
+
99
+  // Get all equipped items for a character
100
+  getEquipped(character) {
101
+    return this.equipped[character] || []
102
+  },
103
+
104
+  // Place a building
105
+  placeBuilding(buildingType, zoneId) {
106
+    this.buildings.push({ type: buildingType, zoneId })
107
+    this.save()
108
+  },
109
+
110
+  // Check if a zone is occupied
111
+  isZoneOccupied(zoneId) {
112
+    return this.buildings.some(b => b.zoneId === zoneId)
113
+  },
114
+
115
+  // Get all placed buildings
116
+  getBuildings() {
117
+    return [...this.buildings]
118
+  },
119
+
120
+  // Alias for getBuildings (used by PlacementManager)
121
+  getPlacedBuildings() {
122
+    return this.getBuildings()
123
+  },
124
+
125
+  // Remove a placed building by zone ID
126
+  removeBuilding(zoneId) {
127
+    const index = this.buildings.findIndex(b => b.zoneId === zoneId)
128
+    if (index !== -1) {
129
+      this.buildings.splice(index, 1)
130
+      this.save()
131
+      return true
132
+    }
133
+    return false
134
+  },
135
+
136
+  // Save all state
137
+  save() {
138
+    saveJSON(STORAGE_KEYS.OWNED, this.ownedItems)
139
+    saveJSON(STORAGE_KEYS.EQUIPPED, this.equipped)
140
+    saveJSON(STORAGE_KEYS.BUILDINGS, this.buildings)
141
+  },
142
+
143
+  // Clear all data (for testing)
144
+  reset() {
145
+    this.ownedItems = []
146
+    this.equipped = { doug: [], donny: [], ollie: [] }
147
+    this.buildings = []
148
+    this.save()
149
+  }
150
+}
151
+
152
+export default inventory
src/renderers/three/shop/items.jsadded
@@ -0,0 +1,307 @@
1
+// Item catalog for dougk shop
2
+// Defines all purchasable outfits and buildings
3
+
4
+import * as THREE from 'three'
5
+
6
+// Outfit types
7
+export const OUTFIT_TYPES = {
8
+  COLOR_BODY: 'color_body',
9
+  COLOR_ACCENT: 'color_accent',
10
+  ACCESSORY_HEAD: 'accessory_head',
11
+  ACCESSORY_FACE: 'accessory_face',
12
+  ACCESSORY_HELD: 'accessory_held'
13
+}
14
+
15
+// Character IDs
16
+export const CHARACTERS = {
17
+  DOUG: 'doug',
18
+  DONNY: 'donny',
19
+  OLLIE: 'ollie'
20
+}
21
+
22
+// Outfit definitions
23
+export const OUTFITS = {
24
+  // Doug outfits - starter tier (cheap)
25
+  doug_mint: {
26
+    id: 'doug_mint',
27
+    name: 'Mint Fresh',
28
+    character: CHARACTERS.DOUG,
29
+    type: OUTFIT_TYPES.COLOR_BODY,
30
+    price: 5,
31
+    colors: { body: 0x98fb98, highlight: 0xb0ffb0 }
32
+  },
33
+  doug_bubblegum: {
34
+    id: 'doug_bubblegum',
35
+    name: 'Bubblegum',
36
+    character: CHARACTERS.DOUG,
37
+    type: OUTFIT_TYPES.COLOR_BODY,
38
+    price: 5,
39
+    colors: { body: 0xffb6c1, highlight: 0xffd1dc }
40
+  },
41
+  // Doug outfits - mid tier
42
+  doug_golden: {
43
+    id: 'doug_golden',
44
+    name: 'Golden Glow',
45
+    character: CHARACTERS.DOUG,
46
+    type: OUTFIT_TYPES.COLOR_BODY,
47
+    price: 12,
48
+    colors: { body: 0xffd700, highlight: 0xffec8b }
49
+  },
50
+  doug_sunset: {
51
+    id: 'doug_sunset',
52
+    name: 'Sunset Orange',
53
+    character: CHARACTERS.DOUG,
54
+    type: OUTFIT_TYPES.COLOR_BODY,
55
+    price: 12,
56
+    colors: { body: 0xff6b35, highlight: 0xffa07a }
57
+  },
58
+  doug_tophat: {
59
+    id: 'doug_tophat',
60
+    name: 'Top Hat',
61
+    character: CHARACTERS.DOUG,
62
+    type: OUTFIT_TYPES.ACCESSORY_HEAD,
63
+    price: 25,
64
+    meshFactory: (gradientMap) => {
65
+      const group = new THREE.Group()
66
+      const material = new THREE.MeshToonMaterial({ color: 0x1a1a1a, gradientMap })
67
+
68
+      // Hat brim
69
+      const brimGeom = new THREE.CylinderGeometry(0.25, 0.25, 0.03, 12)
70
+      const brim = new THREE.Mesh(brimGeom, material)
71
+      group.add(brim)
72
+
73
+      // Hat top
74
+      const topGeom = new THREE.CylinderGeometry(0.15, 0.15, 0.25, 12)
75
+      const top = new THREE.Mesh(topGeom, material)
76
+      top.position.y = 0.14
77
+      group.add(top)
78
+
79
+      // Hat band
80
+      const bandMat = new THREE.MeshToonMaterial({ color: 0x8b0000, gradientMap })
81
+      const bandGeom = new THREE.CylinderGeometry(0.155, 0.155, 0.04, 12)
82
+      const band = new THREE.Mesh(bandGeom, bandMat)
83
+      band.position.y = 0.04
84
+      group.add(band)
85
+
86
+      return group
87
+    }
88
+  },
89
+  doug_shades: {
90
+    id: 'doug_shades',
91
+    name: 'Cool Shades',
92
+    character: CHARACTERS.DOUG,
93
+    type: OUTFIT_TYPES.ACCESSORY_FACE,
94
+    price: 20,
95
+    meshFactory: (gradientMap) => {
96
+      const group = new THREE.Group()
97
+      const frameMat = new THREE.MeshToonMaterial({ color: 0x1a1a1a, gradientMap })
98
+      const lensMat = new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.7 })
99
+
100
+      // Left lens
101
+      const lensGeom = new THREE.CircleGeometry(0.08, 8)
102
+      const leftLens = new THREE.Mesh(lensGeom, lensMat)
103
+      leftLens.position.set(-0.1, 0, 0.01)
104
+      group.add(leftLens)
105
+
106
+      // Right lens
107
+      const rightLens = new THREE.Mesh(lensGeom, lensMat)
108
+      rightLens.position.set(0.1, 0, 0.01)
109
+      group.add(rightLens)
110
+
111
+      // Bridge
112
+      const bridgeGeom = new THREE.BoxGeometry(0.06, 0.02, 0.02)
113
+      const bridge = new THREE.Mesh(bridgeGeom, frameMat)
114
+      group.add(bridge)
115
+
116
+      // Frames
117
+      const frameGeom = new THREE.TorusGeometry(0.08, 0.01, 4, 12)
118
+      const leftFrame = new THREE.Mesh(frameGeom, frameMat)
119
+      leftFrame.position.set(-0.1, 0, 0)
120
+      group.add(leftFrame)
121
+
122
+      const rightFrame = new THREE.Mesh(frameGeom, frameMat)
123
+      rightFrame.position.set(0.1, 0, 0)
124
+      group.add(rightFrame)
125
+
126
+      return group
127
+    }
128
+  },
129
+
130
+  // Donny outfits - starter tier
131
+  donny_seafoam: {
132
+    id: 'donny_seafoam',
133
+    name: 'Seafoam',
134
+    character: CHARACTERS.DONNY,
135
+    type: OUTFIT_TYPES.COLOR_BODY,
136
+    price: 8,
137
+    colors: { body: 0x5f9ea0, belly: 0x98d8d8 }
138
+  },
139
+  // Donny outfits - mid tier
140
+  donny_royal: {
141
+    id: 'donny_royal',
142
+    name: 'Royal Purple',
143
+    character: CHARACTERS.DONNY,
144
+    type: OUTFIT_TYPES.COLOR_BODY,
145
+    price: 15,
146
+    colors: { body: 0x6b3fa0, belly: 0x9b7bc0 }
147
+  },
148
+  donny_arctic: {
149
+    id: 'donny_arctic',
150
+    name: 'Arctic White',
151
+    character: CHARACTERS.DONNY,
152
+    type: OUTFIT_TYPES.COLOR_BODY,
153
+    price: 18,
154
+    colors: { body: 0xe8e8e8, belly: 0xffffff }
155
+  },
156
+  donny_ruby_monocle: {
157
+    id: 'donny_ruby_monocle',
158
+    name: 'Ruby Monocle',
159
+    character: CHARACTERS.DONNY,
160
+    type: OUTFIT_TYPES.ACCESSORY_FACE,
161
+    price: 25,
162
+    colors: { rim: 0xb22222, glass: 0xff6666 }
163
+  },
164
+  donny_bowler: {
165
+    id: 'donny_bowler',
166
+    name: 'Bowler Hat',
167
+    character: CHARACTERS.DONNY,
168
+    type: OUTFIT_TYPES.ACCESSORY_HEAD,
169
+    price: 25,
170
+    meshFactory: (gradientMap) => {
171
+      const group = new THREE.Group()
172
+      const material = new THREE.MeshToonMaterial({ color: 0x2f2f2f, gradientMap })
173
+
174
+      // Hat dome
175
+      const domeGeom = new THREE.SphereGeometry(0.15, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2)
176
+      const dome = new THREE.Mesh(domeGeom, material)
177
+      group.add(dome)
178
+
179
+      // Hat brim
180
+      const brimGeom = new THREE.CylinderGeometry(0.22, 0.22, 0.025, 12)
181
+      const brim = new THREE.Mesh(brimGeom, material)
182
+      brim.position.y = -0.01
183
+      group.add(brim)
184
+
185
+      return group
186
+    }
187
+  },
188
+
189
+  // Ollie outfits
190
+  ollie_coral: {
191
+    id: 'ollie_coral',
192
+    name: 'Coral Pink',
193
+    character: CHARACTERS.OLLIE,
194
+    type: OUTFIT_TYPES.COLOR_BODY,
195
+    price: 20,
196
+    colors: { body: 0xff7f7f, belly: 0xffb3b3, suckers: 0xffcccc }
197
+  },
198
+  ollie_deepsea: {
199
+    id: 'ollie_deepsea',
200
+    name: 'Deep Sea Blue',
201
+    character: CHARACTERS.OLLIE,
202
+    type: OUTFIT_TYPES.COLOR_BODY,
203
+    price: 20,
204
+    colors: { body: 0x1e3a5f, belly: 0x4a6fa5, suckers: 0x6b8cae }
205
+  },
206
+  ollie_golden_mag: {
207
+    id: 'ollie_golden_mag',
208
+    name: 'Golden Magnifier',
209
+    character: CHARACTERS.OLLIE,
210
+    type: OUTFIT_TYPES.ACCESSORY_HELD,
211
+    price: 30,
212
+    colors: { rim: 0xffd700, glass: 0xffffcc }
213
+  },
214
+  ollie_detective: {
215
+    id: 'ollie_detective',
216
+    name: 'Detective Cap',
217
+    character: CHARACTERS.OLLIE,
218
+    type: OUTFIT_TYPES.ACCESSORY_HEAD,
219
+    price: 25,
220
+    meshFactory: (gradientMap) => {
221
+      const group = new THREE.Group()
222
+      const material = new THREE.MeshToonMaterial({ color: 0x8b4513, gradientMap })
223
+
224
+      // Cap body
225
+      const capGeom = new THREE.SphereGeometry(0.18, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2)
226
+      const cap = new THREE.Mesh(capGeom, material)
227
+      cap.scale.y = 0.5
228
+      group.add(cap)
229
+
230
+      // Front brim
231
+      const brimGeom = new THREE.CylinderGeometry(0.12, 0.15, 0.02, 8, 1, false, -Math.PI/3, Math.PI * 2/3)
232
+      const brim = new THREE.Mesh(brimGeom, material)
233
+      brim.position.set(0.12, -0.02, 0)
234
+      brim.rotation.z = -0.3
235
+      group.add(brim)
236
+
237
+      return group
238
+    }
239
+  }
240
+}
241
+
242
+// Building definitions
243
+export const BUILDINGS = {
244
+  dock_wooden: {
245
+    id: 'dock_wooden',
246
+    buildingType: 'dock_wooden',
247
+    name: 'Wooden Dock',
248
+    price: 40,
249
+    zoneType: 'waterEdge',
250
+    forbiddenRadius: 0.8
251
+  },
252
+  fishing_hut: {
253
+    id: 'fishing_hut',
254
+    buildingType: 'fishing_hut',
255
+    name: 'Fishing Hut',
256
+    price: 50,
257
+    zoneType: 'waterEdge',
258
+    forbiddenRadius: 0.9
259
+  },
260
+  lighthouse: {
261
+    id: 'lighthouse',
262
+    buildingType: 'lighthouse',
263
+    name: 'Mini Lighthouse',
264
+    price: 50,
265
+    zoneType: 'shore',
266
+    forbiddenRadius: 0.5
267
+  },
268
+  reeds: {
269
+    id: 'reeds',
270
+    buildingType: 'reeds',
271
+    name: 'Reed Cluster',
272
+    price: 25,
273
+    zoneType: 'water',
274
+    forbiddenRadius: 0.4
275
+  },
276
+  fence: {
277
+    id: 'fence',
278
+    buildingType: 'fence',
279
+    name: 'Fence Segment',
280
+    price: 25,
281
+    zoneType: 'shore',
282
+    forbiddenRadius: 0.3
283
+  },
284
+  onion_house: {
285
+    id: 'onion_house',
286
+    buildingType: 'onion_house',
287
+    name: 'Onion House',
288
+    price: 45,
289
+    zoneType: 'shore',
290
+    forbiddenRadius: 0.6
291
+  }
292
+}
293
+
294
+// Get all outfits for a character
295
+export function getOutfitsForCharacter(character) {
296
+  return Object.values(OUTFITS).filter(o => o.character === character)
297
+}
298
+
299
+// Get all buildings
300
+export function getAllBuildings() {
301
+  return Object.values(BUILDINGS)
302
+}
303
+
304
+// Get item by ID (outfit or building)
305
+export function getItem(itemId) {
306
+  return OUTFITS[itemId] || BUILDINGS[itemId] || null
307
+}
src/renderers/three/shop/shopUI.jsadded
@@ -0,0 +1,524 @@
1
+// Shop UI overlay for dougk
2
+// DOM-based shop interface that appears when Donny or Ollie approach
3
+
4
+import gameState from '../gameState.js'
5
+import inventory from './inventory.js'
6
+import { OUTFITS, BUILDINGS, CHARACTERS, getOutfitsForCharacter, getAllBuildings, getItem } from './items.js'
7
+import { playPurchase, playShopOpen, playShopClose } from '../sounds.js'
8
+
9
+let shopOverlay = null
10
+let currentTab = 'outfits'
11
+let currentCharacter = CHARACTERS.DOUG
12
+let currentShopkeeper = null
13
+let onCloseCallback = null
14
+let onPurchaseCallback = null
15
+
16
+const STYLES = `
17
+  .shop-overlay {
18
+    position: fixed;
19
+    top: 0;
20
+    left: 0;
21
+    right: 0;
22
+    bottom: 0;
23
+    background: rgba(0, 0, 0, 0.7);
24
+    display: flex;
25
+    justify-content: center;
26
+    align-items: center;
27
+    z-index: 2000;
28
+    font-family: 'Courier New', monospace;
29
+  }
30
+
31
+  .shop-container {
32
+    background: linear-gradient(135deg, #2a1f1a 0%, #3d2e26 100%);
33
+    border: 4px solid #8b6914;
34
+    border-radius: 16px;
35
+    padding: 20px;
36
+    max-width: 500px;
37
+    width: 90%;
38
+    max-height: 80vh;
39
+    overflow-y: auto;
40
+    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
41
+  }
42
+
43
+  .shop-header {
44
+    display: flex;
45
+    justify-content: space-between;
46
+    align-items: center;
47
+    margin-bottom: 16px;
48
+    padding-bottom: 12px;
49
+    border-bottom: 2px solid #8b6914;
50
+  }
51
+
52
+  .shop-title {
53
+    color: #ffd700;
54
+    font-size: 24px;
55
+    font-weight: bold;
56
+    margin: 0;
57
+  }
58
+
59
+  .shop-koi-count {
60
+    color: #ffd700;
61
+    font-size: 18px;
62
+    display: flex;
63
+    align-items: center;
64
+    gap: 6px;
65
+  }
66
+
67
+  .shop-close {
68
+    background: #8b0000;
69
+    color: white;
70
+    border: none;
71
+    border-radius: 50%;
72
+    width: 32px;
73
+    height: 32px;
74
+    font-size: 18px;
75
+    cursor: pointer;
76
+    display: flex;
77
+    align-items: center;
78
+    justify-content: center;
79
+  }
80
+
81
+  .shop-close:hover {
82
+    background: #a00000;
83
+  }
84
+
85
+  .shop-tabs {
86
+    display: flex;
87
+    gap: 8px;
88
+    margin-bottom: 16px;
89
+  }
90
+
91
+  .shop-tab {
92
+    flex: 1;
93
+    padding: 10px 16px;
94
+    background: #4a3828;
95
+    border: 2px solid #6b4423;
96
+    border-radius: 8px;
97
+    color: #c9a96e;
98
+    font-size: 14px;
99
+    font-weight: bold;
100
+    cursor: pointer;
101
+    transition: all 0.2s;
102
+  }
103
+
104
+  .shop-tab:hover {
105
+    background: #5a4838;
106
+  }
107
+
108
+  .shop-tab.active {
109
+    background: #8b6914;
110
+    border-color: #ffd700;
111
+    color: #fff;
112
+  }
113
+
114
+  .character-selector {
115
+    display: flex;
116
+    gap: 8px;
117
+    margin-bottom: 16px;
118
+  }
119
+
120
+  .character-btn {
121
+    flex: 1;
122
+    padding: 8px;
123
+    background: #3a2a1a;
124
+    border: 2px solid #5a4a3a;
125
+    border-radius: 6px;
126
+    color: #a89070;
127
+    font-size: 12px;
128
+    cursor: pointer;
129
+    transition: all 0.2s;
130
+  }
131
+
132
+  .character-btn:hover {
133
+    background: #4a3a2a;
134
+  }
135
+
136
+  .character-btn.active {
137
+    background: #6b5030;
138
+    border-color: #c9a96e;
139
+    color: #ffd700;
140
+  }
141
+
142
+  .item-grid {
143
+    display: grid;
144
+    grid-template-columns: repeat(2, 1fr);
145
+    gap: 12px;
146
+  }
147
+
148
+  .item-card {
149
+    background: #3a2a1a;
150
+    border: 2px solid #5a4a3a;
151
+    border-radius: 8px;
152
+    padding: 12px;
153
+    text-align: center;
154
+    transition: all 0.2s;
155
+  }
156
+
157
+  .item-card:hover {
158
+    border-color: #8b6914;
159
+  }
160
+
161
+  .item-card.owned {
162
+    background: #2a3a2a;
163
+    border-color: #4a8b4a;
164
+  }
165
+
166
+  .item-card.equipped {
167
+    border-color: #ffd700;
168
+    box-shadow: 0 0 8px rgba(255, 215, 0, 0.3);
169
+  }
170
+
171
+  .item-card.cant-afford {
172
+    opacity: 0.5;
173
+  }
174
+
175
+  .item-name {
176
+    color: #e8d8c8;
177
+    font-size: 14px;
178
+    font-weight: bold;
179
+    margin-bottom: 8px;
180
+  }
181
+
182
+  .item-price {
183
+    color: #ffd700;
184
+    font-size: 16px;
185
+    margin-bottom: 8px;
186
+    display: flex;
187
+    align-items: center;
188
+    justify-content: center;
189
+    gap: 4px;
190
+  }
191
+
192
+  .item-btn {
193
+    width: 100%;
194
+    padding: 8px 12px;
195
+    border: none;
196
+    border-radius: 6px;
197
+    font-size: 12px;
198
+    font-weight: bold;
199
+    cursor: pointer;
200
+    transition: all 0.2s;
201
+  }
202
+
203
+  .item-btn.buy {
204
+    background: #8b6914;
205
+    color: white;
206
+  }
207
+
208
+  .item-btn.buy:hover {
209
+    background: #a07a1a;
210
+  }
211
+
212
+  .item-btn.buy:disabled {
213
+    background: #4a4a4a;
214
+    cursor: not-allowed;
215
+  }
216
+
217
+  .item-btn.equip {
218
+    background: #4a8b4a;
219
+    color: white;
220
+  }
221
+
222
+  .item-btn.equip:hover {
223
+    background: #5a9b5a;
224
+  }
225
+
226
+  .item-btn.unequip {
227
+    background: #8b4a4a;
228
+    color: white;
229
+  }
230
+
231
+  .item-btn.unequip:hover {
232
+    background: #9b5a5a;
233
+  }
234
+
235
+  .empty-message {
236
+    color: #888;
237
+    text-align: center;
238
+    padding: 40px;
239
+    font-style: italic;
240
+  }
241
+`
242
+
243
+function injectStyles() {
244
+  if (document.getElementById('shop-styles')) return
245
+  const style = document.createElement('style')
246
+  style.id = 'shop-styles'
247
+  style.textContent = STYLES
248
+  document.head.appendChild(style)
249
+}
250
+
251
+function createShopOverlay() {
252
+  injectStyles()
253
+
254
+  const overlay = document.createElement('div')
255
+  overlay.className = 'shop-overlay'
256
+  overlay.innerHTML = `
257
+    <div class="shop-container">
258
+      <div class="shop-header">
259
+        <h2 class="shop-title">${getShopTitle()}</h2>
260
+        <div class="shop-koi-count">
261
+          <span style="font-size: 20px;">🐟</span>
262
+          <span id="shop-koi-count">${gameState.getKoi()}</span>
263
+        </div>
264
+        <button class="shop-close">&times;</button>
265
+      </div>
266
+
267
+      <div id="shop-content"></div>
268
+    </div>
269
+  `
270
+
271
+  // Event listeners
272
+  overlay.querySelector('.shop-close').addEventListener('click', closeShop)
273
+  overlay.addEventListener('click', (e) => {
274
+    if (e.target === overlay) closeShop()
275
+  })
276
+
277
+  return overlay
278
+}
279
+
280
+function getShopTitle() {
281
+  if (currentShopkeeper === 'donny') return "Donny's Fine Structures"
282
+  if (currentShopkeeper === 'ollie') return "Ollie's Outfit Oddities"
283
+  return 'Shop'
284
+}
285
+
286
+function updateTabStyles() {
287
+  shopOverlay.querySelectorAll('.shop-tab').forEach(tab => {
288
+    tab.classList.toggle('active', tab.dataset.tab === currentTab)
289
+  })
290
+}
291
+
292
+function updateShopContent() {
293
+  const content = shopOverlay.querySelector('#shop-content')
294
+
295
+  // Donny sells buildings, Ollie sells outfits
296
+  if (currentShopkeeper === 'donny') {
297
+    content.innerHTML = renderBuildingsTab()
298
+    attachBuildingListeners(content)
299
+  } else if (currentShopkeeper === 'ollie') {
300
+    content.innerHTML = renderOutfitsTab()
301
+    attachOutfitListeners(content)
302
+  }
303
+
304
+  // Update koi count
305
+  const koiCount = shopOverlay.querySelector('#shop-koi-count')
306
+  if (koiCount) koiCount.textContent = gameState.getKoi()
307
+}
308
+
309
+function renderOutfitsTab() {
310
+  const characterBtns = Object.values(CHARACTERS).map(char => `
311
+    <button class="character-btn ${currentCharacter === char ? 'active' : ''}" data-character="${char}">
312
+      ${char.charAt(0).toUpperCase() + char.slice(1)}
313
+    </button>
314
+  `).join('')
315
+
316
+  const outfits = getOutfitsForCharacter(currentCharacter)
317
+
318
+  if (outfits.length === 0) {
319
+    return `
320
+      <div class="character-selector">${characterBtns}</div>
321
+      <div class="empty-message">No outfits available for ${currentCharacter}</div>
322
+    `
323
+  }
324
+
325
+  const itemCards = outfits.map(outfit => {
326
+    const owned = inventory.owns(outfit.id)
327
+    const equipped = inventory.isEquipped(outfit.character, outfit.id)
328
+    const canAfford = gameState.getKoi() >= outfit.price
329
+
330
+    let btnClass, btnText
331
+    if (!owned) {
332
+      btnClass = 'buy'
333
+      btnText = canAfford ? 'Buy' : 'Need more 🐟'
334
+    } else if (equipped) {
335
+      btnClass = 'unequip'
336
+      btnText = 'Unequip'
337
+    } else {
338
+      btnClass = 'equip'
339
+      btnText = 'Equip'
340
+    }
341
+
342
+    const cardClasses = ['item-card']
343
+    if (owned) cardClasses.push('owned')
344
+    if (equipped) cardClasses.push('equipped')
345
+    if (!owned && !canAfford) cardClasses.push('cant-afford')
346
+
347
+    return `
348
+      <div class="${cardClasses.join(' ')}" data-item-id="${outfit.id}">
349
+        <div class="item-name">${outfit.name}</div>
350
+        ${!owned ? `<div class="item-price">🐟 ${outfit.price}</div>` : ''}
351
+        <button class="item-btn ${btnClass}" ${!owned && !canAfford ? 'disabled' : ''} data-action="${btnClass}" data-item-id="${outfit.id}">
352
+          ${btnText}
353
+        </button>
354
+      </div>
355
+    `
356
+  }).join('')
357
+
358
+  return `
359
+    <div class="character-selector">${characterBtns}</div>
360
+    <div class="item-grid">${itemCards}</div>
361
+  `
362
+}
363
+
364
+function renderBuildingsTab() {
365
+  const buildings = getAllBuildings()
366
+
367
+  const itemCards = buildings.map(building => {
368
+    const owned = inventory.owns(building.id)
369
+    const canAfford = gameState.getKoi() >= building.price
370
+
371
+    let btnClass, btnText
372
+    if (owned) {
373
+      btnClass = 'equip'
374
+      btnText = 'Place'
375
+    } else {
376
+      btnClass = 'buy'
377
+      btnText = canAfford ? 'Buy' : 'Need more 🐟'
378
+    }
379
+
380
+    const cardClasses = ['item-card']
381
+    if (owned) cardClasses.push('owned')
382
+    if (!owned && !canAfford) cardClasses.push('cant-afford')
383
+
384
+    return `
385
+      <div class="${cardClasses.join(' ')}" data-item-id="${building.id}">
386
+        <div class="item-name">${building.name}</div>
387
+        ${!owned ? `<div class="item-price">🐟 ${building.price}</div>` : ''}
388
+        <button class="item-btn ${btnClass}" ${!owned && !canAfford ? 'disabled' : ''} data-action="${btnClass}" data-item-id="${building.id}">
389
+          ${btnText}
390
+        </button>
391
+      </div>
392
+    `
393
+  }).join('')
394
+
395
+  return `<div class="item-grid">${itemCards}</div>`
396
+}
397
+
398
+function attachOutfitListeners(content) {
399
+  // Character selector
400
+  content.querySelectorAll('.character-btn').forEach(btn => {
401
+    btn.addEventListener('click', () => {
402
+      currentCharacter = btn.dataset.character
403
+      updateShopContent()
404
+    })
405
+  })
406
+
407
+  // Item buttons
408
+  content.querySelectorAll('.item-btn').forEach(btn => {
409
+    btn.addEventListener('click', () => {
410
+      const itemId = btn.dataset.itemId
411
+      const action = btn.dataset.action
412
+      const item = getItem(itemId)
413
+
414
+      if (action === 'buy') {
415
+        if (gameState.spendKoi(item.price)) {
416
+          inventory.purchase(itemId)
417
+          // Auto-equip after buying (this also unequips same-type items)
418
+          const unequippedIds = inventory.equip(item.character, itemId)
419
+          // Notify about unequipped items first
420
+          if (onPurchaseCallback && unequippedIds) {
421
+            for (const oldId of unequippedIds) {
422
+              const oldItem = getItem(oldId)
423
+              if (oldItem) onPurchaseCallback(oldItem, 'unequip')
424
+            }
425
+          }
426
+          if (onPurchaseCallback) onPurchaseCallback(item, 'equip')
427
+          playPurchase()
428
+          updateShopContent()
429
+        }
430
+      } else if (action === 'equip') {
431
+        // Equip returns any auto-unequipped items of the same type
432
+        const unequippedIds = inventory.equip(item.character, itemId)
433
+        // Notify about unequipped items first
434
+        if (onPurchaseCallback && unequippedIds) {
435
+          for (const oldId of unequippedIds) {
436
+            const oldItem = getItem(oldId)
437
+            if (oldItem) onPurchaseCallback(oldItem, 'unequip')
438
+          }
439
+        }
440
+        if (onPurchaseCallback) onPurchaseCallback(item, 'equip')
441
+        updateShopContent()
442
+      } else if (action === 'unequip') {
443
+        inventory.unequip(item.character, itemId)
444
+        if (onPurchaseCallback) onPurchaseCallback(item, 'unequip')
445
+        updateShopContent()
446
+      }
447
+    })
448
+  })
449
+}
450
+
451
+function attachBuildingListeners(content) {
452
+  content.querySelectorAll('.item-btn').forEach(btn => {
453
+    btn.addEventListener('click', () => {
454
+      const itemId = btn.dataset.itemId
455
+      const action = btn.dataset.action
456
+      const item = getItem(itemId)
457
+
458
+      if (action === 'buy') {
459
+        if (gameState.spendKoi(item.price)) {
460
+          inventory.purchase(itemId)
461
+          if (onPurchaseCallback) onPurchaseCallback(item)
462
+          playPurchase()
463
+          updateShopContent()
464
+        }
465
+      } else if (action === 'equip') {
466
+        // Building placement - close shop and enter placement mode
467
+        // Save callback before closeShop clears it
468
+        const callback = onPurchaseCallback
469
+        closeShop()
470
+        if (callback) callback(item, 'place')
471
+      }
472
+    })
473
+  })
474
+}
475
+
476
+export function openShop(shopkeeper, container, callbacks = {}) {
477
+  if (shopOverlay) return // Already open
478
+
479
+  currentShopkeeper = shopkeeper
480
+  onCloseCallback = callbacks.onClose
481
+  onPurchaseCallback = callbacks.onPurchase
482
+
483
+  // Set default tab based on shopkeeper specialty
484
+  // Donny sells buildings, Ollie sells outfits
485
+  currentTab = shopkeeper === 'donny' ? 'buildings' : 'outfits'
486
+
487
+  shopOverlay = createShopOverlay()
488
+  container.appendChild(shopOverlay)
489
+  updateShopContent()
490
+
491
+  // Listen for koi count changes
492
+  gameState.addListener(updateKoiDisplay)
493
+
494
+  playShopOpen()
495
+}
496
+
497
+function updateKoiDisplay(count) {
498
+  if (shopOverlay) {
499
+    const koiCount = shopOverlay.querySelector('#shop-koi-count')
500
+    if (koiCount) koiCount.textContent = count
501
+  }
502
+}
503
+
504
+export function closeShop() {
505
+  if (!shopOverlay) return
506
+
507
+  playShopClose()
508
+
509
+  gameState.removeListener(updateKoiDisplay)
510
+  shopOverlay.remove()
511
+  shopOverlay = null
512
+
513
+  if (onCloseCallback) {
514
+    onCloseCallback()
515
+    onCloseCallback = null
516
+  }
517
+
518
+  currentShopkeeper = null
519
+  onPurchaseCallback = null
520
+}
521
+
522
+export function isShopOpen() {
523
+  return shopOverlay !== null
524
+}
src/renderers/three/sounds.jsmodified
@@ -193,3 +193,242 @@ export function playMonch() {
193193
   gulp.start(now)
194194
   gulp.stop(now + 0.15)
195195
 }
196
+
197
+// Koi capture sound - magical sparkle pop
198
+export function playCapture() {
199
+  const ctx = getContext()
200
+  if (ctx.state === 'suspended') ctx.resume()
201
+
202
+  const now = ctx.currentTime
203
+
204
+  const master = ctx.createGain()
205
+  master.gain.value = 0.3
206
+  master.connect(ctx.destination)
207
+
208
+  // Rising sparkle tones
209
+  const notes = [523, 659, 784, 1047] // C5, E5, G5, C6
210
+  notes.forEach((freq, i) => {
211
+    const osc = ctx.createOscillator()
212
+    osc.type = 'sine'
213
+    osc.frequency.value = freq
214
+
215
+    const env = ctx.createGain()
216
+    const startTime = now + i * 0.05
217
+    env.gain.setValueAtTime(0, startTime)
218
+    env.gain.linearRampToValueAtTime(0.15, startTime + 0.02)
219
+    env.gain.linearRampToValueAtTime(0, startTime + 0.15)
220
+
221
+    osc.connect(env)
222
+    env.connect(master)
223
+    osc.start(startTime)
224
+    osc.stop(startTime + 0.2)
225
+  })
226
+
227
+  // Soft pop at the end
228
+  const popOsc = ctx.createOscillator()
229
+  popOsc.type = 'sine'
230
+  popOsc.frequency.setValueAtTime(400, now + 0.15)
231
+  popOsc.frequency.linearRampToValueAtTime(200, now + 0.25)
232
+
233
+  const popEnv = ctx.createGain()
234
+  popEnv.gain.setValueAtTime(0, now + 0.15)
235
+  popEnv.gain.linearRampToValueAtTime(0.2, now + 0.17)
236
+  popEnv.gain.linearRampToValueAtTime(0, now + 0.3)
237
+
238
+  popOsc.connect(popEnv)
239
+  popEnv.connect(master)
240
+  popOsc.start(now + 0.15)
241
+  popOsc.stop(now + 0.35)
242
+}
243
+
244
+// Purchase/coin sound - satisfying cha-ching
245
+export function playPurchase() {
246
+  const ctx = getContext()
247
+  if (ctx.state === 'suspended') ctx.resume()
248
+
249
+  const now = ctx.currentTime
250
+
251
+  const master = ctx.createGain()
252
+  master.gain.value = 0.25
253
+  master.connect(ctx.destination)
254
+
255
+  // High bell tone
256
+  const bell1 = ctx.createOscillator()
257
+  bell1.type = 'sine'
258
+  bell1.frequency.value = 1200
259
+
260
+  const bell1Env = ctx.createGain()
261
+  bell1Env.gain.setValueAtTime(0.3, now)
262
+  bell1Env.gain.exponentialRampToValueAtTime(0.01, now + 0.3)
263
+
264
+  bell1.connect(bell1Env)
265
+  bell1Env.connect(master)
266
+  bell1.start(now)
267
+  bell1.stop(now + 0.35)
268
+
269
+  // Second bell tone (slightly delayed)
270
+  const bell2 = ctx.createOscillator()
271
+  bell2.type = 'sine'
272
+  bell2.frequency.value = 1600
273
+
274
+  const bell2Env = ctx.createGain()
275
+  bell2Env.gain.setValueAtTime(0, now)
276
+  bell2Env.gain.setValueAtTime(0.25, now + 0.08)
277
+  bell2Env.gain.exponentialRampToValueAtTime(0.01, now + 0.35)
278
+
279
+  bell2.connect(bell2Env)
280
+  bell2Env.connect(master)
281
+  bell2.start(now)
282
+  bell2.stop(now + 0.4)
283
+
284
+  // Metallic shimmer (noise burst)
285
+  const shimmerLength = 0.15
286
+  const shimmerBuffer = ctx.createBuffer(1, ctx.sampleRate * shimmerLength, ctx.sampleRate)
287
+  const shimmerData = shimmerBuffer.getChannelData(0)
288
+  for (let i = 0; i < shimmerData.length; i++) {
289
+    shimmerData[i] = (Math.random() * 2 - 1) * 0.3
290
+  }
291
+
292
+  const shimmer = ctx.createBufferSource()
293
+  shimmer.buffer = shimmerBuffer
294
+
295
+  const shimmerFilter = ctx.createBiquadFilter()
296
+  shimmerFilter.type = 'highpass'
297
+  shimmerFilter.frequency.value = 3000
298
+
299
+  const shimmerEnv = ctx.createGain()
300
+  shimmerEnv.gain.setValueAtTime(0.15, now)
301
+  shimmerEnv.gain.linearRampToValueAtTime(0, now + shimmerLength)
302
+
303
+  shimmer.connect(shimmerFilter)
304
+  shimmerFilter.connect(shimmerEnv)
305
+  shimmerEnv.connect(master)
306
+  shimmer.start(now)
307
+  shimmer.stop(now + shimmerLength)
308
+}
309
+
310
+// Shop open sound - friendly chime
311
+export function playShopOpen() {
312
+  const ctx = getContext()
313
+  if (ctx.state === 'suspended') ctx.resume()
314
+
315
+  const now = ctx.currentTime
316
+
317
+  const master = ctx.createGain()
318
+  master.gain.value = 0.2
319
+  master.connect(ctx.destination)
320
+
321
+  // Ascending arpeggio - warm and inviting
322
+  const notes = [392, 494, 587, 784] // G4, B4, D5, G5
323
+  notes.forEach((freq, i) => {
324
+    const osc = ctx.createOscillator()
325
+    osc.type = 'triangle'
326
+    osc.frequency.value = freq
327
+
328
+    const env = ctx.createGain()
329
+    const startTime = now + i * 0.08
330
+    env.gain.setValueAtTime(0, startTime)
331
+    env.gain.linearRampToValueAtTime(0.2, startTime + 0.03)
332
+    env.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4)
333
+
334
+    osc.connect(env)
335
+    env.connect(master)
336
+    osc.start(startTime)
337
+    osc.stop(startTime + 0.45)
338
+  })
339
+}
340
+
341
+// Shop close sound - gentle descending tone
342
+export function playShopClose() {
343
+  const ctx = getContext()
344
+  if (ctx.state === 'suspended') ctx.resume()
345
+
346
+  const now = ctx.currentTime
347
+
348
+  const master = ctx.createGain()
349
+  master.gain.value = 0.15
350
+  master.connect(ctx.destination)
351
+
352
+  // Single soft descending tone
353
+  const osc = ctx.createOscillator()
354
+  osc.type = 'triangle'
355
+  osc.frequency.setValueAtTime(600, now)
356
+  osc.frequency.linearRampToValueAtTime(400, now + 0.2)
357
+
358
+  const env = ctx.createGain()
359
+  env.gain.setValueAtTime(0.2, now)
360
+  env.gain.linearRampToValueAtTime(0, now + 0.25)
361
+
362
+  osc.connect(env)
363
+  env.connect(master)
364
+  osc.start(now)
365
+  osc.stop(now + 0.3)
366
+}
367
+
368
+// Dialog blip sound - for typewriter text
369
+export function playDialogBlip() {
370
+  const ctx = getContext()
371
+  if (ctx.state === 'suspended') ctx.resume()
372
+
373
+  const now = ctx.currentTime
374
+
375
+  const osc = ctx.createOscillator()
376
+  osc.type = 'square'
377
+  osc.frequency.value = 440 + Math.random() * 60 // Slight variation
378
+
379
+  const env = ctx.createGain()
380
+  env.gain.setValueAtTime(0.08, now)
381
+  env.gain.linearRampToValueAtTime(0, now + 0.04)
382
+
383
+  const filter = ctx.createBiquadFilter()
384
+  filter.type = 'lowpass'
385
+  filter.frequency.value = 1000
386
+
387
+  osc.connect(filter)
388
+  filter.connect(env)
389
+  env.connect(ctx.destination)
390
+  osc.start(now)
391
+  osc.stop(now + 0.05)
392
+}
393
+
394
+// Placement confirm sound - solid thunk
395
+export function playPlaceBuilding() {
396
+  const ctx = getContext()
397
+  if (ctx.state === 'suspended') ctx.resume()
398
+
399
+  const now = ctx.currentTime
400
+
401
+  const master = ctx.createGain()
402
+  master.gain.value = 0.25
403
+  master.connect(ctx.destination)
404
+
405
+  // Low thump
406
+  const thump = ctx.createOscillator()
407
+  thump.type = 'sine'
408
+  thump.frequency.setValueAtTime(150, now)
409
+  thump.frequency.linearRampToValueAtTime(60, now + 0.1)
410
+
411
+  const thumpEnv = ctx.createGain()
412
+  thumpEnv.gain.setValueAtTime(0.4, now)
413
+  thumpEnv.gain.linearRampToValueAtTime(0, now + 0.15)
414
+
415
+  thump.connect(thumpEnv)
416
+  thumpEnv.connect(master)
417
+  thump.start(now)
418
+  thump.stop(now + 0.2)
419
+
420
+  // Wood knock overtone
421
+  const knock = ctx.createOscillator()
422
+  knock.type = 'triangle'
423
+  knock.frequency.setValueAtTime(300, now)
424
+  knock.frequency.linearRampToValueAtTime(200, now + 0.05)
425
+
426
+  const knockEnv = ctx.createGain()
427
+  knockEnv.gain.setValueAtTime(0.2, now)
428
+  knockEnv.gain.linearRampToValueAtTime(0, now + 0.08)
429
+
430
+  knock.connect(knockEnv)
431
+  knockEnv.connect(master)
432
+  knock.start(now)
433
+  knock.stop(now + 0.1)
434
+}