herd / commit
Add Sheep entity with fluffy wool and smooth rotation
src/entities/Sheep.ts | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/entities/Sheep.ts b/src/entities/Sheep.ts new file mode 100644 index 0000000..b80e8cf --- /dev/null +++ b/src/entities/Sheep.ts @@ -0,0 +1,99 @@ +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 +] + +export interface SheepProps { + stubborn?: boolean + leader?: boolean + subFlock?: number + startFear?: number +} + +export class Sheep { + pos: Vec2 + vel: Vec2 + facing: number = -Math.PI / 2 // radians, starts facing up + fearLevel: number + + readonly stubborn: boolean + readonly leader: boolean + readonly subFlock: number + + constructor(x: number, y: number, props: SheepProps = {}) { + this.pos = new Vec2(x, y) + this.vel = Vec2.ZERO + this.fearLevel = props.startFear ?? 0 + this.stubborn = props.stubborn ?? false + this.leader = props.leader ?? false + this.subFlock = props.subFlock ?? 0 + } + + draw(ctx: CanvasRenderingContext2D, dark = false) { + if (dark) return // caller handles visibility in dark mode + + const { x, y } = this.pos + + // fear tint: white → orange-red + const g = Math.round(255 - this.fearLevel * 160) + const b = Math.round(255 - this.fearLevel * 255) + const bodyColor = `rgb(255,${g},${b})` + + // 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 = bodyColor + for (const [dx, dy, r] of WOOL_BLOBS) { + ctx.beginPath() + ctx.arc(dx, dy, r, 0, Math.PI * 2) + ctx.fill() + } + + // Head always at the front (+x side in local space) + ctx.fillStyle = '#d4c5a0' + ctx.beginPath() + ctx.arc(9, 0, 4.5, 0, Math.PI * 2) + ctx.fill() + + // Eyes on either side of the head centre + ctx.fillStyle = '#1a1a1a' + for (const side of [-1, 1]) { + ctx.beginPath() + ctx.arc(9, side * 2.5, 1.2, 0, Math.PI * 2) + ctx.fill() + } + + ctx.restore() + + // Indicators stay upright in world space regardless of facing + if (this.leader) { + ctx.fillStyle = '#f5c518' + ctx.font = '10px sans-serif' + ctx.textAlign = 'center' + ctx.fillText('★', x, y - 16) + } + + if (this.stubborn) { + ctx.strokeStyle = '#e05' + ctx.lineWidth = 1.5 + const s = 4 + ctx.beginPath() + ctx.moveTo(x - s, y - 16 - s); ctx.lineTo(x + s, y - 16 + s) + ctx.moveTo(x + s, y - 16 - s); ctx.lineTo(x - s, y - 16 + s) + ctx.stroke() + } + } +}