herd / commit
Add Dog entity drawn as a border collie
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() + } + } +}