lost track, updates
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
befc9316cdf80fec49409e4a1c729f0a355b5e6c- Parents
-
3fef8d6 - Tree
8b133bc
befc931
befc9316cdf80fec49409e4a1c729f0a355b5e6c3fef8d6
8b133bc| Status | File | + | - |
|---|---|---|---|
| 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 | 5 | export function createDoug(scene, gradientMap) { |
| 6 | 6 | const group = new THREE.Group() |
| 7 | 7 | |
| 8 | - // Color palette - vibrant Wind Waker yellows | |
| 9 | - const bodyColor = 0xffdc50 // Warm yellow | |
| 10 | - const bodyHighlight = 0xfff0a0 // Light yellow | |
| 11 | - const beakColor = 0xff9020 // Bright orange | |
| 12 | - const eyeWhite = 0xffffff | |
| 13 | - const eyePupil = 0x191410 | |
| 8 | + // Store gradientMap for accessory creation | |
| 9 | + const storedGradientMap = gradientMap | |
| 10 | + | |
| 11 | + // Default colors - vibrant Wind Waker yellows | |
| 12 | + const defaultColors = { | |
| 13 | + body: 0xffdc50, | |
| 14 | + highlight: 0xfff0a0, | |
| 15 | + beak: 0xff9020 | |
| 16 | + } | |
| 14 | 17 | |
| 15 | - // Toon materials | |
| 18 | + // Toon materials (stored for outfit swapping) | |
| 16 | 19 | const bodyMaterial = new THREE.MeshToonMaterial({ |
| 17 | - color: bodyColor, | |
| 20 | + color: defaultColors.body, | |
| 18 | 21 | gradientMap: gradientMap |
| 19 | 22 | }) |
| 20 | 23 | |
| 21 | 24 | const highlightMaterial = new THREE.MeshToonMaterial({ |
| 22 | - color: bodyHighlight, | |
| 25 | + color: defaultColors.highlight, | |
| 23 | 26 | gradientMap: gradientMap |
| 24 | 27 | }) |
| 25 | 28 | |
| 26 | 29 | const beakMaterial = new THREE.MeshToonMaterial({ |
| 27 | - color: beakColor, | |
| 30 | + color: defaultColors.beak, | |
| 28 | 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 | 49 | const eyeWhiteMaterial = new THREE.MeshToonMaterial({ |
| 32 | 50 | color: eyeWhite, |
| 33 | 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 | 308 | // Find closest bread |
| 251 | 309 | let closestBread = null |
| 252 | 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 | 537 | return { |
| 401 | 538 | group, |
| 402 | 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 | 10 | import { createDonny } from './narwhal.js' |
| 11 | 11 | import { createKoiSchool } from './koi.js' |
| 12 | 12 | import { createOllie } from './octopus.js' |
| 13 | +import { PlacementManager } from './buildingPlacement.js' | |
| 13 | 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 | 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 | 24 | let clock |
| 18 | 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 | 82 | function createToonGradient() { |
| 21 | 83 | const canvas = document.createElement('canvas') |
@@ -95,6 +157,7 @@ export function start(container) { | ||
| 95 | 157 | donny = createDonny(scene, toonGradient) |
| 96 | 158 | koiSchool = createKoiSchool(scene, toonGradient, pond.radius) |
| 97 | 159 | ollie = createOllie(scene, toonGradient) |
| 160 | + placementManager = new PlacementManager(scene, pond, camera, toonGradient) | |
| 98 | 161 | |
| 99 | 162 | // Post-processing |
| 100 | 163 | composer = new EffectComposer(renderer) |
@@ -110,7 +173,7 @@ export function start(container) { | ||
| 110 | 173 | outlinePass.edgeThickness = 1.5 |
| 111 | 174 | outlinePass.visibleEdgeColor.set(0x191410) |
| 112 | 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 | 177 | composer.addPass(outlinePass) |
| 115 | 178 | composer.addPass(new OutputPass()) |
| 116 | 179 | |
@@ -121,25 +184,206 @@ export function start(container) { | ||
| 121 | 184 | // Unlock audio on first touch (for mobile) |
| 122 | 185 | renderer.domElement.addEventListener('touchstart', unlockAudio, { once: true }) |
| 123 | 186 | |
| 124 | - renderer.domElement.addEventListener('click', (event) => { | |
| 125 | - // Unlock audio on first interaction (required for mobile) | |
| 187 | + // Mouse move - handle placement preview | |
| 188 | + renderer.domElement.addEventListener('pointermove', (event) => { | |
| 189 | + if (placementManager.isPlacing()) { | |
| 190 | + placementManager.onMouseMove(event, window.innerWidth, window.innerHeight) | |
| 191 | + } | |
| 192 | + }) | |
| 193 | + | |
| 194 | + // Helper to handle tap-to-shop for a creature | |
| 195 | + const handleCreatureTap = (creature, characterName) => { | |
| 196 | + if (!creature.isTappable()) return false | |
| 197 | + if (isDialogOpen() || isShopOpen()) return false | |
| 198 | + | |
| 199 | + // Show return dialog, then open shop | |
| 200 | + const dialogLines = getReturnDialog(characterName) | |
| 201 | + showDialog(characterName, dialogLines, container, () => { | |
| 202 | + openShop(characterName, container, { | |
| 203 | + onClose: () => { | |
| 204 | + creature.dismissShop() | |
| 205 | + }, | |
| 206 | + onPurchase: (item, action) => { | |
| 207 | + if (item.character) { | |
| 208 | + handleOutfitChange(item, action) | |
| 209 | + } | |
| 210 | + if (action === 'place' && item.buildingType) { | |
| 211 | + placementManager.startPlacement(item.buildingType, { | |
| 212 | + onComplete: () => {}, | |
| 213 | + onCancel: () => {} | |
| 214 | + }) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + }) | |
| 218 | + }) | |
| 219 | + | |
| 220 | + // Transition creature to shop mode if surfaced | |
| 221 | + creature.triggerShopFromTap(pond, doug) | |
| 222 | + return true | |
| 223 | + } | |
| 224 | + | |
| 225 | + // Pointer down - handle placement, capture, or bread spawn | |
| 226 | + renderer.domElement.addEventListener('pointerdown', (event) => { | |
| 126 | 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 | 240 | mouse.x = (event.clientX / window.innerWidth) * 2 - 1 |
| 129 | 241 | mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 |
| 130 | 242 | |
| 131 | 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 | 259 | const intersects = raycaster.intersectObject(pond.water) |
| 133 | 260 | |
| 134 | 261 | if (intersects.length > 0) { |
| 135 | 262 | const point = intersects[0].point |
| 136 | - breadManager.spawnBread(point.x, point.z) | |
| 137 | - pond.addRipple(point.x, point.z) | |
| 138 | - koiSchool.triggerPanic(point.x, point.z) | |
| 263 | + captureState.clickPos = { x: point.x, z: point.z } | |
| 264 | + captureState.isHolding = true | |
| 265 | + | |
| 266 | + // Check if Doug is over a koi | |
| 267 | + const dougPos = doug.getPosition() | |
| 268 | + const koiUnderDoug = koiSchool.getKoiUnderPosition(dougPos.x, dougPos.z, 0.4) | |
| 269 | + | |
| 270 | + if (koiUnderDoug) { | |
| 271 | + // Start capturing | |
| 272 | + koiSchool.startCapture(koiUnderDoug) | |
| 273 | + captureState.targetKoi = koiUnderDoug | |
| 274 | + } | |
| 275 | + } | |
| 276 | + }) | |
| 277 | + | |
| 278 | + // Pointer up - complete capture or spawn bread | |
| 279 | + renderer.domElement.addEventListener('pointerup', () => { | |
| 280 | + if (!captureState.isHolding) return | |
| 281 | + | |
| 282 | + if (captureState.targetKoi) { | |
| 283 | + // If still capturing (not completed), cancel it | |
| 284 | + if (captureState.targetKoi.state.beingCaptured) { | |
| 285 | + koiSchool.cancelCapture(captureState.targetKoi) | |
| 286 | + } | |
| 287 | + } else if (captureState.clickPos) { | |
| 288 | + // No capture was started - spawn bread | |
| 289 | + breadManager.spawnBread(captureState.clickPos.x, captureState.clickPos.z) | |
| 290 | + pond.addRipple(captureState.clickPos.x, captureState.clickPos.z) | |
| 291 | + koiSchool.triggerPanic(captureState.clickPos.x, captureState.clickPos.z) | |
| 139 | 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 | 387 | // Resize handler |
| 144 | 388 | window.addEventListener('resize', onResize) |
| 145 | 389 | |
@@ -166,12 +410,99 @@ function animate() { | ||
| 166 | 410 | const delta = clock.getDelta() |
| 167 | 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 | 490 | breadManager.update(delta, elapsed) |
| 171 | 491 | pond.update(delta, elapsed) |
| 172 | 492 | donny.update(delta, elapsed, pond, doug) |
| 173 | 493 | koiSchool.update(delta, elapsed) |
| 174 | 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 | 507 | composer.render() |
| 177 | 508 | } |
@@ -184,6 +515,14 @@ export function stop() { | ||
| 184 | 515 | |
| 185 | 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 | 526 | if (renderer) { |
| 188 | 527 | renderer.domElement.remove() |
| 189 | 528 | renderer.dispose() |
@@ -193,6 +532,20 @@ export function stop() { | ||
| 193 | 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 | 549 | scene = null |
| 197 | 550 | camera = null |
| 198 | 551 | renderer = null |
@@ -203,6 +556,7 @@ export function stop() { | ||
| 203 | 556 | koiSchool = null |
| 204 | 557 | ollie = null |
| 205 | 558 | breadManager = null |
| 559 | + placementManager = null | |
| 206 | 560 | } |
| 207 | 561 | |
| 208 | 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 | 2 | import * as THREE from 'three' |
| 3 | +import gameState from './gameState.js' | |
| 4 | +import { playCapture } from './sounds.js' | |
| 3 | 5 | |
| 4 | 6 | export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 5 | 7 | const group = new THREE.Group() |
| 6 | 8 | const kois = [] |
| 7 | 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 | 78 | // Koi color variations |
| 10 | 79 | const koiColors = [ |
| 11 | 80 | { body: 0xff6b35, spots: 0xffffff }, // Orange with white |
@@ -33,11 +102,18 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { | ||
| 33 | 102 | |
| 34 | 103 | koi.state = { |
| 35 | 104 | speed: 0.3 + Math.random() * 0.3, |
| 36 | - turnRate: 0, // Current turning rate | |
| 105 | + turnRate: 0, | |
| 37 | 106 | targetTurnRate: 0, |
| 38 | 107 | turnTimer: Math.random() * 3, |
| 39 | - panicTimer: 0, | |
| 40 | - flickerPhase: Math.random() * Math.PI * 2 | |
| 108 | + flickerPhase: Math.random() * Math.PI * 2, | |
| 109 | + // Attraction state | |
| 110 | + attractedTo: null, // { x, z } of bread attracting this koi | |
| 111 | + // Capture state | |
| 112 | + captured: false, | |
| 113 | + beingCaptured: false, | |
| 114 | + captureProgress: 0, | |
| 115 | + respawnTimer: 0, | |
| 116 | + originalScale: 0.9 | |
| 41 | 117 | } |
| 42 | 118 | |
| 43 | 119 | group.add(koi.group) |
@@ -105,59 +181,208 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { | ||
| 105 | 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 | 209 | function triggerPanic(x, z) { |
| 210 | + // Now just a brief scatter, not sustained panic | |
| 109 | 211 | for (const koi of kois) { |
| 212 | + if (koi.state.captured || koi.state.beingCaptured) continue | |
| 213 | + | |
| 110 | 214 | const dist = Math.hypot( |
| 111 | 215 | koi.group.position.x - x, |
| 112 | 216 | koi.group.position.z - z |
| 113 | 217 | ) |
| 114 | 218 | |
| 115 | - if (dist < 1.5) { | |
| 116 | - koi.state.panicTimer = 1 + Math.random() * 0.5 | |
| 117 | - | |
| 118 | - // Turn away from the disturbance | |
| 219 | + if (dist < 0.8) { | |
| 220 | + // Brief scatter only when bread lands very close | |
| 221 | + koi.state.attractedTo = null | |
| 222 | + // Turn slightly away then resume | |
| 119 | 223 | const awayAngle = Math.atan2( |
| 120 | 224 | koi.group.position.x - x, |
| 121 | 225 | koi.group.position.z - z |
| 122 | 226 | ) |
| 123 | - // Set a strong turn toward the away direction | |
| 124 | 227 | let turnNeeded = awayAngle - koi.group.rotation.y |
| 125 | 228 | while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2 |
| 126 | 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 | 328 | function update(delta, elapsed) { |
| 133 | 329 | for (const koi of kois) { |
| 134 | 330 | const s = koi.state |
| 135 | 331 | const pos = koi.group.position |
| 136 | 332 | |
| 137 | - // Update panic | |
| 138 | - const isPanicked = s.panicTimer > 0 | |
| 139 | - if (isPanicked) { | |
| 140 | - s.panicTimer -= delta | |
| 333 | + // Handle respawn timer | |
| 334 | + if (s.captured) { | |
| 335 | + s.respawnTimer -= delta | |
| 336 | + if (s.respawnTimer <= 0) { | |
| 337 | + respawnKoi(koi) | |
| 338 | + } | |
| 339 | + continue | |
| 340 | + } | |
| 341 | + | |
| 342 | + // Skip movement if being captured | |
| 343 | + if (s.beingCaptured) { | |
| 344 | + continue | |
| 141 | 345 | } |
| 142 | 346 | |
| 143 | 347 | // Decide turning behavior |
| 144 | 348 | s.turnTimer -= delta |
| 145 | - if (s.turnTimer <= 0 && !isPanicked) { | |
| 146 | - // Occasionally change turn rate for natural wandering | |
| 147 | - s.targetTurnRate = (Math.random() - 0.5) * 1.5 | |
| 148 | - s.turnTimer = 1 + Math.random() * 3 | |
| 349 | + | |
| 350 | + // If attracted to bread, swim toward it | |
| 351 | + if (s.attractedTo) { | |
| 352 | + const toBreakX = s.attractedTo.x - pos.x | |
| 353 | + const toBreadZ = s.attractedTo.z - pos.z | |
| 354 | + const distToBread = Math.hypot(toBreakX, toBreadZ) | |
| 355 | + | |
| 356 | + if (distToBread < 0.15) { | |
| 357 | + // Very close to bread - slow down and circle | |
| 358 | + s.targetTurnRate = 0.3 | |
| 359 | + s.speed = 0.1 | |
| 360 | + } else { | |
| 361 | + // Swim toward bread | |
| 362 | + const toBreadAngle = Math.atan2(toBreakX, toBreadZ) | |
| 363 | + let turnNeeded = toBreadAngle - koi.group.rotation.y | |
| 364 | + while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2 | |
| 365 | + while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2 | |
| 366 | + | |
| 367 | + s.targetTurnRate = Math.sign(turnNeeded) * Math.min(Math.abs(turnNeeded) * 2, 2) | |
| 368 | + s.speed = 0.4 + Math.min(distToBread * 0.2, 0.3) // Faster when far | |
| 369 | + } | |
| 370 | + } else { | |
| 371 | + // Natural wandering behavior | |
| 372 | + if (s.turnTimer <= 0) { | |
| 373 | + s.targetTurnRate = (Math.random() - 0.5) * 1.5 | |
| 374 | + s.turnTimer = 1 + Math.random() * 3 | |
| 375 | + s.speed = 0.3 + Math.random() * 0.3 | |
| 376 | + } | |
| 149 | 377 | } |
| 150 | 378 | |
| 151 | 379 | // Check if heading toward pond edge |
| 152 | 380 | const distFromCenter = Math.hypot(pos.x, pos.z) |
| 153 | 381 | if (distFromCenter > pondRadius * 0.75) { |
| 154 | - // Calculate angle to center | |
| 155 | 382 | const toCenter = Math.atan2(-pos.x, -pos.z) |
| 156 | 383 | let turnNeeded = toCenter - koi.group.rotation.y |
| 157 | 384 | while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2 |
| 158 | 385 | while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2 |
| 159 | - | |
| 160 | - // Steer back toward center | |
| 161 | 386 | s.targetTurnRate = Math.sign(turnNeeded) * 1.5 |
| 162 | 387 | } |
| 163 | 388 | |
@@ -167,19 +392,15 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { | ||
| 167 | 392 | // Apply rotation |
| 168 | 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) | |
| 171 | - const speed = isPanicked ? s.speed * 2.5 : s.speed | |
| 172 | - | |
| 173 | - // Get forward direction from rotation | |
| 395 | + // Move forward | |
| 174 | 396 | const forwardX = Math.sin(koi.group.rotation.y) |
| 175 | 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 | |
| 178 | - pos.z += forwardZ * speed * delta | |
| 179 | - | |
| 180 | - // Tail wiggle - faster when moving fast | |
| 181 | - const wiggleSpeed = isPanicked ? 18 : 10 | |
| 182 | - const wiggleAmount = isPanicked ? 0.4 : 0.25 | |
| 401 | + // Tail wiggle | |
| 402 | + const wiggleSpeed = s.attractedTo ? 14 : 10 | |
| 403 | + const wiggleAmount = s.attractedTo ? 0.35 : 0.25 | |
| 183 | 404 | koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount |
| 184 | 405 | |
| 185 | 406 | // Gentle vertical bob |
@@ -193,12 +414,20 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { | ||
| 193 | 414 | pos.z *= scale |
| 194 | 415 | } |
| 195 | 416 | } |
| 417 | + | |
| 418 | + // Update sparkle particles | |
| 419 | + updateSparkles(delta) | |
| 196 | 420 | } |
| 197 | 421 | |
| 198 | 422 | return { |
| 199 | 423 | group, |
| 200 | 424 | update, |
| 201 | 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 | 4 | export function createDonny(scene, gradientMap) { |
| 5 | 5 | const group = new THREE.Group() |
| 6 | 6 | |
| 7 | - // Color palette | |
| 8 | - const bodyColor = 0x7a9eb8 // Dusty blue-grey | |
| 9 | - const bellyColor = 0xc8d8e4 // Pale belly | |
| 10 | - const tuskColor = 0xf5f0e6 // Ivory | |
| 11 | - const monocleColor = 0xd4af37 // Gold | |
| 7 | + // Store gradientMap for accessory creation | |
| 8 | + const storedGradientMap = gradientMap | |
| 9 | + | |
| 10 | + // Default colors | |
| 11 | + const defaultColors = { | |
| 12 | + body: 0x7a9eb8, | |
| 13 | + belly: 0xc8d8e4, | |
| 14 | + monocleRim: 0xd4af37, | |
| 15 | + monocleGlass: 0x88ccff | |
| 16 | + } | |
| 12 | 17 | |
| 13 | - // Materials | |
| 18 | + // Materials (stored for outfit swapping) | |
| 14 | 19 | const bodyMaterial = new THREE.MeshToonMaterial({ |
| 15 | - color: bodyColor, | |
| 20 | + color: defaultColors.body, | |
| 16 | 21 | gradientMap: gradientMap |
| 17 | 22 | }) |
| 18 | 23 | |
| 19 | 24 | const bellyMaterial = new THREE.MeshToonMaterial({ |
| 20 | - color: bellyColor, | |
| 25 | + color: defaultColors.belly, | |
| 21 | 26 | gradientMap: gradientMap |
| 22 | 27 | }) |
| 23 | 28 | |
| 24 | 29 | const tuskMaterial = new THREE.MeshToonMaterial({ |
| 25 | - color: tuskColor, | |
| 30 | + color: 0xf5f0e6, | |
| 26 | 31 | gradientMap: gradientMap |
| 27 | 32 | }) |
| 28 | 33 | |
| 29 | 34 | const monocleMaterial = new THREE.MeshToonMaterial({ |
| 30 | - color: monocleColor, | |
| 35 | + color: defaultColors.monocleRim, | |
| 31 | 36 | gradientMap: gradientMap |
| 32 | 37 | }) |
| 33 | 38 | |
| 34 | 39 | const glassMaterial = new THREE.MeshBasicMaterial({ |
| 35 | - color: 0x88ccff, | |
| 40 | + color: defaultColors.monocleGlass, | |
| 36 | 41 | transparent: true, |
| 37 | 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 | 55 | // Main body - elongated oval |
| 41 | 56 | const bodyGeom = new THREE.SphereGeometry(0.5, 8, 6) |
| 42 | 57 | bodyGeom.scale(2.2, 0.7, 0.8) |
@@ -115,7 +130,7 @@ export function createDonny(scene, gradientMap) { | ||
| 115 | 130 | |
| 116 | 131 | // Chain (simple dangling segments) |
| 117 | 132 | const chainMaterial = new THREE.MeshToonMaterial({ |
| 118 | - color: monocleColor, | |
| 133 | + color: defaultColors.monocleRim, | |
| 119 | 134 | gradientMap: gradientMap |
| 120 | 135 | }) |
| 121 | 136 | for (let i = 0; i < 4; i++) { |
@@ -168,28 +183,68 @@ export function createDonny(scene, gradientMap) { | ||
| 168 | 183 | |
| 169 | 184 | // State |
| 170 | 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 | 187 | timer: 30 + Math.random() * 30, // First appearance in 30-60 seconds |
| 173 | 188 | emergeX: 0, |
| 174 | 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 | 201 | function startRumble(pond) { |
| 179 | 202 | state.mode = 'rumbling' |
| 180 | 203 | state.timer = 0 |
| 181 | 204 | |
| 182 | - // Pick random spot in the pond | |
| 183 | - const angle = Math.random() * Math.PI * 2 | |
| 184 | - const dist = Math.random() * pond.radius * 0.5 + pond.radius * 0.2 | |
| 185 | - state.emergeX = Math.cos(angle) * dist | |
| 186 | - state.emergeZ = Math.sin(angle) * dist | |
| 205 | + // Pick random spot in the pond, avoiding forbidden zones (dock, boat) | |
| 206 | + let attempts = 0 | |
| 207 | + let angle, dist | |
| 208 | + do { | |
| 209 | + angle = Math.random() * Math.PI * 2 | |
| 210 | + dist = Math.random() * pond.radius * 0.5 + pond.radius * 0.2 | |
| 211 | + state.emergeX = Math.cos(angle) * dist | |
| 212 | + state.emergeZ = Math.sin(angle) * dist | |
| 213 | + attempts++ | |
| 214 | + } while (!pond.isValidEmergenceSpot(state.emergeX, state.emergeZ) && attempts < 20) | |
| 187 | 215 | |
| 188 | 216 | group.position.x = state.emergeX |
| 189 | 217 | group.position.z = state.emergeZ |
| 190 | 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 | 248 | // Helper to smoothly interpolate angles |
| 194 | 249 | function lerpAngle(from, to, t) { |
| 195 | 250 | let diff = to - from |
@@ -315,11 +370,238 @@ export function createDonny(scene, gradientMap) { | ||
| 315 | 370 | group.rotation.z = 0 |
| 316 | 371 | } |
| 317 | 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 | 595 | return { |
| 322 | 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 | 4 | export function createOllie(scene, gradientMap) { |
| 5 | 5 | const group = new THREE.Group() |
| 6 | 6 | |
| 7 | - // Color palette - purple theme | |
| 8 | - const bodyColor = 0x7b4b94 // Deep purple | |
| 9 | - const bellyColor = 0xb89bc9 // Lighter lavender | |
| 10 | - const suckerColor = 0xd4a5c9 // Pink-ish | |
| 11 | - const glassRimColor = 0xd4af37 // Gold | |
| 7 | + // Store gradientMap for accessory creation | |
| 8 | + const storedGradientMap = gradientMap | |
| 9 | + | |
| 10 | + // Default colors - purple theme | |
| 11 | + const defaultColors = { | |
| 12 | + body: 0x7b4b94, | |
| 13 | + belly: 0xb89bc9, | |
| 14 | + suckers: 0xd4a5c9, | |
| 15 | + magRim: 0xd4af37, | |
| 16 | + magGlass: 0x88ccff | |
| 17 | + } | |
| 12 | 18 | |
| 13 | - // Materials | |
| 19 | + // Materials (stored for outfit swapping) | |
| 14 | 20 | const bodyMaterial = new THREE.MeshToonMaterial({ |
| 15 | - color: bodyColor, | |
| 21 | + color: defaultColors.body, | |
| 16 | 22 | gradientMap: gradientMap |
| 17 | 23 | }) |
| 18 | 24 | |
| 19 | 25 | const bellyMaterial = new THREE.MeshToonMaterial({ |
| 20 | - color: bellyColor, | |
| 26 | + color: defaultColors.belly, | |
| 21 | 27 | gradientMap: gradientMap |
| 22 | 28 | }) |
| 23 | 29 | |
| 24 | 30 | const suckerMaterial = new THREE.MeshToonMaterial({ |
| 25 | - color: suckerColor, | |
| 31 | + color: defaultColors.suckers, | |
| 26 | 32 | gradientMap: gradientMap |
| 27 | 33 | }) |
| 28 | 34 | |
| 29 | 35 | const glassRimMaterial = new THREE.MeshToonMaterial({ |
| 30 | - color: glassRimColor, | |
| 36 | + color: defaultColors.magRim, | |
| 31 | 37 | gradientMap: gradientMap |
| 32 | 38 | }) |
| 33 | 39 | |
| 34 | 40 | const glassMaterial = new THREE.MeshBasicMaterial({ |
| 35 | - color: 0x88ccff, | |
| 41 | + color: defaultColors.magGlass, | |
| 36 | 42 | transparent: true, |
| 37 | 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 | 56 | // Head/Mantle - bulbous dome |
| 41 | 57 | const mantleGeom = new THREE.SphereGeometry(0.5, 10, 8) |
| 42 | 58 | mantleGeom.scale(1.2, 1.4, 1.0) |
@@ -169,9 +185,17 @@ export function createOllie(scene, gradientMap) { | ||
| 169 | 185 | timer: 45 + Math.random() * 30, // First appearance in 45-75 seconds (after narwhal) |
| 170 | 186 | emergeX: 0, |
| 171 | 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 | 199 | function createTentacle(bodyMat, suckerMat, gradient) { |
| 176 | 200 | // Build tentacle as a chain of segments, each one a child of the previous |
| 177 | 201 | // This creates a smooth curve by rotating each joint |
@@ -229,17 +253,49 @@ export function createOllie(scene, gradientMap) { | ||
| 229 | 253 | state.mode = 'rumbling' |
| 230 | 254 | state.timer = 0 |
| 231 | 255 | |
| 232 | - // Pick random spot in outer zone of pond (70-90% radius) | |
| 233 | - const angle = Math.random() * Math.PI * 2 | |
| 234 | - const dist = Math.random() * pond.radius * 0.2 + pond.radius * 0.7 | |
| 235 | - state.emergeX = Math.cos(angle) * dist | |
| 236 | - state.emergeZ = Math.sin(angle) * dist | |
| 256 | + // Pick random spot in outer zone of pond (70-90% radius), avoiding forbidden zones | |
| 257 | + let attempts = 0 | |
| 258 | + let angle, dist | |
| 259 | + do { | |
| 260 | + angle = Math.random() * Math.PI * 2 | |
| 261 | + dist = Math.random() * pond.radius * 0.2 + pond.radius * 0.7 | |
| 262 | + state.emergeX = Math.cos(angle) * dist | |
| 263 | + state.emergeZ = Math.sin(angle) * dist | |
| 264 | + attempts++ | |
| 265 | + } while (!pond.isValidEmergenceSpot(state.emergeX, state.emergeZ) && attempts < 20) | |
| 237 | 266 | |
| 238 | 267 | group.position.x = state.emergeX |
| 239 | 268 | group.position.z = state.emergeZ |
| 240 | 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 | 299 | // Helper to smoothly interpolate angles |
| 244 | 300 | function lerpAngle(from, to, t) { |
| 245 | 301 | let diff = to - from |
@@ -378,11 +434,260 @@ export function createOllie(scene, gradientMap) { | ||
| 378 | 434 | group.rotation.z = 0 |
| 379 | 435 | } |
| 380 | 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 | 681 | return { |
| 385 | 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 | 365 | const boathouse = new THREE.Group() |
| 366 | - const boathouseX = -4.2 | |
| 367 | - const boathouseZ = 3.2 | |
| 366 | + const boathouseX = -3.3 | |
| 367 | + const boathouseZ = 3.8 | |
| 368 | 368 | |
| 369 | 369 | // Boathouse materials |
| 370 | 370 | const boathouseWoodMaterial = new THREE.MeshToonMaterial({ |
@@ -464,6 +464,96 @@ export function createPond(scene, gradientMap) { | ||
| 464 | 464 | |
| 465 | 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 | 558 | // TREES - scattered around the edges |
| 469 | 559 | // ============================================ |
@@ -581,6 +671,25 @@ export function createPond(scene, gradientMap) { | ||
| 581 | 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 | 693 | // Update smoke particles |
| 585 | 694 | for (let i = smokeParticles.length - 1; i >= 0; i--) { |
| 586 | 695 | const smoke = smokeParticles[i] |
@@ -607,11 +716,108 @@ export function createPond(scene, gradientMap) { | ||
| 607 | 716 | |
| 608 | 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 | 809 | return { |
| 611 | 810 | group, |
| 612 | 811 | water, |
| 613 | 812 | radius, |
| 614 | 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">×</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 | 193 | gulp.start(now) |
| 194 | 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 | +} | |