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'
5
 export function createDoug(scene, gradientMap) {
5
 export function createDoug(scene, gradientMap) {
6
   const group = new THREE.Group()
6
   const group = new THREE.Group()
7
 
7
 
8
-  // Color palette - vibrant Wind Waker yellows
8
+  // Store gradientMap for accessory creation
9
-  const bodyColor = 0xffdc50 // Warm yellow
9
+  const storedGradientMap = gradientMap
10
-  const bodyHighlight = 0xfff0a0 // Light yellow
10
+
11
-  const beakColor = 0xff9020 // Bright orange
11
+  // Default colors - vibrant Wind Waker yellows
12
-  const eyeWhite = 0xffffff
12
+  const defaultColors = {
13
-  const eyePupil = 0x191410
13
+    body: 0xffdc50,
14
+    highlight: 0xfff0a0,
15
+    beak: 0xff9020
16
+  }
14
 
17
 
15
-  // Toon materials
18
+  // Toon materials (stored for outfit swapping)
16
   const bodyMaterial = new THREE.MeshToonMaterial({
19
   const bodyMaterial = new THREE.MeshToonMaterial({
17
-    color: bodyColor,
20
+    color: defaultColors.body,
18
     gradientMap: gradientMap
21
     gradientMap: gradientMap
19
   })
22
   })
20
 
23
 
21
   const highlightMaterial = new THREE.MeshToonMaterial({
24
   const highlightMaterial = new THREE.MeshToonMaterial({
22
-    color: bodyHighlight,
25
+    color: defaultColors.highlight,
23
     gradientMap: gradientMap
26
     gradientMap: gradientMap
24
   })
27
   })
25
 
28
 
26
   const beakMaterial = new THREE.MeshToonMaterial({
29
   const beakMaterial = new THREE.MeshToonMaterial({
27
-    color: beakColor,
30
+    color: defaultColors.beak,
28
     gradientMap: gradientMap
31
     gradientMap: gradientMap
29
   })
32
   })
30
 
33
 
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
+
31
   const eyeWhiteMaterial = new THREE.MeshToonMaterial({
49
   const eyeWhiteMaterial = new THREE.MeshToonMaterial({
32
     color: eyeWhite,
50
     color: eyeWhite,
33
     gradientMap: gradientMap
51
     gradientMap: gradientMap
@@ -246,7 +264,47 @@ export function createDoug(scene, gradientMap) {
246
     )
264
     )
247
   }
265
   }
248
 
266
 
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
+
250
     // Find closest bread
308
     // Find closest bread
251
     let closestBread = null
309
     let closestBread = null
252
     let closestDist = Infinity
310
     let closestDist = Infinity
@@ -397,9 +455,90 @@ export function createDoug(scene, gradientMap) {
397
     }
455
     }
398
   }
456
   }
399
 
457
 
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
+
400
   return {
537
   return {
401
     group,
538
     group,
402
     update,
539
     update,
403
-    getPosition: () => state.position.clone()
540
+    getPosition: () => state.position.clone(),
541
+    applyOutfit,
542
+    removeOutfit
404
   }
543
   }
405
 }
544
 }
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'
10
 import { createDonny } from './narwhal.js'
10
 import { createDonny } from './narwhal.js'
11
 import { createKoiSchool } from './koi.js'
11
 import { createKoiSchool } from './koi.js'
12
 import { createOllie } from './octopus.js'
12
 import { createOllie } from './octopus.js'
13
+import { PlacementManager } from './buildingPlacement.js'
13
 import { unlockAudio } from './sounds.js'
14
 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'
14
 
21
 
15
 let scene, camera, renderer, composer, outlinePass
22
 let scene, camera, renderer, composer, outlinePass
16
-let doug, pond, breadManager, donny, koiSchool, ollie
23
+let doug, pond, breadManager, donny, koiSchool, ollie, placementManager
17
 let clock
24
 let clock
18
 let animationId = null
25
 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
+}
19
 
81
 
20
 function createToonGradient() {
82
 function createToonGradient() {
21
   const canvas = document.createElement('canvas')
83
   const canvas = document.createElement('canvas')
@@ -95,6 +157,7 @@ export function start(container) {
95
   donny = createDonny(scene, toonGradient)
157
   donny = createDonny(scene, toonGradient)
96
   koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
158
   koiSchool = createKoiSchool(scene, toonGradient, pond.radius)
97
   ollie = createOllie(scene, toonGradient)
159
   ollie = createOllie(scene, toonGradient)
160
+  placementManager = new PlacementManager(scene, pond, camera, toonGradient)
98
 
161
 
99
   // Post-processing
162
   // Post-processing
100
   composer = new EffectComposer(renderer)
163
   composer = new EffectComposer(renderer)
@@ -110,7 +173,7 @@ export function start(container) {
110
   outlinePass.edgeThickness = 1.5
173
   outlinePass.edgeThickness = 1.5
111
   outlinePass.visibleEdgeColor.set(0x191410)
174
   outlinePass.visibleEdgeColor.set(0x191410)
112
   outlinePass.hiddenEdgeColor.set(0x191410)
175
   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()]
114
   composer.addPass(outlinePass)
177
   composer.addPass(outlinePass)
115
   composer.addPass(new OutputPass())
178
   composer.addPass(new OutputPass())
116
 
179
 
@@ -121,25 +184,206 @@ export function start(container) {
121
   // Unlock audio on first touch (for mobile)
184
   // Unlock audio on first touch (for mobile)
122
   renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true })
185
   renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true })
123
 
186
 
124
-  renderer.domElement.addEventListener('click', (event) => {
187
+  // Mouse move - handle placement preview
125
-    // Unlock audio on first interaction (required for mobile)
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) => {
126
     unlockAudio()
227
     unlockAudio()
127
 
228
 
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
+
128
     mouse.x = (event.clientX / window.innerWidth) * 2 - 1
240
     mouse.x = (event.clientX / window.innerWidth) * 2 - 1
129
     mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
241
     mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
130
 
242
 
131
     raycaster.setFromCamera(mouse, camera)
243
     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
+
132
     const intersects = raycaster.intersectObject(pond.water)
259
     const intersects = raycaster.intersectObject(pond.water)
133
 
260
 
134
     if (intersects.length > 0) {
261
     if (intersects.length > 0) {
135
       const point = intersects[0].point
262
       const point = intersects[0].point
136
-      breadManager.spawnBread(point.x, point.z)
263
+      captureState.clickPos = { x: point.x, z: point.z }
137
-      pond.addRipple(point.x, point.z)
264
+      captureState.isHolding = true
138
-      koiSchool.triggerPanic(point.x, point.z)
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)
139
       outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()]
292
       outlinePass.selectedObjects = [doug.group, pond.group, ...breadManager.getMeshes()]
140
     }
293
     }
294
+
295
+    // Reset capture state
296
+    captureState.isHolding = false
297
+    captureState.targetKoi = null
298
+    captureState.clickPos = null
141
   })
299
   })
142
 
300
 
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
+
143
   // Resize handler
387
   // Resize handler
144
   window.addEventListener('resize', onResize)
388
   window.addEventListener('resize', onResize)
145
 
389
 
@@ -166,12 +410,99 @@ function animate() {
166
   const delta = clock.getDelta()
410
   const delta = clock.getDelta()
167
   const elapsed = clock.getElapsedTime()
411
   const elapsed = clock.getElapsedTime()
168
 
412
 
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
+  })
170
   breadManager.update(delta, elapsed)
490
   breadManager.update(delta, elapsed)
171
   pond.update(delta, elapsed)
491
   pond.update(delta, elapsed)
172
   donny.update(delta, elapsed, pond, doug)
492
   donny.update(delta, elapsed, pond, doug)
173
   koiSchool.update(delta, elapsed)
493
   koiSchool.update(delta, elapsed)
174
   ollie.update(delta, elapsed, pond, doug)
494
   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
+  }
175
 
506
 
176
   composer.render()
507
   composer.render()
177
 }
508
 }
