diff --git a/game/manager.go b/game/manager.go new file mode 100644 index 0000000..ea628f2 --- /dev/null +++ b/game/manager.go @@ -0,0 +1,257 @@ +package game + +import ( + "errors" + "fmt" + "math/rand" + "time" +) + +type GameManager struct { + State *GameState +} + +func NewGame(id string) *GameManager { + return &GameManager{ + State: &GameState{ + ID: id, + Players: make([]*Player, 0), + PlayerMap: make(map[string]*Player), + WaitingForRoll: false, + WaitingForMove: false, + }, + } +} + +func (gm *GameManager) AddPlayer(userID, name string, color Color) { + pieces := make([]Piece, 4) + for i := 0; i < 4; i++ { + pieces[i] = Piece{ + ID: fmt.Sprintf("%s-piece-%d", userID, i+1), + Color: color, + PathPosition: -1 - i, // -1, -2, -3, -4 (Home) + } + } + + player := &Player{ + User: User{ID: userID, Name: name}, + Color: color, + Pieces: pieces, + Dice: Dice{ + Value: 1, + Enabled: false, + }, + } + + gm.State.Players = append(gm.State.Players, player) + gm.State.PlayerMap[userID] = player +} + +func (gm *GameManager) StartGame() error { + if len(gm.State.Players) < 2 { + return errors.New("need at least 2 players") + } + gm.State.CurrentPlayerIdx = 0 + gm.startTurn() + return nil +} + +func (gm *GameManager) startTurn() { + player := gm.State.Players[gm.State.CurrentPlayerIdx] + fmt.Printf("Turn: %s (%s)\n", player.User.Name, player.Color) + + gm.State.WaitingForRoll = true + gm.State.WaitingForMove = false + player.Dice.Enabled = true + player.IsTurn = true + + // Reset others + for _, p := range gm.State.Players { + if p.User.ID != player.User.ID { + p.IsTurn = false + p.Dice.Enabled = false + } + } +} + +func (gm *GameManager) SwitchTurn() { + gm.State.CurrentPlayerIdx = (gm.State.CurrentPlayerIdx + 1) % len(gm.State.Players) + gm.startTurn() +} + +func (gm *GameManager) RollDice(userID string) (int, error) { + player, ok := gm.State.PlayerMap[userID] + if !ok { + return 0, errors.New("player not found") + } + + currentTurnPlayer := gm.State.Players[gm.State.CurrentPlayerIdx] + if currentTurnPlayer.User.ID != userID { + return 0, errors.New("not your turn") + } + + if !gm.State.WaitingForRoll { + return 0, errors.New("already rolled") + } + + // Random logic + rand.Seed(time.Now().UnixNano()) + val := rand.Intn(6) + 1 + // Deterministic override for debug if needed? No, use random. + + gm.State.CurrentDiceVal = val + player.Dice.Value = val + player.Dice.Enabled = false + gm.State.WaitingForRoll = false + + if !gm.HasPossibleMoves(player, val) { + gm.State.WaitingForMove = false + // Auto switch turn after short delay (caller handles delay or we just switch state) + gm.SwitchTurn() + return val, nil + } + + gm.State.WaitingForMove = true + return val, nil +} + +func (gm *GameManager) MovePiece(userID string, pieceID string) error { + if !gm.State.WaitingForMove { + return errors.New("not waiting for move") + } + + player, ok := gm.State.PlayerMap[userID] + if !ok { + return errors.New("player not found") + } + + currentTurnPlayer := gm.State.Players[gm.State.CurrentPlayerIdx] + if currentTurnPlayer.User.ID != userID { + return errors.New("not your turn") + } + + // Find piece + var piece *Piece + for i := range player.Pieces { + if player.Pieces[i].ID == pieceID { + piece = &player.Pieces[i] + break + } + } + if piece == nil { + return errors.New("piece not found") + } + + if !gm.isValidMove(piece, gm.State.CurrentDiceVal) { + return errors.New("invalid move") + } + + // Execute Move + if piece.PathPosition < 0 { + piece.PathPosition = 0 // Start + } else { + piece.PathPosition += gm.State.CurrentDiceVal + } + + // Captures + gm.checkCaptures(piece, player) + + // Win Check + if gm.checkWin(player) { + player.HasWon = true + gm.State.Gameover = true + return nil + } + + // Retry rule (6) + if gm.State.CurrentDiceVal == 6 { + gm.startTurn() // Same player + } else { + gm.SwitchTurn() + } + + return nil +} + +func (gm *GameManager) isValidMove(piece *Piece, diceVal int) bool { + if piece.PathPosition < 0 { + return diceVal == 6 + } + // Check bounds + // Max index is 56 (Game finishes at 56 or 57?) + // Frontend logic: maxIndex = 56. + if piece.PathPosition+diceVal > MaxPosition { + return false + } + return true +} + +func (gm *GameManager) HasPossibleMoves(player *Player, diceVal int) bool { + for i := range player.Pieces { + if gm.isValidMove(&player.Pieces[i], diceVal) { + return true + } + } + return false +} + +func (gm *GameManager) checkWin(player *Player) bool { + for _, p := range player.Pieces { + if p.PathPosition != MaxPosition { + return false + } + } + return true +} + +func (gm *GameManager) checkCaptures(movedPiece *Piece, currentPlayer *Player) { + // Need mapping logic. + // Red Offset = 0 + // Green Offset = 13 + // Blue Offset = 26 + // Yellow Offset = 39 + + movedGlobal := gm.getGlobalIndex(movedPiece.Color, movedPiece.PathPosition) + if movedGlobal == -1 { + return // Safe zone + } + + for _, otherPlayer := range gm.State.Players { + if otherPlayer.User.ID == currentPlayer.User.ID { + continue + } + for i := range otherPlayer.Pieces { + otherPiece := &otherPlayer.Pieces[i] + otherGlobal := gm.getGlobalIndex(otherPiece.Color, otherPiece.PathPosition) + if otherGlobal != -1 && otherGlobal == movedGlobal { + // Capture! + fmt.Printf("Captured %s!\n", otherPiece.ID) + // Reset to Home (-1, -2, -3, -4) + // We need original home index. Go logic simplifies this: + // Just set to -1. Or we should store HomeIndex in struct. + // For now, -1 is fine, visual stack. + otherPiece.PathPosition = -1 + } + } + } +} + +func (gm *GameManager) getGlobalIndex(color Color, pathPos int) int { + if pathPos < 0 || pathPos > 50 { + return -1 // Home or Home Run (Safe) + } + + offset := 0 + switch color { + case Red: + offset = 0 + case Green: + offset = 13 + case Blue: + offset = 26 + case Yellow: + offset = 39 + } + + return (pathPos + offset) % 52 +} diff --git a/game/models.go b/game/models.go new file mode 100644 index 0000000..98e8157 --- /dev/null +++ b/game/models.go @@ -0,0 +1,55 @@ +package game + +// Constants for game rules +const ( + MaxPosition = 56 // 0-50 (main) + 51-56 (home run) + MainPathLength = 52 + HomeRunStart = 51 + FinishedPosition = 57 // Special marker for "in goal" +) + +type Color string + +const ( + Red Color = "RED" + Green Color = "GREEN" + Blue Color = "BLUE" + Yellow Color = "YELLOW" +) + +type User struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Piece struct { + ID string `json:"id"` + Color Color `json:"color"` + PathPosition int `json:"pathPosition"` // -1 = Home, 0-56 = Path +} + +type Player struct { + User User `json:"user"` + Color Color `json:"color"` + Pieces []Piece `json:"pieces"` + Dice Dice `json:"dice"` + HasWon bool `json:"hasWon"` + IsTurn bool `json:"isTurn"` +} + +type Dice struct { + Value int `json:"value"` + IsRolling bool `json:"isRolling"` + Enabled bool `json:"enabled"` +} + +type GameState struct { + ID string `json:"id"` + Players []*Player `json:"players"` + CurrentPlayerIdx int `json:"currentPlayerIndex"` + WaitingForRoll bool `json:"waitingForRoll"` + WaitingForMove bool `json:"waitingForMove"` + CurrentDiceVal int `json:"currentDiceValue"` + PlayerMap map[string]*Player `json:"-"` // Internal lookup + Gameover bool `json:"gameover"` +} diff --git a/go-ludo b/go-ludo new file mode 100755 index 0000000..2f0c6f0 Binary files /dev/null and b/go-ludo differ diff --git a/go.mod b/go.mod index a82a6e0..003f130 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,10 @@ module github.com/Suloch/go-ludo go 1.25.4 +require github.com/nats-io/nats.go v1.47.0 + require ( - github.com/gorilla/websocket v1.5.3 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.37.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..76dd281 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= +github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/main.go b/main.go index bd339f1..9b012e4 100644 --- a/main.go +++ b/main.go @@ -7,180 +7,153 @@ import ( "os/signal" "syscall" + "github.com/Suloch/go-ludo/game" "github.com/nats-io/nats.go" ) -var games = make(map[string]*GameState) +// Global game store +var games = make(map[string]*game.GameManager) var nc *nats.Conn -func rollDiceRequest(m *nats.Msg) { - fmt.Println("Rolling dice...") - var rollRequest struct { - GameID string `json:"game_id"` - User User `json:"user"` - } - err := json.Unmarshal(m.Data, &rollRequest) - if err != nil { - fmt.Println("Error unmarshalling roll request:", err) - m.Respond([]byte("Invalid roll request")) - return - } - game, ok := games[rollRequest.GameID] - if !ok { - fmt.Println("Game not found:", rollRequest.GameID) - m.Respond([]byte("Game not found")) - return - } - err = rollDice(rollRequest.User, game) +// --- Helper to broadcast state --- +func broadcastState(gm *game.GameManager) { + data, err := json.Marshal(gm.State) if err != nil { - fmt.Println("Error rolling dice:", err) - m.Respond([]byte("Error rolling dice")) + fmt.Println("Error marshalling state:", err) return } - gameData, err := json.Marshal(game) - if err != nil { - fmt.Println("Error marshalling game state:", err) - m.Respond([]byte("Error rolling dice")) - return - } - nc.Publish("ludo."+game.ID, gameData) - m.Respond([]byte("Dice rolled")) + nc.Publish("ludo."+gm.State.ID, data) } -func createGameRequest(m *nats.Msg) { - fmt.Println("Creating a new game...") - newgame := NewGameState(4, User{ID: "user1", Name: "Alice"}) - games[newgame.ID] = newgame - gameData, err := json.Marshal(newgame) - if err != nil { - fmt.Println("Error marshalling game state:", err) - return - } +// --- Handlers --- + +func createGameHandler(m *nats.Msg) { + // Simple ID generation + gameID := fmt.Sprintf("game-%d", len(games)+1) + + gm := game.NewGame(gameID) + // Add creator as Red player? Or wait for Join? + // User prompt implied "NewGameState(nplayers, user)". + // Let's assume the Creator joins immediately. + // Message body might have user info? + + // For simplicity, we just create the room. + games[gameID] = gm - fmt.Println("New game created:", string(gameData)) - nc.Publish("ludo."+newgame.ID, gameData) - m.Respond([]byte(newgame.ID)) + fmt.Printf("Created game: %s\n", gameID) + m.Respond([]byte(gameID)) } -func joinGameRequest(m *nats.Msg) { - fmt.Println("Joining an existing game...") - fmt.Println("Message Data:", string(m.Data)) - //unmarshal m.Data to get game ID and user info - var joinRequest struct { - GameID string `json:"game_id"` - User User `json:"user"` +func joinGameHandler(m *nats.Msg) { + var req struct { + GameID string `json:"game_id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Color game.Color `json:"color"` // Optional? } - err := json.Unmarshal(m.Data, &joinRequest) - if err != nil { - fmt.Println("Error unmarshalling join request:", err) - m.Respond([]byte("Invalid join request")) + if err := json.Unmarshal(m.Data, &req); err != nil { + fmt.Println("Join error:", err) return } - game, ok := games[joinRequest.GameID] + gm, ok := games[req.GameID] if !ok { - fmt.Println("Game not found:", joinRequest.GameID) m.Respond([]byte("Game not found")) return } - addUserToGame(game, joinRequest.User) - gameData, err := json.Marshal(game) - if err != nil { - fmt.Println("Error marshalling game state:", err) - m.Respond([]byte("Error joining game")) - return + + // Auto-assign color if missing + if req.Color == "" { + colors := []game.Color{game.Red, game.Green, game.Blue, game.Yellow} + if len(gm.State.Players) < 4 { + req.Color = colors[len(gm.State.Players)] + } } - fmt.Println("User joined game:", string(gameData)) - nc.Publish("ludo."+game.ID, gameData) - m.Respond([]byte("Joined game")) -} -func moveRequest(m *nats.Msg) { - fmt.Println("Moving a piece...") - var moveRequest struct { - GameID string `json:"game_id"` - User User `json:"user"` - PieceIndex int `json:"piece_index"` + gm.AddPlayer(req.UserID, req.Name, req.Color) + fmt.Printf("Player %s joined %s as %s\n", req.Name, req.GameID, req.Color) + + // If 2 players, maybe auto start? Or wait for "Start" command? + // For demo, auto-start if 2 players + if len(gm.State.Players) == 2 { + gm.StartGame() } - err := json.Unmarshal(m.Data, &moveRequest) - if err != nil { - fmt.Println("Error unmarshalling move request:", err) - m.Respond([]byte("Invalid move request")) - return + + broadcastState(gm) + m.Respond([]byte("Joined")) +} + +func rollDiceHandler(m *nats.Msg) { + var req struct { + GameID string `json:"game_id"` + UserID string `json:"user_id"` } - game, ok := games[moveRequest.GameID] + json.Unmarshal(m.Data, &req) + + gm, ok := games[req.GameID] if !ok { - fmt.Println("Game not found:", moveRequest.GameID) - m.Respond([]byte("Game not found")) return } - err = move(moveRequest.User, game, moveRequest.PieceIndex) - if err != nil { - fmt.Println("Error moving piece:", err) - m.Respond([]byte("Error moving piece")) - return - } - gameData, err := json.Marshal(game) + + val, err := gm.RollDice(req.UserID) if err != nil { - fmt.Println("Error marshalling game state:", err) - m.Respond([]byte("Error moving piece")) + fmt.Println("Roll error:", err) + m.Respond([]byte(err.Error())) return } - nc.Publish("ludo."+game.ID, gameData) - m.Respond([]byte("Piece moved")) + + fmt.Printf("User %s rolled %d\n", req.UserID, val) + broadcastState(gm) + m.Respond([]byte(fmt.Sprintf("%d", val))) } -func ludoStateRequest(m *nats.Msg) { - fmt.Println("Ludo state request received...") - var stateRequest struct { - GameID string `json:"game_id"` - } - err := json.Unmarshal(m.Data, &stateRequest) - if err != nil { - fmt.Println("Error unmarshalling state request:", err) - m.Respond([]byte("Invalid state request")) - return +func moveHandler(m *nats.Msg) { + var req struct { + GameID string `json:"game_id"` + UserID string `json:"user_id"` + PieceID string `json:"piece_id"` // Using Piece ID instead of index } - game, ok := games[stateRequest.GameID] + json.Unmarshal(m.Data, &req) + + gm, ok := games[req.GameID] if !ok { - fmt.Println("Game not found:", stateRequest.GameID) - m.Respond([]byte("Game not found")) - return - } - gameData, err := json.Marshal(game) - if err != nil { - fmt.Println("Error marshalling game state:", err) - m.Respond([]byte("Error retrieving game state")) return } - fmt.Println("Publishing game state on ludo." + game.ID) - err = nc.Publish("ludo."+game.ID, gameData) + + err := gm.MovePiece(req.UserID, req.PieceID) if err != nil { - fmt.Println("Error publishing game state:", err) - m.Respond([]byte("Error retrieving game state")) + fmt.Println("Move error:", err) + m.Respond([]byte(err.Error())) return } - m.Respond([]byte("Game state sent")) + + broadcastState(gm) + m.Respond([]byte("Moved")) } func main() { + var err error + // Connect to NATS (User local default) + nc, err = nats.Connect(nats.DefaultURL) + if err != nil { + fmt.Printf("NATS Connect Error: %v\n", err) + // For demo, we might exit, but let's try to keep running just in case + // os.Exit(1) + } else { + fmt.Println("Connected to NATS") + defer nc.Close() + } - nc, _ = nats.Connect("nats://localhost:4222") + // Subscribe + nc.Subscribe("ludo.create", createGameHandler) + nc.Subscribe("ludo.join", joinGameHandler) + nc.Subscribe("ludo.roll", rollDiceHandler) + nc.Subscribe("ludo.move", moveHandler) - nc.Publish("ludo.game-1", []byte("game is up")) - nc.Subscribe("ludo.create", createGameRequest) - nc.Subscribe("ludo.join", joinGameRequest) - nc.Subscribe("ludo.roll", rollDiceRequest) - nc.Subscribe("ludo.move", moveRequest) - nc.Subscribe("ludo.state", ludoStateRequest) + fmt.Println("Ludo Server Listening...") + // Wait for interrupt sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - for { - <-sigs - break - } - nc.Drain() - nc.Close() + <-sigs } diff --git a/server.go b/server.go deleted file mode 100644 index 955b1fa..0000000 --- a/server.go +++ /dev/null @@ -1,176 +0,0 @@ -package main - -import ( - "errors" -) - -type Color int - -const finished_position = 1000 -const home_position = -1 -const max_position = 54 - -const ( - Red Color = iota - Blue - Green - Yellow -) - -type User struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type Player struct { - User User `json:"user"` - Color Color `json:"color"` - Positions []int `json:"positions"` - Active bool `json:"active"` - Won bool `json:"won"` - Rank int `json:"rank"` - CanMove []bool `json:"canMove"` -} - -type GameState struct { - ID string `json:"id"` - Players []Player `json:"players"` - Gameover bool `json:"gameover"` - Turn Player `json:"turn"` - Dice byte `json:"dice"` - DiceRolled bool `json:"diceRolled"` -} - -func NewGameState(nplayers int, user User) *GameState { - player1 := Player{ - User: user, - Color: Red, - Positions: make([]int, 4), - Active: true, - Won: false, - Rank: 0, - CanMove: make([]bool, 4), - } - player2 := Player{ - User: User{ID: "no-user", Name: "Player"}, - Color: Blue, - Positions: make([]int, 4), - Active: false, - Won: false, - Rank: 0, - CanMove: make([]bool, 4), - } - - player3 := Player{ - User: User{ID: "no-user", Name: "Player"}, - Color: Green, - Positions: make([]int, 4), - Active: false, - Won: false, - Rank: 0, - CanMove: make([]bool, 4), - } - - player4 := Player{ - User: User{ID: "no-user", Name: "Player"}, - Color: Green, - Positions: make([]int, 4), - Active: false, - Won: false, - Rank: 0, - CanMove: make([]bool, 4), - } - - gamestate := &GameState{ - ID: "game-1", - Players: []Player{player1, player2, player3, player4}[:nplayers], - Gameover: false, - Turn: player1, - } - - return gamestate -} - -func addUserToGame(gamestate *GameState, user User) bool { - for i, player := range gamestate.Players { - if player.User.ID == "no-user" { - gamestate.Players[i].User = user - gamestate.Players[i].Active = true - return true - } - } - return false -} - -func isMoveValid(initialPosition int, diceValue byte) bool { - - if initialPosition == home_position && diceValue == 6 { - return true - } - if initialPosition == finished_position { - return false - } - if initialPosition+int(diceValue) > max_position { - return false - } - return true -} - -func rollDice(user User, gamestate *GameState) error { - if gamestate.Turn.User.ID != user.ID { - return errors.New("not your turn") - } - dice := 1 + (user.ID[0] % 6) - gamestate.Dice = dice - gamestate.DiceRolled = true - - for i, position := range gamestate.Turn.Positions { - gamestate.Turn.CanMove[i] = isMoveValid(position, dice) - } - - // Check if no moves are possible - canMove := false - for _, valid := range gamestate.Turn.CanMove { - if valid { - canMove = true - break - } - } - if !canMove { - gamestate.Turn = getNextPlayer(gamestate) - gamestate.DiceRolled = false - } - - return nil -} - -func getNextPlayer(gamestate *GameState) Player { - for i, player := range gamestate.Players { - if player.User.ID == gamestate.Turn.User.ID { - return gamestate.Players[(i+1)%len(gamestate.Players)] - } - } - return gamestate.Turn -} - -func move(user User, gamestate *GameState, pieceIndex int) error { - if gamestate.Turn.User.ID != user.ID { - return errors.New("not your turn") - } - if !gamestate.Turn.CanMove[pieceIndex] { - return errors.New("invalid move") - } - if gamestate.Turn.Positions[pieceIndex] == home_position && gamestate.Dice == 6 { - gamestate.Turn.Positions[pieceIndex] = 0 - } else if gamestate.Turn.Positions[pieceIndex] != finished_position { - gamestate.Turn.Positions[pieceIndex] += int(gamestate.Dice) - } - if gamestate.Turn.Positions[pieceIndex] == max_position { - gamestate.Turn.Positions[pieceIndex] = finished_position - } - if gamestate.Dice != 6 { - gamestate.Turn = getNextPlayer(gamestate) - } - gamestate.DiceRolled = false - return nil -} diff --git a/ui/Ludo/src/board.ts b/ui/Ludo/src/board.ts index a79d688..6be844d 100644 --- a/ui/Ludo/src/board.ts +++ b/ui/Ludo/src/board.ts @@ -1,197 +1,199 @@ -import type { GameObject, Vector2 } from "./gameobject"; -import {getRedPath, type Path} from "./red"; -import {getGreenPath} from "./green"; -import { Dice } from "./dice"; - -type Color = "RED" | "BLUE" | "GREEN" | "YELLOW"; - -const drawCircle = (context: CanvasRenderingContext2D, x: number, y: number, radius: number, color: string) => { - context.beginPath(); - context.arc(x, y, radius, 0, Math.PI * 2); - context.fillStyle = color; - context.fill(); - context.lineWidth = 4; - context.strokeStyle = "black" - context.stroke(); -} +import type { GameState, PlayerModel, PieceModel, Color } from "./model"; +import type { DiceService, PieceFactory } from "./services"; +import type { BoardMapper } from "./boardMapper"; + +export class GameManager { + state: GameState; + diceService: DiceService; + pieceFactory: PieceFactory; + boardMapper?: BoardMapper; // Optional for now, or injected + + constructor(diceService: DiceService, pieceFactory: PieceFactory, boardMapper?: BoardMapper) { + this.diceService = diceService; + this.pieceFactory = pieceFactory; + this.boardMapper = boardMapper; + this.state = { + players: [], + playerMap: new Map(), + currentPlayerIndex: 0, + waitingForRoll: false, + waitingForMove: false, + currentDiceValue: 0 + }; + } + addPlayer(id: string, name: string, color: Color) { + const pieces = this.pieceFactory.createPieces(id, color); + + const player: PlayerModel = { + id, + name, + color, + pieces, + dice: { + value: 1, + isRolling: false, + rollStartTime: 0, + enabled: false + }, + hasWon: false + }; + + this.state.players.push(player); + this.state.playerMap.set(id, player); + } -class Piece implements GameObject{ - context: CanvasRenderingContext2D; - name: string; - position: Vector2; - color: Color; - - constructor( - context: CanvasRenderingContext2D, - name: string, - position: Vector2, - color: Color - ){ - this.context = context; - this.name = name; - this.position = position; - this.color = color; + startGame() { + if (this.state.players.length === 0) return; + this.state.currentPlayerIndex = 0; + this.startTurn(); } - onUpdate(dt: number): void { - dt + startTurn() { + const player = this.state.players[this.state.currentPlayerIndex]; + console.log(`Turn: ${player.name} (${player.color})`); + + this.state.waitingForRoll = true; + this.state.waitingForMove = false; + + // Enable dice + player.dice.enabled = true; } - render(dt: number): void { - drawCircle(this.context, this.position.x, this.position.y, 20, this.color); - dt + switchTurn() { + this.state.currentPlayerIndex = (this.state.currentPlayerIndex + 1) % this.state.players.length; + this.startTurn(); } -} + async handleDiceClick(playerId: string) { + const player = this.state.playerMap.get(playerId); + if (!player) return; -class RedPiece extends Piece{ - path: Path - pathPosition: number - - constructor( - context: CanvasRenderingContext2D, - name: string, - pathPosition: number - ){ - super(context, name, {x: 0, y: 0}, "RED"); - this.path = getRedPath(this.context.canvas.height, this.context.canvas.width) - this.pathPosition = pathPosition; - - if(pathPosition < 0){ - this.position = this.path.homePositions[4+pathPosition] - }else{ - this.position = this.path.path[pathPosition] - } - + // Validate it's their turn and dice is enabled + if (this.state.players[this.state.currentPlayerIndex].id !== playerId) return; + if (!player.dice.enabled) return; + + // Start Roll + player.dice.enabled = false; + player.dice.isRolling = true; + player.dice.rollStartTime = Date.now(); + + // Use Injected Dice Service + const val = await this.diceService.roll(); + + // Update State + player.dice.isRolling = false; + player.dice.value = val; + this.handleDiceRollFinished(val); } -} -class GreenPiece extends Piece{ - path: Path - pathPosition: number - - constructor( - context: CanvasRenderingContext2D, - name: string, - pathPosition: number - ){ - super(context, name, {x: 0, y: 0}, "GREEN") - this.path = getGreenPath(this.context.canvas.height, this.context.canvas.width) - this.pathPosition = pathPosition - - if(pathPosition < 0){ - this.position = this.path.homePositions[4+pathPosition] - }else{ - this.position = this.path.path[pathPosition] + handleDiceRollFinished(val: number) { + console.log("Rolled: " + val); + this.state.currentDiceValue = val; + this.state.waitingForRoll = false; + + const player = this.state.players[this.state.currentPlayerIndex]; + + // Check moves + if (!this.hasPossibleMoves(player, val)) { + console.log("No moves possible."); + setTimeout(() => this.switchTurn(), 1000); + return; } + + this.state.waitingForMove = true; + console.log("Waiting for move..."); } -} -class Player implements GameObject{ - - color: Color - name: string - position: Vector2; - context: CanvasRenderingContext2D; - pieces: Array = [] - dice: Dice - playing = false; - id : string = ""; - - constructor( - context: CanvasRenderingContext2D, - name: string, - color: Color - ){ - this.context = context; - this.name = name; - this.position = {x: 0, y: 0} - this.color = color; - - for(let i = 0; i < 4; i++){ - switch(this.color){ - case "RED": - this.pieces.push(new RedPiece(context, name+"-red-"+(i+1).toString(), i-4)); - this.dice = new Dice(context, name+'dice', {x: 50, y: 0}) - break; - case "GREEN": - this.pieces.push(new GreenPiece(context, name+"-red-"+(i+1).toString(), i-4)); - this.dice = new Dice(context, name+'dice', {x: context.canvas.width - 100, y: 0}); - break; + handlePieceClick(piece: PieceModel) { + if (!this.state.waitingForMove) return; + + const player = this.state.players[this.state.currentPlayerIndex]; + + // Ensure piece belongs to current player + if (piece.color !== player.color) return; + + if (this.isValidMove(piece, this.state.currentDiceValue)) { + this.movePiece(piece, this.state.currentDiceValue, player); + + // Check win + if (this.checkWin(player)) { + console.log(player.name + " WINS!"); + alert(player.name + " WINS!"); + player.hasWon = true; + return; // Game Over } - } - } - - setId(id: string){ - this.id = id; - } - render(dt: number): void { - for(let piece of this.pieces){ - piece.render(dt) + // Next turn or repeat + if (this.state.currentDiceValue === 6) { + console.log("Rolled 6! Roll again."); + this.startTurn(); // Same player + } else { + this.switchTurn(); + } + } else { + console.log("Invalid move for this piece."); } - this.dice.render(dt); } - onUpdate(dt: number): void { - for(let piece of this.pieces){ - piece.onUpdate(dt) + movePiece(piece: PieceModel, steps: number, currentPlayer: PlayerModel) { + if (piece.pathPosition < 0) { + // Move to start + piece.pathPosition = 0; + } else { + piece.pathPosition += steps; } - } -} -class BoardBackground implements GameObject{ - - context: CanvasRenderingContext2D; - name: string; - position: Vector2; - image: HTMLImageElement; - - constructor( - context: CanvasRenderingContext2D, - name: string, - position: Vector2, - image: HTMLImageElement - ){ - this.context = context; - this.name = name; - this.position = position; - this.image = image; + // Check Captures + this.checkCaptures(piece, currentPlayer); } - onUpdate(dt: number): void { - dt - } - - render(dt: number): void { - dt - this.context.drawImage( - this.image, - 0, - 0, - this.context.canvas.width, - this.context.canvas.height - ) - } - -} + checkCaptures(movedPiece: PieceModel, currentPlayer: PlayerModel) { + if (!this.boardMapper) return; + + // Get Global Index of moved piece + const movedGlobalIndex = this.boardMapper.getGlobalTileIndex(movedPiece.color, movedPiece.pathPosition); -class GameManager{ + // If it's null, piece is in home run or home base, safe from capture (usually) + if (movedGlobalIndex === null) return; - players: Map = new Map(); + // Check collision with other pieces + for (const otherPlayer of this.state.players) { + if (otherPlayer.id === currentPlayer.id) continue; - constructor(players: Array){ - for(let player of players){ - if(player.id == "") - throw new Error("Player not connected: "+ player.color) - this.players.set(player.id, player); + for (const otherPiece of otherPlayer.pieces) { + const otherGlobalIndex = this.boardMapper.getGlobalTileIndex(otherPlayer.color, otherPiece.pathPosition); + + if (otherGlobalIndex !== null && otherGlobalIndex === movedGlobalIndex) { + console.log(`Captured ${otherPiece.id}!`); + // Reset to home + // We need to set pathPosition back to homeIndex (-1 to -4) + otherPiece.pathPosition = otherPiece.homeIndex; + } + } } } + // ... helper functions + hasPossibleMoves(player: PlayerModel, diceVal: number): boolean { + for (const piece of player.pieces) { + if (this.isValidMove(piece, diceVal)) return true; + } + return false; + } - -} + isValidMove(piece: PieceModel, diceVal: number): boolean { + if (piece.pathPosition < 0) { + return diceVal === 6; + } + const maxIndex = 56; + if (piece.pathPosition + diceVal > maxIndex) return false; + return true; + } -export {BoardBackground, Player}; + checkWin(player: PlayerModel): boolean { + const maxIndex = 56; + return player.pieces.every(p => p.pathPosition === maxIndex); + } +} diff --git a/ui/Ludo/src/boardMapper.ts b/ui/Ludo/src/boardMapper.ts new file mode 100644 index 0000000..514dcca --- /dev/null +++ b/ui/Ludo/src/boardMapper.ts @@ -0,0 +1,177 @@ + +import type { Color, Vector2 } from "./model"; + +export class BoardMapper { + private width: number; + private height: number; + private redPath: Vector2[]; + private redHome: Vector2[]; + private greenPath: Vector2[]; + private greenHome: Vector2[]; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + + // Initialize Paths + this.redPath = this.generateRedPath(); + this.redHome = this.generateRedHome(); + this.greenPath = this.generateGreenPath(); + this.greenHome = this.generateGreenHome(); + + // Transform Grid Coordinates to Pixels immediately + this.transformToPixels(this.redPath); + this.transformToPixels(this.redHome, true); // Home has slightly different calc if needed? + // Actually the original code did transformToPixels on both path arrays. + // But `homePositions` in original code were calculated using direct logic, not grid. + + // Let's stick to the original logic where `homePositions` are calculated directly in pixels + // and `path` is calculated as grid then transformed. + + this.transformToPixels(this.greenPath); + } + + getCoordinate(color: Color, pathIndex: number): Vector2 | null { + let path: Vector2[]; + let home: Vector2[]; + + if (color === "RED") { + path = this.redPath; + home = this.redHome; + } else if (color === "GREEN") { + path = this.greenPath; + home = this.greenHome; + } else { + return null; + } + + if (pathIndex < 0) { + // Home Index: -4, -3, -2, -1 -> Map to 0, 1, 2, 3 + // In original code: homePositions[4 + pathPosition] + const idx = 4 + pathIndex; + if (idx >= 0 && idx < home.length) return home[idx]; + return null; + } else { + if (pathIndex < path.length) return path[pathIndex]; + return null; + } + } + + getGlobalTileIndex(color: Color, pathIndex: number): number | null { + // Map local path index to a global 0-51 loop index. + // Logic: + // Main Loop Length = 52. + // Red Start (Path 0) = Global 0. + // Green Start (Path 0) = Global 13. + // Blue Start = Global 26. + // Yellow Start = Global 39. + + // Path indices > 50 are usually "Home Run" (entering the center) and are not on the main loop. + // Let's verify the path array length. + // In `red.ts`, path length is 57 (0..56). + // 0..50 are main track? + // Let's check coords. + // Index 50: {x: 0, y: 7} (Left side of center row) + // Index 51: {x: 1, y: 7} (Entering Red Home Run) + // So 0..50 are 51 tiles? Standard Ludo is 52. + // Let's count explicitly. + // 5 arms * 3 columns = 15... no. + // Standard: 52 tiles on perimeter. + + if (pathIndex > 50) return null; // In Home Run, protected from capture + + let offset = 0; + if (color === "RED") offset = 0; + else if (color === "GREEN") offset = 13; + // else if Blue = 26, Yellow = 39 + + return (pathIndex + offset) % 52; + } + + private transformToPixels(gridCoords: Vector2[], isHome: boolean = false) { + if (isHome) return; // Home calculated in pixels already + + const cellSize = this.height / 15; + const offsetX = (this.width - this.height) / 2; + + for (const coord of gridCoords) { + coord.x = coord.x * cellSize + offsetX + cellSize / 2; + coord.y = coord.y * cellSize + cellSize / 2; + } + } + + private generateRedHome(): Vector2[] { + const homePositions: Vector2[] = []; + const homeCellSize = 15; + const offsetX = (this.width - this.height) / 2; + const homePositionFactor = 6.3; + const homeXDifference = this.height / 7.5; + const homeYDifference = this.height / 7.5; + + const p1 = { + x: offsetX + this.height / homePositionFactor - homeCellSize, + y: this.height / homePositionFactor - homeCellSize + }; + + homePositions.push(p1); + homePositions.push({x: p1.x + homeXDifference, y: p1.y}); + homePositions.push({x: p1.x, y: p1.y + homeYDifference}); + homePositions.push({x: p1.x + homeXDifference, y: p1.y + homeYDifference}); + + return homePositions; + } + + private generateGreenHome(): Vector2[] { + const homePositions: Vector2[] = []; + const homeCellSize = 15; + const offsetX = (this.width - this.height) / 2; + const homePositionFactor = 6.3; + const homeXDifference = this.height / 7.5; + const homeYDifference = this.height / 7.5; + + // Green is top right? Original logic: + // x: width - (...) + const p1 = { + x: this.width - (offsetX + this.height / homePositionFactor - homeCellSize + homeXDifference), + y: this.height / homePositionFactor - homeCellSize + }; + + homePositions.push(p1); + homePositions.push({x: p1.x + homeXDifference, y: p1.y}); + homePositions.push({x: p1.x, y: p1.y + homeYDifference}); + homePositions.push({x: p1.x + homeXDifference, y: p1.y + homeYDifference}); + + return homePositions; + } + + private generateRedPath(): Vector2[] { + // Raw Grid Coordinates + return [ + {x: 1, y: 6}, {x: 2, y: 6}, {x: 3, y: 6}, {x: 4, y: 6}, {x: 5, y: 6}, + {x: 6, y: 5}, {x: 6, y: 4}, {x: 6, y: 3}, {x: 6, y: 2}, {x: 6, y: 1}, {x: 6, y: 0}, + {x: 7, y: 0}, {x: 8, y: 0}, {x: 8, y: 1}, {x: 8, y: 2}, {x: 8, y: 3}, {x: 8, y: 4}, {x: 8, y: 5}, + {x: 9, y: 6}, {x: 10, y: 6}, {x: 11, y: 6}, {x: 12, y: 6}, {x: 13, y: 6}, {x: 14, y: 6}, + {x: 14, y: 7}, {x: 14, y: 8}, {x: 13, y: 8}, {x: 12, y: 8}, {x: 11, y: 8}, {x: 10, y: 8}, {x: 9, y: 8}, + {x: 8, y: 9}, {x: 8, y: 10}, {x: 8, y: 11}, {x: 8, y: 12}, {x: 8, y: 13}, {x: 8, y: 14}, + {x: 7, y: 14}, {x: 6, y: 14}, {x: 6, y: 13}, {x: 6, y: 12}, {x: 6, y: 11}, {x: 6, y: 10}, {x: 6, y: 9}, + {x: 5, y: 8}, {x: 4, y: 8}, {x: 3, y: 8}, {x: 2, y: 8}, {x: 1, y: 8}, {x: 0, y: 8}, + {x: 0, y: 7}, // 50 + {x: 1, y: 7}, {x: 2, y: 7}, {x: 3, y: 7}, {x: 4, y: 7}, {x: 5, y: 7}, // Home Run + ]; + } + + private generateGreenPath(): Vector2[] { + return [ + {x: 9, y: 1}, {x: 9, y: 2}, {x: 9, y: 3}, {x: 9, y: 4}, {x: 9, y: 5}, + {x: 6, y: 5}, {x: 6, y: 4}, {x: 6, y: 3}, {x: 6, y: 2}, {x: 6, y: 1}, {x: 6, y: 0}, + {x: 7, y: 0}, {x: 8, y: 0}, {x: 8, y: 1}, {x: 8, y: 2}, {x: 8, y: 3}, {x: 8, y: 4}, {x: 8, y: 5}, + {x: 9, y: 6}, {x: 10, y: 6}, {x: 11, y: 6}, {x: 12, y: 6}, {x: 13, y: 6}, {x: 14, y: 6}, + {x: 14, y: 7}, {x: 14, y: 8}, {x: 13, y: 8}, {x: 12, y: 8}, {x: 11, y: 8}, {x: 10, y: 8}, {x: 9, y: 8}, + {x: 8, y: 9}, {x: 8, y: 10}, {x: 8, y: 11}, {x: 8, y: 12}, {x: 8, y: 13}, {x: 8, y: 14}, + {x: 7, y: 14}, {x: 6, y: 14}, {x: 6, y: 13}, {x: 6, y: 12}, {x: 6, y: 11}, {x: 6, y: 10}, {x: 6, y: 9}, + {x: 5, y: 8}, {x: 4, y: 8}, {x: 3, y: 8}, {x: 2, y: 8}, {x: 1, y: 8}, {x: 0, y: 8}, + {x: 0, y: 7}, // 50 + {x: 1, y: 7}, {x: 2, y: 7}, {x: 3, y: 7}, {x: 4, y: 7}, {x: 5, y: 7}, // Home Run + ]; + } +} diff --git a/ui/Ludo/src/dice.ts b/ui/Ludo/src/dice.ts deleted file mode 100644 index 0c87160..0000000 --- a/ui/Ludo/src/dice.ts +++ /dev/null @@ -1,98 +0,0 @@ - -import type { GameObject, Vector2 } from "./gameobject"; -import { assetReader } from "./assets"; -import { addEventListener, addEvent, dispatchEvent } from "./events"; - -const diceValues = ["1", "2", "3", "4", "5", "6"] as const; -type DiceVal = typeof diceValues[number] - - -class Dice implements GameObject{ - context: CanvasRenderingContext2D; - name: string; - position: Vector2; - val: DiceVal = "1"; - diceImage: HTMLImageElement; - diceMap: Map; - rolling: boolean = false; - rollTotalTime: number = 2000; //ms for rolling animation - rollCurrTime: number = 0; - - constructor( - context: CanvasRenderingContext2D, - name: string, - position: Vector2 - ){ - this.context = context; - this.name = name; - this.position = position; - this.diceImage = assetReader('dice'); - - let sx = this.diceImage.width / 3; - let sy = this.diceImage.height / 2; - - this.diceMap = new Map(); - this.diceMap.set("1", {x: 0, y: 0}); - this.diceMap.set("2", {x: sx, y: 0}); - this.diceMap.set("3", {x: 2*sx, y: 0}); - this.diceMap.set("4", {x: 0, y: sy}); - this.diceMap.set("5", {x: sx, y: sy}); - this.diceMap.set("6", {x: 2*sx, y: sy}); - - - addEvent("DICE_CLICKED"+this.name); - addEventListener("CANVAS_CLICKED", "dice_event"+name, (data: any) =>{ - if(this.checkDiceBounds(data.x, data.y)) - dispatchEvent("DICE_CLICKED"+this.name, {}); - }); - - addEventListener("DICE_CLICKED"+this.name, "dice_clicked"+name, (_: any) => { - this.onDiceClick(); - }) - - - } - - onDiceClick(){ - console.log("dice was clicked: "+this.name); - this.rolling = true; - this.rollCurrTime = 0; - } - - checkDiceBounds(x: number, y: number){ - return x < this.position.x + 50 && x > this.position.x && y < this.position.y + 50 && y > this.position.y; - } - - setVal(value: DiceVal){ - this.val = value - } - - onUpdate(dt: number): void { - dt; - } - - render(dt: number): void { - - if(this.rolling){ - this.val = diceValues[Math.floor(Math.random()*6)] - this.rollCurrTime = this.rollCurrTime + dt; - if(this.rollCurrTime > this.rollTotalTime) - this.rolling = false; - } - - this.context.drawImage( - this.diceImage, - this.diceMap.get(this.val)!.x, - this.diceMap.get(this.val)!.y, - this.diceImage.width/3, - this.diceImage.height/2, - this.position.x, - this.position.y, - 50, - 50 - ) - - } -} - -export {Dice}; diff --git a/ui/Ludo/src/events.ts b/ui/Ludo/src/events.ts deleted file mode 100644 index e2f078e..0000000 --- a/ui/Ludo/src/events.ts +++ /dev/null @@ -1,33 +0,0 @@ - - -type EventCallBack = (data: any) => void; - - -const GLOBAL_EVENT_LISTENERS : Map> = new Map(); - - -function addEvent(eventName: string){ - GLOBAL_EVENT_LISTENERS.set(eventName, new Map()); -} - -function addEventListener(event: string, name: string, cb: EventCallBack){ - if(GLOBAL_EVENT_LISTENERS.has(event)){ - GLOBAL_EVENT_LISTENERS.get(event)!.set(name, cb); - return; - } - - throw new Error("Event not found: "+event); -} - -function dispatchEvent(event: string, data: any){ - if(!GLOBAL_EVENT_LISTENERS.get(event)) - return; - - const callbackMap = GLOBAL_EVENT_LISTENERS.get(event)!; - for(let cb of callbackMap.values()){ - cb(data); - } -} - - -export {addEvent, addEventListener, dispatchEvent}; diff --git a/ui/Ludo/src/gameobject.ts b/ui/Ludo/src/gameobject.ts deleted file mode 100644 index 3a04b12..0000000 --- a/ui/Ludo/src/gameobject.ts +++ /dev/null @@ -1,17 +0,0 @@ - -type Vector2={ - x: number; - y: number; -} - -interface GameObject{ - context: CanvasRenderingContext2D; - name: string; - position: Vector2; - - onUpdate(dt: number): void; - render(dt: number): void; -} - - -export type {GameObject, Vector2}; diff --git a/ui/Ludo/src/green.ts b/ui/Ludo/src/green.ts deleted file mode 100644 index 02355cf..0000000 --- a/ui/Ludo/src/green.ts +++ /dev/null @@ -1,91 +0,0 @@ - -function getGreenPath(height: number, width: number){ - const path: Array<{x: number, y: number}> = [ - {x: 9, y: 1}, - {x: 9, y: 2}, - {x: 9, y: 3}, - {x: 9, y: 4}, - {x: 9, y: 5}, - {x: 6, y: 5}, - {x: 6, y: 4}, - {x: 6, y: 3}, - {x: 6, y: 2}, - {x: 6, y: 1}, - {x: 6, y: 0}, - {x: 7, y: 0}, - {x: 8, y: 0}, - {x: 8, y: 1}, - {x: 8, y: 2}, - {x: 8, y: 3}, - {x: 8, y: 4}, - {x: 8, y: 5}, - {x: 9, y: 6}, - {x: 10, y: 6}, - {x: 11, y: 6}, - {x: 12, y: 6}, - {x: 13, y: 6}, - {x: 14, y: 6}, - {x: 14, y: 7}, - {x: 14, y: 8}, - {x: 13, y: 8}, - {x: 12, y: 8}, - {x: 11, y: 8}, - {x: 10, y: 8}, - {x: 9, y: 8}, - {x: 8, y: 9}, - {x: 8, y: 10}, - {x: 8, y: 11}, - {x: 8, y: 12}, - {x: 8, y: 13}, - {x: 8, y: 14}, - {x: 7, y: 14}, - {x: 6, y: 14}, - {x: 6, y: 13}, - {x: 6, y: 12}, - {x: 6, y: 11}, - {x: 6, y: 10}, - {x: 6, y: 9}, - {x: 5, y: 8}, - {x: 4, y: 8}, - {x: 3, y: 8}, - {x: 2, y: 8}, - {x: 1, y: 8}, - {x: 0, y: 8}, - {x: 0, y: 7}, - {x: 1, y: 7}, - {x: 2, y: 7}, - {x: 3, y: 7}, - {x: 4, y: 7}, - {x: 5, y: 7}, - ]; - const homePositions: Array<{x: number, y: number}> = []; - const cellSize = height / 15; - const homeCellSzie = 15; - const offsetX = (width - height) / 2; - const homePositionFactor = 6.3; - const homeXDifference = height / 7.5; - const homeYDifference = height / 7.5; - - const homePosition1 = {x: width - (offsetX + height / homePositionFactor - homeCellSzie + homeXDifference), y: height / homePositionFactor - homeCellSzie} - const homePosition2 = {x: homePosition1.x + homeXDifference, y: homePosition1.y} - const homePosition3 = {x: homePosition1.x , y: homePosition1.y + homeYDifference} - const homePosition4 = {x: homePosition1.x + homeXDifference, y: homePosition1.y + homeYDifference} - - homePositions.push(homePosition1); - homePositions.push(homePosition2) - homePositions.push(homePosition3) - homePositions.push(homePosition4) - - for(let pathPosition of path){ - pathPosition.x = pathPosition.x * cellSize + offsetX + cellSize/2; - pathPosition.y = pathPosition.y * cellSize + cellSize/2; - } - - return { - homePositions: homePositions, - path: path - } - -} - -export {getGreenPath}; diff --git a/ui/Ludo/src/main.ts b/ui/Ludo/src/main.ts index 13c0943..dada1e5 100644 --- a/ui/Ludo/src/main.ts +++ b/ui/Ludo/src/main.ts @@ -1,13 +1,12 @@ -import { loadAssets, assetReader } from "./assets"; -import { Player, BoardBackground } from "./board"; -import type { GameObject, } from "./gameobject"; -import { dispatchEvent, addEvent } from "./events"; +import { loadAssets } from "./assets"; +import { GameManager } from "./board"; +import { Renderer } from "./renderer"; +import { RandomDiceService, StandardPieceFactory } from "./services"; +import { BoardMapper } from "./boardMapper"; const fps = 60; - function initCanvas(){ - const canvas = document.getElementById('ludoCanvas') as HTMLCanvasElement; const context = canvas.getContext('2d') as CanvasRenderingContext2D; @@ -16,44 +15,21 @@ function initCanvas(){ canvas.style.top = '50%'; canvas.style.transform = 'translate(-50%, -50%)'; - context.fillStyle = 'lightblue'; context.fillRect(0, 0, canvas.width, canvas.height); - addEvent("CANVAS_CLICKED"); - - canvas.addEventListener('click', (event) => { - const pos = getClickPosition(event, canvas); - dispatchEvent('CANVAS_CLICKED', {x:pos.x, y:pos.y}); - console.log(`Clicked at x: ${pos.x}, y: ${pos.y}`); - }); return {canvas, context}; } - - - - - -function startRenderingLoop(gameObjects: Array){ +function startRenderingLoop(renderer: Renderer, gameManager: GameManager){ let lastTimestamp = 0; - let start = 0; - let dt = 0; function renderFrame(timestamp: number){ requestAnimationFrame(renderFrame); - if(start === 0){ - start = timestamp; - } - - dt = timestamp - lastTimestamp - + const dt = timestamp - lastTimestamp; if(dt >= 1000 / fps){ - - for(let gameObject of gameObjects){ - gameObject.render(dt); - } + renderer.render(gameManager.state, dt); lastTimestamp = timestamp; } } @@ -67,50 +43,53 @@ const getClickPosition = (event: MouseEvent, canvas: HTMLCanvasElement) => { return {x, y}; } - const main = async () => { const assetsURL : Map = new Map([ ["board", "ludo.jpg"], - ["piece", "redpiece.png"], ["dice", "dice.png"] ]) await loadAssets(assetsURL); const {canvas, context} = initCanvas(); - const gameObjects = [] - - gameObjects.push( - new BoardBackground( - context, - "Background", - {x: 0, y: 0}, - assetReader("board") - ) - ) - - const redPlayer = new Player( - context, - "Red Player", - "RED" - ) + // 1. Init Dependencies + const boardMapper = new BoardMapper(canvas.width, canvas.height); + const diceService = new RandomDiceService(); + const pieceFactory = new StandardPieceFactory(); + + // 2. Init Logic + const gameManager = new GameManager(diceService, pieceFactory, boardMapper); + gameManager.addPlayer("red-1", "Red Player", "RED"); + gameManager.addPlayer("green-1", "Green Player", "GREEN"); - redPlayer.playing = true; + // 3. Init View + const renderer = new Renderer(context, boardMapper); - gameObjects.push(redPlayer) + // 4. Input Handling (Controller) + canvas.addEventListener('click', (event) => { + const pos = getClickPosition(event, canvas); + console.log(`Clicked at x: ${pos.x}, y: ${pos.y}`); - gameObjects.push(new Player( - context, - "Green Player", - "GREEN" - )) - + // Check for Piece Click + const clickedPiece = renderer.getPieceAtPosition(gameManager.state, pos.x, pos.y); + if (clickedPiece) { + console.log("Piece clicked:", clickedPiece.id); + gameManager.handlePieceClick(clickedPiece); + return; + } - startRenderingLoop(gameObjects); + // Check for Dice Click + const clickedPlayer = renderer.getDiceAtPosition(gameManager.state, pos.x, pos.y); + if (clickedPlayer) { + console.log("Dice clicked for:", clickedPlayer.name); + gameManager.handleDiceClick(clickedPlayer.id); + return; + } + }); - + // Start Game + gameManager.startGame(); + startRenderingLoop(renderer, gameManager); } - - -main() +main(); diff --git a/ui/Ludo/src/model.ts b/ui/Ludo/src/model.ts new file mode 100644 index 0000000..db6b685 --- /dev/null +++ b/ui/Ludo/src/model.ts @@ -0,0 +1,40 @@ + +export type Color = "RED" | "GREEN" | "BLUE" | "YELLOW"; + +export interface Vector2 { + x: number; + y: number; +} + +export interface PieceModel { + id: string; + color: Color; + pathPosition: number; // -1 to -4 are home, 0-56 are path + homeIndex: number; // The specific home slot (-1 to -4) +} + +export interface DiceModel { + value: number; + isRolling: boolean; + rollStartTime: number; // For animation timing + enabled: boolean; +} + +export interface PlayerModel { + id: string; + name: string; + color: Color; + pieces: PieceModel[]; + dice: DiceModel; + hasWon: boolean; +} + +export interface GameState { + players: PlayerModel[]; + currentPlayerIndex: number; + waitingForRoll: boolean; + waitingForMove: boolean; + currentDiceValue: number; + // Map of player ID to PlayerModel for quick access + playerMap: Map; +} diff --git a/ui/Ludo/src/red.ts b/ui/Ludo/src/red.ts deleted file mode 100644 index 72f842f..0000000 --- a/ui/Ludo/src/red.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type {Vector2} from "./gameobject" - -type Path = { - homePositions: Array; - path: Array; -} - -function getRedPath(height: number, width: number): Path{ - const path: Array<{x: number, y: number}> = [ - {x: 1, y: 6}, - {x: 2, y: 6}, - {x: 3, y: 6}, - {x: 4, y: 6}, - {x: 5, y: 6}, - {x: 6, y: 5}, - {x: 6, y: 4}, - {x: 6, y: 3}, - {x: 6, y: 2}, - {x: 6, y: 1}, - {x: 6, y: 0}, - {x: 7, y: 0}, - {x: 8, y: 0}, - {x: 8, y: 1}, - {x: 8, y: 2}, - {x: 8, y: 3}, - {x: 8, y: 4}, - {x: 8, y: 5}, - {x: 9, y: 6}, - {x: 10, y: 6}, - {x: 11, y: 6}, - {x: 12, y: 6}, - {x: 13, y: 6}, - {x: 14, y: 6}, - {x: 14, y: 7}, - {x: 14, y: 8}, - {x: 13, y: 8}, - {x: 12, y: 8}, - {x: 11, y: 8}, - {x: 10, y: 8}, - {x: 9, y: 8}, - {x: 8, y: 9}, - {x: 8, y: 10}, - {x: 8, y: 11}, - {x: 8, y: 12}, - {x: 8, y: 13}, - {x: 8, y: 14}, - {x: 7, y: 14}, - {x: 6, y: 14}, - {x: 6, y: 13}, - {x: 6, y: 12}, - {x: 6, y: 11}, - {x: 6, y: 10}, - {x: 6, y: 9}, - {x: 5, y: 8}, - {x: 4, y: 8}, - {x: 3, y: 8}, - {x: 2, y: 8}, - {x: 1, y: 8}, - {x: 0, y: 8}, - {x: 0, y: 7}, - {x: 1, y: 7}, - {x: 2, y: 7}, - {x: 3, y: 7}, - {x: 4, y: 7}, - {x: 5, y: 7}, - ]; - const homePositions: Array<{x: number, y: number}> = []; - const cellSize = height / 15; - const homeCellSzie = 15; - const offsetX = (width - height) / 2; - const homePositionFactor = 6.3; - const homeXDifference = height / 7.5; - const homeYDifference = height / 7.5; - - const homePosition1 = {x: offsetX + height / homePositionFactor - homeCellSzie, y: height / homePositionFactor - homeCellSzie} - const homePosition2 = {x: homePosition1.x + homeXDifference, y: homePosition1.y} - const homePosition3 = {x: homePosition1.x , y: homePosition1.y + homeYDifference} - const homePosition4 = {x: homePosition1.x + homeXDifference, y: homePosition1.y + homeYDifference} - - homePositions.push(homePosition1); - homePositions.push(homePosition2) - homePositions.push(homePosition3) - homePositions.push(homePosition4) - - for(let pathPosition of path){ - pathPosition.x = pathPosition.x * cellSize + offsetX + cellSize/2; - pathPosition.y = pathPosition.y * cellSize + cellSize/2; - } - - const redPath: Path = { - homePositions: homePositions, - path: path - } - - return redPath - -} - -export {getRedPath, type Path}; diff --git a/ui/Ludo/src/renderer.ts b/ui/Ludo/src/renderer.ts new file mode 100644 index 0000000..0ecff9d --- /dev/null +++ b/ui/Ludo/src/renderer.ts @@ -0,0 +1,132 @@ + +import type { GameState, PieceModel, PlayerModel, Color, Vector2, DiceModel } from "./model"; +import { assetReader } from "./assets"; +import { BoardMapper } from "./boardMapper"; + +export class Renderer { + context: CanvasRenderingContext2D; + boardMapper: BoardMapper; + backgroundImage: HTMLImageElement; + diceImage: HTMLImageElement; + diceMap: Map; + + // View specific state: Dice positions + dicePositions: Map = new Map(); + + constructor(context: CanvasRenderingContext2D, boardMapper: BoardMapper) { + this.context = context; + this.boardMapper = boardMapper; + this.backgroundImage = assetReader("board"); + this.diceImage = assetReader("dice"); + + // Precompute dice sprite map + const sx = this.diceImage.width / 3; + const sy = this.diceImage.height / 2; + this.diceMap = new Map(); + this.diceMap.set(1, {x: 0, y: 0}); + this.diceMap.set(2, {x: sx, y: 0}); + this.diceMap.set(3, {x: 2*sx, y: 0}); + this.diceMap.set(4, {x: 0, y: sy}); + this.diceMap.set(5, {x: sx, y: sy}); + this.diceMap.set(6, {x: 2*sx, y: sy}); + + // Setup dice positions on screen + this.dicePositions.set("RED", {x: 50, y: 20}); + this.dicePositions.set("GREEN", {x: context.canvas.width - 100, y: 20}); + } + + render(gameState: GameState, dt: number) { + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + + // 1. Draw Background + this.context.drawImage(this.backgroundImage, 0, 0, this.context.canvas.width, this.context.canvas.height); + + // 2. Draw Players + for (const player of gameState.players) { + this.drawPlayer(player, dt); + } + } + + drawPlayer(player: PlayerModel, dt: number) { + // Draw Pieces + for (const piece of player.pieces) { + this.drawPiece(piece, player.color); + } + + // Draw Dice + this.drawDice(player.dice, player.color, dt); + } + + drawPiece(piece: PieceModel, color: Color) { + const pos = this.boardMapper.getCoordinate(color, piece.pathPosition); + if (!pos) return; + + this.context.beginPath(); + this.context.arc(pos.x, pos.y, 20, 0, Math.PI * 2); + this.context.fillStyle = color; + this.context.fill(); + this.context.lineWidth = 4; + this.context.strokeStyle = "black"; + this.context.stroke(); + } + + drawDice(dice: DiceModel, color: Color, _dt: number) { + const pos = this.dicePositions.get(color); + if (!pos) return; + + let valToDraw = dice.value; + if (dice.isRolling) { + valToDraw = Math.floor(Math.random() * 6) + 1; + } + + const spritePos = this.diceMap.get(valToDraw); + if (!spritePos) return; + + this.context.drawImage( + this.diceImage, + spritePos.x, + spritePos.y, + this.diceImage.width / 3, + this.diceImage.height / 2, + pos.x, + pos.y, + 50, + 50 + ); + + // Visual indicator if enabled + if (dice.enabled) { + this.context.strokeStyle = "yellow"; + this.context.lineWidth = 3; + this.context.strokeRect(pos.x - 2, pos.y - 2, 54, 54); + } + } + + // Helper for Input Handling + getPieceAtPosition(gameState: GameState, x: number, y: number): PieceModel | null { + for (const player of gameState.players) { + for (const piece of player.pieces) { + const pos = this.boardMapper.getCoordinate(player.color, piece.pathPosition); + if (!pos) continue; + const dist = Math.sqrt(Math.pow(x - pos.x, 2) + Math.pow(y - pos.y, 2)); + if (dist <= 20) { // Radius + return piece; + } + } + } + return null; + } + + // Helper for Input Handling + getDiceAtPosition(gameState: GameState, x: number, y: number): PlayerModel | null { + for (const player of gameState.players) { + const pos = this.dicePositions.get(player.color); + if (!pos) continue; + if (x >= pos.x && x <= pos.x + 50 && + y >= pos.y && y <= pos.y + 50) { + return player; + } + } + return null; + } +} diff --git a/ui/Ludo/src/services.ts b/ui/Ludo/src/services.ts new file mode 100644 index 0000000..98b29c0 --- /dev/null +++ b/ui/Ludo/src/services.ts @@ -0,0 +1,37 @@ + +import type { Color, PieceModel } from "./model"; + +export interface DiceService { + roll(): Promise; +} + +export class RandomDiceService implements DiceService { + async roll(): Promise { + // Simulate animation delay + return new Promise((resolve) => { + setTimeout(() => { + const val = Math.floor(Math.random() * 6) + 1; + resolve(val); + }, 1000); + }); + } +} + +export interface PieceFactory { + createPieces(playerId: string, color: Color): PieceModel[]; +} + +export class StandardPieceFactory implements PieceFactory { + createPieces(playerId: string, color: Color): PieceModel[] { + const pieces: PieceModel[] = []; + for (let i = 0; i < 4; i++) { + pieces.push({ + id: `${playerId}-piece-${i+1}`, + color: color, + pathPosition: i - 4, // -4, -3, -2, -1 + homeIndex: i - 4 + }); + } + return pieces; + } +} diff --git a/verification/ludo_after_roll.png b/verification/ludo_after_roll.png new file mode 100644 index 0000000..8fb4ee9 Binary files /dev/null and b/verification/ludo_after_roll.png differ diff --git a/verification/ludo_initial.png b/verification/ludo_initial.png new file mode 100644 index 0000000..db09bec Binary files /dev/null and b/verification/ludo_initial.png differ diff --git a/verification/verify_ludo.py b/verification/verify_ludo.py new file mode 100644 index 0000000..6cf9747 --- /dev/null +++ b/verification/verify_ludo.py @@ -0,0 +1,54 @@ +from playwright.sync_api import sync_playwright + +def verify_ludo(page): + # Navigate to the local server (Vite default is usually 5173) + page.goto("http://localhost:5173") + + # Wait for canvas to be present + page.wait_for_selector("#ludoCanvas") + + # Wait a bit for assets to load and render + page.wait_for_timeout(2000) + + # Take a screenshot of the initial state + page.screenshot(path="verification/ludo_initial.png") + + # Click the red dice (approximate position based on code: x=50, y=0) + # The canvas is centered, so we need to calculate click position relative to viewport or canvas. + # Canvas is 50% left/top translated -50%. + # Let's just click the center-ish left where red dice is. + # Red dice is at x=50, y=0 relative to canvas. + # Canvas size: we need to know canvas size. + # Looking at main.ts/initCanvas, it doesn't set width/height explicitly? + # Default canvas size is 300x150. + # board.ts RedPiece uses context.canvas.height. + # main.ts: context.fillRect(0,0,width,height). + # Wait, main.ts doesn't set canvas size. The HTML file must set it. + + # Let's inspect the page to get canvas bounding box + box = page.locator("#ludoCanvas").bounding_box() + if box: + print(f"Canvas box: {box}") + # Red Dice is at 50, 0 relative to canvas. + # Click Red Dice + page.mouse.click(box['x'] + 50 + 25, box['y'] + 25) + + # Wait for roll animation (2000ms) + page.wait_for_timeout(2500) + + # Take screenshot after roll + page.screenshot(path="verification/ludo_after_roll.png") + + # Click again if 6? Or just verify the console logs if possible (harder in screenshot) + # We can just show the state. + +if __name__ == "__main__": + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + try: + verify_ludo(page) + except Exception as e: + print(f"Error: {e}") + finally: + browser.close()