Replace REQUIREMENTS.md as source of truth with the pm-board yaml. Drop README friction-scalar lies. Include pending HUD frontend work.
248 lines
8.2 KiB
TypeScript
248 lines
8.2 KiB
TypeScript
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 }
|
||
}
|