import { BACK_LINE_Y, BUTTON_RADIUS, EIGHT_FT_RADIUS, FOUR_FT_RADIUS, HOG_LINE_Y, SHEET_LENGTH, SHEET_WIDTH, STONE_RADIUS, TWELVE_FT_RADIUS, HOUSE_CENTER, type DrawableStone, type StoneState, } from './protocol' export interface Renderer { canvas: HTMLCanvasElement ctx: CanvasRenderingContext2D setSize: () => void worldToScreen: (x: number, y: number) => { x: number; y: number } screenToWorld: (sx: number, sy: number) => { x: number; y: number } setViewYOffset: (y: number) => void clampViewYOffset: () => number draw: (state: { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean activeStonePos: DrawableStone[] }) => void } export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! // Fit full sheet width (5 m) with a little side padding so both sidelines show. // Scale is primarily width-based, but also capped so house + backline fit above the HUD. const SIDE_PADDING_M = 0.25 const viewportWidth = SHEET_WIDTH + SIDE_PADDING_M * 2 // Min world height: top of 12ft ring through backline + small pad. const MIN_VIEW_HEIGHT_M = BACK_LINE_Y - (HOUSE_CENTER.y - TWELVE_FT_RADIUS) + SIDE_PADDING_M // Throw button (80) + hud padding (12×2) + small gap above controls. const BOTTOM_UI_PX = 120 const viewportCenter = { x: 0, y: HOUSE_CENTER.y } // Prefer backline at the bottom of the usable ice area (just above HUD). // First setSize()/setViewYOffset clamps this to the valid minOffset. let viewportYOffset = -(BACK_LINE_Y - HOUSE_CENTER.y) const setSize = () => { const dpr = window.devicePixelRatio || 1 const width = window.innerWidth const height = window.innerHeight canvas.width = width * dpr canvas.height = height * dpr canvas.style.width = `${width}px` canvas.style.height = `${height}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // Re-clamp so default framing keeps the backline just above the HUD. setViewYOffset(viewportYOffset) } /** Screen height available for ice (above bottom controls). */ const usableHeightPx = (): number => { return Math.max(1, window.innerHeight - BOTTOM_UI_PX) } const scale = (): number => { const byWidth = window.innerWidth / viewportWidth const byHeight = usableHeightPx() / MIN_VIEW_HEIGHT_M // Width fit for sidelines; height cap so house/backline stay on-screen on wide displays. return Math.min(byWidth, byHeight) } const viewportHeight = (): number => { return usableHeightPx() / scale() } /** Vertical center of the usable ice area (not the full window). */ const viewCenterY = (): number => { return usableHeightPx() / 2 } const effectiveViewportCenter = () => ({ x: viewportCenter.x, y: viewportCenter.y - viewportYOffset, }) const worldToScreen = (x: number, y: number) => { const s = scale() const cx = window.innerWidth / 2 const cy = viewCenterY() const center = effectiveViewportCenter() return { x: cx + (x - center.x) * s, y: cy + (y - center.y) * s, } } const screenToWorld = (sx: number, sy: number) => { const s = scale() const cx = window.innerWidth / 2 const cy = viewCenterY() const center = effectiveViewportCenter() return { x: (sx - cx) / s + center.x, y: center.y + (sy - cy) / s, } } const clampViewYOffset = (): number => { // Positive offset pans the view upward (toward the hog line). // When the usable viewport is tall enough, minOffset pins BACK_LINE_Y to the // bottom of the usable area (just above the HUD), not under the controls. const halfHeight = viewportHeight() / 2 const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight)) return Math.max(minOffset, Math.min(maxOffset, viewportYOffset)) } const setViewYOffset = (y: number) => { const halfHeight = viewportHeight() / 2 const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight)) viewportYOffset = Math.max(minOffset, Math.min(maxOffset, y)) } const drawLine = ( x1: number, y1: number, x2: number, y2: number, alpha = 0.4, ) => { const a = worldToScreen(x1, y1) const b = worldToScreen(x2, y2) ctx.beginPath() ctx.moveTo(a.x, a.y) ctx.lineTo(b.x, b.y) ctx.strokeStyle = `rgba(255,255,255,${alpha})` ctx.lineWidth = Math.max(1, scale() * 0.03) ctx.stroke() } const drawCircle = (wx: number, wy: number, wr: number, fill: string, stroke?: string) => { const c = worldToScreen(wx, wy) const r = wr * scale() ctx.beginPath() ctx.arc(c.x, c.y, r, 0, Math.PI * 2) if (fill) { ctx.fillStyle = fill ctx.fill() } if (stroke) { ctx.strokeStyle = stroke ctx.lineWidth = Math.max(1, scale() * 0.015) ctx.stroke() } } const drawStone = (stone: DrawableStone) => { const c = worldToScreen(stone.x, stone.y) const r = STONE_RADIUS * scale() // team1 = red palette, team2 = yellow palette const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00' const color = stone.team === 'team1' ? '#d93025' : '#f9ab00' const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666' // Paint but body fully in stone frame so θ from physics is obvious while spinning. // Canvas +Y is down; negate so CCW body angle matches ice coordinates. ctx.save() ctx.translate(c.x, c.y) ctx.rotate(-stone.rotation) const bodyGrad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r) bodyGrad.addColorStop(0, highlight) bodyGrad.addColorStop(0.55, color) bodyGrad.addColorStop(1, rim) ctx.beginPath() ctx.arc(0, 0, r, 0, Math.PI * 2) ctx.fillStyle = bodyGrad ctx.fill() // Asymmetric handle: bright bar + dark toe so spin reads clearly. ctx.fillStyle = 'rgba(255,255,255,0.92)' ctx.fillRect(-r * 0.12, -r * 0.18, r * 0.9, r * 0.36) ctx.fillStyle = 'rgba(20,20,20,0.55)' ctx.beginPath() ctx.arc(-r * 0.4, 0, r * 0.22, 0, Math.PI * 2) ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.35)' ctx.lineWidth = Math.max(1, scale() * 0.015) ctx.beginPath() ctx.moveTo(0, 0) ctx.lineTo(r * 0.72, 0) ctx.stroke() ctx.restore() } const draw = (state: { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean activeStonePos: DrawableStone[] }) => { ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) ctx.fillStyle = '#1c4a7a' ctx.fillRect(0, 0, window.innerWidth, window.innerHeight) // Sheet markings (larger y is lower on screen, so stones enter from top) drawLine(0, 0, 0, SHEET_LENGTH) // center line drawLine(-SHEET_WIDTH / 2, 0, -SHEET_WIDTH / 2, SHEET_LENGTH) // left sideline drawLine(SHEET_WIDTH / 2, 0, SHEET_WIDTH / 2, SHEET_LENGTH) // right sideline drawLine(-SHEET_WIDTH / 2, HOG_LINE_Y, SHEET_WIDTH / 2, HOG_LINE_Y) // hog line drawLine(-SHEET_WIDTH / 2, BACK_LINE_Y, SHEET_WIDTH / 2, BACK_LINE_Y) // back line drawLine(-SHEET_WIDTH / 2, HOUSE_CENTER.y, SHEET_WIDTH / 2, HOUSE_CENTER.y) // tee line drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, TWELVE_FT_RADIUS, 'rgba(255,255,255,0.08)', 'rgba(255,255,255,0.35)') drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, EIGHT_FT_RADIUS, 'rgba(255,255,255,0.1)', 'rgba(255,255,255,0.35)') drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, FOUR_FT_RADIUS, 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0.35)') drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, BUTTON_RADIUS, '#e8e8e8', '#fff') if (state.animating) { for (const stone of state.activeStonePos) { drawStone(stone) } } else { for (const stone of state.stones) { drawStone(stone) } } if (state.broom) { const b = worldToScreen(state.broom.x, state.broom.y) ctx.beginPath() ctx.arc(b.x, b.y, Math.max(2, scale() * 0.06), 0, Math.PI * 2) ctx.strokeStyle = '#ffcc00' ctx.lineWidth = Math.max(1, scale() * 0.04) ctx.stroke() } } setSize() window.addEventListener('resize', setSize) return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw } }