| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { CanvasTexture, RepeatWrapping, Sprite, SpriteMaterial } from 'three'; |
|
|
| const FISH_PALETTES = [ |
| { body: '#ff7a3a', stripe: '#fff', edge: '#a64412' }, |
| { body: '#ffd34d', stripe: '#3a2b00', edge: '#a87a0a' }, |
| { body: '#3e8be9', stripe: '#103a72', edge: '#15497b' }, |
| { body: '#ff5d8f', stripe: '#fff', edge: '#9a2748' }, |
| { body: '#67e1c0', stripe: '#0a3a32', edge: '#1b6d59' }, |
| { body: '#c98bff', stripe: '#3a1466', edge: '#5b2298' }, |
| ]; |
|
|
| |
| function shade(hex, amt) { |
| const n = parseInt(hex.slice(1), 16); |
| let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; |
| if (amt >= 0) { r += (255 - r) * amt; g += (255 - g) * amt; b += (255 - b) * amt; } |
| else { r *= (1 + amt); g *= (1 + amt); b *= (1 + amt); } |
| return `rgb(${r | 0},${g | 0},${b | 0})`; |
| } |
|
|
| |
| |
| |
| function makeFishTexture(palette, tail) { |
| const W = 256, H = 128; |
| const c = document.createElement('canvas'); |
| c.width = W; c.height = H; |
| const ctx = c.getContext('2d'); |
| ctx.clearRect(0, 0, W, H); |
|
|
| const cx = 142, cy = 64, bw = 60, bh = 30; |
| const bodyDark = shade(palette.body, -0.4); |
| const bodyLite = shade(palette.body, 0.4); |
| const finCol = palette.edge; |
|
|
| |
| let tipX = 40, topY = 30, botY = 98; |
| if (tail === 1) { tipX = 36; topY = 22; botY = 86; } |
| else if (tail === 2) { tipX = 44; topY = 42; botY = 106; } |
| const tg = ctx.createLinearGradient(tipX, 0, 96, 0); |
| tg.addColorStop(0, shade(finCol, -0.15)); |
| tg.addColorStop(1, finCol); |
| ctx.fillStyle = tg; |
| ctx.beginPath(); |
| ctx.moveTo(96, cy); |
| ctx.lineTo(tipX, topY); |
| ctx.quadraticCurveTo(tipX + 12, cy, tipX, botY); |
| ctx.closePath(); ctx.fill(); |
| ctx.strokeStyle = shade(finCol, -0.3); ctx.lineWidth = 1.3; |
| for (let i = 0; i <= 4; i++) { |
| const yy = topY + (botY - topY) * (i / 4); |
| ctx.beginPath(); ctx.moveTo(92, cy); ctx.lineTo(tipX + 4, yy); ctx.stroke(); |
| } |
|
|
| |
| ctx.fillStyle = shade(finCol, 0.05); |
| ctx.beginPath(); |
| ctx.moveTo(cx - 34, cy - bh + 6); |
| ctx.quadraticCurveTo(cx - 4, cy - bh - 18, cx + 30, cy - bh + 4); |
| ctx.closePath(); ctx.fill(); |
| ctx.beginPath(); |
| ctx.moveTo(cx - 24, cy + bh - 6); |
| ctx.quadraticCurveTo(cx, cy + bh + 16, cx + 26, cy + bh - 4); |
| ctx.closePath(); ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.moveTo(cx - bw, cy); |
| ctx.bezierCurveTo(cx - bw, cy - bh, cx + bw - 10, cy - bh, cx + bw + 22, cy - 4); |
| ctx.bezierCurveTo(cx + bw + 30, cy, cx + bw + 22, cy + 4, cx + bw - 10, cy + bh); |
| ctx.bezierCurveTo(cx + bw - 10, cy + bh, cx - bw, cy + bh, cx - bw, cy); |
| ctx.closePath(); |
| const bg = ctx.createLinearGradient(0, cy - bh, 0, cy + bh); |
| bg.addColorStop(0, bodyDark); |
| bg.addColorStop(0.45, palette.body); |
| bg.addColorStop(1, bodyLite); |
| ctx.fillStyle = bg; ctx.fill(); |
|
|
| |
| ctx.save(); ctx.clip(); |
|
|
| |
| ctx.strokeStyle = 'rgba(255,255,255,0.16)'; ctx.lineWidth = 1; |
| for (let row = 0; row < 6; row++) { |
| for (let col = 0; col < 11; col++) { |
| const sx = cx - bw + 8 + col * 14 + (row % 2) * 7; |
| const sy = cy - bh + 8 + row * 11; |
| ctx.beginPath(); ctx.arc(sx, sy, 7, Math.PI * 0.15, Math.PI * 0.85); ctx.stroke(); |
| } |
| } |
| |
| ctx.fillStyle = palette.stripe; ctx.globalAlpha = 0.5; |
| for (const sx of [cx - 22, cx + 6, cx + 32]) { |
| ctx.beginPath(); ctx.ellipse(sx, cy, 5, bh, 0, 0, Math.PI * 2); ctx.fill(); |
| } |
| ctx.globalAlpha = 1; |
| |
| const sg = ctx.createLinearGradient(0, cy - bh, 0, cy); |
| sg.addColorStop(0, 'rgba(0,0,0,0.28)'); sg.addColorStop(1, 'rgba(0,0,0,0)'); |
| ctx.fillStyle = sg; ctx.fillRect(cx - bw, cy - bh, bw * 2 + 34, bh); |
| |
| const hg = ctx.createLinearGradient(0, cy, 0, cy + bh); |
| hg.addColorStop(0, 'rgba(255,255,255,0)'); hg.addColorStop(1, 'rgba(255,255,255,0.28)'); |
| ctx.fillStyle = hg; ctx.fillRect(cx - bw, cy, bw * 2 + 34, bh); |
| ctx.restore(); |
|
|
| |
| ctx.strokeStyle = shade(finCol, -0.1); ctx.lineWidth = 2; |
| ctx.beginPath(); ctx.arc(cx + bw - 6, cy, bh - 5, -Math.PI * 0.4, Math.PI * 0.4); ctx.stroke(); |
|
|
| |
| ctx.fillStyle = shade(palette.body, -0.18); |
| ctx.beginPath(); |
| ctx.moveTo(cx + bw - 14, cy + 2); |
| ctx.quadraticCurveTo(cx + bw - 30, cy + 18, cx + bw - 6, cy + 16); |
| ctx.closePath(); ctx.fill(); |
|
|
| |
| const ex = cx + bw + 6, ey = cy - 6; |
| ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ex, ey, 5.5, 0, Math.PI * 2); ctx.fill(); |
| ctx.fillStyle = '#10121a'; ctx.beginPath(); ctx.arc(ex + 0.5, ey, 3, 0, Math.PI * 2); ctx.fill(); |
| ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.beginPath(); ctx.arc(ex - 1.4, ey - 1.4, 1.2, 0, Math.PI * 2); ctx.fill(); |
|
|
| const t = new CanvasTexture(c); t.needsUpdate = true; return t; |
| } |
|
|
| class FishSprite { |
| constructor(scene, bounds) { |
| this.scene = scene; |
| this.bounds = bounds; |
| this.palette = FISH_PALETTES[Math.floor(Math.random() * FISH_PALETTES.length)]; |
| this.frames = [makeFishTexture(this.palette, 0), makeFishTexture(this.palette, 1), makeFishTexture(this.palette, 2)]; |
| |
| |
| for (const f of this.frames) f.wrapS = RepeatWrapping; |
| this.mat = new SpriteMaterial({ map: this.frames[0], transparent: true, opacity: 0.95, depthWrite: false }); |
| this.sprite = new Sprite(this.mat); |
| |
| |
| |
| const s = 0.18 + Math.pow(Math.random(), 1.8) * 0.55; |
| this.sprite.scale.set(s, s * 0.5, 1); |
| scene.add(this.sprite); |
|
|
| this.x = (Math.random() * 2 - 1) * bounds.x; |
| this.y = bounds.yTop - Math.random() * (bounds.yTop - bounds.yBot); |
| this.z = bounds.zMin + Math.random() * (bounds.zMax - bounds.zMin); |
| this.dir = Math.random() < 0.5 ? 1 : -1; |
| this.speed = 0.6 + Math.random() * 1.1; |
| this.bobSeed = Math.random() * 100; |
| this.flickPhase = Math.random() * Math.PI * 2; |
| this.size = s; |
| this.targetZ = this.z; |
| this.targetY = this.y; |
| this._retargetIn = 0; |
| } |
|
|
| update(dt, elapsed) { |
| const b = this.bounds; |
| |
| this.x += this.speed * this.dir * dt; |
| if (this.x > b.x) this.dir = -1; |
| else if (this.x < -b.x) this.dir = 1; |
| |
| this._retargetIn -= dt; |
| if (this._retargetIn <= 0) { |
| this.targetY = b.yTop - Math.random() * (b.yTop - b.yBot); |
| this.targetZ = b.zMin + Math.random() * (b.zMax - b.zMin); |
| this._retargetIn = 3 + Math.random() * 5; |
| } |
| this.y += (this.targetY - this.y) * Math.min(1, dt * 0.6); |
| this.z += (this.targetZ - this.z) * Math.min(1, dt * 0.4); |
| this.sprite.position.set(this.x, this.y + Math.sin(elapsed * 2 + this.bobSeed) * 0.05, this.z); |
| this.sprite.scale.x = this.size; |
| |
| this.flickPhase += dt * 6; |
| const s = Math.sin(this.flickPhase); |
| const frame = s > 0.3 ? 1 : (s < -0.3 ? 2 : 0); |
| const map = this.frames[frame]; |
| |
| if (this.dir < 0) { map.repeat.x = -1; map.offset.x = 1; } |
| else { map.repeat.x = 1; map.offset.x = 0; } |
| this.mat.map = map; |
| this.mat.needsUpdate = true; |
| } |
| } |
|
|
| export class Fish { |
| constructor(scene, getBounds) { |
| this.scene = scene; |
| this.getBounds = getBounds; |
| this.fish = []; |
| const bounds = getBounds(); |
| for (let i = 0; i < 14; i++) this.fish.push(new FishSprite(scene, bounds)); |
| } |
| update(dt, elapsed) { |
| for (const f of this.fish) f.update(dt, elapsed); |
| } |
| } |
|
|