Compare commits
16 Commits
0ef0dc3d78
...
473e18a751
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473e18a751 | ||
|
|
1239394578 | ||
|
|
1e720c22cb | ||
|
|
2eb5760922 | ||
|
|
20c592d8ac | ||
|
|
2e11f005dc | ||
|
|
dc62c4f55f | ||
|
|
b06a78227f | ||
|
|
c37026b0a0 | ||
|
|
56a7d5abba | ||
|
|
c28bf32eb8 | ||
|
|
722e6090d1 | ||
|
|
a4e848c849 | ||
|
|
0e08e21c9b | ||
|
|
5eeaf8929d | ||
|
|
2f68325801 |
26
README.md
26
README.md
@ -1,6 +1,6 @@
|
|||||||
## curltastic
|
## curltastic
|
||||||
|
|
||||||
A 1v1 async multiplayer 2D curling game for mobile browser.
|
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick Red or Yellow freely, and throw when it is that team's turn (solo pass-and-play or remote opponents).
|
||||||
|
|
||||||
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
||||||
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
||||||
@ -21,22 +21,24 @@ A 1v1 async multiplayer 2D curling game for mobile browser.
|
|||||||
npm run dev
|
npm run dev
|
||||||
# opens on 0.0.0.0:5173 by default
|
# opens on 0.0.0.0:5173 by default
|
||||||
```
|
```
|
||||||
4. Open two browser tabs to the same room URL, e.g.:
|
4. Open one or more browser tabs to the same room URL, e.g.:
|
||||||
```
|
```
|
||||||
http://localhost:5173/?room=DEMO1
|
http://localhost:5173/?room=DEMO1
|
||||||
```
|
```
|
||||||
The first tab waits; the second starts the match. Use the *Copy share link* button to invite an opponent.
|
Use the team dropdown in the top-right to switch between Red and Yellow — this enables local pass-and-play on one device. Use the *Copy share link* button to invite an opponent on another device.
|
||||||
|
|
||||||
### Mobile devices
|
### Mobile devices
|
||||||
|
|
||||||
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<ip>:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`.
|
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<ip>:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`. The default view is zoomed in on the house; drag the sheet vertically to scroll up to the hog line.
|
||||||
|
|
||||||
### Controls
|
### Controls
|
||||||
|
|
||||||
- Drag inside the house to place the broom (aim point).
|
- When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house.
|
||||||
- Tap a weight `1`–`10`.
|
- When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line).
|
||||||
- Tap **THROW**.
|
- Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play).
|
||||||
- The server runs the physics and streams the trajectory; the frontend interpolates the animation.
|
- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar.
|
||||||
|
- Tap **THROW**. Anyone identifying as the turn team may throw.
|
||||||
|
- The server runs physics for every stone and streams multi-stone paths; the client animates them on one clock so collisions move together.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@ -52,8 +54,12 @@ With the backend and frontend dev server running:
|
|||||||
```bash
|
```bash
|
||||||
cd e2e
|
cd e2e
|
||||||
npm install -g ws # or npm install ws locally in the project
|
npm install -g ws # or npm install ws locally in the project
|
||||||
node e2e_test.cjs # room lifecycle, throw, trajectory, out-of-play removal
|
node e2e_test.cjs # room lifecycle, throw, trajectory
|
||||||
node e2e_score.cjs # stones remain in play and alternate turns
|
node e2e_score.cjs # alternate turns / stones in play
|
||||||
|
node e2e_persistence.cjs # multi-throw stone persistence
|
||||||
|
node e2e_multi_client.cjs # 3 clients share state (no room-full)
|
||||||
|
node e2e_end_score.cjs # full end → end_scored + next end
|
||||||
|
node collision_trajectory_qa.cjs # multi-stone trajectory on collision
|
||||||
```
|
```
|
||||||
|
|
||||||
### Limitations / known simplifications
|
### Limitations / known simplifications
|
||||||
|
|||||||
@ -12,8 +12,6 @@ pub enum GamePhase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Game {
|
pub struct Game {
|
||||||
red: Option<Player>,
|
|
||||||
yellow: Option<Player>,
|
|
||||||
phase: GamePhase,
|
phase: GamePhase,
|
||||||
end: u8,
|
end: u8,
|
||||||
scores: [i32; 2],
|
scores: [i32; 2],
|
||||||
@ -27,7 +25,7 @@ pub struct Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ThrowOutcome {
|
pub struct ThrowOutcome {
|
||||||
pub trajectory: Vec<(f32, f32, f32)>,
|
pub trajectory: Vec<StoneTrajectory>,
|
||||||
pub end_scored: Option<ServerMessage>,
|
pub end_scored: Option<ServerMessage>,
|
||||||
pub state_message: ServerMessage,
|
pub state_message: ServerMessage,
|
||||||
pub game_over: Option<ServerMessage>,
|
pub game_over: Option<ServerMessage>,
|
||||||
@ -36,8 +34,6 @@ pub struct ThrowOutcome {
|
|||||||
impl Game {
|
impl Game {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
red: None,
|
|
||||||
yellow: None,
|
|
||||||
phase: GamePhase::Waiting,
|
phase: GamePhase::Waiting,
|
||||||
end: 1,
|
end: 1,
|
||||||
scores: [0, 0],
|
scores: [0, 0],
|
||||||
@ -51,57 +47,7 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
|
|
||||||
let team = preferred
|
|
||||||
.filter(|t| self.slot_for(t).is_none())
|
|
||||||
.or_else(|| self.first_open_team())?;
|
|
||||||
|
|
||||||
let player = Player { id, team, connected: true };
|
|
||||||
*self.slot_for(&team) = Some(player);
|
|
||||||
Some(team)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slot_for(&mut self, team: &Team) -> &mut Option<Player> {
|
|
||||||
match team {
|
|
||||||
Team::Red => &mut self.red,
|
|
||||||
Team::Yellow => &mut self.yellow,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_open_team(&self) -> Option<Team> {
|
|
||||||
if self.red.is_none() {
|
|
||||||
Some(Team::Red)
|
|
||||||
} else if self.yellow.is_none() {
|
|
||||||
Some(Team::Yellow)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_player(&mut self, id: &str) -> Option<Team> {
|
|
||||||
if let Some(ref p) = self.red {
|
|
||||||
if p.id == id {
|
|
||||||
self.red = None;
|
|
||||||
return Some(Team::Red);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref p) = self.yellow {
|
|
||||||
if p.id == id {
|
|
||||||
self.yellow = None;
|
|
||||||
return Some(Team::Yellow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn can_start(&self) -> bool {
|
|
||||||
self.red.is_some() && self.yellow.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start(&mut self) {
|
pub fn start(&mut self) {
|
||||||
if !self.can_start() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
||||||
self.turn_team = self.hammer.other();
|
self.turn_team = self.hammer.other();
|
||||||
self.phase = GamePhase::Playing;
|
self.phase = GamePhase::Playing;
|
||||||
@ -114,31 +60,24 @@ impl Game {
|
|||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_player_id(&self) -> Option<&str> {
|
pub fn handle_throw(
|
||||||
let p = match self.turn_team {
|
&mut self,
|
||||||
Team::Red => self.red.as_ref()?,
|
team: Team,
|
||||||
Team::Yellow => self.yellow.as_ref()?,
|
broom_x: f32,
|
||||||
};
|
broom_y: f32,
|
||||||
Some(&p.id)
|
weight: u8,
|
||||||
}
|
curl: i8,
|
||||||
|
friction: f32,
|
||||||
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let current_id = self.current_player_id().ok_or("No current player")?;
|
if self.turn_team != team {
|
||||||
if current_id != player_id {
|
|
||||||
return Err("Not your turn".to_string());
|
return Err("Not your turn".to_string());
|
||||||
}
|
}
|
||||||
if self.phase != GamePhase::Playing {
|
if self.phase != GamePhase::Playing {
|
||||||
return Err("Cannot throw now".to_string());
|
return Err("Cannot throw now".to_string());
|
||||||
}
|
}
|
||||||
let dx = broom_x - HOUSE_CENTER.0;
|
|
||||||
let dy = broom_y - HOUSE_CENTER.1;
|
|
||||||
let dist = (dx * dx + dy * dy).sqrt();
|
|
||||||
if dist > HOUSE_RADIUS {
|
|
||||||
return Err("Broom must be in the house".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
||||||
self.active_stones = self.physics.current_stones();
|
self.active_stones = self.physics.current_stones();
|
||||||
self.phase = GamePhase::Simulating;
|
self.phase = GamePhase::Simulating;
|
||||||
|
|
||||||
@ -147,19 +86,19 @@ impl Game {
|
|||||||
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(path)
|
Ok(trajectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_throw(
|
pub fn process_throw(
|
||||||
&mut self,
|
&mut self,
|
||||||
player_id: &str,
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
weight: u8,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction: f32,
|
||||||
) -> Result<ThrowOutcome, String> {
|
) -> Result<ThrowOutcome, String> {
|
||||||
let trajectory = self.handle_throw(player_id, broom_x, broom_y, weight, curl, friction)?;
|
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
|
||||||
self.finish_simulation();
|
self.finish_simulation();
|
||||||
|
|
||||||
let end_scored = self.take_last_end_scored();
|
let end_scored = self.take_last_end_scored();
|
||||||
@ -223,7 +162,7 @@ impl Game {
|
|||||||
Team::Red => 0,
|
Team::Red => 0,
|
||||||
Team::Yellow => 1,
|
Team::Yellow => 1,
|
||||||
};
|
};
|
||||||
self.scores[team_idx] += points as i32;
|
self.scores[team_idx] += points;
|
||||||
self.hammer = team.other();
|
self.hammer = team.other();
|
||||||
} else {
|
} else {
|
||||||
points = 0;
|
points = 0;
|
||||||
@ -315,29 +254,46 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefers_red_when_requested_and_free() {
|
fn starts_in_waiting_phase() {
|
||||||
let mut game = Game::new();
|
let game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
assert!(matches!(game.phase, GamePhase::Waiting));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefers_yellow_when_requested_and_free() {
|
fn starts_when_called() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow));
|
game.start();
|
||||||
|
assert!(matches!(game.phase, GamePhase::Playing));
|
||||||
|
assert_eq!(game.end, 1);
|
||||||
|
assert_eq!(game.scores, [0, 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_when_preferred_taken() {
|
fn rejects_throw_for_wrong_team() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
game.start();
|
||||||
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow));
|
let turn = game.turn_team;
|
||||||
|
let wrong = turn.other();
|
||||||
|
let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0);
|
||||||
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_order_without_preference() {
|
fn accepts_throw_for_turn_team() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red));
|
game.start();
|
||||||
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow));
|
let turn = game.turn_team;
|
||||||
assert_eq!(game.add_player("p3".into(), None), None);
|
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_broom_outside_house() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
game.start();
|
||||||
|
let turn = game.turn_team;
|
||||||
|
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
|
||||||
|
let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0);
|
||||||
|
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,6 @@ impl AppState {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RoomQuery {
|
struct RoomQuery {
|
||||||
room: String,
|
room: String,
|
||||||
team: Option<Team>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@ -95,74 +94,43 @@ async fn ws_handler(
|
|||||||
Query(query): Query<RoomQuery>,
|
Query(query): Query<RoomQuery>,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team))
|
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_socket(
|
async fn handle_socket(
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
room_id: String,
|
room_id: String,
|
||||||
preferred_team: Option<Team>,
|
|
||||||
) {
|
) {
|
||||||
let room = state.get_or_create_room(&room_id).await;
|
let room = state.get_or_create_room(&room_id).await;
|
||||||
let (mut sender, receiver) = socket.split();
|
let (mut sender, receiver) = socket.split();
|
||||||
|
|
||||||
if try_join_room(&room).await.is_err() {
|
{
|
||||||
let err = serde_json::to_string(&ServerMessage::Error {
|
let mut room_guard = room.lock().await;
|
||||||
message: "Room is full".to_string(),
|
if matches!(room_guard.game.game_state_message(), ServerMessage::GameState { phase: Phase::Waiting, .. }) {
|
||||||
})
|
room_guard.game.start();
|
||||||
.unwrap();
|
}
|
||||||
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let player_id = Uuid::new_v4().to_string();
|
|
||||||
let team = register_player(&room, &player_id, preferred_team).await;
|
|
||||||
|
|
||||||
let joined = serde_json::to_string(&ServerMessage::Joined {
|
let joined = serde_json::to_string(&ServerMessage::Joined {
|
||||||
room: room_id.clone(),
|
room: room_id.clone(),
|
||||||
team,
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).await;
|
if sender.send(Message::Text(Utf8Bytes::from(joined))).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let tx = { room.lock().await.tx.clone() };
|
let tx = { room.lock().await.tx.clone() };
|
||||||
|
|
||||||
let send_task = spawn_forwarder(sender, tx.subscribe());
|
let send_task = spawn_forwarder(sender, tx.subscribe());
|
||||||
broadcast_room_state(&room, &tx).await;
|
broadcast_room_state(&room, &tx).await;
|
||||||
|
|
||||||
let recv_task = spawn_message_handler(room.clone(), player_id.clone(), tx, receiver);
|
let recv_task = spawn_message_handler(room.clone(), tx, receiver);
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = send_task => {}
|
_ = send_task => {}
|
||||||
_ = recv_task => {}
|
_ = recv_task => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_player(&room, &player_id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_join_room(room: &Arc<Mutex<Room>>) -> Result<(), ()> {
|
|
||||||
let room_guard = room.lock().await;
|
|
||||||
if room_guard.game.can_start() {
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn register_player(
|
|
||||||
room: &Arc<Mutex<Room>>,
|
|
||||||
player_id: &str,
|
|
||||||
preferred_team: Option<Team>,
|
|
||||||
) -> Team {
|
|
||||||
let mut room_guard = room.lock().await;
|
|
||||||
let team = room_guard
|
|
||||||
.game
|
|
||||||
.add_player(player_id.to_string(), preferred_team)
|
|
||||||
.unwrap_or(Team::Red);
|
|
||||||
if room_guard.game.can_start() {
|
|
||||||
room_guard.game.start();
|
|
||||||
}
|
|
||||||
team
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_forwarder(
|
fn spawn_forwarder(
|
||||||
@ -192,33 +160,25 @@ async fn broadcast_room_state(
|
|||||||
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
|
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
|
||||||
) {
|
) {
|
||||||
let room_guard = room.lock().await;
|
let room_guard = room.lock().await;
|
||||||
let msg = if room_guard.game.can_start() {
|
let _ = tx.send(room_guard.game.game_state_message());
|
||||||
room_guard.game.game_state_message()
|
|
||||||
} else {
|
|
||||||
ServerMessage::Waiting {
|
|
||||||
message: "Waiting for other player".to_string(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = tx.send(msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_message_handler(
|
fn spawn_message_handler(
|
||||||
room: Arc<Mutex<Room>>,
|
room: Arc<Mutex<Room>>,
|
||||||
player_id: String,
|
|
||||||
tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||||
mut receiver: WsReceiver,
|
mut receiver: WsReceiver,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = receiver.next().await {
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
let Message::Text(text) = msg else { continue; };
|
let Message::Text(text) = msg else { continue };
|
||||||
let text_ref = text.as_str();
|
let text_ref = text.as_str();
|
||||||
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
||||||
match parsed {
|
match parsed {
|
||||||
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => {
|
Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
|
||||||
let mut room_guard = room.lock().await;
|
let mut room_guard = room.lock().await;
|
||||||
match room_guard
|
match room_guard
|
||||||
.game
|
.game
|
||||||
.process_throw(&player_id, broom_x, broom_y, weight, curl, friction)
|
.process_throw(team, broom_x, broom_y, weight, curl, friction)
|
||||||
{
|
{
|
||||||
Ok(ThrowOutcome {
|
Ok(ThrowOutcome {
|
||||||
trajectory,
|
trajectory,
|
||||||
@ -226,7 +186,7 @@ fn spawn_message_handler(
|
|||||||
state_message,
|
state_message,
|
||||||
game_over,
|
game_over,
|
||||||
}) => {
|
}) => {
|
||||||
let _ = tx.send(ServerMessage::Trajectory { path: trajectory });
|
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
|
||||||
if let Some(scored) = end_scored {
|
if let Some(scored) = end_scored {
|
||||||
let _ = tx.send(scored);
|
let _ = tx.send(scored);
|
||||||
}
|
}
|
||||||
@ -247,8 +207,3 @@ fn spawn_message_handler(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_player(room: &Arc<Mutex<Room>>, player_id: &str) {
|
|
||||||
let mut room_guard = room.lock().await;
|
|
||||||
room_guard.game.remove_player(player_id);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -107,7 +107,7 @@ impl PhysicsWorld {
|
|||||||
weight: u8,
|
weight: u8,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction: f32,
|
||||||
) -> Result<Vec<(f32, f32, f32)>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let weight = weight.clamp(1, 10) as f32;
|
let weight = weight.clamp(1, 10) as f32;
|
||||||
let t = (weight - 1.0) / 9.0;
|
let t = (weight - 1.0) / 9.0;
|
||||||
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
||||||
@ -132,7 +132,7 @@ impl PhysicsWorld {
|
|||||||
vy: f32,
|
vy: f32,
|
||||||
curl_sign: i8,
|
curl_sign: i8,
|
||||||
damping_mult: f32,
|
damping_mult: f32,
|
||||||
) -> Result<Vec<(f32, f32, f32)>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let id = self.next_stone_id;
|
let id = self.next_stone_id;
|
||||||
self.next_stone_id += 1;
|
self.next_stone_id += 1;
|
||||||
|
|
||||||
@ -161,15 +161,31 @@ impl PhysicsWorld {
|
|||||||
self.simulate_until_rest(id)
|
self.simulate_until_rest(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> {
|
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let mut path: Vec<(f32, f32, f32)> = Vec::new();
|
// All paths share the thrown stone's release instant as t=0. This keeps the
|
||||||
|
// frontend's existing trajectory helpers (which expect the thrown stone to
|
||||||
|
// start at x=0, y=HACK_Y with t=0) working unchanged while also giving every
|
||||||
|
// other stone a consistent timeline.
|
||||||
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
||||||
let mut sample_accum: f32 = 0.0;
|
let mut sample_accum: f32 = 0.0;
|
||||||
let mut time: f32 = 0.0;
|
let mut time: f32 = 0.0;
|
||||||
|
|
||||||
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
// Pre-allocate a path buffer for every stone currently in the world.
|
||||||
let body = &self.bodies[*h];
|
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
|
||||||
path.push((body.translation().x, body.translation().y, time));
|
.stone_handles
|
||||||
|
.iter()
|
||||||
|
.map(|(id, handle, _, _)| (*id, *handle, Vec::new()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Record the initial sample at t=0 for every stone.
|
||||||
|
for (id, handle, path) in &mut paths {
|
||||||
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
|
let pos = body.translation();
|
||||||
|
path.push((pos.x, pos.y, time));
|
||||||
|
} else {
|
||||||
|
// Body missing for an tracked stone; this should not happen.
|
||||||
|
return Err(format!("stone {} has no rigid body", id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@ -180,9 +196,11 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
if sample_accum >= sample_step {
|
if sample_accum >= sample_step {
|
||||||
sample_accum -= sample_step;
|
sample_accum -= sample_step;
|
||||||
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
for (_, handle, path) in &mut paths {
|
||||||
let body = &self.bodies[*h];
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
path.push((body.translation().x, body.translation().y, time));
|
let pos = body.translation();
|
||||||
|
path.push((pos.x, pos.y, time));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +211,27 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
self.prune_out_of_play();
|
self.prune_out_of_play();
|
||||||
|
|
||||||
Ok(path)
|
// The thrown stone is released at (0.0, HACK_Y). Shift every path in time so
|
||||||
|
// that t=0 corresponds to that release instant. Because we already started
|
||||||
|
// sampling at the release instant, the first sample time is 0.0 and no shift
|
||||||
|
// is required; this comment documents the invariant.
|
||||||
|
let thrown_first_t = paths
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _, _)| *id == thrown_id)
|
||||||
|
.and_then(|(_, _, path)| path.first().map(|(_, _, t)| *t))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
Ok(paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _, mut path)| {
|
||||||
|
if thrown_first_t != 0.0 {
|
||||||
|
for (_, _, t) in &mut path {
|
||||||
|
*t -= thrown_first_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoneTrajectory { stone_id: id, path }
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate each stone's velocity slightly based on its selected curl direction.
|
// Rotate each stone's velocity slightly based on its selected curl direction.
|
||||||
@ -377,4 +415,70 @@ mod tests {
|
|||||||
let stones = world.current_stones();
|
let stones = world.current_stones();
|
||||||
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collision_records_trajectories_for_both_stones() {
|
||||||
|
// Place a stationary stone on the center line and throw a second stone
|
||||||
|
// straight at it so they collide. Both stones must have sampled paths.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
|
||||||
|
// First stone: place it far enough up-sheet to stay in play after impact.
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let first_id = world.next_stone_id - 1;
|
||||||
|
|
||||||
|
// Second stone: aimed directly at the first stone's final position.
|
||||||
|
let target_y = final_y(&world, first_id);
|
||||||
|
let target_x = final_x(&world, first_id);
|
||||||
|
world
|
||||||
|
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let second_id = world.next_stone_id - 1;
|
||||||
|
|
||||||
|
// Re-run the collision throw and capture trajectories.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let first_id = world.next_stone_id - 1;
|
||||||
|
let target_y = final_y(&world, first_id);
|
||||||
|
let target_x = final_x(&world, first_id);
|
||||||
|
let trajectories = world
|
||||||
|
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories
|
||||||
|
.into_iter()
|
||||||
|
.map(|st| (st.stone_id, st.path))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
by_id.contains_key(&first_id),
|
||||||
|
"trajectories should contain the first stone (id={})",
|
||||||
|
first_id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
by_id.contains_key(&second_id),
|
||||||
|
"trajectories should contain the thrown stone (id={})",
|
||||||
|
second_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let first_path = by_id.get(&first_id).unwrap();
|
||||||
|
let second_path = by_id.get(&second_id).unwrap();
|
||||||
|
assert!(
|
||||||
|
first_path.len() > 1,
|
||||||
|
"first stone path should have multiple samples, got {}",
|
||||||
|
first_path.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
second_path.len() > 1,
|
||||||
|
"thrown stone path should have multiple samples, got {}",
|
||||||
|
second_path.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both paths should share the same t=0 reference (the thrown stone's release).
|
||||||
|
assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0");
|
||||||
|
assert_eq!(second_path[0].2, 0.0, "thrown stone path should start at t=0");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use std::fmt;
|
|||||||
pub const TICK_RATE_HZ: u16 = 120;
|
pub const TICK_RATE_HZ: u16 = 120;
|
||||||
pub const SAMPLE_RATE_HZ: u16 = 40;
|
pub const SAMPLE_RATE_HZ: u16 = 40;
|
||||||
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
|
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
|
||||||
|
pub const FEET_TO_METERS: f32 = 0.3048;
|
||||||
|
|
||||||
pub const STONES_PER_TEAM: u8 = 8;
|
pub const STONES_PER_TEAM: u8 = 8;
|
||||||
pub const ENDS: u8 = 10;
|
pub const ENDS: u8 = 10;
|
||||||
@ -12,7 +13,7 @@ pub const ENDS: u8 = 10;
|
|||||||
pub const SHEET_WIDTH: f32 = 5.0;
|
pub const SHEET_WIDTH: f32 = 5.0;
|
||||||
pub const SHEET_LENGTH: f32 = 45.0;
|
pub const SHEET_LENGTH: f32 = 45.0;
|
||||||
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
||||||
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft
|
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
||||||
pub const HOG_LINE_Y: f32 = 21.0;
|
pub const HOG_LINE_Y: f32 = 21.0;
|
||||||
pub const BACK_LINE_Y: f32 = 42.0;
|
pub const BACK_LINE_Y: f32 = 42.0;
|
||||||
pub const HACK_Y: f32 = 2.0;
|
pub const HACK_Y: f32 = 2.0;
|
||||||
@ -55,6 +56,7 @@ impl fmt::Display for Team {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
Throw {
|
Throw {
|
||||||
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
weight: u8,
|
||||||
@ -71,7 +73,7 @@ fn default_friction() -> f32 { 1.0 }
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ServerMessage {
|
pub enum ServerMessage {
|
||||||
Joined { room: String, team: Team },
|
Joined { room: String },
|
||||||
Waiting { message: String },
|
Waiting { message: String },
|
||||||
GameState {
|
GameState {
|
||||||
end: u8,
|
end: u8,
|
||||||
@ -82,7 +84,7 @@ pub enum ServerMessage {
|
|||||||
phase: Phase,
|
phase: Phase,
|
||||||
},
|
},
|
||||||
Trajectory {
|
Trajectory {
|
||||||
path: Vec<(f32, f32, f32)>,
|
paths: Vec<StoneTrajectory>,
|
||||||
},
|
},
|
||||||
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
||||||
GameOver {
|
GameOver {
|
||||||
@ -104,6 +106,12 @@ pub enum Phase {
|
|||||||
GameComplete,
|
GameComplete,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoneTrajectory {
|
||||||
|
pub stone_id: u32,
|
||||||
|
pub path: Vec<(f32, f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoneState {
|
pub struct StoneState {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@ -113,10 +121,3 @@ pub struct StoneState {
|
|||||||
pub rotation: f32,
|
pub rotation: f32,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Player {
|
|
||||||
pub id: String,
|
|
||||||
pub team: Team,
|
|
||||||
pub connected: bool,
|
|
||||||
}
|
|
||||||
|
|||||||
72
e2e/collision_trajectory_qa.cjs
Normal file
72
e2e/collision_trajectory_qa.cjs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const WebSocket = require('ws')
|
||||||
|
const room = 'COLQA' + Math.floor(Math.random() * 1000)
|
||||||
|
const url = 'ws://127.0.0.1:3000/ws?room=' + room
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
const messages = []
|
||||||
|
ws.on('open', () => resolve({ ws, messages }))
|
||||||
|
ws.on('message', (data) => messages.push(JSON.parse(data.toString())))
|
||||||
|
ws.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitFor(messages, pred, timeout = 10000) {
|
||||||
|
const start = Date.now()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const check = () => {
|
||||||
|
if (pred()) return resolve(undefined)
|
||||||
|
if (Date.now() - start > timeout) return reject(new Error('timeout'))
|
||||||
|
setTimeout(check, 50)
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const c = await connect()
|
||||||
|
await waitFor(c.messages, () => {
|
||||||
|
const last = c.messages[c.messages.length - 1]
|
||||||
|
return last && last.type === 'game_state' && last.phase === 'playing'
|
||||||
|
})
|
||||||
|
|
||||||
|
// First throw: weight 7 so it stays in house.
|
||||||
|
let state = c.messages[c.messages.length - 1]
|
||||||
|
c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.0, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 }))
|
||||||
|
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1)
|
||||||
|
state = c.messages[c.messages.length - 1]
|
||||||
|
console.log('After first throw stones:', state.stones.map(s => ({ id: s.id, x: s.x, y: s.y })))
|
||||||
|
if (state.stones.length !== 1) throw new Error('expected first stone in play')
|
||||||
|
|
||||||
|
const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length
|
||||||
|
console.log('trajectory count before second throw:', trajCountBefore)
|
||||||
|
|
||||||
|
// Second throw aimed slightly off-center so it hits the first stone.
|
||||||
|
c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.25, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 }))
|
||||||
|
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore)
|
||||||
|
|
||||||
|
const traj = c.messages.filter(m => m.type === 'trajectory').pop()
|
||||||
|
console.log('Trajectory paths count:', traj.paths.length)
|
||||||
|
for (const p of traj.paths) {
|
||||||
|
console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b)
|
||||||
|
if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids))
|
||||||
|
for (const p of traj.paths) {
|
||||||
|
if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the first stone actually moved because of collision.
|
||||||
|
const stone1Path = traj.paths.find(p => p.stone_id === 1).path
|
||||||
|
const first = stone1Path[0]
|
||||||
|
const last = stone1Path[stone1Path.length - 1]
|
||||||
|
const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2)
|
||||||
|
console.log('stone1 moved', dist, 'm')
|
||||||
|
if (dist < 0.05) throw new Error('expected first stone to move after collision')
|
||||||
|
|
||||||
|
console.log('COLLISION TRAJECTORY QA PASSED')
|
||||||
|
c.ws.close()
|
||||||
|
process.exit(0)
|
||||||
|
})().catch(e => { console.error(e); process.exit(1) })
|
||||||
@ -9,7 +9,7 @@ function connect(name, room) {
|
|||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
const msg = JSON.parse(data.toString())
|
const msg = JSON.parse(data.toString())
|
||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team })
|
if (msg.type === 'joined') resolve({ ws, messages })
|
||||||
})
|
})
|
||||||
ws.on('error', reject)
|
ws.on('error', reject)
|
||||||
})
|
})
|
||||||
@ -47,9 +47,8 @@ function waitFor(messages, pred, timeout = 30000) {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
const turn = getTurn()
|
const turn = getTurn()
|
||||||
if (!turn) throw new Error('no turn')
|
if (!turn) throw new Error('no turn')
|
||||||
const thrower = turn === p1.team ? p1 : p2
|
|
||||||
const broomX = (Math.random() - 0.5) * 0.6
|
const broomX = (Math.random() - 0.5) * 0.6
|
||||||
thrower.ws.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
||||||
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
|
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
|
||||||
await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000)
|
await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000)
|
||||||
}
|
}
|
||||||
|
|||||||
75
e2e/e2e_multi_client.cjs
Normal file
75
e2e/e2e_multi_client.cjs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const room = 'MULTI' + Math.floor(Math.random() * 1000)
|
||||||
|
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
||||||
|
|
||||||
|
function connect(name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(base)
|
||||||
|
const messages = []
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const msg = JSON.parse(data.toString())
|
||||||
|
messages.push(msg)
|
||||||
|
console.log(`[${name}]`, msg.type)
|
||||||
|
if (msg.type === 'joined') {
|
||||||
|
resolve({ ws, messages })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ws.on('open', () => console.log(`[${name}] open`))
|
||||||
|
ws.on('error', (e) => { console.error(`[${name}] error`, e.message); reject(e) })
|
||||||
|
ws.on('close', (code) => console.log(`[${name}] close`, code))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitFor(client, pred, timeout = 10000) {
|
||||||
|
const start = Date.now()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const check = () => {
|
||||||
|
if (pred(client.messages)) return resolve(undefined)
|
||||||
|
if (Date.now() - start > timeout) return reject(new Error('timeout'))
|
||||||
|
setTimeout(check, 50)
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// 3 clients join the same room
|
||||||
|
const p1 = await connect('p1')
|
||||||
|
const p2 = await connect('p2')
|
||||||
|
const p3 = await connect('p3')
|
||||||
|
|
||||||
|
// All 3 receive game_state after join
|
||||||
|
await waitFor(p1, msgs => msgs.some(m => m.type === 'game_state'), 5000)
|
||||||
|
await waitFor(p2, msgs => msgs.some(m => m.type === 'game_state'), 5000)
|
||||||
|
await waitFor(p3, msgs => msgs.some(m => m.type === 'game_state'), 5000)
|
||||||
|
console.log('All 3 clients received game_state')
|
||||||
|
|
||||||
|
// p1 throws for the current turn_team
|
||||||
|
const state = p1.messages.find(m => m.type === 'game_state')
|
||||||
|
const turn = state.turn_team
|
||||||
|
console.log(`p1 throwing for team ${turn}`)
|
||||||
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
||||||
|
|
||||||
|
// All 3 clients eventually see trajectory or updated game_state
|
||||||
|
await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
|
||||||
|
await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
|
||||||
|
await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
|
||||||
|
console.log('All 3 clients received trajectory')
|
||||||
|
|
||||||
|
// All 3 see an updated game_state after the throw
|
||||||
|
const lastIdx = p1.messages.length - 1
|
||||||
|
await waitFor(p1, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
|
await waitFor(p2, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
|
await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
|
console.log('All 3 clients received updated game_state after throw')
|
||||||
|
|
||||||
|
console.log('Multi-client observer test passed')
|
||||||
|
p1.ws.close()
|
||||||
|
p2.ws.close()
|
||||||
|
p3.ws.close()
|
||||||
|
process.exit(0)
|
||||||
|
})().catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@ -12,7 +12,7 @@ function connect(name) {
|
|||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '')
|
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '')
|
||||||
if (msg.type === 'joined') {
|
if (msg.type === 'joined') {
|
||||||
resolve({ ws, messages, team: msg.team })
|
resolve({ ws, messages })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ws.on('open', () => console.log(`[${name}] open`))
|
ws.on('open', () => console.log(`[${name}] open`))
|
||||||
@ -38,27 +38,23 @@ function latestState(messages) {
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'waiting'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
const p2 = await connect('p2')
|
|
||||||
await waitFor(() => p2.messages.some(m => m.type === 'game_state'))
|
|
||||||
|
|
||||||
// Determine the current player and throw two stones in the same end.
|
// Determine the current player and throw two stones in the same end.
|
||||||
let state = latestState(p1.messages)
|
let state = latestState(p1.messages)
|
||||||
console.log('Initial state', state)
|
console.log('Initial state', state)
|
||||||
|
|
||||||
// First throw: yellow aims slightly left of center.
|
// First throw: current turn team.
|
||||||
let turn = state.turn_team
|
let turn = state.turn_team
|
||||||
let turnPlayer = turn === p1.team ? p1 : p2
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
||||||
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
|
||||||
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
|
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
|
||||||
|
|
||||||
state = latestState(p1.messages)
|
state = latestState(p1.messages)
|
||||||
console.log('After first throw:', state)
|
console.log('After first throw:', state)
|
||||||
|
|
||||||
// Second throw: red aims slightly right of center.
|
// Second throw: other team.
|
||||||
turn = state.turn_team
|
turn = state.turn_team
|
||||||
turnPlayer = turn === p1.team ? p1 : p2
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
|
||||||
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
|
|
||||||
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
|
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
|
||||||
|
|
||||||
state = latestState(p1.messages)
|
state = latestState(p1.messages)
|
||||||
@ -77,7 +73,6 @@ function latestState(messages) {
|
|||||||
|
|
||||||
console.log('PERSISTENCE E2E PASSED')
|
console.log('PERSISTENCE E2E PASSED')
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
const WebSocket = require('ws')
|
|
||||||
|
|
||||||
const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}`
|
|
||||||
|
|
||||||
function connect(name, room) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const ws = new WebSocket(base(room))
|
|
||||||
const messages = []
|
|
||||||
ws.on('open', () => resolve({ ws, messages }))
|
|
||||||
ws.on('message', (data) => messages.push(JSON.parse(data.toString())))
|
|
||||||
ws.on('error', reject)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitFor(messages, pred, timeout = 10000) {
|
|
||||||
const start = Date.now()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const check = () => {
|
|
||||||
if (pred()) return resolve(undefined)
|
|
||||||
if (Date.now() - start > timeout) return reject(new Error('timeout'))
|
|
||||||
setTimeout(check, 50)
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
const room = 'ROOMFULL' + Math.floor(Math.random() * 1000)
|
|
||||||
const p1 = await connect('p1', room)
|
|
||||||
const p2 = await connect('p2', room)
|
|
||||||
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000)
|
|
||||||
const p3 = await connect('p3', room)
|
|
||||||
await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'error'), 2000)
|
|
||||||
const p3error = p3.messages.some(m => m.type === 'error' && m.message.includes('Room is full'))
|
|
||||||
const p1error = p1.messages.some(m => m.type === 'error' && m.message.includes('Room is full'))
|
|
||||||
const p2error = p2.messages.some(m => m.type === 'error' && m.message.includes('Room is full'))
|
|
||||||
console.log({ p3error, p1error, p2error })
|
|
||||||
if (!p3error) throw new Error('p3 should get room full error')
|
|
||||||
if (p1error || p2error) throw new Error('existing players should not see room-full error')
|
|
||||||
p1.ws.close()
|
|
||||||
p2.ws.close()
|
|
||||||
p3.ws.close()
|
|
||||||
process.exit(0)
|
|
||||||
})().catch((e) => {
|
|
||||||
console.error(e)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@ -9,7 +9,7 @@ function connect(name) {
|
|||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
const msg = JSON.parse(data.toString())
|
const msg = JSON.parse(data.toString())
|
||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team })
|
if (msg.type === 'joined') resolve({ ws, messages })
|
||||||
})
|
})
|
||||||
ws.on('error', reject)
|
ws.on('error', reject)
|
||||||
})
|
})
|
||||||
@ -29,26 +29,24 @@ function waitFor(condFn, timeout = 5000) {
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
const p2 = await connect('p2')
|
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
let state = p1.messages.find(m => m.type === 'game_state')
|
let state = p1.messages.find(m => m.type === 'game_state')
|
||||||
console.log('start turn', state.turn_team, 'hammer', state.hammer)
|
console.log('start turn', state.turn_team, 'hammer', state.hammer)
|
||||||
|
|
||||||
const thrower1 = state.turn_team === p1.team ? p1 : p2
|
const thrower1 = state.turn_team
|
||||||
thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
state = p1.messages.slice(-1)[0]
|
state = p1.messages.slice(-1)[0]
|
||||||
console.log('After first throw:', state)
|
console.log('After first throw:', state)
|
||||||
const thrower2 = state.turn_team === p1.team ? p1 : p2
|
const thrower2 = state.turn_team
|
||||||
thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
|
p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
|
||||||
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
|
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
console.log('Final stones', p1.messages.slice(-1)[0].stones)
|
console.log('Final stones', p1.messages.slice(-1)[0].stones)
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(e => {
|
})().catch(e => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ function connect(name) {
|
|||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '')
|
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '')
|
||||||
if (msg.type === 'joined') {
|
if (msg.type === 'joined') {
|
||||||
resolve({ ws, messages, team: msg.team })
|
resolve({ ws, messages })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ws.on('open', () => console.log(`[${name}] open`))
|
ws.on('open', () => console.log(`[${name}] open`))
|
||||||
@ -21,31 +21,30 @@ function connect(name) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitFor(condFn, timeout = 5000) {
|
function waitFor(condFn, timeout = 5000) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
while (!condFn()) {
|
return new Promise((resolve, reject) => {
|
||||||
if (Date.now() - start > timeout) throw new Error('Timeout waiting')
|
const check = () => {
|
||||||
await new Promise(r => setTimeout(r, 50))
|
if (condFn()) return resolve(undefined)
|
||||||
|
if (Date.now() - start > timeout) return reject(new Error('Timeout waiting'))
|
||||||
|
setTimeout(check, 50)
|
||||||
}
|
}
|
||||||
|
check()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'waiting'))
|
|
||||||
const p2 = await connect('p2')
|
|
||||||
await waitFor(() => p2.messages.some(m => m.type === 'joined'))
|
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
const state = p1.messages.find(m => m.type === 'game_state')
|
const state = p1.messages.find(m => m.type === 'game_state')
|
||||||
console.log('Game state', state)
|
console.log('Game state', state)
|
||||||
|
|
||||||
const turn = state.turn_team
|
const turn = state.turn_team
|
||||||
const turnPlayer = turn === p1.team ? p1 : p2
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
||||||
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
console.log('Final state after throw:', p1.messages.slice(-1)[0])
|
console.log('Final state after throw:', p1.messages.slice(-1)[0])
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
104
frontend/src/game-model.test.ts
Normal file
104
frontend/src/game-model.test.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { GameModel } from './game-model'
|
||||||
|
import type { ServerStoneTrajectory, StoneState } from './protocol'
|
||||||
|
|
||||||
|
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 30,
|
||||||
|
rotation: 0,
|
||||||
|
active: true,
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GameModel multi-path trajectory animation', () => {
|
||||||
|
it('returns two DrawableStones in parallel mid-trajectory', () => {
|
||||||
|
const model = new GameModel()
|
||||||
|
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
|
||||||
|
model.state.turnTeam = 'red'
|
||||||
|
|
||||||
|
const paths: ServerStoneTrajectory[] = [
|
||||||
|
{
|
||||||
|
stone_id: 1,
|
||||||
|
path: [
|
||||||
|
[0, 10, 0],
|
||||||
|
[0, 11, 0.5],
|
||||||
|
[0, 12, 1.0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stone_id: 2,
|
||||||
|
path: [
|
||||||
|
[1, 10, 0],
|
||||||
|
[1, 11, 0.5],
|
||||||
|
[1, 12, 1.0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
model.startTrajectory(paths)
|
||||||
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
|
const mid = performance.now() + 500
|
||||||
|
const drawn = model.tick(mid)
|
||||||
|
|
||||||
|
expect(drawn).toHaveLength(2)
|
||||||
|
expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow'])
|
||||||
|
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift)
|
||||||
|
for (const d of drawn) {
|
||||||
|
expect(d.y).toBeCloseTo(11, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears animating when elapsed reaches maxTotal on a short path', () => {
|
||||||
|
const model = new GameModel()
|
||||||
|
model.state.stones = [stone({ id: 1, team: 'red' })]
|
||||||
|
model.state.turnTeam = 'red'
|
||||||
|
|
||||||
|
model.startTrajectory([
|
||||||
|
{
|
||||||
|
stone_id: 1,
|
||||||
|
path: [
|
||||||
|
[0, 10, 0],
|
||||||
|
[0, 10.5, 0.2],
|
||||||
|
[0, 11, 0.4],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
|
const afterEnd = performance.now() + 500
|
||||||
|
const drawn = model.tick(afterEnd)
|
||||||
|
|
||||||
|
expect(drawn).toEqual([])
|
||||||
|
expect(model.state.animating).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims thrown stone path to hog line when id is not in existing stones', () => {
|
||||||
|
const model = new GameModel()
|
||||||
|
// Only stone 1 is already on the sheet; stone 2 is the newly thrown rock.
|
||||||
|
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })]
|
||||||
|
model.state.turnTeam = 'red'
|
||||||
|
|
||||||
|
const thrownPath: [number, number, number][] = [
|
||||||
|
[0, 2, 0],
|
||||||
|
[0, 20, 1],
|
||||||
|
[0, 21.5, 2],
|
||||||
|
[0, 30, 3],
|
||||||
|
]
|
||||||
|
|
||||||
|
model.startTrajectory([{ stone_id: 2, path: thrownPath }])
|
||||||
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
|
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0
|
||||||
|
const atStart = performance.now()
|
||||||
|
const drawn = model.tick(atStart)
|
||||||
|
|
||||||
|
expect(drawn).toHaveLength(1)
|
||||||
|
expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id
|
||||||
|
expect(drawn[0].y).toBeCloseTo(20, 0)
|
||||||
|
// Must not still be at the hack (y=2)
|
||||||
|
expect(drawn[0].y).toBeGreaterThan(15)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type StoneState, type Team } from './protocol'
|
import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
|
||||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
import { trimPathToStartAtHogLine } from './game-helpers'
|
||||||
|
|
||||||
export interface GameModelState {
|
export interface GameModelState {
|
||||||
@ -27,10 +27,10 @@ export class GameModel {
|
|||||||
pendingStones: StoneState[] = []
|
pendingStones: StoneState[] = []
|
||||||
broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
|
broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
|
||||||
isDragging = false
|
isDragging = false
|
||||||
|
isPanning = false
|
||||||
|
|
||||||
private activePath: [number, number, number][] = []
|
private activePaths = new Map<number, [number, number, number][]>()
|
||||||
private animationStartTime = 0
|
private animationStartTime = 0
|
||||||
private animationTeam: Team = 'red'
|
|
||||||
|
|
||||||
setMyTeam(team: Team): void {
|
setMyTeam(team: Team): void {
|
||||||
this.state.myTeam = team
|
this.state.myTeam = team
|
||||||
@ -63,36 +63,104 @@ export class GameModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startTrajectory(path: [number, number, number][], team: Team): void {
|
startTrajectory(paths: ServerStoneTrajectory[]): void {
|
||||||
this.activePath = trimPathToStartAtHogLine(path)
|
const existingIds = new Set(this.state.stones.map((s) => s.id))
|
||||||
this.state.animating = this.activePath.length > 1
|
|
||||||
|
let thrownId: number | null = null
|
||||||
|
for (const { stone_id } of paths) {
|
||||||
|
if (!existingIds.has(stone_id)) {
|
||||||
|
thrownId = stone_id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thrownId === null && paths.length > 0) {
|
||||||
|
thrownId = paths[0].stone_id
|
||||||
|
}
|
||||||
|
|
||||||
|
let tRef = 0
|
||||||
|
if (thrownId !== null) {
|
||||||
|
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
|
||||||
|
if (thrownPath.length >= 2) {
|
||||||
|
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const start = Math.max(0, idx - 1)
|
||||||
|
tRef = thrownPath[start][2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMap = new Map<number, [number, number, number][]>()
|
||||||
|
for (const { stone_id, path } of paths) {
|
||||||
|
if (stone_id === thrownId) {
|
||||||
|
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
|
||||||
|
} else {
|
||||||
|
const shifted = path
|
||||||
|
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
|
||||||
|
.filter(([, , t]) => t >= 0)
|
||||||
|
pathMap.set(stone_id, shifted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activePaths = pathMap
|
||||||
|
this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1)
|
||||||
this.animationStartTime = performance.now()
|
this.animationStartTime = performance.now()
|
||||||
this.animationTeam = team
|
|
||||||
this.pendingStones = []
|
this.pendingStones = []
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(now: number): DrawableStone | null {
|
tick(now: number): DrawableStone[] {
|
||||||
if (!this.state.animating || this.activePath.length <= 1) {
|
if (!this.state.animating) {
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = (now - this.animationStartTime) / 1000
|
const elapsed = (now - this.animationStartTime) / 1000
|
||||||
const total = this.activePath[this.activePath.length - 1][2]
|
const maxTotal = Math.max(
|
||||||
if (elapsed >= total) {
|
0,
|
||||||
|
...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (elapsed >= maxTotal) {
|
||||||
this.state.animating = false
|
this.state.animating = false
|
||||||
if (this.pendingStones.length > 0) {
|
if (this.pendingStones.length > 0) {
|
||||||
this.state.stones = this.pendingStones
|
this.state.stones = this.pendingStones
|
||||||
this.pendingStones = []
|
this.pendingStones = []
|
||||||
}
|
}
|
||||||
return null
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: DrawableStone[] = []
|
||||||
|
for (const [stoneId, path] of this.activePaths) {
|
||||||
|
const pos = this.interpolatePath(path, elapsed)
|
||||||
|
if (!pos) continue
|
||||||
|
const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam
|
||||||
|
result.push({ ...pos, team })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolatePath(
|
||||||
|
path: [number, number, number][],
|
||||||
|
elapsed: number,
|
||||||
|
): { x: number; y: number; rotation: number } | null {
|
||||||
|
if (path.length === 0) return null
|
||||||
|
if (path.length === 1) {
|
||||||
|
const [x, y] = path[0]
|
||||||
|
return { x, y, rotation: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed >= path[path.length - 1][2]) {
|
||||||
|
const last = path[path.length - 1]
|
||||||
|
const prev = path[path.length - 2]
|
||||||
|
const dx = last[0] - prev[0]
|
||||||
|
const dy = last[1] - prev[1]
|
||||||
|
return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i + 1 < this.activePath.length && this.activePath[i + 1][2] < elapsed) i++
|
while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
|
||||||
const p0 = this.activePath[i]
|
const p0 = path[i]
|
||||||
const p1 = this.activePath[i + 1] ?? p0
|
const p1 = path[i + 1] ?? p0
|
||||||
const t0 = this.activePath[Math.max(i - 1, 0)]
|
const t0 = path[Math.max(i - 1, 0)]
|
||||||
const t2 = this.activePath[Math.min(i + 2, this.activePath.length - 1)]
|
const t2 = path[Math.min(i + 2, path.length - 1)]
|
||||||
const dt = p1[2] - p0[2]
|
const dt = p1[2] - p0[2]
|
||||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
||||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||||
@ -100,7 +168,7 @@ export class GameModel {
|
|||||||
const dx = t2[0] - t0[0]
|
const dx = t2[0] - t0[0]
|
||||||
const dy = t2[1] - t0[1]
|
const dy = t2[1] - t0[1]
|
||||||
const rotation = Math.atan2(dy, dx) * 2
|
const rotation = Math.atan2(dy, dx) * 2
|
||||||
return { x, y, rotation, team: this.animationTeam }
|
return { x, y, rotation }
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMyTurn(): boolean {
|
get isMyTurn(): boolean {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { connect, sendThrow, type NetCallbacks } from './net'
|
import { connect, sendThrow, type NetCallbacks } from './net'
|
||||||
import { createRenderer } from './renderer'
|
import { createRenderer } from './renderer'
|
||||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
||||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol'
|
import { type Team } from './protocol'
|
||||||
import { GameModel } from './game-model'
|
import { GameModel } from './game-model'
|
||||||
|
|
||||||
export function startGame(): void {
|
export function startGame(): void {
|
||||||
@ -13,6 +13,10 @@ export function startGame(): void {
|
|||||||
const hud = createHud()
|
const hud = createHud()
|
||||||
app.appendChild(hud.root)
|
app.appendChild(hud.root)
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
|
||||||
|
}
|
||||||
|
|
||||||
const velocityContainer = hud.velocityControl
|
const velocityContainer = hud.velocityControl
|
||||||
const curlContainer = hud.curlSelector
|
const curlContainer = hud.curlSelector
|
||||||
const frictionContainer = hud.frictionControl
|
const frictionContainer = hud.frictionControl
|
||||||
@ -31,30 +35,17 @@ export function startGame(): void {
|
|||||||
const shareLink = `${window.location.origin}/?room=${room}`
|
const shareLink = `${window.location.origin}/?room=${room}`
|
||||||
hud.setShareLink(shareLink)
|
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)
|
|
||||||
|
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
|
|
||||||
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
|
||||||
btn.addEventListener('click', () => {
|
model.setMyTeam(initialTeam)
|
||||||
const team = btn.dataset.team as Team
|
hud.setTeam(initialTeam)
|
||||||
picker.remove()
|
|
||||||
connect(room, team, callbacks)
|
// connect() is invoked after callbacks is defined below.
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateControls = () => {
|
const updateControls = () => {
|
||||||
|
const activeTeam = hud.teamSelect.value as Team
|
||||||
|
model.setMyTeam(activeTeam)
|
||||||
const myTurn = model.isMyTurn
|
const myTurn = model.isMyTurn
|
||||||
velocity.setEnabled(myTurn)
|
velocity.setEnabled(myTurn)
|
||||||
curls.setEnabled(myTurn)
|
curls.setEnabled(myTurn)
|
||||||
@ -63,6 +54,11 @@ export function startGame(): void {
|
|||||||
hud.update(model.state)
|
hud.update(model.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hud.teamSelect.addEventListener('change', () => {
|
||||||
|
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
||||||
|
updateControls()
|
||||||
|
})
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const wasAnimating = model.state.animating
|
const wasAnimating = model.state.animating
|
||||||
const activeStonePos = model.tick(performance.now())
|
const activeStonePos = model.tick(performance.now())
|
||||||
@ -85,8 +81,7 @@ export function startGame(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const callbacks: NetCallbacks = {
|
const callbacks: NetCallbacks = {
|
||||||
onJoined: (_roomId, team) => {
|
onJoined: () => {
|
||||||
model.setMyTeam(team)
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onWaiting: () => {
|
onWaiting: () => {
|
||||||
@ -97,8 +92,8 @@ export function startGame(): void {
|
|||||||
model.updateGameState(msg)
|
model.updateGameState(msg)
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectory: (path) => {
|
onTrajectory: (paths) => {
|
||||||
model.startTrajectory(path, model.state.turnTeam)
|
model.startTrajectory(paths)
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onEndScored: (end, points, scoringTeam) => {
|
onEndScored: (end, points, scoringTeam) => {
|
||||||
@ -128,36 +123,45 @@ export function startGame(): void {
|
|||||||
return { x: e.clientX, y: e.clientY }
|
return { x: e.clientX, y: e.clientY }
|
||||||
}
|
}
|
||||||
|
|
||||||
const constrainBroom = (world: { x: number; y: number }) => {
|
let lastPanScreenY = 0
|
||||||
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) => {
|
const handleStart = (e: Event) => {
|
||||||
if (!model.isMyTurn) return
|
|
||||||
model.isDragging = true
|
|
||||||
const pos = getPos(e as TouchEvent)
|
const pos = getPos(e as TouchEvent)
|
||||||
model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
const world = renderer.screenToWorld(pos.x, pos.y)
|
||||||
|
|
||||||
|
if (model.isMyTurn) {
|
||||||
|
model.isDragging = true
|
||||||
|
model.broom = world
|
||||||
|
} else {
|
||||||
|
model.isPanning = true
|
||||||
|
lastPanScreenY = pos.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (e: Event) => {
|
const handleMove = (e: Event) => {
|
||||||
if (!model.isDragging || !model.isMyTurn) return
|
if (model.isDragging && model.isMyTurn) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pos = getPos(e as TouchEvent)
|
const pos = getPos(e as TouchEvent)
|
||||||
model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
model.broom = renderer.screenToWorld(pos.x, pos.y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.isPanning) {
|
||||||
|
e.preventDefault()
|
||||||
|
const pos = getPos(e as TouchEvent)
|
||||||
|
const prevWorld = renderer.screenToWorld(pos.x, lastPanScreenY)
|
||||||
|
const nowWorld = renderer.screenToWorld(pos.x, pos.y)
|
||||||
|
// Direct-manipulation pan: dragging the sheet down reveals the hog line above.
|
||||||
|
const deltaY = nowWorld.y - prevWorld.y
|
||||||
|
const sensitivity = 1.5
|
||||||
|
renderer.setViewYOffset(renderer.clampViewYOffset() + deltaY * sensitivity)
|
||||||
|
lastPanScreenY = pos.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
model.isDragging = false
|
model.isDragging = false
|
||||||
|
model.isPanning = false
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
||||||
@ -170,9 +174,17 @@ export function startGame(): void {
|
|||||||
|
|
||||||
throwBtn.addEventListener('click', () => {
|
throwBtn.addEventListener('click', () => {
|
||||||
if (!model.isMyTurn) return
|
if (!model.isMyTurn) return
|
||||||
sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
sendThrow(
|
||||||
|
hud.teamSelect.value as Team,
|
||||||
|
model.broom.x,
|
||||||
|
model.broom.y,
|
||||||
|
velocity.getWeight(),
|
||||||
|
curls.getSelected(),
|
||||||
|
friction.getFriction(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
connect(room, callbacks)
|
||||||
updateControls()
|
updateControls()
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,18 @@ import { velocityToWeight, weightToVelocity } from './game-helpers'
|
|||||||
|
|
||||||
export interface Hud {
|
export interface Hud {
|
||||||
root: HTMLDivElement
|
root: HTMLDivElement
|
||||||
|
teamSelect: HTMLSelectElement
|
||||||
velocityControl: HTMLDivElement
|
velocityControl: HTMLDivElement
|
||||||
curlSelector: HTMLDivElement
|
curlSelector: HTMLDivElement
|
||||||
frictionControl: HTMLDivElement
|
frictionControl: HTMLDivElement
|
||||||
throwButton: HTMLButtonElement
|
throwButton: HTMLButtonElement
|
||||||
|
setTeam: (team: Team) => void
|
||||||
update: (state: {
|
update: (state: {
|
||||||
phase: Phase
|
phase: Phase
|
||||||
end: number
|
end: number
|
||||||
scores: number[]
|
scores: number[]
|
||||||
hammer: Team
|
hammer: Team
|
||||||
turnTeam: Team
|
turnTeam: Team
|
||||||
myTeam: Team | null
|
|
||||||
animating: boolean
|
animating: boolean
|
||||||
}) => void
|
}) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
@ -48,12 +49,20 @@ export function createHud(): Hud {
|
|||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
root.id = 'hud'
|
root.id = 'hud'
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
|
<div id="hud-top-group">
|
||||||
|
<div class="hud-row" id="share-row">
|
||||||
|
<div id="share"><button>Copy share link</button></div>
|
||||||
|
</div>
|
||||||
<div class="hud-row">
|
<div class="hud-row">
|
||||||
<div id="score">Red 0 - Yellow 0</div>
|
<div id="score">Red 0 - Yellow 0</div>
|
||||||
<div id="end-info">End 1 · Waiting</div>
|
<div id="end-info">End 1 · Waiting</div>
|
||||||
<div id="team">You: -</div>
|
<select id="team-select" aria-label="Team">
|
||||||
|
<option value="red">Red</option>
|
||||||
|
<option value="yellow">Yellow</option>
|
||||||
|
</select>
|
||||||
<div id="hammer">Hammer: -</div>
|
<div id="hammer">Hammer: -</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="hud-row" style="align-items:flex-end;">
|
<div class="hud-row" style="align-items:flex-end;">
|
||||||
<div id="velocity-control"></div>
|
<div id="velocity-control"></div>
|
||||||
<div id="curl-selector"></div>
|
<div id="curl-selector"></div>
|
||||||
@ -66,34 +75,32 @@ export function createHud(): Hud {
|
|||||||
<button id="throw-btn" disabled>THROW</button>
|
<button id="throw-btn" disabled>THROW</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="waiting">Waiting for other player</div>
|
<div id="waiting">Waiting</div>
|
||||||
<div id="share"><button>Copy share link</button></div>
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
||||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||||
const teamEl = root.querySelector<HTMLDivElement>('#team')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
|
teamSelect,
|
||||||
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
||||||
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
||||||
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
||||||
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
||||||
|
setTeam: (team) => {
|
||||||
|
teamSelect.value = team
|
||||||
|
},
|
||||||
update: (state) => {
|
update: (state) => {
|
||||||
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
|
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
|
||||||
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
|
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
|
||||||
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
|
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
|
||||||
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||||
teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -'
|
|
||||||
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
|
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
|
||||||
const waiting =
|
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
||||||
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) => {
|
showToast: (message: string) => {
|
||||||
const toast = document.createElement('div')
|
const toast = document.createElement('div')
|
||||||
@ -179,14 +186,15 @@ export function createCurlSelector(
|
|||||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
const state = { selected: 1, enabled: true }
|
const state = { selected: 1, enabled: true }
|
||||||
const options = [
|
const options = [
|
||||||
{ value: -1, label: '↶ Left' },
|
{ value: -1, label: '↷', ariaLabel: 'Left curl' },
|
||||||
{ value: 1, label: 'Right ↷' },
|
{ value: 1, label: '↶', ariaLabel: 'Right curl' },
|
||||||
]
|
]
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
const btn = document.createElement('button')
|
const btn = document.createElement('button')
|
||||||
btn.className = 'curl-btn'
|
btn.className = 'curl-btn'
|
||||||
btn.textContent = opt.label
|
btn.textContent = opt.label
|
||||||
|
btn.ariaLabel = opt.ariaLabel
|
||||||
btn.dataset.curl = String(opt.value)
|
btn.dataset.curl = String(opt.value)
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (!state.enabled) return
|
if (!state.enabled) return
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ServerGameStateMessage,
|
ServerGameStateMessage,
|
||||||
|
ServerStoneTrajectory,
|
||||||
ServerMessageTyped as ServerMessage,
|
ServerMessageTyped as ServerMessage,
|
||||||
Team,
|
Team,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
@ -13,10 +14,10 @@ function resolveWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NetCallbacks {
|
export interface NetCallbacks {
|
||||||
onJoined: (room: string, team: Team) => void
|
onJoined: (room: string) => void
|
||||||
onWaiting: (message: string) => void
|
onWaiting: (message: string) => void
|
||||||
onGameState: (msg: ServerGameStateMessage) => void
|
onGameState: (msg: ServerGameStateMessage) => void
|
||||||
onTrajectory: (path: [number, number, number][]) => void
|
onTrajectory: (paths: ServerStoneTrajectory[]) => void
|
||||||
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
|
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
|
||||||
onGameOver: (scores: number[], winner: Team | null) => void
|
onGameOver: (scores: number[], winner: Team | null) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
@ -25,10 +26,9 @@ export interface NetCallbacks {
|
|||||||
|
|
||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
|
|
||||||
export function connect(room: string, team: Team | null, callbacks: NetCallbacks): void {
|
export function connect(room: string, callbacks: NetCallbacks): void {
|
||||||
if (socket) return
|
if (socket) return
|
||||||
const teamParam = team ? `&team=${encodeURIComponent(team)}` : ''
|
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}`
|
||||||
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}${teamParam}`
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@ -42,7 +42,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'joined':
|
case 'joined':
|
||||||
callbacks.onJoined(msg.room, msg.team)
|
callbacks.onJoined(msg.room)
|
||||||
break
|
break
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
callbacks.onWaiting(msg.message)
|
callbacks.onWaiting(msg.message)
|
||||||
@ -51,7 +51,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
|
|||||||
callbacks.onGameState(msg)
|
callbacks.onGameState(msg)
|
||||||
break
|
break
|
||||||
case 'trajectory':
|
case 'trajectory':
|
||||||
callbacks.onTrajectory(msg.path)
|
callbacks.onTrajectory(msg.paths)
|
||||||
break
|
break
|
||||||
case 'end_scored':
|
case 'end_scored':
|
||||||
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
|
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
|
||||||
@ -76,7 +76,24 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
|
|||||||
ws.onerror = () => {}
|
ws.onerror = () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendThrow(broomX: number, broomY: number, weight: number, curl: number, friction: number): void {
|
export function sendThrow(
|
||||||
|
team: Team,
|
||||||
|
broomX: number,
|
||||||
|
broomY: number,
|
||||||
|
weight: number,
|
||||||
|
curl: number,
|
||||||
|
friction: number,
|
||||||
|
): void {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||||
socket.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: broomY, weight, curl, friction }))
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'throw',
|
||||||
|
team,
|
||||||
|
broom_x: broomX,
|
||||||
|
broom_y: broomY,
|
||||||
|
weight,
|
||||||
|
curl,
|
||||||
|
friction,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,13 @@ export const STONES_PER_TEAM = 8
|
|||||||
export const ENDS = 10
|
export const ENDS = 10
|
||||||
export const SHEET_WIDTH = 5.0
|
export const SHEET_WIDTH = 5.0
|
||||||
export const SHEET_LENGTH = 45.0
|
export const SHEET_LENGTH = 45.0
|
||||||
|
export const FEET_TO_METERS = 0.3048
|
||||||
export const HOUSE_CENTER = { x: 0, y: 38.5 }
|
export const HOUSE_CENTER = { x: 0, y: 38.5 }
|
||||||
export const HOUSE_RADIUS = 1.83
|
export const HOUSE_RADIUS = 6 * FEET_TO_METERS
|
||||||
export const BUTTON_RADIUS = 0.1524
|
export const BUTTON_RADIUS = 0.5 * FEET_TO_METERS
|
||||||
export const FOUR_FT_RADIUS = 0.6096
|
export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
|
||||||
export const EIGHT_FT_RADIUS = 1.2192
|
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
|
||||||
export const TWELVE_FT_RADIUS = 1.8288
|
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
|
||||||
export const HOG_LINE_Y = 21.0
|
export const HOG_LINE_Y = 21.0
|
||||||
export const BACK_LINE_Y = 42.0
|
export const BACK_LINE_Y = 42.0
|
||||||
export const HACK_Y = 2.0
|
export const HACK_Y = 2.0
|
||||||
@ -35,6 +36,7 @@ export interface DrawableStone {
|
|||||||
|
|
||||||
export interface ClientThrowMessage {
|
export interface ClientThrowMessage {
|
||||||
type: 'throw'
|
type: 'throw'
|
||||||
|
team: Team
|
||||||
broom_x: number
|
broom_x: number
|
||||||
broom_y: number
|
broom_y: number
|
||||||
weight: number
|
weight: number
|
||||||
@ -45,7 +47,6 @@ export interface ClientThrowMessage {
|
|||||||
export interface ServerJoinedMessage {
|
export interface ServerJoinedMessage {
|
||||||
type: 'joined'
|
type: 'joined'
|
||||||
room: string
|
room: string
|
||||||
team: Team
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerWaitingMessage {
|
export interface ServerWaitingMessage {
|
||||||
@ -63,9 +64,14 @@ export interface ServerGameStateMessage {
|
|||||||
phase: Phase
|
phase: Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerStoneTrajectory {
|
||||||
|
stone_id: number
|
||||||
|
path: [number, number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerTrajectoryMessage {
|
export interface ServerTrajectoryMessage {
|
||||||
type: 'trajectory'
|
type: 'trajectory'
|
||||||
path: [number, number, number][]
|
paths: ServerStoneTrajectory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerEndScoredMessage {
|
export interface ServerEndScoredMessage {
|
||||||
|
|||||||
@ -19,19 +19,31 @@ export interface Renderer {
|
|||||||
setSize: () => void
|
setSize: () => void
|
||||||
worldToScreen: (x: number, y: number) => { x: number; y: number }
|
worldToScreen: (x: number, y: number) => { x: number; y: number }
|
||||||
screenToWorld: (sx: number, sy: number) => { x: number; y: number }
|
screenToWorld: (sx: number, sy: number) => { x: number; y: number }
|
||||||
|
setViewYOffset: (y: number) => void
|
||||||
|
clampViewYOffset: () => number
|
||||||
draw: (state: {
|
draw: (state: {
|
||||||
stones: StoneState[]
|
stones: StoneState[]
|
||||||
broom: { x: number; y: number } | null
|
broom: { x: number; y: number } | null
|
||||||
animating: boolean
|
animating: boolean
|
||||||
activeStonePos: DrawableStone | null
|
activeStonePos: DrawableStone[]
|
||||||
}) => void
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
// Show the area from the hog line (top) to the back line (bottom).
|
// Fit full sheet width (5 m) with a little side padding so both sidelines show.
|
||||||
const viewportHeight = BACK_LINE_Y - HOG_LINE_Y
|
// Scale is primarily width-based, but also capped so house + backline fit above the HUD.
|
||||||
const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 }
|
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 setSize = () => {
|
||||||
const dpr = window.devicePixelRatio || 1
|
const dpr = window.devicePixelRatio || 1
|
||||||
@ -42,33 +54,75 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
canvas.style.width = `${width}px`
|
canvas.style.width = `${width}px`
|
||||||
canvas.style.height = `${height}px`
|
canvas.style.height = `${height}px`
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
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 scale = (): number => {
|
||||||
const height = window.innerHeight
|
const byWidth = window.innerWidth / viewportWidth
|
||||||
return height / viewportHeight
|
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 worldToScreen = (x: number, y: number) => {
|
||||||
const s = scale()
|
const s = scale()
|
||||||
const cx = window.innerWidth / 2
|
const cx = window.innerWidth / 2
|
||||||
const cy = window.innerHeight / 2
|
const cy = viewCenterY()
|
||||||
|
const center = effectiveViewportCenter()
|
||||||
return {
|
return {
|
||||||
x: cx + (x - viewportCenter.x) * s,
|
x: cx + (x - center.x) * s,
|
||||||
y: cy + (y - viewportCenter.y) * s,
|
y: cy + (y - center.y) * s,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenToWorld = (sx: number, sy: number) => {
|
const screenToWorld = (sx: number, sy: number) => {
|
||||||
const s = scale()
|
const s = scale()
|
||||||
const cx = window.innerWidth / 2
|
const cx = window.innerWidth / 2
|
||||||
const cy = window.innerHeight / 2
|
const cy = viewCenterY()
|
||||||
|
const center = effectiveViewportCenter()
|
||||||
return {
|
return {
|
||||||
x: (sx - cx) / s + viewportCenter.x,
|
x: (sx - cx) / s + center.x,
|
||||||
y: viewportCenter.y + (sy - cy) / s,
|
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 = (
|
const drawLine = (
|
||||||
x1: number,
|
x1: number,
|
||||||
y1: number,
|
y1: number,
|
||||||
@ -129,7 +183,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
stones: StoneState[]
|
stones: StoneState[]
|
||||||
broom: { x: number; y: number } | null
|
broom: { x: number; y: number } | null
|
||||||
animating: boolean
|
animating: boolean
|
||||||
activeStonePos: DrawableStone | null
|
activeStonePos: DrawableStone[]
|
||||||
}) => {
|
}) => {
|
||||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||||
|
|
||||||
@ -149,12 +203,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, 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')
|
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) {
|
for (const stone of state.stones) {
|
||||||
drawStone(stone)
|
drawStone(stone)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.activeStonePos) {
|
|
||||||
drawStone(state.activeStonePos)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.broom) {
|
if (state.broom) {
|
||||||
@ -170,5 +226,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
setSize()
|
setSize()
|
||||||
window.addEventListener('resize', setSize)
|
window.addEventListener('resize', setSize)
|
||||||
|
|
||||||
return { canvas, ctx, setSize, worldToScreen, screenToWorld, draw }
|
return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,30 +84,57 @@ html, body {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#hud-top-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
#share {
|
#share {
|
||||||
position: absolute;
|
display: flex;
|
||||||
bottom: 12px;
|
justify-content: center;
|
||||||
left: 50%;
|
width: 100%;
|
||||||
transform: translateX(-50%);
|
}
|
||||||
text-align: center;
|
|
||||||
|
#share-row {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#share-row #share {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#share button {
|
#share button {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 14px;
|
padding: 6px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#team {
|
#team {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#team-select {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 10px;
|
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 {
|
#velocity-control {
|
||||||
@ -205,67 +232,6 @@ html, body {
|
|||||||
text-align: center;
|
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 {
|
#throw-btn {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
@ -283,3 +249,53 @@ html, body {
|
|||||||
background: #557766;
|
background: #557766;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user