@@ -184,6 +515,14 @@ export function stop() {
184
 
515
 
185
   window.removeEventListener('resize', onResize)
516
   window.removeEventListener('resize', onResize)
186
 
517
 
518
+  // Close dialog and shop if open
519
+  if (isDialogOpen()) {
520
+    closeDialog()
521
+  }
522
+  if (isShopOpen()) {
523
+    closeShop()
524
+  }
525
+
187
   if (renderer) {
526
   if (renderer) {
188
     renderer.domElement.remove()
527
     renderer.domElement.remove()
189
     renderer.dispose()
528
     renderer.dispose()
@@ -193,6 +532,20 @@ export function stop() {
193
     composer.dispose()
532
     composer.dispose()
194
   }
533
   }
195
 
534
 
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
+
196
   scene = null
549
   scene = null
197
   camera = null
550
   camera = null
198
   renderer = null
551
   renderer = null
@@ -203,6 +556,7 @@ export function stop() {
203
   koiSchool = null
556
   koiSchool = null
204
   ollie = null
557
   ollie = null
205
   breadManager = null
558
   breadManager = null
559
+  placementManager = null
206
 }
560
 }
207
 
561
 
208
 export const name = '3D'
562
 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
2
 import * as THREE from 'three'
2
 import * as THREE from 'three'
3
+import gameState from './gameState.js'
4
+import { playCapture } from './sounds.js'
3
 
5
 
4
 export function createKoiSchool(scene, gradientMap, pondRadius) {
6
 export function createKoiSchool(scene, gradientMap, pondRadius) {
5
   const group = new THREE.Group()
7
   const group = new THREE.Group()
6
   const kois = []
8
   const kois = []
7
   const koiCount = 5
9
   const koiCount = 5
8
 
10
 
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
+
9
   // Koi color variations
78
   // Koi color variations
10
   const koiColors = [
79
   const koiColors = [
11
     { body: 0xff6b35, spots: 0xffffff },  // Orange with white
80
     { body: 0xff6b35, spots: 0xffffff },  // Orange with white
@@ -33,11 +102,18 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
33
 
102
 
34
     koi.state = {
103
     koi.state = {
35
       speed: 0.3 + Math.random() * 0.3,
104
       speed: 0.3 + Math.random() * 0.3,
36
-      turnRate: 0,  // Current turning rate
105
+      turnRate: 0,
37
       targetTurnRate: 0,
106
       targetTurnRate: 0,
38
       turnTimer: Math.random() * 3,
107
       turnTimer: Math.random() * 3,
39
-      panicTimer: 0,
108
+      flickerPhase: Math.random() * Math.PI * 2,
40
-      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
41
     }
117
     }
42
 
118
 
43
     group.add(koi.group)
119
     group.add(koi.group)
@@ -105,59 +181,208 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
105
     return { group: koiGroup, tail }
181
     return { group: koiGroup, tail }
106
   }
182
   }
107
 
183
 
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
108
   function triggerPanic(x, z) {
209
   function triggerPanic(x, z) {
210
+    // Now just a brief scatter, not sustained panic
109
     for (const koi of kois) {
211
     for (const koi of kois) {
212
+      if (koi.state.captured || koi.state.beingCaptured) continue
213
+
110
       const dist = Math.hypot(
214
       const dist = Math.hypot(
111
         koi.group.position.x - x,
215
         koi.group.position.x - x,
112
         koi.group.position.z - z
216
         koi.group.position.z - z
113
       )
217
       )
114
 
218
 
115
-      if (dist < 1.5) {
219
+      if (dist < 0.8) {
116
-        koi.state.panicTimer = 1 + Math.random() * 0.5
220
+        // Brief scatter only when bread lands very close
117
-
221
+        koi.state.attractedTo = null
118
-        // Turn away from the disturbance
222
+        // Turn slightly away then resume
119
         const awayAngle = Math.atan2(
223
         const awayAngle = Math.atan2(
120
           koi.group.position.x - x,
224
           koi.group.position.x - x,
121
           koi.group.position.z - z
225
           koi.group.position.z - z
122
         )
226
         )
123
-        // Set a strong turn toward the away direction
124
         let turnNeeded = awayAngle - koi.group.rotation.y
227
         let turnNeeded = awayAngle - koi.group.rotation.y
125
         while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
228
         while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
126
         while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
229
         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
128
       }
232
       }
129
     }
233
     }
130
   }
234
   }
131
 
235
 
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
+
132
   function update(delta, elapsed) {
328
   function update(delta, elapsed) {
133
     for (const koi of kois) {
329
     for (const koi of kois) {
134
       const s = koi.state
330
       const s = koi.state
135
       const pos = koi.group.position
331
       const pos = koi.group.position
136
 
332
 
137
-      // Update panic
333
+      // Handle respawn timer
138
-      const isPanicked = s.panicTimer > 0
334
+      if (s.captured) {
139
-      if (isPanicked) {
335
+        s.respawnTimer -= delta
140
-        s.panicTimer -= 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
141
       }
345
       }
142
 
346
 
143
       // Decide turning behavior
347
       // Decide turning behavior
144
       s.turnTimer -= delta
348
       s.turnTimer -= delta
145
-      if (s.turnTimer <= 0 && !isPanicked) {
349
+
146
-        // Occasionally change turn rate for natural wandering
350
+      // If attracted to bread, swim toward it
147
-        s.targetTurnRate = (Math.random() - 0.5) * 1.5
351
+      if (s.attractedTo) {
148
-        s.turnTimer = 1 + Math.random() * 3
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
+        }
149
       }
377
       }
150
 
378
 
151
       // Check if heading toward pond edge
379
       // Check if heading toward pond edge
152
       const distFromCenter = Math.hypot(pos.x, pos.z)
380
       const distFromCenter = Math.hypot(pos.x, pos.z)
153
       if (distFromCenter > pondRadius * 0.75) {
381
       if (distFromCenter > pondRadius * 0.75) {
154
-        // Calculate angle to center
155
         const toCenter = Math.atan2(-pos.x, -pos.z)
382
         const toCenter = Math.atan2(-pos.x, -pos.z)
156
         let turnNeeded = toCenter - koi.group.rotation.y
383
         let turnNeeded = toCenter - koi.group.rotation.y
157
         while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
384
         while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
158
         while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
385
         while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
159
-
160
-        // Steer back toward center
161
         s.targetTurnRate = Math.sign(turnNeeded) * 1.5
386
         s.targetTurnRate = Math.sign(turnNeeded) * 1.5
162
       }
387
       }
163
 
388
 
@@ -167,19 +392,15 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
167
       // Apply rotation
392
       // Apply rotation
168
       koi.group.rotation.y += s.turnRate * delta
393
       koi.group.rotation.y += s.turnRate * delta
169
 
394
 
170
-      // Move forward (in the direction the fish is facing, which is +Z in local space)
395
+      // Move forward
171
-      const speed = isPanicked ? s.speed * 2.5 : s.speed
172
-
173
-      // Get forward direction from rotation
174
       const forwardX = Math.sin(koi.group.rotation.y)
396
       const forwardX = Math.sin(koi.group.rotation.y)
175
       const forwardZ = Math.cos(koi.group.rotation.y)
397
       const forwardZ = Math.cos(koi.group.rotation.y)
398
+      pos.x += forwardX * s.speed * delta
399
+      pos.z += forwardZ * s.speed * delta
176
 
400
 
177
-      pos.x += forwardX * speed * delta
401
+      // Tail wiggle
178
-      pos.z += forwardZ * speed * delta
402
+      const wiggleSpeed = s.attractedTo ? 14 : 10
179
-
403
+      const wiggleAmount = s.attractedTo ? 0.35 : 0.25
180
-      // Tail wiggle - faster when moving fast
181
-      const wiggleSpeed = isPanicked ? 18 : 10
182
-      const wiggleAmount = isPanicked ? 0.4 : 0.25
183
       koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount
404
       koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount
184
 
405
 
185
       // Gentle vertical bob
406
       // Gentle vertical bob
@@ -193,12 +414,20 @@ export function createKoiSchool(scene, gradientMap, pondRadius) {
193
         pos.z *= scale
414
         pos.z *= scale
194
       }
415
       }
195
     }
416
     }
417
+
418
+    // Update sparkle particles
419
+    updateSparkles(delta)
196
   }
420
   }
197
 
421
 
198
   return {
422
   return {
199
     group,
423
     group,
200
     update,
424
     update,
201
     triggerPanic,
425
     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)
203
   }
432
   }
