fuzzyworld

herd / commit

Add Dog entity drawn as a border collie

369d8083f7a54c974507a8b65983766aed91200d · Pavel Safronov · 2026-05-16 22:04:10 -0700 · parents e410805ce518

Add Dog entity drawn as a border collie

Rendered in local space (save/translate/rotate) so the whole body —
black elongated body, white chest saddle, head with blaze, floppy ears,
amber eyes, curved tail — rotates with the movement direction.
Dashed line and circle indicate the current tap target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 src/entities/Dog.ts | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 125 insertions(+)

diff --git a/src/entities/Dog.ts b/src/entities/Dog.ts
new file mode 100644
index 0000000..1239081
--- /dev/null
+++ b/src/entities/Dog.ts
@@ -0,0 +1,125 @@
+import { Vec2 } from '../math/Vec2.ts'
+
+const ARRIVAL_THRESHOLD_SQ = 10 * 10
+
+export class Dog {
+  pos: Vec2
+  vel: Vec2 = Vec2.ZERO
+  target: Vec2 | null = null
+  speed: number
+
+  constructor(x: number, y: number, speed = 160) {
+    this.pos = new Vec2(x, y)
+    this.speed = speed
+  }
+
+  setTarget(worldX: number, worldY: number) {
+    this.target = new Vec2(worldX, worldY)
+  }
+
+  update(dt: number) {
+    if (!this.target) return
+    const diff = this.target.sub(this.pos)
+    if (diff.lengthSq() < ARRIVAL_THRESHOLD_SQ) {
+      this.target = null
+      this.vel = Vec2.ZERO
+      return
+    }
+    this.vel = diff.normalize().scale(this.speed)
+    this.pos = this.pos.add(this.vel.scale(dt))
+  }
+
+  draw(ctx: CanvasRenderingContext2D) {
+    const { x, y } = this.pos
+    const facing = this.vel.lengthSq() > 0
+      ? Math.atan2(this.vel.y, this.vel.x)
+      : -Math.PI / 2
+
+    ctx.save()
+    ctx.translate(x, y)
+    ctx.rotate(facing)
+
+    // tail — curves up from rear, drawn behind body
+    ctx.strokeStyle = '#111'
+    ctx.lineWidth = 3
+    ctx.lineCap = 'round'
+    ctx.beginPath()
+    ctx.moveTo(-10, 0)
+    ctx.quadraticCurveTo(-16, -5, -12, -13)
+    ctx.stroke()
+
+    // body — elongated black ellipse, border-collie proportions
+    ctx.fillStyle = '#111'
+    ctx.beginPath()
+    ctx.ellipse(0, 0, 11, 7, 0, 0, Math.PI * 2)
+    ctx.fill()
+
+    // white chest/belly saddle
+    ctx.fillStyle = '#eee8d8'
+    ctx.beginPath()
+    ctx.ellipse(-1, 0, 5.5, 4.5, 0, 0, Math.PI * 2)
+    ctx.fill()
+
+    // head at front (+x in local space)
+    ctx.fillStyle = '#111'
+    ctx.beginPath()
+    ctx.arc(13, 0, 5.5, 0, Math.PI * 2)
+    ctx.fill()
+
+    // white blaze down centre of face
+    ctx.fillStyle = '#eee8d8'
+    ctx.beginPath()
+    ctx.ellipse(14.5, 0, 2, 3.5, 0, 0, Math.PI * 2)
+    ctx.fill()
+
+    // floppy ears — drop slightly behind/below head centre
+    ctx.fillStyle = '#111'
+    for (const side of [-1, 1]) {
+      ctx.beginPath()
+      ctx.ellipse(10, side * 6, 3, 4.5, Math.PI * 0.25 * side, 0, Math.PI * 2)
+      ctx.fill()
+    }
+
+    // amber eyes
+    ctx.fillStyle = '#c8860a'
+    for (const side of [-1, 1]) {
+      ctx.beginPath()
+      ctx.arc(15, side * 2.2, 1.6, 0, Math.PI * 2)
+      ctx.fill()
+    }
+
+    // pupils
+    ctx.fillStyle = '#111'
+    for (const side of [-1, 1]) {
+      ctx.beginPath()
+      ctx.arc(15.4, side * 2.2, 0.7, 0, Math.PI * 2)
+      ctx.fill()
+    }
+
+    // nose
+    ctx.fillStyle = '#111'
+    ctx.beginPath()
+    ctx.ellipse(18.5, 0, 1.8, 1.3, 0, 0, Math.PI * 2)
+    ctx.fill()
+
+    ctx.restore()
+
+    // target indicator — world space
+    if (this.target) {
+      ctx.strokeStyle = 'rgba(255,255,255,0.25)'
+      ctx.lineWidth = 1
+      ctx.setLineDash([4, 4])
+      ctx.beginPath()
+      ctx.moveTo(x, y)
+      ctx.lineTo(this.target.x, this.target.y)
+      ctx.stroke()
+      ctx.setLineDash([])
+
+      ctx.strokeStyle = 'rgba(255,255,255,0.5)'
+      ctx.lineWidth = 1.5
+      ctx.beginPath()
+      ctx.arc(this.target.x, this.target.y, 5, 0, Math.PI * 2)
+      ctx.stroke()
+    }
+  }
+}