curltastic/frontend/src/renderer.ts
Jason Dekarske 78b8ebeb7d docs: move product spec to .pm/board.yaml
Replace REQUIREMENTS.md as source of truth with the pm-board yaml.
Drop README friction-scalar lies. Include pending HUD frontend work.
2026-09-07 23:06:31 +00:00

248 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}