Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331import { 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 }); } }}