import { OBSTACLES, PLAYER, SPRITES, type ObstacleKind } from "./sprites"; export type Phase = "idle" | "running" | "dead"; export interface PlayerState { y: number; vy: number; frame: 0 | 1; grounded: boolean; } export interface ObstacleState { kind: ObstacleKind; x: number; counted: boolean; } export interface GameState { phase: Phase; score: number; night: boolean; speed: number; player: PlayerState; obstacles: ObstacleState[]; clouds: { x: number; y: number }[]; stars: { x: number; y: number }[]; groundOffset: number; } export const VIEW = { w: 640, h: 200, ground: 170 }; export const TUNING = { gravity: 2400, jumpVelocity: -760, shortHopFactor: 0.45, maxDtMs: 100, runFrameMs: 120, playerX: 56, baseSpeed: 240, speedPerPoint: 6, maxSpeed: 520, firstSpawnDistance: 240, spawnGapBase: 260, spawnGapSpeedFactor: 0.35, spawnGapJitter: 220, pairMinScore: 4, stackMinScore: 8, milestoneEvery: 10, nightEvery: 25, restartLockoutMs: 250, groundTile: 48, cloudCount: 3, cloudSpeedFactor: 0.25, cloudBandTop: 26, cloudBandHeight: 52, starCount: 14, starSpeedFactor: 0.06, starBandTop: 10, starBandHeight: 96 }; const KIND_WEIGHTS: { kind: ObstacleKind; weight: number }[] = [ { kind: "standing", weight: 45 }, { kind: "sleeping", weight: 25 }, { kind: "pair", weight: 20 }, { kind: "stack", weight: 10 } ]; export interface Box { left: number; right: number; top: number; bottom: number; } export function playerBox(player: PlayerState): Box { const inset = PLAYER.inset; return { left: TUNING.playerX + inset.left, right: TUNING.playerX + PLAYER.w - inset.right, top: player.y - PLAYER.h + inset.top, bottom: player.y - inset.bottom }; } export function obstacleBox(obstacle: ObstacleState): Box { const def = OBSTACLES[obstacle.kind]; return { left: obstacle.x + def.inset, right: obstacle.x + def.w - def.inset, top: VIEW.ground - def.h + def.inset, bottom: VIEW.ground - def.inset }; } function overlaps(a: Box, b: Box): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } export interface SheepEngineOptions { rng?: () => number; } export class SheepEngine { readonly state: GameState; onScore?: (score: number) => void; onMilestone?: (score: number) => void; onNightFlip?: (night: boolean) => void; onGameOver?: (score: number) => void; private readonly rng: () => number; private spawnCountdown = 0; private frameTimer = 0; private diedAt = 0; constructor(opts: SheepEngineOptions = {}) { this.rng = opts.rng ?? Math.random; this.state = { phase: "idle", score: 0, night: false, speed: TUNING.baseSpeed, player: { y: VIEW.ground, vy: 0, frame: 0, grounded: true }, obstacles: [], clouds: [], stars: [], groundOffset: 0 }; this.reset(); } start(): void { const s = this.state; if (s.phase === "running") return; if (s.phase === "dead") { if (Date.now() - this.diedAt < TUNING.restartLockoutMs) return; this.reset(); } s.phase = "running"; } reset(): void { const s = this.state; s.phase = "idle"; s.score = 0; s.night = false; s.speed = TUNING.baseSpeed; s.player.y = VIEW.ground; s.player.vy = 0; s.player.frame = 0; s.player.grounded = true; s.obstacles.length = 0; s.groundOffset = 0; this.spawnCountdown = TUNING.firstSpawnDistance; this.frameTimer = 0; this.diedAt = 0; this.seedScenery(); } jumpPress(): void { const s = this.state; if (s.phase !== "running") { this.start(); return; } if (!s.player.grounded) return; s.player.vy = TUNING.jumpVelocity; s.player.grounded = false; s.player.frame = 0; this.frameTimer = 0; } jumpRelease(): void { const p = this.state.player; if (this.state.phase !== "running") return; if (p.grounded || p.vy >= 0) return; p.vy *= TUNING.shortHopFactor; } tick(dtMs: number): void { const s = this.state; if (s.phase !== "running") return; const dt = Math.min(dtMs, TUNING.maxDtMs) / 1000; if (dt <= 0) return; s.speed = Math.min(TUNING.maxSpeed, TUNING.baseSpeed + s.score * TUNING.speedPerPoint); const dx = s.speed * dt; this.stepPlayer(dt); this.stepScenery(dx); this.stepObstacles(dx); if (this.hitsObstacle()) { s.phase = "dead"; s.player.frame = 0; this.diedAt = Date.now(); this.onGameOver?.(s.score); return; } this.countPassed(); } private stepPlayer(dt: number): void { const p = this.state.player; if (p.grounded) { this.frameTimer += dt * 1000; while (this.frameTimer >= TUNING.runFrameMs) { this.frameTimer -= TUNING.runFrameMs; p.frame = p.frame === 0 ? 1 : 0; } return; } p.vy += TUNING.gravity * dt; p.y += p.vy * dt; if (p.y >= VIEW.ground) { p.y = VIEW.ground; p.vy = 0; p.grounded = true; this.frameTimer = 0; } } private stepScenery(dx: number): void { const s = this.state; s.groundOffset = (s.groundOffset + dx) % TUNING.groundTile; const cloudW = SPRITES.cloud.w; for (const cloud of s.clouds) { cloud.x -= dx * TUNING.cloudSpeedFactor; if (cloud.x + cloudW < 0) { cloud.x = VIEW.w + this.rng() * 80; cloud.y = TUNING.cloudBandTop + this.rng() * TUNING.cloudBandHeight; } } for (const star of s.stars) { star.x -= dx * TUNING.starSpeedFactor; if (star.x < -SPRITES.star.w) star.x += VIEW.w + SPRITES.star.w; } } private stepObstacles(dx: number): void { const s = this.state; for (const obstacle of s.obstacles) obstacle.x -= dx; for (let i = s.obstacles.length - 1; i >= 0; i--) { const obstacle = s.obstacles[i]; if (obstacle.x + OBSTACLES[obstacle.kind].w < 0) s.obstacles.splice(i, 1); } this.spawnCountdown -= dx; while (this.spawnCountdown <= 0) { s.obstacles.push({ kind: this.pickKind(), x: VIEW.w + this.spawnCountdown, counted: false }); this.spawnCountdown += this.nextGap(); } } private nextGap(): number { const minGap = TUNING.spawnGapBase + this.state.speed * TUNING.spawnGapSpeedFactor; return minGap + this.rng() * TUNING.spawnGapJitter; } private pickKind(): ObstacleKind { const pool = KIND_WEIGHTS.filter((entry) => { if (entry.kind === "stack") return this.state.score >= TUNING.stackMinScore; if (entry.kind === "pair") return this.state.score >= TUNING.pairMinScore; return true; }); const total = pool.reduce((sum, entry) => sum + entry.weight, 0); let roll = this.rng() * total; for (const entry of pool) { roll -= entry.weight; if (roll < 0) return entry.kind; } return pool[pool.length - 1].kind; } private hitsObstacle(): boolean { const box = playerBox(this.state.player); for (const obstacle of this.state.obstacles) { if (overlaps(box, obstacleBox(obstacle))) return true; } return false; } private countPassed(): void { for (const obstacle of this.state.obstacles) { if (obstacle.counted) continue; if (obstacle.x + OBSTACLES[obstacle.kind].w >= TUNING.playerX) continue; obstacle.counted = true; this.award(OBSTACLES[obstacle.kind].count); } } private award(gain: number): void { const s = this.state; const before = s.score; s.score = before + gain; this.onScore?.(s.score); const milestone = TUNING.milestoneEvery; if (Math.floor(s.score / milestone) > Math.floor(before / milestone)) { this.onMilestone?.(s.score); } const night = TUNING.nightEvery; if (Math.floor(s.score / night) > Math.floor(before / night)) { s.night = !s.night; this.onNightFlip?.(s.night); } } private seedScenery(): void { const s = this.state; s.clouds.length = 0; for (let i = 0; i < TUNING.cloudCount; i++) { s.clouds.push({ x: (VIEW.w / TUNING.cloudCount) * i + this.rng() * 120, y: TUNING.cloudBandTop + this.rng() * TUNING.cloudBandHeight }); } s.stars.length = 0; for (let i = 0; i < TUNING.starCount; i++) { s.stars.push({ x: this.rng() * VIEW.w, y: TUNING.starBandTop + this.rng() * TUNING.starBandHeight }); } } }