feat: add Vite TypeScript frontend for curling game
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
5e0c188d9b
commit
86153894ac
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>curltastic</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3693
frontend/package-lock.json
generated
Normal file
3693
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "curltastic-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^1.6.0",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
9
frontend/screenshot.cjs
Normal file
9
frontend/screenshot.cjs
Normal file
@ -0,0 +1,9 @@
|
||||
const { chromium } = require('playwright')
|
||||
;(async () => {
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) // iPhone-ish
|
||||
await page.goto('http://localhost:5173/?room=SHOTTEST')
|
||||
await page.waitForTimeout(3000)
|
||||
await page.screenshot({ path: '/tmp/curltastic_waiting.png', fullPage: false })
|
||||
await browser.close()
|
||||
})()
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/typescript.svg
Normal file
1
frontend/src/assets/typescript.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
9
frontend/src/counter.ts
Normal file
9
frontend/src/counter.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
46
frontend/src/game-helpers.test.ts
Normal file
46
frontend/src/game-helpers.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
trimPathToStartAtHogLine,
|
||||
velocityToWeight,
|
||||
weightToVelocity,
|
||||
} from './game-helpers'
|
||||
|
||||
describe('trimPathToStartAtHogLine', () => {
|
||||
it('trims path at the first hog-line crossing and zeroes time', () => {
|
||||
const path: [number, number, number][] = [
|
||||
[0, 2, 0],
|
||||
[0, 20, 1],
|
||||
[0, 21.5, 2],
|
||||
[0, 30, 3],
|
||||
]
|
||||
const trimmed = trimPathToStartAtHogLine(path)
|
||||
expect(trimmed[0][1]).toBe(20)
|
||||
expect(trimmed[0][2]).toBe(0)
|
||||
expect(trimmed[trimmed.length - 1][2]).toBe(2)
|
||||
})
|
||||
|
||||
it('returns full path when hog line is never reached', () => {
|
||||
const path: [number, number, number][] = [
|
||||
[0, 2, 0],
|
||||
[0, 10, 1],
|
||||
]
|
||||
expect(trimPathToStartAtHogLine(path)).toEqual(path)
|
||||
})
|
||||
})
|
||||
|
||||
describe('velocity ↔ weight', () => {
|
||||
it('maps endpoints correctly', () => {
|
||||
expect(velocityToWeight(3.0)).toBe(1)
|
||||
expect(velocityToWeight(6.45)).toBe(10)
|
||||
expect(weightToVelocity(1)).toBe(3.0)
|
||||
expect(weightToVelocity(10)).toBe(6.45)
|
||||
})
|
||||
|
||||
it('clamps out-of-range inputs', () => {
|
||||
expect(velocityToWeight(2.5)).toBe(1)
|
||||
expect(velocityToWeight(7.0)).toBe(10)
|
||||
expect(weightToVelocity(0)).toBe(3.0)
|
||||
expect(weightToVelocity(11)).toBe(6.45)
|
||||
})
|
||||
})
|
||||
|
||||
26
frontend/src/game-helpers.ts
Normal file
26
frontend/src/game-helpers.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED } from './protocol'
|
||||
|
||||
export function trimPathToStartAtHogLine(
|
||||
path: [number, number, number][],
|
||||
): [number, number, number][] {
|
||||
if (path.length < 2) return path
|
||||
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||
if (idx < 0) return path
|
||||
// Start just before the hog line crossing so the stone enters smoothly.
|
||||
const start = Math.max(0, idx - 1)
|
||||
const t0 = path[start][2]
|
||||
return path.slice(start).map(([x, y, t]) => [x, y, t - t0])
|
||||
}
|
||||
|
||||
export function velocityToWeight(velocity: number): number {
|
||||
const t = (velocity - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)
|
||||
const weight = 1 + Math.round(t * 9)
|
||||
return Math.max(1, Math.min(10, weight))
|
||||
}
|
||||
|
||||
export function weightToVelocity(weight: number): number {
|
||||
const clamped = Math.max(1, Math.min(10, weight))
|
||||
const t = (clamped - 1) / 9
|
||||
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
||||
}
|
||||
|
||||
274
frontend/src/game.ts
Normal file
274
frontend/src/game.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import { connect, sendThrow, type NetCallbacks } from './net'
|
||||
import { createRenderer } from './renderer'
|
||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol'
|
||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
||||
|
||||
interface GameState {
|
||||
end: number
|
||||
scores: number[]
|
||||
hammer: Team
|
||||
turnTeam: Team
|
||||
myTeam: Team | null
|
||||
phase: Phase
|
||||
stones: StoneState[]
|
||||
animating: boolean
|
||||
}
|
||||
|
||||
export function startGame(): void {
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
app.innerHTML = '<canvas id="game-canvas"></canvas>'
|
||||
|
||||
const canvas = app.querySelector<HTMLCanvasElement>('#game-canvas')!
|
||||
const renderer = createRenderer(canvas)
|
||||
const hud = createHud()
|
||||
app.appendChild(hud.root)
|
||||
|
||||
const velocityContainer = hud.velocityControl
|
||||
const curlContainer = hud.curlSelector
|
||||
const frictionContainer = hud.frictionControl
|
||||
const throwBtn = hud.throwButton
|
||||
const velocity = createVelocitySelector(velocityContainer, () => {})
|
||||
const curls = createCurlSelector(curlContainer, () => {})
|
||||
const friction = createFrictionSlider(frictionContainer)
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
let room = params.get('room')
|
||||
if (!room) {
|
||||
room = generateRoomCode()
|
||||
window.history.replaceState({}, '', `/?room=${room}`)
|
||||
}
|
||||
|
||||
const shareLink = `${window.location.origin}/?room=${room}`
|
||||
hud.setShareLink(shareLink)
|
||||
|
||||
const picker = document.createElement('div')
|
||||
picker.id = 'team-picker'
|
||||
picker.innerHTML = `
|
||||
<div class="team-picker-box">
|
||||
<h2>Choose your team</h2>
|
||||
<div class="team-picker-buttons">
|
||||
<button class="team-btn team-red" data-team="red">Play as Red</button>
|
||||
<button class="team-btn team-yellow" data-team="yellow">Play as Yellow</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
app.appendChild(picker)
|
||||
|
||||
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const team = btn.dataset.team as Team
|
||||
picker.remove()
|
||||
connect(room, team, callbacks)
|
||||
})
|
||||
})
|
||||
|
||||
let gameState: GameState = {
|
||||
end: 1,
|
||||
scores: [0, 0],
|
||||
hammer: 'red',
|
||||
turnTeam: 'red',
|
||||
myTeam: null,
|
||||
phase: 'waiting',
|
||||
stones: [],
|
||||
animating: false,
|
||||
}
|
||||
let pendingStones: StoneState[] = []
|
||||
|
||||
let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
|
||||
let isDragging = false
|
||||
let activePath: [number, number, number][] = []
|
||||
let animationStartTime = 0
|
||||
let animationTeam: Team = 'red'
|
||||
|
||||
const updateControls = () => {
|
||||
const myTurn = Boolean(
|
||||
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing',
|
||||
)
|
||||
velocity.setEnabled(myTurn && !gameState.animating)
|
||||
curls.setEnabled(myTurn && !gameState.animating)
|
||||
friction.setEnabled(myTurn && !gameState.animating)
|
||||
throwBtn.disabled = !myTurn || gameState.animating
|
||||
if (gameState.phase === 'simulating' || gameState.animating) {
|
||||
velocity.setEnabled(false)
|
||||
curls.setEnabled(false)
|
||||
friction.setEnabled(false)
|
||||
}
|
||||
hud.update(gameState)
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null
|
||||
if (gameState.animating && activePath.length > 1) {
|
||||
const elapsed = (performance.now() - animationStartTime) / 1000
|
||||
const total = activePath[activePath.length - 1][2]
|
||||
if (elapsed >= total) {
|
||||
gameState.animating = false
|
||||
if (pendingStones.length > 0) {
|
||||
gameState.stones = pendingStones
|
||||
pendingStones = []
|
||||
}
|
||||
updateControls()
|
||||
} else {
|
||||
let i = 0
|
||||
while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++
|
||||
const p0 = activePath[i]
|
||||
const p1 = activePath[i + 1] ?? p0
|
||||
const t0 = activePath[Math.max(i - 1, 0)]
|
||||
const t2 = activePath[Math.min(i + 2, activePath.length - 1)]
|
||||
const dt = p1[2] - p0[2]
|
||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||
const dx = t2[0] - t0[0]
|
||||
const dy = t2[1] - t0[1]
|
||||
const rotation = Math.atan2(dy, dx) * 2
|
||||
activeStonePos = { x, y, rotation, team: animationTeam }
|
||||
}
|
||||
}
|
||||
|
||||
renderer.draw({
|
||||
stones: gameState.stones,
|
||||
broom:
|
||||
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam
|
||||
? broom
|
||||
: null,
|
||||
animating: gameState.animating,
|
||||
activeStonePos,
|
||||
})
|
||||
requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
const callbacks: NetCallbacks = {
|
||||
onJoined: (_roomId, team) => {
|
||||
gameState.myTeam = team
|
||||
updateControls()
|
||||
},
|
||||
onWaiting: () => {
|
||||
gameState.phase = 'waiting'
|
||||
updateControls()
|
||||
},
|
||||
onGameState: (msg) => {
|
||||
const reset = msg.phase === 'waiting' || msg.end !== gameState.end
|
||||
if (reset) {
|
||||
gameState.stones = []
|
||||
pendingStones = []
|
||||
}
|
||||
|
||||
if (gameState.animating) {
|
||||
pendingStones = msg.stones
|
||||
} else {
|
||||
gameState.stones = msg.stones
|
||||
}
|
||||
|
||||
gameState = {
|
||||
...gameState,
|
||||
end: msg.end,
|
||||
scores: msg.scores,
|
||||
hammer: msg.hammer,
|
||||
turnTeam: msg.turn_team,
|
||||
phase: msg.phase,
|
||||
}
|
||||
updateControls()
|
||||
},
|
||||
onTrajectory: (path) => {
|
||||
activePath = trimPathToStartAtHogLine(path)
|
||||
gameState.animating = activePath.length > 1
|
||||
animationStartTime = performance.now()
|
||||
animationTeam = gameState.turnTeam
|
||||
pendingStones = []
|
||||
updateControls()
|
||||
},
|
||||
onEndScored: (end, points, scoringTeam) => {
|
||||
if (points > 0 && scoringTeam) {
|
||||
hud.showToast(`${scoringTeam.toUpperCase()} scores ${points} in end ${end}`)
|
||||
} else {
|
||||
hud.showToast(`End ${end} scored: blank end`)
|
||||
}
|
||||
},
|
||||
onGameOver: (scores, winner) => {
|
||||
const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!'
|
||||
hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`)
|
||||
},
|
||||
onError: (message) => {
|
||||
hud.showToast(message)
|
||||
},
|
||||
onClose: () => {
|
||||
// Disconnect => do nothing; keep UI as-is.
|
||||
},
|
||||
}
|
||||
|
||||
const getPos = (e: TouchEvent | MouseEvent) => {
|
||||
if ('touches' in e) {
|
||||
const t = e.touches[0] ?? e.changedTouches[0]
|
||||
return { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
return { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
const constrainBroom = (world: { x: number; y: number }) => {
|
||||
const dx = world.x - HOUSE_CENTER.x
|
||||
const dy = world.y - HOUSE_CENTER.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist > HOUSE_RADIUS) {
|
||||
const ratio = HOUSE_RADIUS / dist
|
||||
return {
|
||||
x: HOUSE_CENTER.x + dx * ratio,
|
||||
y: HOUSE_CENTER.y + dy * ratio,
|
||||
}
|
||||
}
|
||||
return world
|
||||
}
|
||||
|
||||
const handleStart = (e: Event) => {
|
||||
if (!isMyDragTurn()) return
|
||||
isDragging = true
|
||||
const pos = getPos(e as TouchEvent)
|
||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
}
|
||||
|
||||
const handleMove = (e: Event) => {
|
||||
if (!isDragging || !isMyDragTurn()) return
|
||||
e.preventDefault()
|
||||
const pos = getPos(e as TouchEvent)
|
||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
const isMyDragTurn = () => {
|
||||
return (
|
||||
gameState.myTeam !== null &&
|
||||
gameState.turnTeam === gameState.myTeam &&
|
||||
gameState.phase === 'playing' &&
|
||||
!gameState.animating
|
||||
)
|
||||
}
|
||||
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
||||
canvas.addEventListener('touchmove', handleMove, { passive: false })
|
||||
canvas.addEventListener('touchend', handleEnd)
|
||||
canvas.addEventListener('mousedown', handleStart)
|
||||
canvas.addEventListener('mousemove', handleMove)
|
||||
canvas.addEventListener('mouseup', handleEnd)
|
||||
canvas.addEventListener('mouseleave', handleEnd)
|
||||
|
||||
throwBtn.addEventListener('click', () => {
|
||||
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return
|
||||
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
||||
})
|
||||
|
||||
updateControls()
|
||||
render()
|
||||
}
|
||||
|
||||
function generateRoomCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let s = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
s += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return s
|
||||
}
|
||||
236
frontend/src/hud.ts
Normal file
236
frontend/src/hud.ts
Normal file
@ -0,0 +1,236 @@
|
||||
import { MAX_SPEED, MIN_SPEED, type Phase, type Team } from './protocol'
|
||||
import { velocityToWeight, weightToVelocity } from './game-helpers'
|
||||
|
||||
export interface Hud {
|
||||
root: HTMLDivElement
|
||||
velocityControl: HTMLDivElement
|
||||
curlSelector: HTMLDivElement
|
||||
frictionControl: HTMLDivElement
|
||||
throwButton: HTMLButtonElement
|
||||
update: (state: {
|
||||
phase: Phase
|
||||
end: number
|
||||
scores: number[]
|
||||
hammer: Team
|
||||
turnTeam: Team
|
||||
myTeam: Team | null
|
||||
animating: boolean
|
||||
}) => void
|
||||
showToast: (message: string) => void
|
||||
setShareLink: (link: string) => void
|
||||
}
|
||||
|
||||
function copyText(text: string): Promise<void> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
try {
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (ok) resolve()
|
||||
else reject(new Error('execCommand copy failed'))
|
||||
} catch (e) {
|
||||
document.body.removeChild(ta)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createHud(): Hud {
|
||||
const root = document.createElement('div')
|
||||
root.id = 'hud'
|
||||
root.innerHTML = `
|
||||
<div class="hud-row">
|
||||
<div id="score">Red 0 - Yellow 0</div>
|
||||
<div id="end-info">End 1 · Waiting</div>
|
||||
<div id="team">You: -</div>
|
||||
<div id="hammer">Hammer: -</div>
|
||||
</div>
|
||||
<div class="hud-row" style="align-items:flex-end;">
|
||||
<div id="velocity-control"></div>
|
||||
<div id="curl-selector"></div>
|
||||
<div id="friction-control">
|
||||
<label for="friction-slider">Friction</label>
|
||||
<input id="friction-slider" type="range" min="0.5" max="2.0" step="0.1" value="1.0" />
|
||||
<span id="friction-value">1.0</span>
|
||||
</div>
|
||||
<div>
|
||||
<button id="throw-btn" disabled>THROW</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="waiting">Waiting for other player</div>
|
||||
<div id="share"><button>Copy share link</button></div>
|
||||
`
|
||||
|
||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||
const teamEl = root.querySelector<HTMLDivElement>('#team')!
|
||||
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||
|
||||
return {
|
||||
root,
|
||||
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
||||
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
||||
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
||||
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
||||
update: (state) => {
|
||||
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
|
||||
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
|
||||
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
|
||||
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||
teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -'
|
||||
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
|
||||
const waiting =
|
||||
state.phase === 'waiting' ||
|
||||
(state.phase === 'playing' && state.myTeam !== null && state.turnTeam !== state.myTeam) ||
|
||||
state.animating
|
||||
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && waiting)
|
||||
},
|
||||
showToast: (message: string) => {
|
||||
const toast = document.createElement('div')
|
||||
toast.id = 'toast'
|
||||
toast.textContent = message
|
||||
document.body.appendChild(toast)
|
||||
toast.style.display = 'block'
|
||||
window.setTimeout(() => toast.remove(), 3000)
|
||||
},
|
||||
setShareLink: (link: string) => {
|
||||
const btn = root.querySelector<HTMLButtonElement>('#share button')!
|
||||
btn.onclick = () => {
|
||||
copyText(link)
|
||||
.then(() => (btn.textContent = 'Copied!'))
|
||||
.catch(() => (btn.textContent = 'Copy failed'))
|
||||
window.setTimeout(() => (btn.textContent = 'Copy share link'), 2500)
|
||||
}
|
||||
btn.textContent = 'Copy share link'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createVelocitySelector(
|
||||
container: HTMLDivElement,
|
||||
onSelect: () => void,
|
||||
): { getWeight: () => number; setEnabled: (enabled: boolean) => void } {
|
||||
const state = { weight: 5 }
|
||||
container.innerHTML = ''
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'velocity-control-inner'
|
||||
|
||||
const label = document.createElement('label')
|
||||
label.textContent = 'Velocity'
|
||||
wrap.appendChild(label)
|
||||
|
||||
const slider = document.createElement('input')
|
||||
slider.type = 'range'
|
||||
slider.min = String(MIN_SPEED)
|
||||
slider.max = String(MAX_SPEED)
|
||||
slider.step = '0.05'
|
||||
slider.value = String(weightToVelocity(state.weight))
|
||||
slider.className = 'velocity-slider'
|
||||
|
||||
const datalist = document.createElement('datalist')
|
||||
datalist.id = 'velocity-marks'
|
||||
for (let w = 1; w <= 10; w++) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = String(weightToVelocity(w))
|
||||
opt.label = String(w)
|
||||
datalist.appendChild(opt)
|
||||
}
|
||||
slider.setAttribute('list', 'velocity-marks')
|
||||
wrap.appendChild(slider)
|
||||
wrap.appendChild(datalist)
|
||||
|
||||
const readout = document.createElement('div')
|
||||
readout.className = 'velocity-readout'
|
||||
wrap.appendChild(readout)
|
||||
|
||||
const update = () => {
|
||||
const v = Number(slider.value)
|
||||
state.weight = velocityToWeight(v)
|
||||
readout.textContent = `${v.toFixed(2)} m/s · Weight ${state.weight}`
|
||||
onSelect()
|
||||
}
|
||||
slider.addEventListener('input', update)
|
||||
update()
|
||||
|
||||
container.appendChild(wrap)
|
||||
|
||||
return {
|
||||
getWeight: () => state.weight,
|
||||
setEnabled: (enabled) => {
|
||||
slider.disabled = !enabled
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCurlSelector(
|
||||
container: HTMLDivElement,
|
||||
onSelect: (curl: number) => void,
|
||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||
const state = { selected: 1, enabled: true }
|
||||
const options = [
|
||||
{ value: -1, label: '↶ Left' },
|
||||
{ value: 1, label: 'Right ↷' },
|
||||
]
|
||||
container.innerHTML = ''
|
||||
for (const opt of options) {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'curl-btn'
|
||||
btn.textContent = opt.label
|
||||
btn.dataset.curl = String(opt.value)
|
||||
btn.addEventListener('click', () => {
|
||||
if (!state.enabled) return
|
||||
state.selected = opt.value
|
||||
updateSelection()
|
||||
onSelect(opt.value)
|
||||
})
|
||||
container.appendChild(btn)
|
||||
}
|
||||
|
||||
const updateSelection = () => {
|
||||
container.querySelectorAll<HTMLButtonElement>('.curl-btn').forEach((btn) => {
|
||||
btn.classList.toggle('selected', Number(btn.dataset.curl) === state.selected)
|
||||
})
|
||||
}
|
||||
updateSelection()
|
||||
|
||||
return {
|
||||
getSelected: () => state.selected,
|
||||
setEnabled: (enabled) => {
|
||||
state.enabled = enabled
|
||||
container.querySelectorAll<HTMLButtonElement>('.curl-btn').forEach((btn) => {
|
||||
btn.disabled = !enabled
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createFrictionSlider(
|
||||
container: HTMLDivElement,
|
||||
): { getFriction: () => number; setEnabled: (enabled: boolean) => void } {
|
||||
const slider = container.querySelector<HTMLInputElement>('#friction-slider')!
|
||||
const valueEl = container.querySelector<HTMLSpanElement>('#friction-value')!
|
||||
|
||||
const updateValue = () => {
|
||||
valueEl.textContent = Number(slider.value).toFixed(1)
|
||||
}
|
||||
slider.addEventListener('input', updateValue)
|
||||
updateValue()
|
||||
|
||||
return {
|
||||
getFriction: () => Number(slider.value),
|
||||
setEnabled: (enabled) => {
|
||||
slider.disabled = !enabled
|
||||
},
|
||||
}
|
||||
}
|
||||
4
frontend/src/main.ts
Normal file
4
frontend/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import './style.css'
|
||||
import { startGame } from './game.js'
|
||||
|
||||
startGame()
|
||||
82
frontend/src/net.ts
Normal file
82
frontend/src/net.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ServerGameStateMessage,
|
||||
ServerMessageTyped as ServerMessage,
|
||||
Team,
|
||||
} from './protocol'
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL
|
||||
|
||||
function resolveWsUrl(): string {
|
||||
if (WS_URL) return WS_URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.hostname}:3000/ws`
|
||||
}
|
||||
|
||||
export interface NetCallbacks {
|
||||
onJoined: (room: string, team: Team) => void
|
||||
onWaiting: (message: string) => void
|
||||
onGameState: (msg: ServerGameStateMessage) => void
|
||||
onTrajectory: (path: [number, number, number][]) => void
|
||||
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
|
||||
onGameOver: (scores: number[], winner: Team | null) => void
|
||||
onError: (message: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
|
||||
export function connect(room: string, team: Team | null, callbacks: NetCallbacks): void {
|
||||
if (socket) return
|
||||
const teamParam = team ? `&team=${encodeURIComponent(team)}` : ''
|
||||
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}${teamParam}`
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => {
|
||||
socket = ws
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as ServerMessage
|
||||
if (!msg || typeof msg.type !== 'string') return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'joined':
|
||||
callbacks.onJoined(msg.room, msg.team)
|
||||
break
|
||||
case 'waiting':
|
||||
callbacks.onWaiting(msg.message)
|
||||
break
|
||||
case 'game_state':
|
||||
callbacks.onGameState(msg)
|
||||
break
|
||||
case 'trajectory':
|
||||
callbacks.onTrajectory(msg.path)
|
||||
break
|
||||
case 'end_scored':
|
||||
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
|
||||
break
|
||||
case 'game_over':
|
||||
callbacks.onGameOver(msg.scores, msg.winner)
|
||||
break
|
||||
case 'error':
|
||||
callbacks.onError(msg.message)
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse server message', err)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
socket = null
|
||||
callbacks.onClose()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
}
|
||||
|
||||
export function sendThrow(broomX: number, broomY: number, weight: number, curl: number, friction: number): void {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: broomY, weight, curl, friction }))
|
||||
}
|
||||
97
frontend/src/protocol.ts
Normal file
97
frontend/src/protocol.ts
Normal file
@ -0,0 +1,97 @@
|
||||
export const TICK_RATE_HZ = 120
|
||||
export const SAMPLE_RATE_HZ = 40
|
||||
export const STONES_PER_TEAM = 8
|
||||
export const ENDS = 10
|
||||
export const SHEET_WIDTH = 5.0
|
||||
export const SHEET_LENGTH = 45.0
|
||||
export const HOUSE_CENTER = { x: 0, y: 38.5 }
|
||||
export const HOUSE_RADIUS = 1.83
|
||||
export const BUTTON_RADIUS = 0.1524
|
||||
export const FOUR_FT_RADIUS = 0.6096
|
||||
export const EIGHT_FT_RADIUS = 1.2192
|
||||
export const TWELVE_FT_RADIUS = 1.8288
|
||||
export const HOG_LINE_Y = 21.0
|
||||
export const BACK_LINE_Y = 42.0
|
||||
export const HACK_Y = 2.0
|
||||
export const STONE_RADIUS = 0.15
|
||||
export const MIN_SPEED = 3.0
|
||||
export const MAX_SPEED = 6.45
|
||||
|
||||
export interface StoneState {
|
||||
id: number
|
||||
team: Team
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface ClientThrowMessage {
|
||||
type: 'throw'
|
||||
broom_x: number
|
||||
broom_y: number
|
||||
weight: number
|
||||
curl: number
|
||||
friction: number
|
||||
}
|
||||
|
||||
export interface ServerJoinedMessage {
|
||||
type: 'joined'
|
||||
room: string
|
||||
team: Team
|
||||
}
|
||||
|
||||
export interface ServerWaitingMessage {
|
||||
type: 'waiting'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ServerGameStateMessage {
|
||||
type: 'game_state'
|
||||
end: number
|
||||
scores: number[]
|
||||
hammer: Team
|
||||
turn_team: Team
|
||||
stones: StoneState[]
|
||||
phase: Phase
|
||||
}
|
||||
|
||||
export interface ServerTrajectoryMessage {
|
||||
type: 'trajectory'
|
||||
path: [number, number, number][]
|
||||
}
|
||||
|
||||
export interface ServerEndScoredMessage {
|
||||
type: 'end_scored'
|
||||
end: number
|
||||
points: number
|
||||
scoring_team?: Team
|
||||
}
|
||||
|
||||
export interface ServerGameOverMessage {
|
||||
type: 'game_over'
|
||||
scores: number[]
|
||||
winner: Team | null
|
||||
}
|
||||
|
||||
export interface ServerErrorMessage {
|
||||
type: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ServerMessageTyped =
|
||||
| ServerJoinedMessage
|
||||
| ServerWaitingMessage
|
||||
| ServerGameStateMessage
|
||||
| ServerTrajectoryMessage
|
||||
| ServerEndScoredMessage
|
||||
| ServerGameOverMessage
|
||||
| ServerErrorMessage
|
||||
|
||||
export type Team = 'red' | 'yellow'
|
||||
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
||||
export type ServerMessage = ServerMessageTyped
|
||||
|
||||
export function isTeam(value: unknown): value is Team {
|
||||
return value === 'red' || value === 'yellow'
|
||||
}
|
||||
174
frontend/src/renderer.ts
Normal file
174
frontend/src/renderer.ts
Normal file
@ -0,0 +1,174 @@
|
||||
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 StoneState,
|
||||
type Team,
|
||||
} 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 }
|
||||
draw: (state: {
|
||||
stones: StoneState[]
|
||||
broom: { x: number; y: number } | null
|
||||
animating: boolean
|
||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
||||
}) => 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 }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const scale = (): number => {
|
||||
const height = window.innerHeight
|
||||
return height / viewportHeight
|
||||
}
|
||||
|
||||
const worldToScreen = (x: number, y: number) => {
|
||||
const s = scale()
|
||||
const cx = window.innerWidth / 2
|
||||
const cy = window.innerHeight / 2
|
||||
return {
|
||||
x: cx + (x - viewportCenter.x) * s,
|
||||
y: cy + (y - viewportCenter.y) * s,
|
||||
}
|
||||
}
|
||||
|
||||
const screenToWorld = (sx: number, sy: number) => {
|
||||
const s = scale()
|
||||
const cx = window.innerWidth / 2
|
||||
const cy = window.innerHeight / 2
|
||||
return {
|
||||
x: (sx - cx) / s + viewportCenter.x,
|
||||
y: viewportCenter.y + (sy - cy) / s,
|
||||
}
|
||||
}
|
||||
|
||||
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: StoneState | { x: number; y: number; rotation: number; team: Team }) => {
|
||||
const c = worldToScreen(stone.x, stone.y)
|
||||
const r = STONE_RADIUS * scale()
|
||||
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
|
||||
ctx.beginPath()
|
||||
ctx.arc(c.x, c.y, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = Math.max(1, scale() * 0.02)
|
||||
ctx.stroke()
|
||||
ctx.save()
|
||||
ctx.translate(c.x, c.y)
|
||||
ctx.rotate(-stone.rotation)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(r * 0.8, 0)
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
|
||||
ctx.lineWidth = Math.max(1, scale() * 0.03)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const draw = (state: {
|
||||
stones: StoneState[]
|
||||
broom: { x: number; y: number } | null
|
||||
animating: boolean
|
||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
||||
}) => {
|
||||
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')
|
||||
|
||||
for (const stone of state.stones) {
|
||||
drawStone(stone)
|
||||
}
|
||||
|
||||
if (state.activeStonePos) {
|
||||
drawStone(state.activeStonePos)
|
||||
}
|
||||
|
||||
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, draw }
|
||||
}
|
||||
285
frontend/src/style.css
Normal file
285
frontend/src/style.css
Normal file
@ -0,0 +1,285 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
background: #0b1f3a;
|
||||
color: #ffffff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#hud {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#hud > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hud-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#waiting {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 8px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#waiting.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 50, 50, 0.9);
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#share {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#share button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#team {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#velocity-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.velocity-control-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#velocity-control label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.velocity-slider {
|
||||
width: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.velocity-readout {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 130px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#curl-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.curl-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 64px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.curl-btn.selected {
|
||||
background: #00aaff;
|
||||
color: #000;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.curl-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#friction-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
#friction-control label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#friction-slider {
|
||||
width: 100px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#friction-value {
|
||||
font-size: 12px;
|
||||
min-width: 24px;
|
||||
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;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #00aa66;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#throw-btn:disabled {
|
||||
background: #557766;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "esnext",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user