From 4dd437ef69553453bf269a5b6a17754c898a4814 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 23 Jun 2025 23:50:21 -0700 Subject: [PATCH 01/12] refactor: rename all hooks to use___ --- frontend/src/app/live/boardgame/page.tsx | 4 +- frontend/src/app/live/draw/page.tsx | 2 +- frontend/src/app/live/layout.tsx | 2 +- frontend/src/app/live/video/page.tsx | 2 +- frontend/src/components/Chat/ChatBox.tsx | 4 +- frontend/src/components/Chat/ChatWindow.tsx | 4 +- frontend/src/components/Chat/LoginBox.tsx | 2 +- frontend/src/components/UI/ThemeSwitcher.tsx | 2 +- .../src/components/games/Connect4Board.tsx | 2 +- frontend/src/components/games/GameStatus.tsx | 2 +- .../src/components/games/TicTacToeBoard.tsx | 2 +- .../src/components/games/chess/ChessBoard.tsx | 2 +- frontend/src/hooks/useDraw.ts | 2 +- frontend/src/hooks/useGameBoard.ts | 84 +++++++++++++++++++ frontend/src/hooks/useGroupCall.ts | 4 +- .../src/hooks/{themeStore.ts => useTheme.ts} | 2 +- .../hooks/{userStore.ts => useUserStore.ts} | 0 .../hooks/{useBoardGame.ts => useWSGame.ts} | 4 +- .../hooks/{webSocket.ts => useWebsocket.ts} | 2 +- 19 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 frontend/src/hooks/useGameBoard.ts rename frontend/src/hooks/{themeStore.ts => useTheme.ts} (96%) rename frontend/src/hooks/{userStore.ts => useUserStore.ts} (100%) rename frontend/src/hooks/{useBoardGame.ts => useWSGame.ts} (95%) rename frontend/src/hooks/{webSocket.ts => useWebsocket.ts} (99%) diff --git a/frontend/src/app/live/boardgame/page.tsx b/frontend/src/app/live/boardgame/page.tsx index e16ece6..3728082 100644 --- a/frontend/src/app/live/boardgame/page.tsx +++ b/frontend/src/app/live/boardgame/page.tsx @@ -7,8 +7,8 @@ import { ChessBoard } from '@/components/games/chess/ChessBoard'; import { GameStatus } from '@/components/games/GameStatus'; import { CreateGame } from '@/components/games/CreateGame'; import { GameBoardProps } from '@/types/gameTypes'; -import useBoardGame from '@/hooks/useBoardGame'; -import { useWebSocket } from '@/hooks/webSocket'; +import useBoardGame from '@/hooks/useWSGame'; +import { useWebSocket } from '@/hooks/useWebsocket'; import { GAME_DISPLAY_NAMES } from '@/config/consts'; import { Trophy } from 'lucide-react'; diff --git a/frontend/src/app/live/draw/page.tsx b/frontend/src/app/live/draw/page.tsx index 15a2fd1..99c6087 100644 --- a/frontend/src/app/live/draw/page.tsx +++ b/frontend/src/app/live/draw/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useDraw } from '@/hooks/useDraw'; -import { useWebSocket } from '@/hooks/webSocket'; +import { useWebSocket } from '@/hooks/useWebsocket'; import { DrawToolbar } from '@/components/draw/DrawToolbar'; import { useState, useRef, useEffect } from 'react'; import { DRAW_CANVAS_WIDTH, DRAW_CANVAS_HEIGHT } from '@/config/consts'; diff --git a/frontend/src/app/live/layout.tsx b/frontend/src/app/live/layout.tsx index b4586f2..6d7e278 100644 --- a/frontend/src/app/live/layout.tsx +++ b/frontend/src/app/live/layout.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import ChatIndicator from '@/components/UI/ChatIndicator'; -import { useWebSocket } from '@/hooks/webSocket'; +import { useWebSocket } from '@/hooks/useWebsocket'; export default function LiveLayout({ children, diff --git a/frontend/src/app/live/video/page.tsx b/frontend/src/app/live/video/page.tsx index eed8982..5986c56 100644 --- a/frontend/src/app/live/video/page.tsx +++ b/frontend/src/app/live/video/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useRef, useState, useEffect } from 'react'; -import { useWebSocket } from '@/hooks/webSocket'; +import { useWebSocket } from '@/hooks/useWebsocket'; import { useGroupCall } from '@/hooks/useGroupCall'; import Button from '@/components/UI/Button'; diff --git a/frontend/src/components/Chat/ChatBox.tsx b/frontend/src/components/Chat/ChatBox.tsx index e242dd5..bf52ec1 100644 --- a/frontend/src/components/Chat/ChatBox.tsx +++ b/frontend/src/components/Chat/ChatBox.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { useState, useEffect, useRef } from 'react'; -import { useWebSocket } from '@/hooks/webSocket'; -import { useUserStore } from '@/hooks/userStore'; +import { useWebSocket } from '@/hooks/useWebsocket'; +import { useUserStore } from '@/hooks/useUserStore'; import Input from '../UI/Input'; import Button from '../UI/Button'; import { msgError } from '@/types/wsTypes'; diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index fb1f299..fe1af89 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { useUserStore } from '@/hooks/userStore'; -import { useWSConnect } from '@/hooks/webSocket'; +import { useUserStore } from '@/hooks/useUserStore'; +import { useWSConnect } from '@/hooks/useWebsocket'; import LoginBox from './LoginBox'; import ChatBox from './ChatBox'; import Button from '../UI/Button'; diff --git a/frontend/src/components/Chat/LoginBox.tsx b/frontend/src/components/Chat/LoginBox.tsx index 87ba4f9..1dc8281 100644 --- a/frontend/src/components/Chat/LoginBox.tsx +++ b/frontend/src/components/Chat/LoginBox.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState } from 'react'; -import { useUserStore } from '@/hooks/userStore'; // Adjust path if different +import { useUserStore } from '@/hooks/useUserStore'; // Adjust path if different import Input from '../UI/Input'; import Button from '../UI/Button'; diff --git a/frontend/src/components/UI/ThemeSwitcher.tsx b/frontend/src/components/UI/ThemeSwitcher.tsx index 5eacc4f..6bd9d02 100644 --- a/frontend/src/components/UI/ThemeSwitcher.tsx +++ b/frontend/src/components/UI/ThemeSwitcher.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { useThemeStore, Theme } from '@/hooks/themeStore'; +import { useThemeStore, Theme } from '@/hooks/useTheme'; export default function ThemeSwitcher() { const { theme, setTheme } = useThemeStore(); diff --git a/frontend/src/components/games/Connect4Board.tsx b/frontend/src/components/games/Connect4Board.tsx index 7faa38c..da215fe 100644 --- a/frontend/src/components/games/Connect4Board.tsx +++ b/frontend/src/components/games/Connect4Board.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { GameBoardProps } from '@/types/gameTypes'; -import { useUserStore } from '@/hooks/userStore'; +import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; export function Connect4Board({ gameState, makeMove }: GameBoardProps) { diff --git a/frontend/src/components/games/GameStatus.tsx b/frontend/src/components/games/GameStatus.tsx index 5ed0196..c2aea62 100644 --- a/frontend/src/components/games/GameStatus.tsx +++ b/frontend/src/components/games/GameStatus.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useUserStore } from '@/hooks/userStore'; +import { useUserStore } from '@/hooks/useUserStore'; import { BoardGameState } from '@/types/wsTypes'; import Button from '@/components/UI/Button'; diff --git a/frontend/src/components/games/TicTacToeBoard.tsx b/frontend/src/components/games/TicTacToeBoard.tsx index 14592be..8da0d4d 100644 --- a/frontend/src/components/games/TicTacToeBoard.tsx +++ b/frontend/src/components/games/TicTacToeBoard.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'; import { GameBoardProps } from '@/types/gameTypes'; -import { useUserStore } from '@/hooks/userStore'; +import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; import { X, Circle } from 'lucide-react'; diff --git a/frontend/src/components/games/chess/ChessBoard.tsx b/frontend/src/components/games/chess/ChessBoard.tsx index 27c48a1..250c79f 100644 --- a/frontend/src/components/games/chess/ChessBoard.tsx +++ b/frontend/src/components/games/chess/ChessBoard.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { GameBoardProps } from '@/types/gameTypes'; -import { useUserStore } from '@/hooks/userStore'; +import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; import { numToPiece } from './pieceMapping'; diff --git a/frontend/src/hooks/useDraw.ts b/frontend/src/hooks/useDraw.ts index dd59b96..15443a2 100644 --- a/frontend/src/hooks/useDraw.ts +++ b/frontend/src/hooks/useDraw.ts @@ -1,7 +1,7 @@ 'use client'; import { useRef, useEffect, useState } from 'react'; -import { useWebSocket } from './webSocket'; +import { useWebSocket } from './useWebsocket'; import type { DrawPayload } from '@/types/wsTypes'; import { msgRawSignal } from '@/types/wsTypes'; import { DRAW_START_COLOR, DRAW_STROKE_INTERVAL, DRAW_START_WIDTH } from '@/config/consts'; diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts new file mode 100644 index 0000000..be5c7a0 --- /dev/null +++ b/frontend/src/hooks/useGameBoard.ts @@ -0,0 +1,84 @@ +import React, { useState, useRef } from 'react'; + +export type UseGameBoardInput = { + onCellClick?: (cellIndex: number) => void; + onCellDrop?: (fromIndex: number, toIndex: number, pointer: { x: number; y: number }) => void; +}; + +export type UseGameBoardOutput = { + hoveredCell: number | null; + dragging: { + fromCell: number | null; + pointer: { x: number; y: number }; + }; + getCellProps: (cellIndex: number) => { + onClick: (e: React.PointerEvent) => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerEnter: (e: React.PointerEvent) => void; + onPointerLeave: (e: React.PointerEvent) => void; + }; +}; + +export function useGameBoard({ onCellClick, onCellDrop }: UseGameBoardInput): UseGameBoardOutput { + const [hoveredCell, setHoveredCell] = useState(null); + const [dragging, setDragging] = useState<{ fromCell: number | null; pointer: { x: number; y: number } }>({ + fromCell: null, + pointer: { x: 0, y: 0 }, + }); + const lastPointer = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + const getCellProps = (cellIndex: number) => ({ + onClick: (e: React.PointerEvent) => { + if (onCellClick) onCellClick(cellIndex); + }, + onPointerDown: (e: React.PointerEvent) => { + setDragging({ fromCell: cellIndex, pointer: { x: e.clientX, y: e.clientY } }); + lastPointer.current = { x: e.clientX, y: e.clientY }; + }, + onPointerUp: (e: React.PointerEvent) => { + if (dragging.fromCell !== null && onCellDrop) { + onCellDrop(dragging.fromCell, cellIndex, { x: e.clientX, y: e.clientY }); + } + setDragging(d => ({ fromCell: null, pointer: { x: e.clientX, y: e.clientY } })); + lastPointer.current = { x: e.clientX, y: e.clientY }; + }, + onPointerEnter: (_e: React.PointerEvent) => { + setHoveredCell(cellIndex); + }, + onPointerLeave: (_e: React.PointerEvent) => { + setHoveredCell(null); + }, + }); + + // Optionally, update pointer position during drag + // (for drag preview following pointer) + const handlePointerMove = (e: PointerEvent) => { + if (dragging.fromCell !== null) { + setDragging(d => ({ ...d, pointer: { x: e.clientX, y: e.clientY } })); + lastPointer.current = { x: e.clientX, y: e.clientY }; + } + }; + + // Attach global pointermove when dragging + React.useEffect(() => { + if (dragging.fromCell !== null) { + window.addEventListener('pointermove', handlePointerMove); + return () => window.removeEventListener('pointermove', handlePointerMove); + } + }, [dragging.fromCell]); + + // Always provide a pointer value, even if not dragging + const pointer = dragging.fromCell !== null + ? dragging.pointer + : lastPointer.current; + + return { + hoveredCell, + dragging: { + fromCell: dragging.fromCell, + pointer, + }, + getCellProps, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useGroupCall.ts b/frontend/src/hooks/useGroupCall.ts index c1e4202..404ac77 100644 --- a/frontend/src/hooks/useGroupCall.ts +++ b/frontend/src/hooks/useGroupCall.ts @@ -1,8 +1,8 @@ 'use client'; import { useState, useReducer, useCallback, useEffect } from 'react'; -import { useWebSocket } from '@/hooks/webSocket'; -import { useUserStore } from '@/hooks/userStore'; +import { useWebSocket } from '@/hooks/useWebsocket'; +import { useUserStore } from '@/hooks/useUserStore'; import { VidSignalMsg } from '@/types/wsTypes'; interface Peer { diff --git a/frontend/src/hooks/themeStore.ts b/frontend/src/hooks/useTheme.ts similarity index 96% rename from frontend/src/hooks/themeStore.ts rename to frontend/src/hooks/useTheme.ts index b8d80ec..e811b93 100644 --- a/frontend/src/hooks/themeStore.ts +++ b/frontend/src/hooks/useTheme.ts @@ -72,7 +72,7 @@ export const useThemeStore = create()( ) ); -// Optional: Listen for system preference changes and update if theme is 'system' +//for handling system pref change if (typeof window !== 'undefined') { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const currentTheme = useThemeStore.getState().theme; diff --git a/frontend/src/hooks/userStore.ts b/frontend/src/hooks/useUserStore.ts similarity index 100% rename from frontend/src/hooks/userStore.ts rename to frontend/src/hooks/useUserStore.ts diff --git a/frontend/src/hooks/useBoardGame.ts b/frontend/src/hooks/useWSGame.ts similarity index 95% rename from frontend/src/hooks/useBoardGame.ts rename to frontend/src/hooks/useWSGame.ts index 335e334..285a12f 100644 --- a/frontend/src/hooks/useBoardGame.ts +++ b/frontend/src/hooks/useWSGame.ts @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; -import { useWebSocket } from './webSocket'; +import { useWebSocket } from './useWebsocket'; import { BoardGameState, GameMove, GameName } from '@/types/wsTypes'; -export default function useBoardGame() { +export default function useWebSocketGame() { const { sendGameMsg, setGameHandler } = useWebSocket(); const [isLoading, setIsLoading] = useState(false); diff --git a/frontend/src/hooks/webSocket.ts b/frontend/src/hooks/useWebsocket.ts similarity index 99% rename from frontend/src/hooks/webSocket.ts rename to frontend/src/hooks/useWebsocket.ts index 8d2c2a7..066ccef 100644 --- a/frontend/src/hooks/webSocket.ts +++ b/frontend/src/hooks/useWebsocket.ts @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { useUserStore } from './userStore'; +import { useUserStore } from './useUserStore'; import * as t from '@/types/wsTypes'; import { create } from 'zustand'; import { RECONNECT_INITIAL_DELAY, WS_URL } from '@/config/consts'; From 0b385c34c48c607830022c4f55d385458e10870c Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 00:09:54 -0700 Subject: [PATCH 02/12] fix: added recover() to live/client's trysend and a small delay to hub's unregistration to minimize sending on closed channel, and to not blow up if that does happen --- backend/internal/live/client.go | 20 +++++++++++++++++--- backend/internal/live/hub.go | 2 ++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/internal/live/client.go b/backend/internal/live/client.go index 31879fc..a1b5b3c 100644 --- a/backend/internal/live/client.go +++ b/backend/internal/live/client.go @@ -50,14 +50,15 @@ func (c *client) start() { func (c *client) stop() { c.cancel() - c.hub.unregister <- c close(c.send) close(c.recv) c.conn.Close(websocket.StatusNormalClosure, "client leaving") } func (c *client) readPump() { - defer c.stop() + defer func() { + c.hub.unregister <- c + }() for { msgType, msgRaw, err := c.conn.Read(c.ctx) @@ -99,11 +100,18 @@ func (c *client) writePump() { if !ok { return } + writeCtx, cancelWrite := context.WithTimeout(c.ctx, c.cfg.WriteTimeout) err := c.conn.Write(writeCtx, websocket.MessageText, message) cancelWrite() + if err != nil { - slog.Error("writePump: WebSocket write error", "error", err, "client", c.ID) + if websocket.CloseStatus(err) == websocket.StatusNormalClosure || + websocket.CloseStatus(err) == websocket.StatusGoingAway { + slog.Debug("WebSocket connection closed", "client", c.ID) + } else { + slog.Error("writePump: WebSocket write error", "error", err, "client", c.ID) + } c.cancel() return } @@ -179,6 +187,12 @@ func (c *client) processPump() { } func (c *client) trySend(msg []byte) { + defer func() { + if r := recover(); r != nil { + slog.Warn("trySend: Attempted to send on closed channel", "client", c.ID, "recover", r) + } + }() + select { case c.send <- msg: default: diff --git a/backend/internal/live/hub.go b/backend/internal/live/hub.go index eb65210..e2ba83c 100644 --- a/backend/internal/live/hub.go +++ b/backend/internal/live/hub.go @@ -4,6 +4,7 @@ import ( "letsgo/internal/config" "letsgo/internal/game" "log/slog" + "time" ) type hub struct { @@ -52,6 +53,7 @@ func (h *hub) run() { delete(h.clients, client) slog.Debug("Unregistered: ", "client", client.ID) } + time.AfterFunc(100*time.Millisecond, client.stop) case pair := <-h.joinRoom: slog.Debug("join room") From efdac86ee0c2bfa6dead14fa5f198e2c22d1f394 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 00:28:46 -0700 Subject: [PATCH 03/12] refactor: moving all touch handling to hook --- .../src/components/games/TicTacToeBoard.tsx | 20 +++++---- frontend/src/hooks/useGameBoard.ts | 42 +++++++------------ 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/games/TicTacToeBoard.tsx b/frontend/src/components/games/TicTacToeBoard.tsx index 8da0d4d..3110b22 100644 --- a/frontend/src/components/games/TicTacToeBoard.tsx +++ b/frontend/src/components/games/TicTacToeBoard.tsx @@ -1,26 +1,32 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { GameBoardProps } from '@/types/gameTypes'; import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; import { X, Circle } from 'lucide-react'; +import { useGameBoard } from '@/hooks/useGameBoard'; export function TicTacToeBoard({ gameState, makeMove }: GameBoardProps) { - const [hoveredCell, setHoveredCell] = useState<{ row: number, col: number } | null>(null); const username = useUserStore(state => state.username); const yourIdx = gameState.players.indexOf(username); const isYourTurn = gameState.status === 'in_progress' && gameState.turn === yourIdx; - const handleCellClick = useCallback((row: number, col: number) => { + const handleCellClick = useCallback((cell: number) => { + const row = Math.floor(cell / 3); + const col = cell % 3; if (gameState.status === 'in_progress' && gameState.board[row][col] === 0 && isYourTurn) makeMove({ to: { row, col } }); }, [gameState.status, gameState.board, makeMove, isYourTurn]); - return
+ const { getCellProps, hoveredCell } = useGameBoard({ + onCellClick: handleCellClick, + }); + + return
{gameState.board.map((cellRow, row) => cellRow.map((cell, col) => { - const isHovered = hoveredCell?.row === row && hoveredCell?.col === col; + const isHovered = row * 3 + col === hoveredCell; return
0 && yourIdx !== -1 && cell !== yourIdx + 1, } )} - onClick={() => handleCellClick(row, col)} - onMouseEnter={() => setHoveredCell({ row, col })} - onMouseLeave={() => setHoveredCell(null)} + {...getCellProps(row * 3 + col)} > {isHovered &&
} {cell === 1 ? diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts index be5c7a0..7462d22 100644 --- a/frontend/src/hooks/useGameBoard.ts +++ b/frontend/src/hooks/useGameBoard.ts @@ -12,55 +12,49 @@ export type UseGameBoardOutput = { pointer: { x: number; y: number }; }; getCellProps: (cellIndex: number) => { - onClick: (e: React.PointerEvent) => void; - onPointerDown: (e: React.PointerEvent) => void; - onPointerUp: (e: React.PointerEvent) => void; - onPointerEnter: (e: React.PointerEvent) => void; - onPointerLeave: (e: React.PointerEvent) => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerEnter: (e: React.PointerEvent) => void; + onPointerLeave: (e: React.PointerEvent) => void; }; }; export function useGameBoard({ onCellClick, onCellDrop }: UseGameBoardInput): UseGameBoardOutput { const [hoveredCell, setHoveredCell] = useState(null); + const sameCell = useRef(true); const [dragging, setDragging] = useState<{ fromCell: number | null; pointer: { x: number; y: number } }>({ fromCell: null, pointer: { x: 0, y: 0 }, }); - const lastPointer = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const getCellProps = (cellIndex: number) => ({ - onClick: (e: React.PointerEvent) => { - if (onCellClick) onCellClick(cellIndex); - }, onPointerDown: (e: React.PointerEvent) => { setDragging({ fromCell: cellIndex, pointer: { x: e.clientX, y: e.clientY } }); - lastPointer.current = { x: e.clientX, y: e.clientY }; + sameCell.current = true; }, onPointerUp: (e: React.PointerEvent) => { - if (dragging.fromCell !== null && onCellDrop) { - onCellDrop(dragging.fromCell, cellIndex, { x: e.clientX, y: e.clientY }); + if (dragging.fromCell !== null) { + if (onCellDrop) + onCellDrop(dragging.fromCell, cellIndex, { x: e.clientX, y: e.clientY }); + if (sameCell.current && onCellClick) + onCellClick(cellIndex); } - setDragging(d => ({ fromCell: null, pointer: { x: e.clientX, y: e.clientY } })); - lastPointer.current = { x: e.clientX, y: e.clientY }; + setDragging({ fromCell: null, pointer: { x: e.clientX, y: e.clientY } }); }, onPointerEnter: (_e: React.PointerEvent) => { setHoveredCell(cellIndex); }, onPointerLeave: (_e: React.PointerEvent) => { setHoveredCell(null); + sameCell.current = false; }, }); - // Optionally, update pointer position during drag - // (for drag preview following pointer) const handlePointerMove = (e: PointerEvent) => { if (dragging.fromCell !== null) { setDragging(d => ({ ...d, pointer: { x: e.clientX, y: e.clientY } })); - lastPointer.current = { x: e.clientX, y: e.clientY }; } }; - - // Attach global pointermove when dragging React.useEffect(() => { if (dragging.fromCell !== null) { window.addEventListener('pointermove', handlePointerMove); @@ -68,17 +62,9 @@ export function useGameBoard({ onCellClick, onCellDrop }: UseGameBoardInput): Us } }, [dragging.fromCell]); - // Always provide a pointer value, even if not dragging - const pointer = dragging.fromCell !== null - ? dragging.pointer - : lastPointer.current; - return { hoveredCell, - dragging: { - fromCell: dragging.fromCell, - pointer, - }, + dragging, getCellProps, }; } \ No newline at end of file From f9ab28bc2199a342e8f7bede2737e935e1a9e534 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 00:35:40 -0700 Subject: [PATCH 04/12] fix: centralize disconnection handling from leaving and timeout --- backend/internal/game/game.go | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/backend/internal/game/game.go b/backend/internal/game/game.go index a44c98e..81bced1 100644 --- a/backend/internal/game/game.go +++ b/backend/internal/game/game.go @@ -47,7 +47,7 @@ type Game interface { getBoardLocked() any getValidMovesLocked() []GameMove updateLoop() - handleDisconnects() + handleDisconnectLocked() } type GameUpdate struct { @@ -158,20 +158,7 @@ func (b *baseGame) Leave(player string, intentional bool) { b.players = slices.Delete(b.players, idx, idx+1) } - if len(b.players) == 0 { - b.status = StatusFin - b.endedAt = time.Now() - b.notify(GameUpdate{ - State: b.stateLocked(), - Action: DeleteAction, - }) - b.Stop() - return - } - b.notify(GameUpdate{ - State: b.stateLocked(), - Action: UpdateAction, - }) + b.handleDisconnectLocked() } func (b *baseGame) checkTurnLocked(sender string) (int, error) { @@ -260,11 +247,11 @@ func (b *baseGame) updateLoop() { b.players = slices.Delete(b.players, idx, idx+1) } } - b.self.handleDisconnects() + b.self.handleDisconnectLocked() } } -func (b *baseGame) handleDisconnects() { +func (b *baseGame) handleDisconnectLocked() { // just stop game if player doesn't come back. b.status = StatusFin b.endedAt = time.Now() From bee7ddf328c630266404ccafad14164a741d83b6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 01:52:26 -0700 Subject: [PATCH 05/12] feat: generally better touch/mouse(/pen?) handling and used on every board. refactor: unnessesary use client --- frontend/src/app/live/boardgame/page.tsx | 2 +- frontend/src/app/live/video/page.tsx | 2 +- frontend/src/components/Chat/ChatBox.tsx | 4 +- frontend/src/components/Chat/LoginBox.tsx | 4 +- frontend/src/components/UI/Dropdown.tsx | 2 +- frontend/src/components/UI/NavBar.tsx | 1 - frontend/src/components/UI/ThemeSwitcher.tsx | 3 - .../src/components/games/Connect4Board.tsx | 24 +++--- frontend/src/components/games/CreateGame.tsx | 2 - .../src/components/games/TicTacToeBoard.tsx | 4 +- frontend/src/hooks/useGameBoard.ts | 85 ++++++++++++------- 11 files changed, 75 insertions(+), 58 deletions(-) diff --git a/frontend/src/app/live/boardgame/page.tsx b/frontend/src/app/live/boardgame/page.tsx index 3728082..fafe051 100644 --- a/frontend/src/app/live/boardgame/page.tsx +++ b/frontend/src/app/live/boardgame/page.tsx @@ -36,7 +36,7 @@ export default function BoardGamePage() { {(gameState.status === 'finished') &&
- {gameState.winner ?? 'Nobody'} Wins! + {gameState.winner == '' ? 'Nobody' : gameState.winner} Wins!
} diff --git a/frontend/src/app/live/video/page.tsx b/frontend/src/app/live/video/page.tsx index 5986c56..94383d0 100644 --- a/frontend/src/app/live/video/page.tsx +++ b/frontend/src/app/live/video/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useRef, useState, useEffect } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { useWebSocket } from '@/hooks/useWebsocket'; import { useGroupCall } from '@/hooks/useGroupCall'; import Button from '@/components/UI/Button'; diff --git a/frontend/src/components/Chat/ChatBox.tsx b/frontend/src/components/Chat/ChatBox.tsx index bf52ec1..8f955d0 100644 --- a/frontend/src/components/Chat/ChatBox.tsx +++ b/frontend/src/components/Chat/ChatBox.tsx @@ -1,6 +1,4 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useWebSocket } from '@/hooks/useWebsocket'; import { useUserStore } from '@/hooks/useUserStore'; import Input from '../UI/Input'; diff --git a/frontend/src/components/Chat/LoginBox.tsx b/frontend/src/components/Chat/LoginBox.tsx index 1dc8281..cb8cd6f 100644 --- a/frontend/src/components/Chat/LoginBox.tsx +++ b/frontend/src/components/Chat/LoginBox.tsx @@ -1,6 +1,4 @@ -'use client'; - -import React, { useState } from 'react'; +import { useState } from 'react'; import { useUserStore } from '@/hooks/useUserStore'; // Adjust path if different import Input from '../UI/Input'; import Button from '../UI/Button'; diff --git a/frontend/src/components/UI/Dropdown.tsx b/frontend/src/components/UI/Dropdown.tsx index edae9a4..451617d 100644 --- a/frontend/src/components/UI/Dropdown.tsx +++ b/frontend/src/components/UI/Dropdown.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import Link from 'next/link'; import Button from './Button'; diff --git a/frontend/src/components/UI/NavBar.tsx b/frontend/src/components/UI/NavBar.tsx index 3a6c64d..6bd0a28 100644 --- a/frontend/src/components/UI/NavBar.tsx +++ b/frontend/src/components/UI/NavBar.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Link from 'next/link'; import Dropdown from './Dropdown'; import ThemeSwitcher from './ThemeSwitcher'; diff --git a/frontend/src/components/UI/ThemeSwitcher.tsx b/frontend/src/components/UI/ThemeSwitcher.tsx index 6bd9d02..f75ae94 100644 --- a/frontend/src/components/UI/ThemeSwitcher.tsx +++ b/frontend/src/components/UI/ThemeSwitcher.tsx @@ -1,6 +1,3 @@ -'use client'; - -import React from 'react'; import { useThemeStore, Theme } from '@/hooks/useTheme'; export default function ThemeSwitcher() { diff --git a/frontend/src/components/games/Connect4Board.tsx b/frontend/src/components/games/Connect4Board.tsx index da215fe..dc01b22 100644 --- a/frontend/src/components/games/Connect4Board.tsx +++ b/frontend/src/components/games/Connect4Board.tsx @@ -2,33 +2,35 @@ import { useState } from 'react'; import { GameBoardProps } from '@/types/gameTypes'; import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; +import { useGameBoard } from '@/hooks/useGameBoard'; export function Connect4Board({ gameState, makeMove }: GameBoardProps) { - const [hoveredCol, setHoveredCol] = useState(-1); const username = useUserStore(state => state.username); const yourIdx = gameState.players.indexOf(username); const isYourTurn = gameState.status == 'in_progress' && gameState.turn === yourIdx; - const handleClick = () => { - if (!isYourTurn || hoveredCol < 0) return; - makeMove({ to: { row: 0, col: hoveredCol } }); + const handleCellClick = (col: number) => { + console.log(col); + if (!isYourTurn) return; + makeMove({ to: { row: 0, col } }); }; - return
setHoveredCol(-1)} + const { getCellProps, hoveredCell } = useGameBoard({ + onCellClick: handleCellClick, + }); + + return
{[...Array(7)].map((_, col) => (
setHoveredCol(col)} - onPointerLeave={() => setHoveredCol(-1)} + {...getCellProps(col)} > - {hoveredCol === col && ( + {hoveredCell === col && (
{isHovered &&
} - {cell === 1 ? - : cell === 2 ? + {cell === 1 ? + : cell === 2 ? : null}
; }) diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts index 7462d22..2486167 100644 --- a/frontend/src/hooks/useGameBoard.ts +++ b/frontend/src/hooks/useGameBoard.ts @@ -1,14 +1,14 @@ -import React, { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; export type UseGameBoardInput = { onCellClick?: (cellIndex: number) => void; - onCellDrop?: (fromIndex: number, toIndex: number, pointer: { x: number; y: number }) => void; + onCellDrop?: (fromIndex: number, toIndex: number) => void; }; export type UseGameBoardOutput = { hoveredCell: number | null; dragging: { - fromCell: number | null; + from: number | null; pointer: { x: number; y: number }; }; getCellProps: (cellIndex: number) => { @@ -16,51 +16,76 @@ export type UseGameBoardOutput = { onPointerUp: (e: React.PointerEvent) => void; onPointerEnter: (e: React.PointerEvent) => void; onPointerLeave: (e: React.PointerEvent) => void; + onPointerCancel: (e: React.PointerEvent) => void; }; }; export function useGameBoard({ onCellClick, onCellDrop }: UseGameBoardInput): UseGameBoardOutput { const [hoveredCell, setHoveredCell] = useState(null); - const sameCell = useRef(true); - const [dragging, setDragging] = useState<{ fromCell: number | null; pointer: { x: number; y: number } }>({ - fromCell: null, + const [dragging, setDragging] = useState<{ from: number | null; pointer: { x: number; y: number } }>({ + from: null, pointer: { x: 0, y: 0 }, }); + const lastPointerType = useRef(null); + const sameCell = useRef(true); // for cursor cell click + + const reset = () => { + setDragging({ from: null, pointer: { x: 0, y: 0 } }); + setHoveredCell(null); + }; + + const setInput = (type: React.PointerEvent['pointerType']) => { + if (lastPointerType.current && lastPointerType.current !== type) reset(); + lastPointerType.current = type; + }; const getCellProps = (cellIndex: number) => ({ - onPointerDown: (e: React.PointerEvent) => { - setDragging({ fromCell: cellIndex, pointer: { x: e.clientX, y: e.clientY } }); - sameCell.current = true; - }, - onPointerUp: (e: React.PointerEvent) => { - if (dragging.fromCell !== null) { - if (onCellDrop) - onCellDrop(dragging.fromCell, cellIndex, { x: e.clientX, y: e.clientY }); - if (sameCell.current && onCellClick) - onCellClick(cellIndex); + onPointerDown: (e: React.PointerEvent) => { + setInput(e.pointerType); + if (e.pointerType === 'touch') { + if (onCellClick) onCellClick(cellIndex); + + setDragging(drag => { + if (drag.from === null) + return { from: cellIndex, pointer: { x: e.clientX, y: e.clientY } } + + onCellDrop && onCellDrop(drag.from, cellIndex); + return { from: null, pointer: { x: 0, y: 0 } } + }); + } else { + setDragging({ from: cellIndex, pointer: { x: e.clientX, y: e.clientY } }); + sameCell.current = true; } - setDragging({ fromCell: null, pointer: { x: e.clientX, y: e.clientY } }); }, - onPointerEnter: (_e: React.PointerEvent) => { - setHoveredCell(cellIndex); + onPointerUp: (e: React.PointerEvent) => { + setInput(e.pointerType); + if (e.pointerType === 'touch') return; + + if (sameCell.current && onCellClick) + onCellClick(cellIndex); + if (dragging.from !== null && onCellDrop) + onCellDrop(dragging.from, cellIndex); + setDragging({ from: null, pointer: { x: e.clientX, y: e.clientY } }); }, - onPointerLeave: (_e: React.PointerEvent) => { + + onPointerEnter: () => { setHoveredCell(cellIndex) }, + onPointerLeave: () => { setHoveredCell(null); sameCell.current = false; }, + onPointerCancel: () => { reset() }, }); - const handlePointerMove = (e: PointerEvent) => { - if (dragging.fromCell !== null) { - setDragging(d => ({ ...d, pointer: { x: e.clientX, y: e.clientY } })); - } - }; - React.useEffect(() => { - if (dragging.fromCell !== null) { - window.addEventListener('pointermove', handlePointerMove); - return () => window.removeEventListener('pointermove', handlePointerMove); + // for dragged pieces + useEffect(() => { + if (dragging.from !== null) { + const onPointerMove = (e: PointerEvent) => { + setDragging(d => ({ ...d, pointer: { x: e.clientX, y: e.clientY } })); + }; + window.addEventListener('pointermove', onPointerMove); + return () => window.removeEventListener('pointermove', onPointerMove); } - }, [dragging.fromCell]); + }, [dragging.from]); return { hoveredCell, From 24a64b4d3379f53edeba9efb3c4a4d6500888749 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 03:59:25 -0700 Subject: [PATCH 06/12] feat: hopefully much more robust game board hook. handles click and drag ft: emulated drag for touch. Also captures pointer. --- frontend/src/app/live/boardgame/page.tsx | 2 +- frontend/src/components/UI/ThemeSwitcher.tsx | 2 + .../src/components/games/Connect4Board.tsx | 2 +- .../src/components/games/TicTacToeBoard.tsx | 2 +- .../src/components/games/chess/ChessBoard.tsx | 80 ++++++--------- frontend/src/hooks/useGameBoard.ts | 98 +++++++++++-------- 6 files changed, 88 insertions(+), 98 deletions(-) diff --git a/frontend/src/app/live/boardgame/page.tsx b/frontend/src/app/live/boardgame/page.tsx index fafe051..77e1527 100644 --- a/frontend/src/app/live/boardgame/page.tsx +++ b/frontend/src/app/live/boardgame/page.tsx @@ -56,7 +56,7 @@ export default function BoardGamePage() { leaveGame={leaveGame} /> -
+
{gameState.gameName !== '' ? GameBoard ?
diff --git a/frontend/src/components/UI/ThemeSwitcher.tsx b/frontend/src/components/UI/ThemeSwitcher.tsx index f75ae94..215831b 100644 --- a/frontend/src/components/UI/ThemeSwitcher.tsx +++ b/frontend/src/components/UI/ThemeSwitcher.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useThemeStore, Theme } from '@/hooks/useTheme'; export default function ThemeSwitcher() { diff --git a/frontend/src/components/games/Connect4Board.tsx b/frontend/src/components/games/Connect4Board.tsx index dc01b22..3fd4ac4 100644 --- a/frontend/src/components/games/Connect4Board.tsx +++ b/frontend/src/components/games/Connect4Board.tsx @@ -20,7 +20,7 @@ export function Connect4Board({ gameState, makeMove }: GameBoardProps) { onCellClick: handleCellClick, }); - return
{[...Array(7)].map((_, col) => (
+ return
{gameState.board.map((cellRow, row) => cellRow.map((cell, col) => { const isHovered = row * 3 + col === hoveredCell; diff --git a/frontend/src/components/games/chess/ChessBoard.tsx b/frontend/src/components/games/chess/ChessBoard.tsx index 250c79f..741918b 100644 --- a/frontend/src/components/games/chess/ChessBoard.tsx +++ b/frontend/src/components/games/chess/ChessBoard.tsx @@ -5,6 +5,7 @@ import { GameBoardProps } from '@/types/gameTypes'; import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; import { numToPiece } from './pieceMapping'; +import { useGameBoard } from '@/hooks/useGameBoard'; type Position = { row: number; col: number }; @@ -16,55 +17,32 @@ interface DragState { export function ChessBoard({ gameState, makeMove }: GameBoardProps) { const username = useUserStore(state => state.username); - const hover = useRef(null); - const [drag, setDrag] = useState(null); - const idx = gameState.players.indexOf(username); const yourTurn = gameState.status === 'in_progress' && gameState.turn === idx; - const handlePointerDown = (e: React.PointerEvent, piece: number, row: number, col: number) => { - if (e.pointerType === 'mouse' && e.button !== 0) return; - e.preventDefault(); - setDrag({ - piece: piece, - from: { row, col }, - pos: { x: e.clientX, y: e.clientY }, - }); - }; - - const validSquares = useMemo(() => gameState.validMoves.filter(vMove => - vMove.from?.row === drag?.from.row && vMove.from?.col === drag?.from.col) - , [gameState.validMoves, drag?.from.row, drag?.from.col]); - - useEffect(() => { - if (!drag) return; - const handlePointerMove = (e: PointerEvent) => { - setDrag(prev => ({ - ...prev!, - pos: { x: e.clientX, y: e.clientY }, - })); - }; - const handlePointerUp = () => { - const hoverSquare = hover.current; - if (hoverSquare && yourTurn && validSquares.some(vMove => - vMove.to.row === hoverSquare.row && vMove.to.col === hoverSquare.col)) {//todo: remove valid check, make sure backend enforces it - makeMove({ - from: drag.from, - to: hoverSquare, - }); - } - setDrag(null); - }; + const { getCellProps, hoveredCell, dragging } = useGameBoard({ + onCellDrop: (from, to) => { + if (!yourTurn) return; + const fromRow = Math.floor(from / 8); + const fromCol = from % 8; + const toRow = Math.floor(to / 8); + const toCol = to % 8; + console.log(fromRow + '-' + fromCol, toRow + '-' + toCol); + if (gameState.validMoves.some(mv => + mv.from?.row === fromRow && mv.from?.col === fromCol && + mv.to.row === toRow && mv.to.col === toCol)) + makeMove({ from: { row: fromRow, col: fromCol }, to: { row: toRow, col: toCol } }); + } + }); - document.addEventListener('pointermove', handlePointerMove); - document.addEventListener('pointerup', handlePointerUp, { once: true }); - return () => { - document.removeEventListener('pointermove', handlePointerMove); - document.removeEventListener('pointerup', handlePointerUp); - }; - }, [drag, makeMove, validSquares, yourTurn]); + const validSquares = useMemo(() => gameState.validMoves.filter(vMove => { + if (dragging.from === null) return false; + const row = Math.floor(dragging.from / 8); + const col = dragging.from % 8; + return vMove.from?.row === row && vMove.from?.col === col; + }), [gameState.validMoves, dragging.from]); - return
+ return

attention: work in progress

{gameState.board.map((cellRow, row) => cellRow.map((cell, col) => { @@ -74,20 +52,18 @@ export function ChessBoard({ gameState, makeMove }: GameBoardProps) { return
move.to.row === row && move.to.col === col) ? isLight ? 'bg-primary/33' : 'bg-primary/66' : isLight ? 'bg-secondary/10' : 'bg-secondary/50', + hoveredCell === row * 8 + col && 'bg-accent/30', + dragging.from === row * 8 + col && 'bg-accent/60' )} - onPointerEnter={() => hover.current = { row, col }} - onPointerLeave={() => hover.current = null} - onPointerDown={(e) => handlePointerDown(e, cell, row, col)} + {...getCellProps(row * 8 + col)} > {Piece - && !(drag && drag.from.row === row && drag.from.col === col) - && } + && !(dragging.from === row * 8 + col) + && }
}) )} diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts index 2486167..d9133ce 100644 --- a/frontend/src/hooks/useGameBoard.ts +++ b/frontend/src/hooks/useGameBoard.ts @@ -1,71 +1,71 @@ import { useState, useRef, useEffect } from 'react'; -export type UseGameBoardInput = { +type useBoardProps = { onCellClick?: (cellIndex: number) => void; onCellDrop?: (fromIndex: number, toIndex: number) => void; }; -export type UseGameBoardOutput = { - hoveredCell: number | null; - dragging: { - from: number | null; - pointer: { x: number; y: number }; - }; - getCellProps: (cellIndex: number) => { - onPointerDown: (e: React.PointerEvent) => void; - onPointerUp: (e: React.PointerEvent) => void; - onPointerEnter: (e: React.PointerEvent) => void; - onPointerLeave: (e: React.PointerEvent) => void; - onPointerCancel: (e: React.PointerEvent) => void; - }; -}; +const noDrag = { from: null, pos: { x: 0, y: 0 } }; -export function useGameBoard({ onCellClick, onCellDrop }: UseGameBoardInput): UseGameBoardOutput { +export function useGameBoard({ onCellClick, onCellDrop }: useBoardProps) { const [hoveredCell, setHoveredCell] = useState(null); - const [dragging, setDragging] = useState<{ from: number | null; pointer: { x: number; y: number } }>({ - from: null, - pointer: { x: 0, y: 0 }, - }); - const lastPointerType = useRef(null); + const [dragging, setDrag] = useState<{ from: number | null; pos: { x: number; y: number } }>(noDrag); const sameCell = useRef(true); // for cursor cell click - const reset = () => { - setDragging({ from: null, pointer: { x: 0, y: 0 } }); - setHoveredCell(null); + const lastInputType = useRef(null); + const setInputType = (type: React.PointerEvent['pointerType']) => { + if (lastInputType.current && lastInputType.current !== type) setDrag(noDrag); + lastInputType.current = type; }; - const setInput = (type: React.PointerEvent['pointerType']) => { - if (lastPointerType.current && lastPointerType.current !== type) reset(); - lastPointerType.current = type; - }; + const captured = useRef<{ ele: Element | null; ptr: number | null }> + ({ ele: null, ptr: null }); + const capture = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + captured.current = { ele: e.currentTarget, ptr: e.pointerId }; + } + const release = () => { + if (captured.current.ele && captured.current.ptr !== null) { + try { + captured.current.ele.releasePointerCapture(captured.current.ptr); + } finally { + captured.current = { ele: null, ptr: null }; + } + } + } + const getCellProps = (cellIndex: number) => ({ onPointerDown: (e: React.PointerEvent) => { - setInput(e.pointerType); + e.stopPropagation() + setInputType(e.pointerType); + capture(e); if (e.pointerType === 'touch') { if (onCellClick) onCellClick(cellIndex); - setDragging(drag => { + setDrag(drag => { if (drag.from === null) - return { from: cellIndex, pointer: { x: e.clientX, y: e.clientY } } + return { from: cellIndex, pos: { x: e.clientX, y: e.clientY } } onCellDrop && onCellDrop(drag.from, cellIndex); - return { from: null, pointer: { x: 0, y: 0 } } + return { from: null, pos: { x: 0, y: 0 } } }); } else { - setDragging({ from: cellIndex, pointer: { x: e.clientX, y: e.clientY } }); + setDrag({ from: cellIndex, pos: { x: e.clientX, y: e.clientY } }); sameCell.current = true; } }, onPointerUp: (e: React.PointerEvent) => { - setInput(e.pointerType); + e.stopPropagation() + release(); + setInputType(e.pointerType); if (e.pointerType === 'touch') return; if (sameCell.current && onCellClick) onCellClick(cellIndex); if (dragging.from !== null && onCellDrop) onCellDrop(dragging.from, cellIndex); - setDragging({ from: null, pointer: { x: e.clientX, y: e.clientY } }); + setDrag({ from: null, pos: { x: e.clientX, y: e.clientY } }); }, onPointerEnter: () => { setHoveredCell(cellIndex) }, @@ -73,18 +73,30 @@ export function useGameBoard({ onCellClick, onCellDrop }: UseGameBoardInput): Us setHoveredCell(null); sameCell.current = false; }, - onPointerCancel: () => { reset() }, }); - // for dragged pieces + // useEffect(() => { - if (dragging.from !== null) { - const onPointerMove = (e: PointerEvent) => { - setDragging(d => ({ ...d, pointer: { x: e.clientX, y: e.clientY } })); - }; - window.addEventListener('pointermove', onPointerMove); - return () => window.removeEventListener('pointermove', onPointerMove); + if (dragging.from === null) return + const onPointerMove = (e: PointerEvent) => + setDrag(d => ({ ...d, pos: { x: e.clientX, y: e.clientY } })) + + const reset = () => { + release(); + setDrag(noDrag) } + + //cancel drag if outside. Up needed for mouse and down for touch + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', reset); + window.addEventListener('pointerdown', reset); + window.addEventListener('pointercancel', reset); + return () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', reset); + window.removeEventListener('pointerdown', reset); + window.removeEventListener('pointercancel', reset); + }; }, [dragging.from]); return { From f7a21bb34a80744e09d1a9a76faf32f733222324 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 04:26:14 -0700 Subject: [PATCH 07/12] feat: add drag piece preview and touch simulated offset to chess game --- frontend/src/app/live/draw/page.tsx | 3 +-- frontend/src/components/UI/ChatIndicator.tsx | 5 +---- frontend/src/components/games/Connect4Board.tsx | 11 ++++------- .../src/components/games/chess/ChessBoard.tsx | 13 +++++++++++++ frontend/src/hooks/useGameBoard.ts | 15 +++++++++++---- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/live/draw/page.tsx b/frontend/src/app/live/draw/page.tsx index 99c6087..8c2c56c 100644 --- a/frontend/src/app/live/draw/page.tsx +++ b/frontend/src/app/live/draw/page.tsx @@ -204,7 +204,7 @@ export default function DrawPage() {
- +
; } diff --git a/frontend/src/components/games/Connect4Board.tsx b/frontend/src/components/games/Connect4Board.tsx index 3fd4ac4..039c325 100644 --- a/frontend/src/components/games/Connect4Board.tsx +++ b/frontend/src/components/games/Connect4Board.tsx @@ -28,15 +28,12 @@ export function Connect4Board({ gameState, makeMove }: GameBoardProps) { className='relative' {...getCellProps(col)} > - {hoveredCell === col && ( + {true && (
)} diff --git a/frontend/src/components/games/chess/ChessBoard.tsx b/frontend/src/components/games/chess/ChessBoard.tsx index 741918b..919f62b 100644 --- a/frontend/src/components/games/chess/ChessBoard.tsx +++ b/frontend/src/components/games/chess/ChessBoard.tsx @@ -67,5 +67,18 @@ export function ChessBoard({ gameState, makeMove }: GameBoardProps) {
}) )} + {dragging.from !== null && (() => { + const row = Math.floor(dragging.from / 8); + const col = dragging.from % 8; + const pieceNum = gameState.board[row][col]; + const Piece = numToPiece[pieceNum]; + if (!Piece) return null; + return ; + })()}
; } diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts index d9133ce..1ebfe3c 100644 --- a/frontend/src/hooks/useGameBoard.ts +++ b/frontend/src/hooks/useGameBoard.ts @@ -3,11 +3,12 @@ import { useState, useRef, useEffect } from 'react'; type useBoardProps = { onCellClick?: (cellIndex: number) => void; onCellDrop?: (fromIndex: number, toIndex: number) => void; + touchOffset?: { x: number; y: number }; }; const noDrag = { from: null, pos: { x: 0, y: 0 } }; -export function useGameBoard({ onCellClick, onCellDrop }: useBoardProps) { +export function useGameBoard({ onCellClick, onCellDrop, touchOffset = { x: 0, y: 10 } }: useBoardProps) { const [hoveredCell, setHoveredCell] = useState(null); const [dragging, setDrag] = useState<{ from: number | null; pos: { x: number; y: number } }>(noDrag); const sameCell = useRef(true); // for cursor cell click @@ -43,9 +44,13 @@ export function useGameBoard({ onCellClick, onCellDrop }: useBoardProps) { if (e.pointerType === 'touch') { if (onCellClick) onCellClick(cellIndex); + const rect = e.currentTarget.getBoundingClientRect(); setDrag(drag => { - if (drag.from === null) - return { from: cellIndex, pos: { x: e.clientX, y: e.clientY } } + if (drag.from === null) { + const x = rect.left + rect.width / 2 + touchOffset.x; + const y = rect.top + rect.height / 2 - touchOffset.y; + return { from: cellIndex, pos: { x, y } } + } onCellDrop && onCellDrop(drag.from, cellIndex); return { from: null, pos: { x: 0, y: 0 } } @@ -78,8 +83,10 @@ export function useGameBoard({ onCellClick, onCellDrop }: useBoardProps) { // useEffect(() => { if (dragging.from === null) return - const onPointerMove = (e: PointerEvent) => + const onPointerMove = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; setDrag(d => ({ ...d, pos: { x: e.clientX, y: e.clientY } })) + } const reset = () => { release(); From ae6c974d929a94df8ae5e11cfbc49b232696731e Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 04:36:03 -0700 Subject: [PATCH 08/12] feat: better zoom/pan handling on draw --- frontend/src/app/live/draw/page.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/live/draw/page.tsx b/frontend/src/app/live/draw/page.tsx index 8c2c56c..1fc8079 100644 --- a/frontend/src/app/live/draw/page.tsx +++ b/frontend/src/app/live/draw/page.tsx @@ -65,7 +65,6 @@ export default function DrawPage() { return () => window.removeEventListener('resize', updateZoom); }, [containerRef]); - const getCanvasCoords = (clientX: number, clientY: number) => { if (!containerRef.current) return { x: 0, y: 0 }; const rect = containerRef.current.getBoundingClientRect(); @@ -75,6 +74,24 @@ export default function DrawPage() { }; }; + const handleZoom = (zoom: number) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + const x = ((centerX / containerZoom) - viewState.offset.x) / viewState.zoom; + const y = ((centerY / containerZoom) - viewState.offset.y) / viewState.zoom; + + setViewState({ + zoom: zoom, + offset: { + x: (centerX / containerZoom) - x * zoom, + y: (centerY / containerZoom) - y * zoom, + }, + }); + } + const startPan = (clientX: number, clientY: number) => { setIsPanning(true); panStartRef.current = { @@ -88,7 +105,7 @@ export default function DrawPage() { const handlePan = (clientX: number, clientY: number) => { if (!isPanning || !panStartRef.current) return; - const scale = 1 / (containerZoom * viewState.zoom); + const scale = 1 / (containerZoom); const dx = (clientX - panStartRef.current.x) * scale; const dy = (clientY - panStartRef.current.y) * scale; @@ -197,8 +214,8 @@ export default function DrawPage() { currentZoom={viewState.zoom} changeColor={color => setDrawState(s => ({ ...s, color }))} changeWidth={lineWidth => setDrawState(s => ({ ...s, lineWidth }))} - zoomIn={() => setViewState(s => ({ ...s, zoom: Math.min(s.zoom * 1.1, 3) }))} - zoomOut={() => setViewState(s => ({ ...s, zoom: Math.max(s.zoom * 0.9, 1) }))} + zoomIn={() => handleZoom(Math.min(viewState.zoom * 1.1, 3))} + zoomOut={() => handleZoom(Math.max(viewState.zoom * 0.9, 1))} zoomReset={() => setViewState({ zoom: 1, offset: { x: 0, y: 0 } })} /> From 795f2ea42cf98bb0ae095712c8272dfc0e335a5d Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 05:07:47 -0700 Subject: [PATCH 09/12] fix: pointer capture creating problems with pointerUp listeners --- .../src/components/games/chess/ChessBoard.tsx | 2 +- frontend/src/config/consts.ts | 2 +- frontend/src/hooks/useGameBoard.ts | 69 +++++++++---------- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/games/chess/ChessBoard.tsx b/frontend/src/components/games/chess/ChessBoard.tsx index 919f62b..309b138 100644 --- a/frontend/src/components/games/chess/ChessBoard.tsx +++ b/frontend/src/components/games/chess/ChessBoard.tsx @@ -27,7 +27,7 @@ export function ChessBoard({ gameState, makeMove }: GameBoardProps) { const fromCol = from % 8; const toRow = Math.floor(to / 8); const toCol = to % 8; - console.log(fromRow + '-' + fromCol, toRow + '-' + toCol); + if (gameState.validMoves.some(mv => mv.from?.row === fromRow && mv.from?.col === fromCol && mv.to.row === toRow && mv.to.col === toCol)) diff --git a/frontend/src/config/consts.ts b/frontend/src/config/consts.ts index dbe0ca0..4b0013e 100644 --- a/frontend/src/config/consts.ts +++ b/frontend/src/config/consts.ts @@ -1,6 +1,6 @@ import { GameName } from '@/types/wsTypes'; -export const RECONNECT_INITIAL_DELAY = 5000; +export const RECONNECT_INITIAL_DELAY = 100; export const RECONNECT_MAX_DELAY = 10000; export const RECONNECT_MAX_ATTEMPTS = 10; diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts index 1ebfe3c..37eb623 100644 --- a/frontend/src/hooks/useGameBoard.ts +++ b/frontend/src/hooks/useGameBoard.ts @@ -15,46 +15,45 @@ export function useGameBoard({ onCellClick, onCellDrop, touchOffset = { x: 0, y: const lastInputType = useRef(null); const setInputType = (type: React.PointerEvent['pointerType']) => { - if (lastInputType.current && lastInputType.current !== type) setDrag(noDrag); + if (lastInputType.current && lastInputType.current !== type) setDrag({ ...noDrag }); lastInputType.current = type; }; - const captured = useRef<{ ele: Element | null; ptr: number | null }> - ({ ele: null, ptr: null }); - const capture = (e: React.PointerEvent) => { - e.currentTarget.setPointerCapture(e.pointerId); - captured.current = { ele: e.currentTarget, ptr: e.pointerId }; - } - const release = () => { - if (captured.current.ele && captured.current.ptr !== null) { - try { - captured.current.ele.releasePointerCapture(captured.current.ptr); - } finally { - captured.current = { ele: null, ptr: null }; - } - } - } + /* causing problems with mouse drag. Prolly need to be used with manual hit test */ + // const captured = useRef<{ ele: Element | null; ptr: number | null }> + // ({ ele: null, ptr: null }); + // const capture = (e: React.PointerEvent) => { + // e.currentTarget.setPointerCapture(e.pointerId); + // captured.current = { ele: e.currentTarget, ptr: e.pointerId }; + // } + // const release = () => { + // if (captured.current.ele && captured.current.ptr !== null) { + // try { + // captured.current.ele.releasePointerCapture(captured.current.ptr); + // } finally { + // captured.current = { ele: null, ptr: null }; + // } + // } + // } const getCellProps = (cellIndex: number) => ({ onPointerDown: (e: React.PointerEvent) => { e.stopPropagation() setInputType(e.pointerType); - capture(e); + // capture(e); if (e.pointerType === 'touch') { if (onCellClick) onCellClick(cellIndex); - const rect = e.currentTarget.getBoundingClientRect(); - setDrag(drag => { - if (drag.from === null) { - const x = rect.left + rect.width / 2 + touchOffset.x; - const y = rect.top + rect.height / 2 - touchOffset.y; - return { from: cellIndex, pos: { x, y } } - } - - onCellDrop && onCellDrop(drag.from, cellIndex); - return { from: null, pos: { x: 0, y: 0 } } - }); + if (dragging.from === null) { + const rect = e.currentTarget.getBoundingClientRect(); + const x = rect.left + rect.width / 2 + touchOffset.x; + const y = rect.top + rect.height / 2 - touchOffset.y; + setDrag({ from: cellIndex, pos: { x, y } }) + } else { + setDrag({ ...noDrag }) + onCellDrop && onCellDrop(dragging.from, cellIndex); + } } else { setDrag({ from: cellIndex, pos: { x: e.clientX, y: e.clientY } }); sameCell.current = true; @@ -62,15 +61,15 @@ export function useGameBoard({ onCellClick, onCellDrop, touchOffset = { x: 0, y: }, onPointerUp: (e: React.PointerEvent) => { e.stopPropagation() - release(); + // release(); setInputType(e.pointerType); if (e.pointerType === 'touch') return; - if (sameCell.current && onCellClick) + if (onCellClick && sameCell.current) onCellClick(cellIndex); - if (dragging.from !== null && onCellDrop) - onCellDrop(dragging.from, cellIndex); - setDrag({ from: null, pos: { x: e.clientX, y: e.clientY } }); + if (onCellDrop && dragging.from !== null && hoveredCell !== null) + onCellDrop(dragging.from, hoveredCell); + setDrag({ ...noDrag }); }, onPointerEnter: () => { setHoveredCell(cellIndex) }, @@ -89,8 +88,8 @@ export function useGameBoard({ onCellClick, onCellDrop, touchOffset = { x: 0, y: } const reset = () => { - release(); - setDrag(noDrag) + // release(); + setDrag({ ...noDrag }) } //cancel drag if outside. Up needed for mouse and down for touch From c368acd54bbcf0d6325c3e7084f78d37482a7fd0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 05:30:32 -0700 Subject: [PATCH 10/12] feat: chess complete --- backend/internal/game/chess.go | 46 ++++++++++++++----- .../src/components/games/chess/ChessBoard.tsx | 1 - frontend/src/hooks/useWebsocket.ts | 5 -- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/backend/internal/game/chess.go b/backend/internal/game/chess.go index 11978f2..ed5d279 100644 --- a/backend/internal/game/chess.go +++ b/backend/internal/game/chess.go @@ -1,7 +1,8 @@ package game import ( - "fmt" + "errors" + "log/slog" "time" "github.com/corentings/chess/v2" @@ -71,17 +72,24 @@ func (c *chessGame) Move(sender string, mv *GameMove) error { return err } - moveStr := rowCol2Move(mv.From) + rowCol2Move(mv.To) - if mv.Change != "" { - moveStr += mv.Change + from := chess.Square((7-mv.From.Row)*8 + mv.From.Col) + to := chess.Square((7-mv.To.Row)*8 + mv.To.Col) + promo := change2Piece(mv.Change) + + var move *chess.Move + for _, m := range c.game.ValidMoves() { + if m.S1() == from && m.S2() == to && m.Promo() == promo { + move = &m + break + } } - notation := chess.UCINotation{} - move, err := notation.Decode(c.game.Position(), moveStr) - if err != nil { - return fmt.Errorf("invalid move format %q: %w", moveStr, err) + if move == nil { + return errors.New("invalid move: not legal in this position") } - if c.game.Move(move, nil) != nil { - return fmt.Errorf("invalid move %q", moveStr) + + if err := c.game.Move(move, nil); err != nil { + slog.Error("Internal error", "error", err) + return errors.New("internal error") } if c.game.Outcome() != chess.NoOutcome { @@ -89,9 +97,13 @@ func (c *chessGame) Move(sender string, mv *GameMove) error { c.endedAt = time.Now() switch c.game.Outcome() { case chess.WhiteWon: + c.status = StatusFin c.winner = c.players[0] case chess.BlackWon: + c.status = StatusFin c.winner = c.players[1] + case chess.Draw: + c.status = StatusFin } } c.turn = 1 - c.turn @@ -102,8 +114,18 @@ func (c *chessGame) Move(sender string, mv *GameMove) error { return nil } -func rowCol2Move(pos Position) string { - return fmt.Sprintf("%c%d", 'a'+pos.Col, 8-pos.Row) +func change2Piece(change string) chess.PieceType { + switch change { + case "q", "Q": + return chess.Queen + case "r", "R": + return chess.Rook + case "b", "B": + return chess.Bishop + case "n", "N": + return chess.Knight + } + return chess.NoPieceType } func pieceToCode(piece chess.Piece) int { diff --git a/frontend/src/components/games/chess/ChessBoard.tsx b/frontend/src/components/games/chess/ChessBoard.tsx index 309b138..4deee59 100644 --- a/frontend/src/components/games/chess/ChessBoard.tsx +++ b/frontend/src/components/games/chess/ChessBoard.tsx @@ -43,7 +43,6 @@ export function ChessBoard({ gameState, makeMove }: GameBoardProps) { }), [gameState.validMoves, dragging.from]); return
-

attention: work in progress

{gameState.board.map((cellRow, row) => cellRow.map((cell, col) => { const isLight = (row + col) % 2 === 0; diff --git a/frontend/src/hooks/useWebsocket.ts b/frontend/src/hooks/useWebsocket.ts index 066ccef..db0bc6a 100644 --- a/frontend/src/hooks/useWebsocket.ts +++ b/frontend/src/hooks/useWebsocket.ts @@ -117,11 +117,6 @@ export const useWebSocket = create()((set, get) => ({ } } - ws.onerror = (event) => { - console.error('WS error:', event); - set({ error: 'WS error' }); - }; - ws.onclose = (event) => { console.debug('WS closed:', event.code, event.reason); set({ error: `WS disconnected: code ${event.code}, reason: ${event.reason || 'Unknown'}.` }); From 4df33649fca0abc1ae256202e2fd8141c4d4caf7 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 05:36:54 -0700 Subject: [PATCH 11/12] lint: --- frontend/src/components/games/Connect4Board.tsx | 3 +-- frontend/src/components/games/chess/ChessBoard.tsx | 10 +--------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/games/Connect4Board.tsx b/frontend/src/components/games/Connect4Board.tsx index 039c325..9296a24 100644 --- a/frontend/src/components/games/Connect4Board.tsx +++ b/frontend/src/components/games/Connect4Board.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { GameBoardProps } from '@/types/gameTypes'; import { useUserStore } from '@/hooks/useUserStore'; import { cn } from '@/lib/utils'; @@ -28,7 +27,7 @@ export function Connect4Board({ gameState, makeMove }: GameBoardProps) { className='relative' {...getCellProps(col)} > - {true && ( + {hoveredCell === col && (
state.username); const idx = gameState.players.indexOf(username); From 8cc2ab805af0412f2e5cb49436ab8d0d1f69ae6b Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Jun 2025 05:39:44 -0700 Subject: [PATCH 12/12] lint: remove fun && fun() --- frontend/src/hooks/useGameBoard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useGameBoard.ts b/frontend/src/hooks/useGameBoard.ts index 37eb623..e660254 100644 --- a/frontend/src/hooks/useGameBoard.ts +++ b/frontend/src/hooks/useGameBoard.ts @@ -52,7 +52,7 @@ export function useGameBoard({ onCellClick, onCellDrop, touchOffset = { x: 0, y: setDrag({ from: cellIndex, pos: { x, y } }) } else { setDrag({ ...noDrag }) - onCellDrop && onCellDrop(dragging.from, cellIndex); + if (onCellDrop) onCellDrop(dragging.from, cellIndex); } } else { setDrag({ from: cellIndex, pos: { x: e.clientX, y: e.clientY } });