204
 }
433
 }
src/renderers/three/narwhal.jsmodified
@@ -4,39 +4,54 @@ import * as THREE from 'three'
4
 export function createDonny(scene, gradientMap) {
4
 export function createDonny(scene, gradientMap) {
5
   const group = new THREE.Group()
5
   const group = new THREE.Group()
6
 
6
 
7
-  // Color palette
7
+  // Store gradientMap for accessory creation
8
-  const bodyColor = 0x7a9eb8 // Dusty blue-grey
8
+  const storedGradientMap = gradientMap
9
-  const bellyColor = 0xc8d8e4 // Pale belly
9
+
10
-  const tuskColor = 0xf5f0e6 // Ivory
10
+  // Default colors
11
-  const monocleColor = 0xd4af37 // Gold
11
+  const defaultColors = {
12
+    body: 0x7a9eb8,
13
+    belly: 0xc8d8e4,
14
+    monocleRim: 0xd4af37,
15
+    monocleGlass: 0x88ccff
16
+  }
12
 
17
 
13
-  // Materials
18
+  // Materials (stored for outfit swapping)
14
   const bodyMaterial = new THREE.MeshToonMaterial({
19
   const bodyMaterial = new THREE.MeshToonMaterial({
15
-    color: bodyColor,
20
+    color: defaultColors.body,
16
     gradientMap: gradientMap
21
     gradientMap: gradientMap
17
   })
22
   })
18
 
23
 
19
   const bellyMaterial = new THREE.MeshToonMaterial({
24
   const bellyMaterial = new THREE.MeshToonMaterial({
20
-    color: bellyColor,
25
+    color: defaultColors.belly,
21
     gradientMap: gradientMap
26
     gradientMap: gradientMap
22
   })
27
   })
23
 
28
 
24
   const tuskMaterial = new THREE.MeshToonMaterial({
29
   const tuskMaterial = new THREE.MeshToonMaterial({
25
-    color: tuskColor,
30
+    color: 0xf5f0e6,
26
     gradientMap: gradientMap
31
     gradientMap: gradientMap
27
   })
32
   })
28
 
33
 
29
   const monocleMaterial = new THREE.MeshToonMaterial({
34
   const monocleMaterial = new THREE.MeshToonMaterial({
30
-    color: monocleColor,
35
+    color: defaultColors.monocleRim,
31
     gradientMap: gradientMap
36
     gradientMap: gradientMap
32
   })
37
   })
33
 
38
 
34
   const glassMaterial = new THREE.MeshBasicMaterial({
39
   const glassMaterial = new THREE.MeshBasicMaterial({
35
-    color: 0x88ccff,
40
+    color: defaultColors.monocleGlass,
36
     transparent: true,
41
     transparent: true,
37
     opacity: 0.3
42
     opacity: 0.3
38
   })
43
   })
39
 
44
 
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
+
40
   // Main body - elongated oval
55
   // Main body - elongated oval
41
   const bodyGeom = new THREE.SphereGeometry(0.5, 8, 6)
56
   const bodyGeom = new THREE.SphereGeometry(0.5, 8, 6)
42
   bodyGeom.scale(2.2, 0.7, 0.8)
57
   bodyGeom.scale(2.2, 0.7, 0.8)
@@ -115,7 +130,7 @@ export function createDonny(scene, gradientMap) {
115
 
130
 
116
   // Chain (simple dangling segments)
131
   // Chain (simple dangling segments)
117
   const chainMaterial = new THREE.MeshToonMaterial({
132
   const chainMaterial = new THREE.MeshToonMaterial({
118
-    color: monocleColor,
133
+    color: defaultColors.monocleRim,
119
     gradientMap: gradientMap
134
     gradientMap: gradientMap
120
   })
135
   })
121
   for (let i = 0; i < 4; i++) {
136
   for (let i = 0; i < 4; i++) {
@@ -168,28 +183,68 @@ export function createDonny(scene, gradientMap) {
168
 
183
 
169
   // State
184
   // State
170
   const state = {
185
   const state = {
171
-    mode: 'waiting', // 'waiting', 'rumbling', 'emerging', 'surfaced', 'submerging'
186
+    mode: 'waiting', // 'waiting', 'rumbling', 'emerging', 'surfaced', 'submerging', 'shop_approaching', 'shop_ready', 'shop_departing'
172
     timer: 30 + Math.random() * 30, // First appearance in 30-60 seconds
187
     timer: 30 + Math.random() * 30, // First appearance in 30-60 seconds
173
     emergeX: 0,
188
     emergeX: 0,
174
     emergeZ: 0,
189
     emergeZ: 0,
175
-    surfaceTime: 0
190
+    surfaceTime: 0,
191
+    // Shop state
192
+    shopMode: false,
193
+    shopCooldown: 0,
194
+    onShopReady: null
176
   }
195
   }
177
 
196
 
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
+
178
   function startRumble(pond) {
201
   function startRumble(pond) {
179
     state.mode = 'rumbling'
202
     state.mode = 'rumbling'
180
     state.timer = 0
203
     state.timer = 0
181
 
204
 
182
-    // Pick random spot in the pond
205
+    // Pick random spot in the pond, avoiding forbidden zones (dock, boat)
183
-    const angle = Math.random() * Math.PI * 2
206
+    let attempts = 0
184
-    const dist = Math.random() * pond.radius * 0.5 + pond.radius * 0.2
207
+    let angle, dist
185
-    state.emergeX = Math.cos(angle) * dist
208
+    do {
186
-    state.emergeZ = Math.sin(angle) * dist
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)
187
 
215
 
188
     group.position.x = state.emergeX
216
     group.position.x = state.emergeX
189
     group.position.z = state.emergeZ
217
     group.position.z = state.emergeZ
190
     group.rotation.y = angle + Math.PI / 2 // Face outward-ish
218
     group.rotation.y = angle + Math.PI / 2 // Face outward-ish
191
   }
219
   }
192
 
220
 
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
+
193
   // Helper to smoothly interpolate angles
248
   // Helper to smoothly interpolate angles
194
   function lerpAngle(from, to, t) {
249
   function lerpAngle(from, to, t) {
195
     let diff = to - from
250
     let diff = to - from
@@ -315,11 +370,238 @@ export function createDonny(scene, gradientMap) {
315
           group.rotation.z = 0
370
           group.rotation.z = 0
316
         }
371
         }
317
         break
372
         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
318
     }
591
     }
592
+    return false
319
   }
593
   }
320
 
594
 
321
   return {
595
   return {
322
     group,
596
     group,
323
-    update
597
+    update,
598
+    tryTriggerShop,
599
+    dismissShop,
600
+    setShopReadyCallback,
601
+    isInShopMode,
602
+    isTappable,
603
+    triggerShopFromTap,
604
+    applyOutfit,
605
+    removeOutfit
324
   }
606
   }
325
 }
607
 }
src/renderers/three/octopus.jsmodified
@@ -4,39 +4,55 @@ import * as THREE from 'three'
4
 export function createOllie(scene, gradientMap) {
4
 export function createOllie(scene, gradientMap) {
5
   const group = new THREE.Group()
5
   const group = new THREE.Group()
6
 
6
 
7
-  // Color palette - purple theme
7
+  // Store gradientMap for accessory creation
8
-  const bodyColor = 0x7b4b94 // Deep purple
8
+  const storedGradientMap = gradientMap
9
-  const bellyColor = 0xb89bc9 // Lighter lavender
9
+
10
-  const suckerColor = 0xd4a5c9 // Pink-ish
10
+  // Default colors - purple theme
11
-  const glassRimColor = 0xd4af37 // Gold
11
+  const defaultColors = {
12
+    body: 0x7b4b94,
13
+    belly: 0xb89bc9,
14
+    suckers: 0xd4a5c9,
15
+    magRim: 0xd4af37,
16
+    magGlass: 0x88ccff
17
+  }
12
 
18
 
13
-  // Materials
19
+  // Materials (stored for outfit swapping)
14
   const bodyMaterial = new THREE.MeshToonMaterial({
20
   const bodyMaterial = new THREE.MeshToonMaterial({
15
-    color: bodyColor,
21
+    color: defaultColors.body,
16
     gradientMap: gradientMap
22
     gradientMap: gradientMap
17
   })
23
   })
18
 
24
 
19
   const bellyMaterial = new THREE.MeshToonMaterial({
25
   const bellyMaterial = new THREE.MeshToonMaterial({
20
-    color: bellyColor,
26
+    color: defaultColors.belly,
21
     gradientMap: gradientMap
27
     gradientMap: gradientMap
22
   })
28
   })
23
 
29
 
24
   const suckerMaterial = new THREE.MeshToonMaterial({
30
   const suckerMaterial = new THREE.MeshToonMaterial({
25
-    color: suckerColor,
31
+    color: defaultColors.suckers,
26
     gradientMap: gradientMap
32
     gradientMap: gradientMap
27
   })
33
   })
28
 
34
 
29
   const glassRimMaterial = new THREE.MeshToonMaterial({
35
   const glassRimMaterial = new THREE.MeshToonMaterial({
30
-    color: glassRimColor,
36
+    color: defaultColors.magRim,
31
     gradientMap: gradientMap
37
     gradientMap: gradientMap
32
   })
38
   })
33
 
39
 
34
   const glassMaterial = new THREE.MeshBasicMaterial({
40
   const glassMaterial = new THREE.MeshBasicMaterial({
35
-    color: 0x88ccff,
41
+    color: defaultColors.magGlass,
36
     transparent: true,
42
     transparent: true,
37
     opacity: 0.3
43
     opacity: 0.3
38
   })
44
   })
39
 
45
 
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
+
40
   // Head/Mantle - bulbous dome
56
   // Head/Mantle - bulbous dome
41
   const mantleGeom = new THREE.SphereGeometry(0.5, 10, 8)
57
   const mantleGeom = new THREE.SphereGeometry(0.5, 10, 8)
42
   mantleGeom.scale(1.2, 1.4, 1.0)
58
   mantleGeom.scale(1.2, 1.4, 1.0)
@@ -169,9 +185,17 @@ export function createOllie(scene, gradientMap) {
169
     timer: 45 + Math.random() * 30, // First appearance in 45-75 seconds (after narwhal)
185
     timer: 45 + Math.random() * 30, // First appearance in 45-75 seconds (after narwhal)
170
     emergeX: 0,
186
     emergeX: 0,
171
     emergeZ: 0,
187
     emergeZ: 0,
172
-    surfaceTime: 0
188
+    surfaceTime: 0,
189
+    // Shop state
190
+    shopMode: false,
191
+    shopCooldown: 0,
192
+    onShopReady: null
173
   }
193
   }
174
 
194
 
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
+
175
   function createTentacle(bodyMat, suckerMat, gradient) {
199
   function createTentacle(bodyMat, suckerMat, gradient) {
176
     // Build tentacle as a chain of segments, each one a child of the previous
200
     // Build tentacle as a chain of segments, each one a child of the previous
177
     // This creates a smooth curve by rotating each joint
201
     // This creates a smooth curve by rotating each joint
@@ -229,17 +253,49 @@ export function createOllie(scene, gradientMap) {
229
     state.mode = 'rumbling'
253
     state.mode = 'rumbling'
230
     state.timer = 0
254
     state.timer = 0
231
 
255
 
232
-    // Pick random spot in outer zone of pond (70-90% radius)
256
+    // Pick random spot in outer zone of pond (70-90% radius), avoiding forbidden zones
233
-    const angle = Math.random() * Math.PI * 2
257
+    let attempts = 0
234
-    const dist = Math.random() * pond.radius * 0.2 + pond.radius * 0.7
258
+    let angle, dist
235
-    state.emergeX = Math.cos(angle) * dist
259
+    do {
236
-    state.emergeZ = Math.sin(angle) * dist
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)
237
 
266
 
238
     group.position.x = state.emergeX
267
     group.position.x = state.emergeX
239
     group.position.z = state.emergeZ
268
     group.position.z = state.emergeZ
240
     group.rotation.y = angle + Math.PI / 2
269
     group.rotation.y = angle + Math.PI / 2
241
   }
270
   }
242
 
271
 
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
+
243
   // Helper to smoothly interpolate angles
299
   // Helper to smoothly interpolate angles
244
   function lerpAngle(from, to, t) {
300
   function lerpAngle(from, to, t) {
245
     let diff = to - from
301
     let diff = to - from
@@ -378,11 +434,260 @@ export function createOllie(scene, gradientMap) {
378
           group.rotation.z = 0
434
           group.rotation.z = 0
379
         }
435
         }
380
         break
436
         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
381
     }
677
     }
678
+    return false
382
   }
679
   }
