diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index 6403bef..fcf64cb 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -19,19 +19,23 @@ export interface Renderer { 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 | null + activeStonePos: DrawableStone[] }) => void } export function createRenderer(canvas: HTMLCanvasElement): Renderer { const ctx = canvas.getContext('2d')! - // Show the area from the hog line (top) to the back line (bottom). - const viewportHeight = BACK_LINE_Y - HOG_LINE_Y - const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 } + // Default viewport: roughly 14 ft (≈ 4.27 m) wide, centered on the house. + // Height is derived from the screen aspect ratio so the whole sheet is visible. + const viewportWidth = 14.0 / 3.28084 + const viewportCenter = { x: 0, y: HOUSE_CENTER.y } + let viewportYOffset = 0 const setSize = () => { const dpr = window.devicePixelRatio || 1 @@ -45,17 +49,26 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { } const scale = (): number => { - const height = window.innerHeight - return height / viewportHeight + return window.innerWidth / viewportWidth } + const viewportHeight = (): number => { + return window.innerHeight / scale() + } + + 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 = window.innerHeight / 2 + const center = effectiveViewportCenter() return { - x: cx + (x - viewportCenter.x) * s, - y: cy + (y - viewportCenter.y) * s, + x: cx + (x - center.x) * s, + y: cy + (y - center.y) * s, } } @@ -63,12 +76,28 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { const s = scale() const cx = window.innerWidth / 2 const cy = window.innerHeight / 2 + const center = effectiveViewportCenter() return { - x: (sx - cx) / s + viewportCenter.x, - y: viewportCenter.y + (sy - cy) / s, + 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). + 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, @@ -129,7 +158,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { stones: StoneState[] broom: { x: number; y: number } | null animating: boolean - activeStonePos: DrawableStone | null + activeStonePos: DrawableStone[] }) => { ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) @@ -149,12 +178,14 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { 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') - for (const stone of state.stones) { - drawStone(stone) - } - - if (state.activeStonePos) { - drawStone(state.activeStonePos) + if (state.animating) { + for (const stone of state.activeStonePos) { + drawStone(stone) + } + } else { + for (const stone of state.stones) { + drawStone(stone) + } } if (state.broom) { @@ -170,5 +201,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { setSize() window.addEventListener('resize', setSize) - return { canvas, ctx, setSize, worldToScreen, screenToWorld, draw } + return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw } } diff --git a/frontend/src/style.css b/frontend/src/style.css index a402a7f..66ceb15 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -84,30 +84,57 @@ html, body { z-index: 10; } +#hud-top-group { + display: flex; + flex-direction: column; + gap: 4px; +} + #share { - position: absolute; - bottom: 12px; - left: 50%; - transform: translateX(-50%); - text-align: center; + display: flex; + justify-content: center; + width: 100%; +} + +#share-row { + justify-content: center; + margin-bottom: 4px; +} + +#share-row #share { + position: static; + transform: none; } #share button { background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); color: white; - padding: 8px 14px; + padding: 6px 12px; border-radius: 12px; - font-size: 13px; + font-size: 12px; pointer-events: auto; } #team { + display: none; +} + +#team-select { font-size: 13px; font-weight: 600; padding: 4px 8px; border-radius: 10px; - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.15); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + pointer-events: auto; + min-width: 80px; +} + +#team-select option { + background: #0b1f3a; + color: white; } #velocity-control { @@ -205,67 +232,6 @@ html, body { text-align: center; } -#team-picker { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.75); - display: flex; - align-items: center; - justify-content: center; - z-index: 20; - pointer-events: auto; -} - -.team-picker-box { - background: #0b1f3a; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 16px; - padding: 24px; - text-align: center; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); -} - -.team-picker-box h2 { - margin: 0 0 16px 0; - font-size: 18px; -} - -.team-picker-buttons { - display: flex; - gap: 16px; - justify-content: center; - flex-wrap: wrap; -} - -.team-btn { - width: 120px; - height: 120px; - border-radius: 16px; - border: 2px solid rgba(255, 255, 255, 0.4); - color: white; - font-weight: 700; - font-size: 16px; - cursor: pointer; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); -} - -.team-btn.team-red { - background: #cc3333; -} -.team-btn.team-red:hover { - background: #e04444; -} - -.team-btn.team-yellow { - background: #ccaa00; -} -.team-btn.team-yellow:hover { - background: #e0be00; -} - #throw-btn { width: 80px; height: 80px; @@ -283,3 +249,53 @@ html, body { background: #557766; color: rgba(255, 255, 255, 0.5); } + +@media (max-width: 480px) { + #hud { + padding: 8px; + } + + .hud-row { + gap: 6px; + } + + #velocity-control { + min-width: 0; + flex: 1 1 auto; + padding: 2px; + } + + .velocity-readout { + min-width: 0; + font-size: 10px; + } + + .curl-btn { + min-width: 44px; + width: 44px; + height: 36px; + font-size: 18px; + border-color: rgba(255, 255, 255, 0.6); + background: rgba(0, 0, 0, 0.5); + } + + #friction-control { + min-width: 0; + flex: 0 1 auto; + padding: 2px; + } + + #friction-slider { + width: 60px; + } + + #friction-value { + font-size: 10px; + } + + #throw-btn { + width: 56px; + height: 56px; + font-size: 12px; + } +}