fuzzyworld

herd / commit

Improve sheep visual differentiation and pen behaviour

3ccad7d4edc16d746c7668cb2b54c0c4be6424ed · Pavel Safronov · 2026-05-17 22:03:00 -0700 · parents 82e8c064ff87

Improve sheep visual differentiation and pen behaviour

- Normal sheep: wider tint palette (cream, cool white, blush, sage,
  linen, light gray) so individuals are easy to tell apart in a flock
- Stubborn sheep: lighter gray (#aaa wool / #888 head) — was too dark
- Remove red X indicator; color alone now signals stubbornness
- Penned sheep ignore the dog; fear decays once inside so eyes return
  to normal; flee resumes immediately if a sheep exits the pen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 src/ai/Boids.ts       |  4 +++-
 src/entities/Sheep.ts | 65 +++++++++++++++++++++++++++------------------------
 2 files changed, 37 insertions(+), 32 deletions(-)

diff --git a/src/ai/Boids.ts b/src/ai/Boids.ts
index b2518d0..9b88a99 100644
--- a/src/ai/Boids.ts
+++ b/src/ai/Boids.ts
@@ -156,8 +156,10 @@ export function updateBoids(
     }
 
     // ── dog flee (linear gradient across flee radius) ─────────────────────
+    // Sheep inside the pen ignore the dog — they've been herded and should
+    // stay calm. Fear decays normally so their eyes return to normal.
     const dogDSq = s.pos.distanceSqTo(dog.pos)
-    if (dogDSq < fleeRadiusSq) {
+    if (!pen.contains(s.pos) && dogDSq < fleeRadiusSq) {
       const dogD = Math.sqrt(dogDSq)
       const gradient = 1 - dogD / fleeRadius        // 1 at dog, 0 at flee radius edge
       steering = steering.add(
diff --git a/src/entities/Sheep.ts b/src/entities/Sheep.ts
index 6417350..0180afd 100644
--- a/src/entities/Sheep.ts
+++ b/src/entities/Sheep.ts
@@ -1,16 +1,24 @@
 import { Vec2 } from '../math/Vec2.ts'
 
-// Overlapping white puffs that form the wool body.
-// [dx, dy, radius] — all drawn in the same wool colour so the lumpy
-// silhouette reads as fluffiness rather than dark spots.
 const WOOL_BLOBS: [number, number, number][] = [
-  [0, 0, 6.5],  // centre
-  [-5, -4, 5.5],  // upper-left
-  [5, -4, 5.5],  // upper-right
-  [-6, 2, 5.0],  // left
-  [6, 2, 5.0],  // right
-  [0, -7, 4.5],  // top
-  [0, 5, 4.5],  // bottom
+  [0, 0, 6.5],
+  [-5, -4, 5.5],
+  [5, -4, 5.5],
+  [-6, 2, 5.0],
+  [6, 2, 5.0],
+  [0, -7, 4.5],
+  [0, 5, 4.5],
+]
+
+// Distinct but natural-looking tints — warm creams, cool whites, light grays.
+const NORMAL_WOOL_COLORS = [
+  '#ffffff',  // pure white
+  '#f0e8d0',  // warm cream
+  '#dce8f2',  // cool blue-white
+  '#f0dde0',  // blush
+  '#d8e8d8',  // pale sage
+  '#e8e0d0',  // linen
+  '#d8d8d8',  // light gray
 ]
 
 export interface SheepProps {
@@ -23,13 +31,16 @@ export interface SheepProps {
 export class Sheep {
   pos: Vec2
   vel: Vec2
-  facing: number = -Math.PI / 2 // radians, starts facing up
+  facing: number = -Math.PI / 2
   fearLevel: number
 
   readonly stubborn: boolean
   readonly leader: boolean
   readonly subFlock: number
 
+  private readonly woolColor: string
+  private readonly headColor: string
+
   constructor(x: number, y: number, props: SheepProps = {}) {
     this.pos = new Vec2(x, y)
     this.vel = Vec2.ZERO
@@ -37,33 +48,35 @@ export class Sheep {
     this.stubborn = props.stubborn ?? false
     this.leader = props.leader ?? false
     this.subFlock = props.subFlock ?? 0
+
+    if (this.stubborn) {
+      this.woolColor = '#aaa'
+      this.headColor = '#888'
+    } else {
+      this.woolColor = NORMAL_WOOL_COLORS[Math.floor(Math.random() * NORMAL_WOOL_COLORS.length)]!
+      this.headColor = '#d4c5a0'
+    }
   }
 
   draw(ctx: CanvasRenderingContext2D, scale = 1) {
     const { x, y } = this.pos
 
-    // Draw wool + head in local space so the whole body rotates together.
     ctx.save()
     ctx.translate(x, y)
     ctx.rotate(this.facing)
 
-    // Fluffy wool body — puffs in local space, facing = +x direction
-    ctx.fillStyle = '#fff'
+    ctx.fillStyle = this.woolColor
     for (const [dx, dy, r] of WOOL_BLOBS) {
       ctx.beginPath()
       ctx.arc(dx * scale, dy * scale, r * scale, 0, Math.PI * 2)
       ctx.fill()
     }
 
-    // Head always at the front (+x side in local space)
-    ctx.fillStyle = '#d4c5a0'
+    ctx.fillStyle = this.headColor
     ctx.beginPath()
     ctx.arc(9 * scale, 0, 4.5 * scale, 0, Math.PI * 2)
     ctx.fill()
 
-    // Eyes: white sclera grows with fear, dark pupil stays fixed.
-    // At fearLevel=0 the sclera equals the pupil and is fully covered — plain dot.
-    // As fear rises a white ring appears, giving a wide-eyed look.
     const eyeX = 9 * scale
     const eyeY = 2.5 * scale
     const pupilR = 1.2 * scale
@@ -84,23 +97,13 @@ export class Sheep {
 
     ctx.restore()
 
-    // Indicators stay upright in world space regardless of facing
-    const indicatorY = y - 16 * scale
+    // Leader star stays — it's the only remaining indicator drawn in world space
     if (this.leader) {
+      const indicatorY = y - 16 * scale
       ctx.fillStyle = '#f5c518'
       ctx.font = `${Math.round(10 * scale)}px sans-serif`
       ctx.textAlign = 'center'
       ctx.fillText('★', x, indicatorY)
     }
-
-    if (this.stubborn) {
-      ctx.strokeStyle = '#e05'
-      ctx.lineWidth = 1.5 * scale
-      const s = 4 * scale
-      ctx.beginPath()
-      ctx.moveTo(x - s, indicatorY - s); ctx.lineTo(x + s, indicatorY + s)
-      ctx.moveTo(x + s, indicatorY - s); ctx.lineTo(x - s, indicatorY + s)
-      ctx.stroke()
-    }
   }
 }