herd / commit
Scale all boids and entity sizes to screen dimensions
src/ai/Boids.ts | 92 ++++++++++++++++++++++++++++++--------------------- src/entities/Dog.ts | 7 ++-- src/entities/Pen.ts | 20 +++++------ src/entities/Sheep.ts | 23 ++++++------- src/game/GameState.ts | 40 +++++++++++++--------- 5 files changed, 105 insertions(+), 77 deletions(-) diff --git a/src/ai/Boids.ts b/src/ai/Boids.ts index e2dcc65..ee570ca 100644 --- a/src/ai/Boids.ts +++ b/src/ai/Boids.ts @@ -3,61 +3,57 @@ import type { Sheep } from '../entities/Sheep.ts' import type { Dog } from '../entities/Dog.ts' import type { Pen } from '../entities/Pen.ts' -// ─── Tuning constants ──────────────────────────────────────────────────────── -// Radii in world pixels, speeds in px/s, forces in px/s² +// ─── Base tuning constants (at reference scale = 1) ───────────────────────── +// Radii in world pixels, speeds in px/s, forces in px/s². +// All physical values are multiplied by the runtime `scale` factor inside +// updateBoids so the game feels identical across screen sizes. -const SEPARATION_RADIUS_SQ = 35 * 35 -const ALIGN_RADIUS_SQ = 80 * 80 -const COHESION_RADIUS_SQ = 100 * 100 -const LONG_RANGE_RADIUS_SQ = 300 * 300 // beyond cohesion — lone sheep spot a distant group +const BASE_SEPARATION_RADIUS = 35 +const BASE_ALIGN_RADIUS = 80 +const BASE_COHESION_RADIUS = 100 +const BASE_LONG_RANGE_RADIUS = 300 // beyond cohesion — lone sheep spot a distant group -const FLEE_RADIUS = 220 // sheep start reacting at this distance -const FLEE_RADIUS_SQ = FLEE_RADIUS * FLEE_RADIUS +const BASE_FLEE_RADIUS = 220 // sheep start reacting at this distance // Flee formula is LINEAR: full W_FLEE at d=0, zero at FLEE_RADIUS. -// This gives a smooth, readable gradient — sheep visibly start moving when the -// dog enters range, not only when it's almost on top of them. -const W_FLEE = 80 - -const W_SEPARATION = 30 -const W_ALIGNMENT = 12 -const W_COHESION = 12 -const W_LONG_RANGE = 5 // gentle pull toward a distant group -const W_PEN = 3 +const BASE_W_FLEE = 80 +const BASE_W_SEPARATION = 30 +const BASE_W_ALIGNMENT = 12 +const BASE_W_COHESION = 12 +const BASE_W_LONG_RANGE = 5 // gentle pull toward a distant group +const BASE_W_PEN = 3 // MAX_FORCE controls how fast sheep accelerate. At dt=16ms: // velocity gain per tick = MAX_FORCE * 0.016 // time to reach MAX_SPEED = MAX_SPEED / MAX_FORCE ≈ 0.9 s at these values -const MAX_SPEED = 90 // px/s (panic multiplier can push this up to ~162) -const MAX_FORCE = 100 // px/s² — previous value of 6 meant ~15 s to full speed +const BASE_MAX_SPEED = 90 // px/s (panic multiplier can push this up to ~162) +const BASE_MAX_FORCE = 100 // px/s² -const FEAR_DECAY = 0.95 // per fixed tick (~60 Hz) +const FEAR_DECAY = 0.95 // per fixed tick (~60 Hz) — dimensionless, no scaling // Wall repulsion: quadratic, peaks at BOUNDARY_PEAK right at the wall -const BOUNDARY_MARGIN = 90 -const BOUNDARY_PEAK = 140 // must exceed flee force near wall +const BASE_BOUNDARY_MARGIN = 90 +const BASE_BOUNDARY_PEAK = 140 // must exceed flee force near wall // Stubborn sheep: smaller flee radius so they require close, sustained pressure -const STUBBORN_FLEE_RADIUS = 100 -const STUBBORN_FLEE_RADIUS_SQ = STUBBORN_FLEE_RADIUS * STUBBORN_FLEE_RADIUS -const STUBBORN_COHESION_WEIGHT = 0.15 // relative to W_COHESION +const BASE_STUBBORN_FLEE_RADIUS = 100 +const STUBBORN_COHESION_WEIGHT = 0.15 // relative to W_COHESION — dimensionless // Leader sheep: others weight their position/velocity more heavily -const LEADER_NEIGHBOR_WEIGHT = 3.0 +const LEADER_NEIGHBOR_WEIGHT = 3.0 // dimensionless + +const BASE_SHEEP_RADIUS = 9 // must match Sheep.draw arc radius // ─── Helpers ───────────────────────────────────────────────────────────────── interface Obstacle { x: number; y: number; r: number } -function wallPush(dist: number): number { - if (dist >= BOUNDARY_MARGIN) return 0 - const t = 1 - dist / BOUNDARY_MARGIN - return t * t * BOUNDARY_PEAK +function wallPush(dist: number, margin: number, peak: number): number { + if (dist >= margin) return 0 + const t = 1 - dist / margin + return t * t * peak } -const SHEEP_RADIUS = 9 // must match Sheep.draw arc radius -const MIN_SHEEP_DIST = SHEEP_RADIUS * 2 - // ─── Main update ───────────────────────────────────────────────────────────── export function updateBoids( @@ -69,9 +65,31 @@ export function updateBoids( obstacles: Obstacle[], dt: number, speedMult = 1, + scale = 1, ) { - const OBSTACLE_MARGIN = 20 - const WALL = 10 + // Derive all physical constants for this screen scale. + const SHEEP_RADIUS = BASE_SHEEP_RADIUS * scale + const MIN_SHEEP_DIST = SHEEP_RADIUS * 2 + const SEPARATION_RADIUS_SQ = (BASE_SEPARATION_RADIUS * scale) ** 2 + const ALIGN_RADIUS_SQ = (BASE_ALIGN_RADIUS * scale) ** 2 + const COHESION_RADIUS_SQ = (BASE_COHESION_RADIUS * scale) ** 2 + const LONG_RANGE_RADIUS_SQ = (BASE_LONG_RANGE_RADIUS * scale) ** 2 + const FLEE_RADIUS = BASE_FLEE_RADIUS * scale + const FLEE_RADIUS_SQ = FLEE_RADIUS ** 2 + const STUBBORN_FLEE_RADIUS = BASE_STUBBORN_FLEE_RADIUS * scale + const STUBBORN_FLEE_RADIUS_SQ = STUBBORN_FLEE_RADIUS ** 2 + const W_FLEE = BASE_W_FLEE * scale + const W_SEPARATION = BASE_W_SEPARATION * scale + const W_ALIGNMENT = BASE_W_ALIGNMENT * scale + const W_COHESION = BASE_W_COHESION * scale + const W_LONG_RANGE = BASE_W_LONG_RANGE * scale + const W_PEN = BASE_W_PEN * scale + const MAX_SPEED = BASE_MAX_SPEED * scale + const MAX_FORCE = BASE_MAX_FORCE * scale + const BOUNDARY_MARGIN = BASE_BOUNDARY_MARGIN * scale + const BOUNDARY_PEAK = BASE_BOUNDARY_PEAK * scale + const OBSTACLE_MARGIN = 20 * scale + const WALL = 10 * scale for (const s of sheep) { const fleeRadius = s.stubborn ? STUBBORN_FLEE_RADIUS : FLEE_RADIUS @@ -152,8 +170,8 @@ export function updateBoids( // ── boundary repulsion (quadratic, peaks at BOUNDARY_PEAK) ─────────── steering = steering.add(new Vec2( - wallPush(s.pos.x) - wallPush(canvasW - s.pos.x), - wallPush(s.pos.y) - wallPush(canvasH - s.pos.y), + wallPush(s.pos.x, BOUNDARY_MARGIN, BOUNDARY_PEAK) - wallPush(canvasW - s.pos.x, BOUNDARY_MARGIN, BOUNDARY_PEAK), + wallPush(s.pos.y, BOUNDARY_MARGIN, BOUNDARY_PEAK) - wallPush(canvasH - s.pos.y, BOUNDARY_MARGIN, BOUNDARY_PEAK), )) // ── obstacle repulsion ──────────────────────────────────────────────── diff --git a/src/entities/Dog.ts b/src/entities/Dog.ts index 1239081..f24981d 100644 --- a/src/entities/Dog.ts +++ b/src/entities/Dog.ts @@ -29,7 +29,7 @@ export class Dog { this.pos = this.pos.add(this.vel.scale(dt)) } - draw(ctx: CanvasRenderingContext2D) { + draw(ctx: CanvasRenderingContext2D, scale = 1) { const { x, y } = this.pos const facing = this.vel.lengthSq() > 0 ? Math.atan2(this.vel.y, this.vel.x) @@ -38,6 +38,7 @@ export class Dog { ctx.save() ctx.translate(x, y) ctx.rotate(facing) + ctx.scale(scale, scale) // tail — curves up from rear, drawn behind body ctx.strokeStyle = '#111' @@ -104,7 +105,7 @@ export class Dog { ctx.restore() - // target indicator — world space + // target indicator — world space (not scaled, always thin) if (this.target) { ctx.strokeStyle = 'rgba(255,255,255,0.25)' ctx.lineWidth = 1 @@ -118,7 +119,7 @@ export class Dog { 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.arc(this.target.x, this.target.y, 5 * scale, 0, Math.PI * 2) ctx.stroke() } } diff --git a/src/entities/Pen.ts b/src/entities/Pen.ts index 3b5f97f..120b3f1 100644 --- a/src/entities/Pen.ts +++ b/src/entities/Pen.ts @@ -17,7 +17,7 @@ export class Pen { return new Vec2(this.x + this.w / 2, this.y + this.h / 2) } - draw(ctx: CanvasRenderingContext2D, filledFraction: number) { + draw(ctx: CanvasRenderingContext2D, filledFraction: number, scale = 1) { const { x, y, w, h, gateSide } = this // filled interior — shifts from transparent to green as sheep enter @@ -27,9 +27,9 @@ export class Pen { // fence posts + rails on three sides ctx.strokeStyle = '#7a5c2e' - ctx.lineWidth = 3 + ctx.lineWidth = 3 * scale - const postR = 5 + const postR = 5 * scale const sides: Array<[number, number, number, number]> = [ [x, y, x + w, y ], // top [x + w, y, x + w, y + h], // right @@ -73,14 +73,14 @@ export class Pen { }) // gate indicator arrow pointing inward - this.drawGateArrow(ctx) + this.drawGateArrow(ctx, scale) } - private drawGateArrow(ctx: CanvasRenderingContext2D) { + private drawGateArrow(ctx: CanvasRenderingContext2D, scale = 1) { const { x, y, w, h, gateSide } = this const cx = x + w / 2 const cy = y + h / 2 - const arrowLen = 14 + const arrowLen = 14 * scale let ax: number, ay: number, dx: number, dy: number switch (gateSide) { @@ -91,19 +91,19 @@ export class Pen { } ctx.strokeStyle = 'rgba(255,255,255,0.6)' - ctx.lineWidth = 2 + ctx.lineWidth = 2 * scale ctx.beginPath() ctx.moveTo(ax, ay) ctx.lineTo(ax + dx * arrowLen, ay + dy * arrowLen) ctx.stroke() // arrowhead - const perpX = -dy * 5, perpY = dx * 5 + const perpX = -dy * 5 * scale, perpY = dx * 5 * scale const tipX = ax + dx * arrowLen, tipY = ay + dy * arrowLen ctx.beginPath() ctx.moveTo(tipX, tipY) - ctx.lineTo(tipX - dx * 8 + perpX, tipY - dy * 8 + perpY) - ctx.lineTo(tipX - dx * 8 - perpX, tipY - dy * 8 - perpY) + ctx.lineTo(tipX - dx * 8 * scale + perpX, tipY - dy * 8 * scale + perpY) + ctx.lineTo(tipX - dx * 8 * scale - perpX, tipY - dy * 8 * scale - perpY) ctx.closePath() ctx.fillStyle = 'rgba(255,255,255,0.6)' ctx.fill() diff --git a/src/entities/Sheep.ts b/src/entities/Sheep.ts index b80e8cf..be4a852 100644 --- a/src/entities/Sheep.ts +++ b/src/entities/Sheep.ts @@ -39,9 +39,7 @@ export class Sheep { this.subFlock = props.subFlock ?? 0 } - draw(ctx: CanvasRenderingContext2D, dark = false) { - if (dark) return // caller handles visibility in dark mode - + draw(ctx: CanvasRenderingContext2D, scale = 1) { const { x, y } = this.pos // fear tint: white → orange-red @@ -58,41 +56,42 @@ export class Sheep { ctx.fillStyle = bodyColor for (const [dx, dy, r] of WOOL_BLOBS) { ctx.beginPath() - ctx.arc(dx, dy, r, 0, Math.PI * 2) + 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.beginPath() - ctx.arc(9, 0, 4.5, 0, Math.PI * 2) + ctx.arc(9 * scale, 0, 4.5 * scale, 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.arc(9 * scale, side * 2.5 * scale, 1.2 * scale, 0, Math.PI * 2) ctx.fill() } ctx.restore() // Indicators stay upright in world space regardless of facing + const indicatorY = y - 16 * scale if (this.leader) { ctx.fillStyle = '#f5c518' - ctx.font = '10px sans-serif' + ctx.font = `${Math.round(10 * scale)}px sans-serif` ctx.textAlign = 'center' - ctx.fillText('★', x, y - 16) + ctx.fillText('★', x, indicatorY) } if (this.stubborn) { ctx.strokeStyle = '#e05' - ctx.lineWidth = 1.5 - const s = 4 + ctx.lineWidth = 1.5 * scale + const s = 4 * scale 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.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() } } diff --git a/src/game/GameState.ts b/src/game/GameState.ts index ed31d5b..d9a6fe4 100644 --- a/src/game/GameState.ts +++ b/src/game/GameState.ts @@ -20,8 +20,8 @@ export class GameState { private pen!: Pen private obstacles: Array<{ x: number; y: number; r: number }> = [] + private scale = 1 // min(W,H) / BASE_DIM — all pixel constants multiply by this private elapsed = 0 // seconds since level start - private penFraction = 0 // fraction of sheep inside pen private endMessageTimer = 0 // pen shrink tracking @@ -44,6 +44,9 @@ export class GameState { const cfg = this.config const { width: W, height: H } = this.canvas + // Reference dimension: all boids/entity constants were tuned at minDim=600. + this.scale = Math.min(W, H) / 600 + // resolve pen rect from fractions const pen = cfg.penRect this.pen = new Pen( @@ -78,14 +81,13 @@ export class GameState { } // place dog at a neutral position away from the pen - this.dog = new Dog(W * 0.5, H * 0.75, cfg.dogSpeed ?? 160) + this.dog = new Dog(W * 0.5, H * 0.75, (cfg.dogSpeed ?? 160) * this.scale) if (cfg.sheepSpeedMult) { // speed mult is applied in boids via a per-level constant injected below } this.elapsed = 0 this.lastShrinkAt = 0 - this.penFraction = 0 this.endMessageTimer = 0 this.phase = 'PLAYING' } @@ -135,14 +137,13 @@ export class GameState { this.dog.update(dt) const speedMult = cfg.sheepSpeedMult ?? 1 - updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult) + updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult, this.scale) // count sheep in pen const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length - this.penFraction = inPen / this.sheep.length // check win - if (this.penFraction >= cfg.winThreshold) { + if (inPen >= cfg.winThreshold) { this.phase = 'WIN' return } @@ -153,7 +154,7 @@ export class GameState { } // check lose — pen too small for last-pasture - if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < 30 * 30) { + if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < (30 * this.scale) ** 2) { this.phase = 'FAIL' } } @@ -183,7 +184,7 @@ export class GameState { // pen const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length - this.pen.draw(ctx, inPen / this.sheep.length) + this.pen.draw(ctx, Math.min(1, inPen / this.config.winThreshold), this.scale) // dark mode overlay if (this.config.darkMode) { @@ -194,13 +195,13 @@ export class GameState { for (const s of this.sheep) { if (this.config.darkMode) { const dSq = s.pos.distanceSqTo(this.dog.pos) - if (dSq > 120 * 120) continue // invisible outside torch radius + if (dSq > (120 * this.scale) ** 2) continue // invisible outside torch radius } - s.draw(ctx) + s.draw(ctx, this.scale) } // dog - this.dog.draw(ctx) + this.dog.draw(ctx, this.scale) // HUD this.drawHUD(ctx, W, H, inPen, alpha) @@ -212,7 +213,8 @@ export class GameState { private applyDarkMode(ctx: CanvasRenderingContext2D, W: number, H: number) { const { x, y } = this.dog.pos - const gradient = ctx.createRadialGradient(x, y, 0, x, y, 120) + const torchR = 120 * this.scale + const gradient = ctx.createRadialGradient(x, y, 0, x, y, torchR) gradient.addColorStop(0, 'rgba(0,0,0,0)') gradient.addColorStop(0.6, 'rgba(0,0,0,0.55)') gradient.addColorStop(1, 'rgba(0,0,0,0.92)') @@ -228,7 +230,7 @@ export class GameState { ctx.globalCompositeOperation = 'destination-out' ctx.fillStyle = gradient ctx.beginPath() - ctx.arc(x, y, 120, 0, Math.PI * 2) + ctx.arc(x, y, torchR, 0, Math.PI * 2) ctx.fill() ctx.restore() @@ -244,15 +246,23 @@ export class GameState { const cfg = this.config // sheep counter + const hasSlack = cfg.winThreshold < this.sheep.length + const counterW = hasSlack ? 185 : 130 ctx.fillStyle = 'rgba(0,0,0,0.45)' ctx.beginPath() - roundRect(ctx, 12, 12, 130, 38, 8) + roundRect(ctx, 12, 12, counterW, 38, 8) ctx.fill() ctx.fillStyle = '#fff' ctx.font = 'bold 16px system-ui' ctx.textAlign = 'left' - ctx.fillText(`🐑 ${inPen} / ${this.sheep.length}`, 20, 36) + ctx.fillText(`🐑 ${inPen} / ${cfg.winThreshold}`, 20, 36) + + if (hasSlack) { + ctx.fillStyle = 'rgba(255,255,255,0.45)' + ctx.font = '11px system-ui' + ctx.fillText(`(${this.sheep.length} total)`, 108, 36) + } // timer bar if (cfg.timeLimitSeconds !== null) {