383
 
680
 
384
   return {
681
   return {
385
     group,
682
     group,
386
-    update
683
+    update,
684
+    tryTriggerShop,
685
+    dismissShop,
686
+    setShopReadyCallback,
687
+    isInShopMode,
688
+    isTappable,
689
+    triggerShopFromTap,
690
+    applyOutfit,
691
+    removeOutfit
387
   }
692
   }
388
 }
693
 }
src/renderers/three/pond.jsmodified
@@ -363,8 +363,8 @@ export function createPond(scene, gradientMap) {
363
   // ============================================
363
   // ============================================
364
 
364
 
365
   const boathouse = new THREE.Group()
365
   const boathouse = new THREE.Group()
366
-  const boathouseX = -4.2
366
+  const boathouseX = -3.3
367
-  const boathouseZ = 3.2
367
+  const boathouseZ = 3.8
368
 
368
 
369
   // Boathouse materials
369
   // Boathouse materials
370
   const boathouseWoodMaterial = new THREE.MeshToonMaterial({
370
   const boathouseWoodMaterial = new THREE.MeshToonMaterial({
@@ -464,6 +464,96 @@ export function createPond(scene, gradientMap) {
464
 
464
 
465
   group.add(boathouse)
465
   group.add(boathouse)
466
 
466
 
467
+  // ============================================
468
+  // ROWBOAT - floating by the dock
469
+  // ============================================
470
+
471
+  const rowboat = new THREE.Group()
472
+
473
+  const rowboatWoodMaterial = new THREE.MeshToonMaterial({
474
+    color: 0x6b4423, // Dark wood
475
+    gradientMap: gradientMap
476
+  })
477
+  const rowboatTrimMaterial = new THREE.MeshToonMaterial({
478
+    color: 0x8b5a2b, // Lighter trim
479
+    gradientMap: gradientMap
480
+  })
481
+
482
+  // Boat hull - elongated bowl shape using lathe geometry
483
+  const hullPoints = []
484
+  hullPoints.push(new THREE.Vector2(0, 0))
485
+  hullPoints.push(new THREE.Vector2(0.18, 0))
486
+  hullPoints.push(new THREE.Vector2(0.22, 0.03))
487
+  hullPoints.push(new THREE.Vector2(0.22, 0.1))
488
+  hullPoints.push(new THREE.Vector2(0.18, 0.14))
489
+  hullPoints.push(new THREE.Vector2(0, 0.14))
490
+
491
+  const hullGeom = new THREE.LatheGeometry(hullPoints, 8)
492
+  hullGeom.scale(1, 1, 2.2) // Stretch into boat shape
493
+  const hull = new THREE.Mesh(hullGeom, rowboatWoodMaterial)
494
+  hull.rotation.x = Math.PI // Flip right side up
495
+  hull.position.y = 0.14
496
+  rowboat.add(hull)
497
+
498
+  // Boat seats (thwarts)
499
+  const seatGeom = new THREE.BoxGeometry(0.32, 0.02, 0.08)
500
+  const seat1 = new THREE.Mesh(seatGeom, rowboatTrimMaterial)
501
+  seat1.position.set(0, 0.08, 0.15)
502
+  rowboat.add(seat1)
503
+  const seat2 = new THREE.Mesh(seatGeom, rowboatTrimMaterial)
504
+  seat2.position.set(0, 0.08, -0.15)
505
+  rowboat.add(seat2)
506
+
507
+  // Oars resting in boat
508
+  const oarMaterial = new THREE.MeshToonMaterial({
509
+    color: 0x9b7b4a,
510
+    gradientMap: gradientMap
511
+  })
512
+  const oarHandleGeom = new THREE.CylinderGeometry(0.012, 0.012, 0.5, 6)
513
+  const oarBladeGeom = new THREE.BoxGeometry(0.06, 0.01, 0.15)
514
+
515
+  // Left oar
516
+  const oar1 = new THREE.Group()
517
+  const oarHandle1 = new THREE.Mesh(oarHandleGeom, oarMaterial)
518
+  oarHandle1.rotation.z = Math.PI / 2
519
+  oar1.add(oarHandle1)
520
+  const oarBlade1 = new THREE.Mesh(oarBladeGeom, oarMaterial)
521
+  oarBlade1.position.x = 0.28
522
+  oar1.add(oarBlade1)
523
+  oar1.position.set(0.12, 0.1, 0)
524
+  oar1.rotation.y = 0.15
525
+  rowboat.add(oar1)
526
+
527
+  // Right oar
528
+  const oar2 = new THREE.Group()
529
+  const oarHandle2 = new THREE.Mesh(oarHandleGeom, oarMaterial)
530
+  oarHandle2.rotation.z = Math.PI / 2
531
+  oar2.add(oarHandle2)
532
+  const oarBlade2 = new THREE.Mesh(oarBladeGeom, oarMaterial)
533
+  oarBlade2.position.x = -0.28
534
+  oar2.add(oarBlade2)
535
+  oar2.position.set(-0.12, 0.1, 0)
536
+  oar2.rotation.y = -0.15
537
+  rowboat.add(oar2)
538
+
539
+  // Position rowboat by the dock (in the water)
540
+  // Place it at the pond edge near the dock - inside the water
541
+  const rowboatAngle = Math.atan2(boathouseZ, boathouseX) // Angle from center to boathouse
542
+  const rowboatDist = 2.7 // Closer to pond center
543
+  rowboat.position.set(
544
+    Math.cos(rowboatAngle) * rowboatDist,
545
+    0,
546
+    Math.sin(rowboatAngle) * rowboatDist
547
+  )
548
+  rowboat.rotation.y = rowboatAngle + Math.PI / 2 + 0.2 // Parallel to shore, slightly askew
549
+  rowboat.userData.baseY = 0
550
+  rowboat.userData.baseX = rowboat.position.x
551
+  rowboat.userData.baseZ = rowboat.position.z
552
+  rowboat.userData.phase = Math.random() * Math.PI * 2
553
+  rowboat.userData.lastRipple = 0
554
+
555
+  group.add(rowboat)
556
+
467
   // ============================================
557
   // ============================================
468
   // TREES - scattered around the edges
558
   // TREES - scattered around the edges
469
   // ============================================
559
   // ============================================
@@ -581,6 +671,25 @@ export function createPond(scene, gradientMap) {
581
       createSmokeParticle()
671
       createSmokeParticle()
582
     }
672
     }
583
 
673
 
674
+    // Animate rowboat - gentle bobbing and rocking
675
+    const boatPhase = rowboat.userData.phase
676
+    rowboat.position.y = rowboat.userData.baseY + Math.sin(elapsed * 1.2 + boatPhase) * 0.025
677
+    rowboat.rotation.x = Math.sin(elapsed * 0.8 + boatPhase) * 0.03
678
+    rowboat.rotation.z = Math.sin(elapsed * 1.0 + boatPhase + 1) * 0.025
679
+    // Slight drift/tug motion
680
+    rowboat.position.x = rowboat.userData.baseX + Math.sin(elapsed * 0.5 + boatPhase) * 0.015
681
+    rowboat.position.z = rowboat.userData.baseZ + Math.cos(elapsed * 0.4 + boatPhase) * 0.015
682
+
683
+    // Occasional ripples from rowboat
684
+    rowboat.userData.lastRipple += delta
685
+    if (rowboat.userData.lastRipple > 2.5 + Math.random() * 2) {
686
+      rowboat.userData.lastRipple = 0
687
+      addRipple(
688
+        rowboat.position.x + (Math.random() - 0.5) * 0.3,
689
+        rowboat.position.z + (Math.random() - 0.5) * 0.3
690
+      )
691
+    }
692
+
584
     // Update smoke particles
693
     // Update smoke particles
585
     for (let i = smokeParticles.length - 1; i >= 0; i--) {
694
     for (let i = smokeParticles.length - 1; i >= 0; i--) {
586
       const smoke = smokeParticles[i]
695
       const smoke = smokeParticles[i]
@@ -607,11 +716,108 @@ export function createPond(scene, gradientMap) {
607
 
716
 
608
   scene.add(group)
717
   scene.add(group)
609
 
718
 
719
+  // Forbidden zones for creature emergence (dock and rowboat areas)
720
+  const forbiddenZones = [
721
+    { x: rowboat.position.x, z: rowboat.position.z, radius: 0.8 }, // Rowboat
722
+    { // Dock area - extends from boathouse toward pond
723
+      x: boathouseX + Math.cos(Math.PI / 4 + 0.3) * 1.5,
724
+      z: boathouseZ + Math.sin(Math.PI / 4 + 0.3) * 1.5,
725
+      radius: 1.2
726
+    }
727
+  ]
728
+
729
+  function isValidEmergenceSpot(x, z) {
730
+    for (const zone of forbiddenZones) {
731
+      const dx = x - zone.x
732
+      const dz = z - zone.z
733
+      const dist = Math.sqrt(dx * dx + dz * dz)
734
+      if (dist < zone.radius) {
735
+        return false
736
+      }
737
+    }
738
+    return true
739
+  }
740
+
741
+  // Add a new forbidden zone (for placed buildings)
742
+  function addForbiddenZone(x, z, zoneRadius) {
743
+    forbiddenZones.push({ x, z, radius: zoneRadius })
744
+  }
745
+
746
+  // 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
+
610
   return {
809
   return {
611
     group,
810
     group,
612
     water,
811
     water,
613
     radius,
812
     radius,
614
     addRipple,
813
     addRipple,
615
-    update
814
+    update,
815
+    isValidEmergenceSpot,
816
+    addForbiddenZone,
817
+    snapZones,
818
+    getAvailableZones,
819
+    getZone,
820
+    occupyZone,
821
+    findNearestZone
616
   }
822
   }
617
 }
823
 }
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() {
193
   gulp.start(now)
193
   gulp.start(now)
194
   gulp.stop(now + 0.15)
194
   gulp.stop(now + 0.15)
195
 }
195
 }
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
+}