From 4a51bf9a633d5a4d61c69286a77143d89f29704d Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 21:22:20 +0200 Subject: [PATCH 01/24] fix: circular import --- src/agents/geneticAlgAgentJon.py | 88 +++++++++++++++++++------------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/src/agents/geneticAlgAgentJon.py b/src/agents/geneticAlgAgentJon.py index 2dbef3c..825f0f2 100644 --- a/src/agents/geneticAlgAgentJon.py +++ b/src/agents/geneticAlgAgentJon.py @@ -1,9 +1,9 @@ import random import numpy as np from src.game.tetris import * -from src.agents.agent_factory import create_agent from src.agents.agent import Agent from src.agents.heuristic_with_parameters_agent import * + # From paper: https://codemyroad.wordpress.com/2013/04/14/tetris-ai-the-near-perfect-player/ # the weigts the author got: # a x (Aggregate Height) + b x (Complete Lines) + c x (Holes) + d x (Bumpiness) @@ -21,6 +21,7 @@ # TODO create method for fetching a random 10%, and finds the two with highest lines cleared, and makes a child (with 5% chance of mutation) # TODO create method that makes 30% new agents from existing agents (last method), replace worst 30% with the new agents + class GeneticAlgAgentJM: agents: list[list[list[float], float]] = [] @@ -34,40 +35,53 @@ def number_of_selection(self, number_of_selections: int): # Run new test for i in range(len(self.agents)): param_list = self.agents[i][0] - average_cleared = self.play_game(param_list[0], param_list[1], param_list[2], param_list[3], param_list[4]) + average_cleared = self.play_game( + param_list[0], + param_list[1], + param_list[2], + param_list[3], + param_list[4], + ) self.agents[i][1] = average_cleared - - print(self.getBestPop()) + print(self.getBestPop()) def initAgents(self) -> list[list[list[float], float]]: number_of_agents = 20 for _ in range(0, number_of_agents): - agg_height = random.randrange(-1000, 0)/1000 - max_height = random.randrange(-1000, 0)/1000 - lines_cleared = random.randrange(0, 1000)/1000 - bumpiness = random.randrange(-1000, 0)/1000 - holes = random.randrange(-1000, 0)/1000 - - average_cleared = self.play_game(agg_height, max_height, lines_cleared, bumpiness, holes) - self.agents.append([[agg_height, max_height, lines_cleared, bumpiness, holes], average_cleared]) + agg_height = random.randrange(-1000, 0) / 1000 + max_height = random.randrange(-1000, 0) / 1000 + lines_cleared = random.randrange(0, 1000) / 1000 + bumpiness = random.randrange(-1000, 0) / 1000 + holes = random.randrange(-1000, 0) / 1000 + + average_cleared = self.play_game( + agg_height, max_height, lines_cleared, bumpiness, holes + ) + self.agents.append( + [ + [agg_height, max_height, lines_cleared, bumpiness, holes], + average_cleared, + ] + ) print(_) - - + def play_game(self, agg_height, max_height, lines_cleared, bumpiness, holes): board = Tetris() - agent: Agent = HeuristicWithParametersAgent([agg_height, max_height, lines_cleared, bumpiness, holes]) + agent: Agent = HeuristicWithParametersAgent( + [agg_height, max_height, lines_cleared, bumpiness, holes] + ) total_cleared = 0 number_of_rounds = 20 for _ in range(0, number_of_rounds): - + max_moves = number_of_rounds move = 0 actions_per_drop = 7 - + while not board.isGameOver() and move < max_moves: - # Get the result of the agent's action + # Get the result of the agent's action for _ in range(actions_per_drop): result = agent.result(board) # Perform the action(s) on the board @@ -76,20 +90,21 @@ def play_game(self, agg_height, max_height, lines_cleared, bumpiness, holes): board.doAction(action) else: board.doAction(result) - + move += 1 - # Advance the game by one frame + # Advance the game by one frame board.doAction(Action.SOFT_DROP) if board.blockHasLanded: board.updateBoard() - #board.printBoard() + # board.printBoard() total_cleared += board.rowsRemoved - + return total_cleared / number_of_rounds - - def replace_30_percent(self, pop_list: list[list[list[float], float]]) -> list[list[float], float]: + def replace_30_percent( + self, pop_list: list[list[list[float], float]] + ) -> list[list[float], float]: # Number of pops needed for 30% of total number num_pops_needed = int(len(pop_list) * 0.3) @@ -100,10 +115,11 @@ def replace_30_percent(self, pop_list: list[list[list[float], float]]) -> list[l pop_list.extend(new_list) return pop_list - - # TODO create method for fetching a random 10%, and finds the two with highest lines cleared, and makes a child (with 5% chance of mutation) - def paring_pop(self, pop_list: list[list[list[float], float]]) -> list[list[float], float]: + # TODO create method for fetching a random 10%, and finds the two with highest lines cleared, and makes a child (with 5% chance of mutation) + def paring_pop( + self, pop_list: list[list[list[float], float]] + ) -> list[list[float], float]: # Gets the number of pops to select # num_pops_to_select = int(len(pop_list) * 0.1) num_pops_to_select = int(len(pop_list) * 0.5) @@ -112,7 +128,7 @@ def paring_pop(self, pop_list: list[list[list[float], float]]) -> list[list[floa random_pop_sample = random.sample(pop_list, num_pops_to_select) # Gets the two pops with the highest lines cleared - highest_values = sorted(random_pop_sample, key=lambda x: x[1], reverse=True)[:2] + highest_values = sorted(random_pop_sample, key=lambda x: x[1], reverse=True)[:2] # Gets the child pop of the two pops new_pop = self.fitness_crossover(highest_values[0], highest_values[1]) @@ -123,20 +139,22 @@ def paring_pop(self, pop_list: list[list[list[float], float]]) -> list[list[floa new_pop[0] = [i / norm for i in new_pop[0]] # Mutate 5% of children pops - if random.randrange(0,1000)/1000 < 0.05: - random_parameter = int(random.randint(0,4)) - new_pop[0][random_parameter] = (random.randrange(-200, 200)/1000) * new_pop[0][random_parameter] + if random.randrange(0, 1000) / 1000 < 0.05: + random_parameter = int(random.randint(0, 4)) + new_pop[0][random_parameter] = ( + random.randrange(-200, 200) / 1000 + ) * new_pop[0][random_parameter] - #new_pop[0] = (new_pop[0] / np.linalg.norm(new_pop[0])).tolist() + # new_pop[0] = (new_pop[0] / np.linalg.norm(new_pop[0])).tolist() return new_pop - - def fitness_crossover(self, pop1: list[list[float], float], pop2: list[list[float], float]) -> list[list[float], float]: + def fitness_crossover( + self, pop1: list[list[float], float], pop2: list[list[float], float] + ) -> list[list[float], float]: # Combines the two vectors proportionaly by how many lines they cleared child_pop = [h1 * pop1[1] + h2 * pop2[1] for h1, h2 in zip(pop1[0], pop2[0])] return [child_pop, 0.0] - def getBestPop(self) -> list[list[float], float]: pop_list = self.agents From 9b17cb60eb8ace6f9130b889bb8a45afce383cf7 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 21:56:59 +0200 Subject: [PATCH 02/24] feat: Add WebSocket support for Tetris game and html client code --- index.html | 64 +++++++++++++++++++++ src/game/TetrisWebGameManager.py | 97 ++++++++++++++++++++++++++++++++ web_app.py | 51 +++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 index.html create mode 100644 src/game/TetrisWebGameManager.py create mode 100644 web_app.py diff --git a/index.html b/index.html new file mode 100644 index 0000000..9cd2077 --- /dev/null +++ b/index.html @@ -0,0 +1,64 @@ + + + + Tetris WebSocket + + +

Tetris Game

+ + + + diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py new file mode 100644 index 0000000..186ddf6 --- /dev/null +++ b/src/game/TetrisWebGameManager.py @@ -0,0 +1,97 @@ +from copy import deepcopy +import time as t +import json +from src.agents.agent import Agent, playGameDemoStepByStep +from src.game.tetris import Action, Tetris + + +class TetrisGameManager: + def __init__(self, board: Tetris, websocket): + """ + Initialize the game manager with a board of type Tetris and a WebSocket connection. + """ + self.board = board # Ensure board is of type Tetris + self.websocket = websocket # WebSocket connection for real-time communication + self.score = 0 + self.currentTime = int(round(t.time() * 1000)) + self.updateTimer = 1 # Timer to control piece dropping + + async def movePiece(self, direction: Action): + """Move the Tetris block in a given direction and send updated game state via WebSocket.""" + self.board.doAction(direction) + await self.send_game_state() # Send updated state after action + + def isGameOver(self): + """Check if the game is over.""" + return self.board.isGameOver() + + async def startGame(self): + """Start the game loop for a normal game, receiving inputs and sending game state via WebSocket.""" + await self.send_game_state() # Send initial game state + + while not self.board.gameOver: + try: + # Receive input action from the WebSocket + input_action = await self.websocket.receive_text() + await self.handle_input(input_action) # Process the input action + + # Update the board after block lands + if self.board.blockHasLanded: + self.board.updateBoard() + + self.checkTimer() + await self.send_game_state() # Send updated state + + except Exception as e: + print(f"Error in game loop: {e}") + break + + await self.stopGame() + + async def startDemo(self, agent: Agent): + """Start the game loop for a demo game with an agent, sending updates via WebSocket.""" + await self.send_game_state() # Send game state to client + while not self.board.gameOver: + playGameDemoStepByStep(agent, self.board) # Agent plays step by step + await t.sleep(0.1) # Small delay to simulate gameplay + await self.send_game_state() # Send updated state + + await self.stopGame() + + async def handle_input(self, input_action): + """Handle input from the client received via WebSocket.""" + if input_action == "SOFT_DROP": + await self.movePiece(Action.SOFT_DROP) + elif input_action == "MOVE_LEFT": + await self.movePiece(Action.MOVE_LEFT) + elif input_action == "MOVE_RIGHT": + await self.movePiece(Action.MOVE_RIGHT) + elif input_action == "HARD_DROP": + await self.movePiece(Action.HARD_DROP) + elif input_action == "ROTATE_CLOCKWISE": + await self.movePiece(Action.ROTATE_CLOCKWISE) + + def checkTimer(self): + """Check if the block needs to drop based on the update timer.""" + checkTime = self.currentTime + 1000 / self.updateTimer + newTime = int(round(t.time() * 1000)) + if checkTime < newTime: + self.currentTime = newTime + self.board.doAction(Action.SOFT_DROP) + + async def send_game_state(self): + """Send the current game state to the client via WebSocket.""" + temp = deepcopy(self.board) + temp_board = temp.board[3:] # Skip the top hidden rows + game_state = { + "board": temp_board, + "score": self.score, + "gameOver": self.isGameOver(), + } + await self.websocket.send_text(json.dumps(game_state)) + + async def stopGame(self): + """Handle game over logic.""" + await self.websocket.close() + print("Game Over") + print(self.board.board) diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..431df81 --- /dev/null +++ b/web_app.py @@ -0,0 +1,51 @@ +from fastapi import FastAPI, WebSocket +from fastapi.middleware.cors import CORSMiddleware +from src.agents.agent_factory import create_agent +from src.game.tetris import Tetris +from src.game.TetrisWebGameManager import TetrisGameManager +import json + + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Adjust this to allow specific origins as needed + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.websocket("/ws/game") +async def websocket_endpoint(websocket: WebSocket): + board = Tetris() # Initialize the game board + manager = TetrisGameManager(board, websocket) + await websocket.accept() + print("WebSocket connection established") + + try: + await manager.startGame() + except Exception as e: + print(f"WebSocket error: {e}") + finally: + print("WebSocket connection closed") + await websocket.close() + + +@app.websocket("/ws/demo/{agent_type}") +async def websocket_demo_endpoint(websocket: WebSocket, agent_type: str): + agent = create_agent(agent_type) # Create agent for demo + board = Tetris() # Initialize the game board + manager = TetrisGameManager(board, websocket) + + await websocket.accept() + print("WebSocket demo connection established") + + try: + await manager.startDemo(agent) + except Exception as e: + print(f"WebSocket error: {e}") + finally: + print("WebSocket demo connection closed") + await websocket.close() From ced6344502706a0112773848b69a9676a830cfee Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 22:30:39 +0200 Subject: [PATCH 03/24] refactor: extract singleplayer.js for WebSocket client code --- index.html | 55 +-------------------------------------- singleplayer.js | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 54 deletions(-) create mode 100644 singleplayer.js diff --git a/index.html b/index.html index 9cd2077..f56bf87 100644 --- a/index.html +++ b/index.html @@ -6,59 +6,6 @@

Tetris Game

- + diff --git a/singleplayer.js b/singleplayer.js new file mode 100644 index 0000000..ff01362 --- /dev/null +++ b/singleplayer.js @@ -0,0 +1,68 @@ +const ws = new WebSocket("ws://127.0.0.1:8000/ws/game"); + +const canvas = document.getElementById("game-canvas"); +const ctx = canvas.getContext("2d"); + +// Define the same color scheme as in the Python code +const COLORS = [ + "rgba(0, 0, 0, 0)", // No color (transparent) + "rgb(0, 255, 255)", // I block (cyan) + "rgb(255, 0, 0)", // Z block (red) + "rgb(0, 255, 0)", // S block (green) + "rgb(255, 165, 0)", // L block (orange) + "rgb(0, 0, 255)", // J block (blue) + "rgb(128, 0, 128)", // T block (purple) + "rgb(255, 255, 0)", // O block (yellow) +]; + +ws.onopen = function () { + console.log("WebSocket connection established"); +}; + +ws.onmessage = function (event) { + const gameState = JSON.parse(event.data); + console.log("Game state received from server:", gameState); + drawBoard(gameState.board); // Render the updated board +}; + +ws.onclose = function () { + console.log("WebSocket connection closed"); +}; + +ws.onerror = function (error) { + console.error("WebSocket error:", error); +}; + +// Draw the Tetris board with the correct colors +function drawBoard(board) { + const blockSize = 40; // Size of each block + ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas + + for (let y = 0; y < board.length; y++) { + for (let x = 0; x < board[y].length; x++) { + const blockType = board[y][x]; + const color = COLORS[blockType]; // Get the color based on the block type + + if (blockType !== 0) { + // Don't draw for empty spaces + ctx.fillStyle = color; + ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize); + } + } + } +} + +// Sending input events to the server +window.addEventListener("keydown", function (e) { + if (e.key === "ArrowDown") { + ws.send("SOFT_DROP"); + } else if (e.key === "ArrowLeft") { + ws.send("MOVE_LEFT"); + } else if (e.key === "ArrowRight") { + ws.send("MOVE_RIGHT"); + } else if (e.key === " ") { + ws.send("HARD_DROP"); + } else if (e.key === "ArrowUp") { + ws.send("ROTATE_CLOCKWISE"); + } +}); From 6e4246fdfcf42a4b1a28c8d1752b19dbfef569cc Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 22:31:57 +0200 Subject: [PATCH 04/24] feat: Improve Tetris game loop with automatic block dropping and adjustable fall speed --- src/game/TetrisWebGameManager.py | 47 ++++++++++++++++++++++---------- web_app.py | 7 ++--- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 186ddf6..97b8367 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -1,5 +1,6 @@ from copy import deepcopy -import time as t +import time +import asyncio import json from src.agents.agent import Agent, playGameDemoStepByStep from src.game.tetris import Action, Tetris @@ -13,8 +14,13 @@ def __init__(self, board: Tetris, websocket): self.board = board # Ensure board is of type Tetris self.websocket = websocket # WebSocket connection for real-time communication self.score = 0 - self.currentTime = int(round(t.time() * 1000)) + self.currentTime = int(round(time.time() * 1000)) self.updateTimer = 1 # Timer to control piece dropping + self.base_fall_delay = ( + 1 # Base delay for blocks to fall automatically (in seconds) + ) + self.fall_delay = self.base_fall_delay # The actual delay for the current speed + self.last_fall_time = time.time() # Track the last time the block fell async def movePiece(self, direction: Action): """Move the Tetris block in a given direction and send updated game state via WebSocket.""" @@ -25,21 +31,40 @@ def isGameOver(self): """Check if the game is over.""" return self.board.isGameOver() + def update_fall_delay(self): + """Update the fall delay based on the score.""" + # For every 10 rows removed (or points scored), decrease the fall delay + # Ensure it does not go below a minimum fall delay (e.g., 0.1 seconds) + self.fall_delay = max(self.base_fall_delay - (self.score // 10) * 0.1, 0.1) + print(f"Updated fall delay: {self.fall_delay}") + async def startGame(self): """Start the game loop for a normal game, receiving inputs and sending game state via WebSocket.""" await self.send_game_state() # Send initial game state while not self.board.gameOver: try: - # Receive input action from the WebSocket - input_action = await self.websocket.receive_text() - await self.handle_input(input_action) # Process the input action + # Track the time and automatically move the block down if enough time has passed + current_time = time.time() + if current_time - self.last_fall_time >= self.fall_delay: + await self.movePiece(Action.SOFT_DROP) + self.last_fall_time = current_time + + # Receive player input, but don't reset the fall delay + try: + input_action = await asyncio.wait_for( + self.websocket.receive_text(), timeout=0.1 + ) + await self.handle_input(input_action) + except asyncio.TimeoutError: + pass # No input received within 0.1 seconds, keep the block falling # Update the board after block lands if self.board.blockHasLanded: self.board.updateBoard() + self.score += 1 # Increase score each time a block lands + self.update_fall_delay() # Adjust fall speed based on the new score - self.checkTimer() await self.send_game_state() # Send updated state except Exception as e: @@ -53,7 +78,7 @@ async def startDemo(self, agent: Agent): await self.send_game_state() # Send game state to client while not self.board.gameOver: playGameDemoStepByStep(agent, self.board) # Agent plays step by step - await t.sleep(0.1) # Small delay to simulate gameplay + await asyncio.sleep(0.1) # Small delay to simulate gameplay await self.send_game_state() # Send updated state await self.stopGame() @@ -71,14 +96,6 @@ async def handle_input(self, input_action): elif input_action == "ROTATE_CLOCKWISE": await self.movePiece(Action.ROTATE_CLOCKWISE) - def checkTimer(self): - """Check if the block needs to drop based on the update timer.""" - checkTime = self.currentTime + 1000 / self.updateTimer - newTime = int(round(t.time() * 1000)) - if checkTime < newTime: - self.currentTime = newTime - self.board.doAction(Action.SOFT_DROP) - async def send_game_state(self): """Send the current game state to the client via WebSocket.""" temp = deepcopy(self.board) diff --git a/web_app.py b/web_app.py index 431df81..5c158bb 100644 --- a/web_app.py +++ b/web_app.py @@ -1,9 +1,8 @@ from fastapi import FastAPI, WebSocket from fastapi.middleware.cors import CORSMiddleware -from src.agents.agent_factory import create_agent from src.game.tetris import Tetris from src.game.TetrisWebGameManager import TetrisGameManager -import json +from src.agents.agent_factory import create_agent app = FastAPI() @@ -25,7 +24,7 @@ async def websocket_endpoint(websocket: WebSocket): print("WebSocket connection established") try: - await manager.startGame() + await manager.startGame() # Start the game loop with automatic block dropping except Exception as e: print(f"WebSocket error: {e}") finally: @@ -45,7 +44,7 @@ async def websocket_demo_endpoint(websocket: WebSocket, agent_type: str): try: await manager.startDemo(agent) except Exception as e: - print(f"WebSocket error: {e}") + print(f"WebSocket demo error: {e}") finally: print("WebSocket demo connection closed") await websocket.close() From 4bddcc089bc51fac0d50203d981f9bed94240cea Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 22:32:06 +0200 Subject: [PATCH 05/24] feat: Add WebSocket endpoint for game information --- web_app.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/web_app.py b/web_app.py index 5c158bb..6a5cf1b 100644 --- a/web_app.py +++ b/web_app.py @@ -48,3 +48,39 @@ async def websocket_demo_endpoint(websocket: WebSocket, agent_type: str): finally: print("WebSocket demo connection closed") await websocket.close() + + +# Dummy route for documentation purposes +@app.get("/ws/game/info", include_in_schema=True) +async def websocket_game_info(): + """ + This endpoint provides information about the `/ws/game` WebSocket. + + ### WebSocket Connection: + - **URL:** `/ws/game` + - **Expected Messages:** + - `"MOVE_LEFT"`: Move the Tetris block to the left + - `"MOVE_RIGHT"`: Move the Tetris block to the right + - `"SOFT_DROP"`: Drop the Tetris block one row + - `"HARD_DROP"`: Drop the Tetris block to the bottom + - `"ROTATE_CLOCKWISE"`: Rotate the block clockwise + + ### Responses: + - The server will periodically send the updated game state via WebSocket. + - The game state is sent as a JSON object with the following fields: + - `"board"`: 2D array representing the Tetris board + - `"score"`: The current score of the player + - `"gameOver"`: Whether the game is over or not + + ### Example Game State Response: + ```json + { + "board": [[0, 0, 1, 1], [1, 1, 0, 0], ...], + "score": 10, + "gameOver": false + } + ``` + """ + return JSONResponse( + {"info": "This is the documentation for the /ws/game WebSocket endpoint."} + ) From a88d0ed0c7663e9fcf6d835e8a195f0007f1bc61 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 22:33:28 +0200 Subject: [PATCH 06/24] fix: add JSONResponse import to fix WebSocket game info endpoint --- web_app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web_app.py b/web_app.py index 6a5cf1b..56750ff 100644 --- a/web_app.py +++ b/web_app.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, WebSocket +from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from src.game.tetris import Tetris from src.game.TetrisWebGameManager import TetrisGameManager @@ -81,6 +82,7 @@ async def websocket_game_info(): } ``` """ + return JSONResponse( {"info": "This is the documentation for the /ws/game WebSocket endpoint."} ) From 160fe77d2d2203a0709726b089d3e475dffd37b6 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 23:09:46 +0200 Subject: [PATCH 07/24] build: Add Dockerfile and docker-compose.yml for containerization --- Dockerfile | 20 ++++++++++++ docker-compose.yml | 24 ++++++++++++++ frontend/Dockerfile | 8 +++++ frontend/agentplayer.js | 52 ++++++++++++++++++++++++++++++ frontend/index.html | 26 +++++++++++++++ frontend/singleplayer.js | 35 ++++++++++++++++++++ frontend/tetris-common.js | 33 +++++++++++++++++++ index.html | 11 ------- requirements.txt | 18 +++++++++++ singleplayer.js | 68 --------------------------------------- 10 files changed, 216 insertions(+), 79 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/agentplayer.js create mode 100644 frontend/index.html create mode 100644 frontend/singleplayer.js create mode 100644 frontend/tetris-common.js delete mode 100644 index.html delete mode 100644 singleplayer.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2816747 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Use an official Python runtime as a parent image +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements.txt file into the container +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code into the container +COPY . . + +# Expose the port the app runs on +EXPOSE 8000 + +# Run the FastAPI server using uvicorn +CMD ["uvicorn", "web_app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..276634a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3" + +services: + backend: + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + networks: + - app-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "80:80" + networks: + - app-network + +networks: + app-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ff54cdd --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +# Use the official Nginx image to serve the frontend +FROM nginx:alpine + +# Copy the frontend files into the appropriate Nginx directory +COPY . /usr/share/nginx/html + +# Expose port 80 for the frontend +EXPOSE 80 diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js new file mode 100644 index 0000000..3ac9cda --- /dev/null +++ b/frontend/agentplayer.js @@ -0,0 +1,52 @@ +const agentSelect = document.getElementById("agent-select"); +const startDemoBtn = document.getElementById("start-demo"); +let wsAgent = null; // WebSocket connection for agent demo + +// Fetch available agents from the server and populate the dropdown +async function loadAgents() { + const response = await fetch("http://127.0.0.1:8000/agents"); + const agents = await response.json(); + + agents.forEach((agent) => { + const option = document.createElement("option"); + option.value = agent; + option.textContent = agent; + agentSelect.appendChild(option); + }); +} + +// Start WebSocket connection for agent demo +function startDemo() { + const selectedAgent = agentSelect.value; + + // Close the existing WebSocket connection if any + if (wsAgent) { + wsAgent.close(); + } + + wsAgent = new WebSocket(`ws://127.0.0.1:8000/ws/demo/${selectedAgent}`); + + wsAgent.onopen = function () { + console.log(`WebSocket connection established with ${selectedAgent} agent`); + }; + + wsAgent.onmessage = function (event) { + const gameState = JSON.parse(event.data); + console.log("Game state received from server:", gameState); + drawBoard(gameState.board); // Render the updated board using shared function + }; + + wsAgent.onclose = function () { + console.log("WebSocket connection closed"); + }; + + wsAgent.onerror = function (error) { + console.error("WebSocket error:", error); + }; +} + +// Load the agents when the page is loaded +window.addEventListener("load", loadAgents); + +// Start the demo when the button is clicked +startDemoBtn.addEventListener("click", startDemo); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..30257b4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,26 @@ + + + + Tetris AI Demo + + +

Tetris AI Demo

+ + + + + + + + + + + + + + + + + diff --git a/frontend/singleplayer.js b/frontend/singleplayer.js new file mode 100644 index 0000000..4d96d5f --- /dev/null +++ b/frontend/singleplayer.js @@ -0,0 +1,35 @@ +// WebSocket connection for single-player mode +const wsSingleplayer = new WebSocket("ws://127.0.0.1:8000/ws/game"); + +wsSingleplayer.onopen = function () { + console.log("Single-player WebSocket connection established"); +}; + +wsSingleplayer.onmessage = function (event) { + const gameState = JSON.parse(event.data); + console.log("Game state received from server:", gameState); + drawBoard(gameState.board); // Render the updated board using shared function +}; + +wsSingleplayer.onclose = function () { + console.log("Single-player WebSocket connection closed"); +}; + +wsSingleplayer.onerror = function (error) { + console.error("WebSocket error:", error); +}; + +// Sending input events to the server +window.addEventListener("keydown", function (e) { + if (e.key === "ArrowDown") { + wsSingleplayer.send("SOFT_DROP"); + } else if (e.key === "ArrowLeft") { + wsSingleplayer.send("MOVE_LEFT"); + } else if (e.key === "ArrowRight") { + wsSingleplayer.send("MOVE_RIGHT"); + } else if (e.key === " ") { + wsSingleplayer.send("HARD_DROP"); + } else if (e.key === "ArrowUp") { + wsSingleplayer.send("ROTATE_CLOCKWISE"); + } +}); diff --git a/frontend/tetris-common.js b/frontend/tetris-common.js new file mode 100644 index 0000000..d22ee19 --- /dev/null +++ b/frontend/tetris-common.js @@ -0,0 +1,33 @@ +const canvas = document.getElementById("game-canvas"); +const ctx = canvas.getContext("2d"); + +// Define block colors +const COLORS = [ + "rgba(0, 0, 0, 0)", // No color (transparent) + "rgb(0, 255, 255)", // I block (cyan) + "rgb(255, 0, 0)", // Z block (red) + "rgb(0, 255, 0)", // S block (green) + "rgb(255, 165, 0)", // L block (orange) + "rgb(0, 0, 255)", // J block (blue) + "rgb(128, 0, 128)", // T block (purple) + "rgb(255, 255, 0)", // O block (yellow) +]; + +// Shared function to draw the Tetris board with the correct colors +function drawBoard(board) { + const blockSize = 40; // Size of each block + ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas + + for (let y = 0; y < board.length; y++) { + for (let x = 0; x < board[y].length; x++) { + const blockType = board[y][x]; + const color = COLORS[blockType]; // Get the color based on the block type + + if (blockType !== 0) { + // Don't draw for empty spaces + ctx.fillStyle = color; + ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize); + } + } + } +} diff --git a/index.html b/index.html deleted file mode 100644 index f56bf87..0000000 --- a/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Tetris WebSocket - - -

Tetris Game

- - - - diff --git a/requirements.txt b/requirements.txt index 9ee3940..8e5b835 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,24 @@ +annotated-types==0.7.0 +anyio==4.4.0 +click==8.1.7 +fastapi==0.114.2 +h11==0.14.0 +httptools==0.6.1 +idna==3.10 iniconfig==2.0.0 numpy==1.26.4 packaging==24.0 pluggy==1.5.0 +pydantic==2.9.1 +pydantic_core==2.23.3 pygame==2.5.2 pytest==8.2.0 +python-dotenv==1.0.1 +PyYAML==6.0.2 +sniffio==1.3.1 +starlette==0.38.5 +typing_extensions==4.12.2 +uvicorn==0.30.6 +uvloop==0.20.0 +watchfiles==0.24.0 +websockets==13.0.1 diff --git a/singleplayer.js b/singleplayer.js deleted file mode 100644 index ff01362..0000000 --- a/singleplayer.js +++ /dev/null @@ -1,68 +0,0 @@ -const ws = new WebSocket("ws://127.0.0.1:8000/ws/game"); - -const canvas = document.getElementById("game-canvas"); -const ctx = canvas.getContext("2d"); - -// Define the same color scheme as in the Python code -const COLORS = [ - "rgba(0, 0, 0, 0)", // No color (transparent) - "rgb(0, 255, 255)", // I block (cyan) - "rgb(255, 0, 0)", // Z block (red) - "rgb(0, 255, 0)", // S block (green) - "rgb(255, 165, 0)", // L block (orange) - "rgb(0, 0, 255)", // J block (blue) - "rgb(128, 0, 128)", // T block (purple) - "rgb(255, 255, 0)", // O block (yellow) -]; - -ws.onopen = function () { - console.log("WebSocket connection established"); -}; - -ws.onmessage = function (event) { - const gameState = JSON.parse(event.data); - console.log("Game state received from server:", gameState); - drawBoard(gameState.board); // Render the updated board -}; - -ws.onclose = function () { - console.log("WebSocket connection closed"); -}; - -ws.onerror = function (error) { - console.error("WebSocket error:", error); -}; - -// Draw the Tetris board with the correct colors -function drawBoard(board) { - const blockSize = 40; // Size of each block - ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas - - for (let y = 0; y < board.length; y++) { - for (let x = 0; x < board[y].length; x++) { - const blockType = board[y][x]; - const color = COLORS[blockType]; // Get the color based on the block type - - if (blockType !== 0) { - // Don't draw for empty spaces - ctx.fillStyle = color; - ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize); - } - } - } -} - -// Sending input events to the server -window.addEventListener("keydown", function (e) { - if (e.key === "ArrowDown") { - ws.send("SOFT_DROP"); - } else if (e.key === "ArrowLeft") { - ws.send("MOVE_LEFT"); - } else if (e.key === "ArrowRight") { - ws.send("MOVE_RIGHT"); - } else if (e.key === " ") { - ws.send("HARD_DROP"); - } else if (e.key === "ArrowUp") { - ws.send("ROTATE_CLOCKWISE"); - } -}); From 63bd7ddcd4267ccfedbfb556315afa0d6d15ced7 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 23:09:55 +0200 Subject: [PATCH 08/24] feat: Add endpoint to retrieve available agents --- src/agents/agent_factory.py | 2 ++ web_app.py | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/agents/agent_factory.py b/src/agents/agent_factory.py index e0f8622..8ce5e06 100644 --- a/src/agents/agent_factory.py +++ b/src/agents/agent_factory.py @@ -5,6 +5,8 @@ from src.agents.heuristic_agent import HeuristicAgent from src.agents.geneticAlgAgentJon import GeneticAlgAgentJM +AVAILABLE_AGENTS = ["random", "heuristic", "genetic"] + def create_agent(agent_type: str) -> Agent: """Create an agent of the specified type.""" diff --git a/web_app.py b/web_app.py index 56750ff..29ec1af 100644 --- a/web_app.py +++ b/web_app.py @@ -3,10 +3,13 @@ from fastapi.middleware.cors import CORSMiddleware from src.game.tetris import Tetris from src.game.TetrisWebGameManager import TetrisGameManager -from src.agents.agent_factory import create_agent +from src.agents.agent_factory import create_agent, AVAILABLE_AGENTS - -app = FastAPI() +app = FastAPI( + title="Cogito TetrisAI API", + description="This API provides a WebSocket interface to play Tetris and watch a Tetris AI agent play the game.", + version="1.0.0", +) app.add_middleware( CORSMiddleware, @@ -17,6 +20,14 @@ ) +@app.get("/agents", response_model=list) +async def get_available_agents(): + """ + Returns a list of available agents for playing the game. + """ + return AVAILABLE_AGENTS + + @app.websocket("/ws/game") async def websocket_endpoint(websocket: WebSocket): board = Tetris() # Initialize the game board From 8e3cca27408aaa2067c7ccad8fcaf976080045bf Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 23:14:54 +0200 Subject: [PATCH 09/24] docs: Add how to run web version to readme --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e111169..688af19 100755 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This project is our attempt at making an AI that can play Tetris. First of all w - Heuristic agent with set weights - Genetic algorithm to find the best weights for the heuristic agent -The game is playable/viable both in the terminal and in a GUI. The GUI is made with Pygame. +The game is playable/viable both in the terminal, in a GUI and Web. The GUI is made with Pygame. ## How to run and install @@ -38,6 +38,7 @@ pip install -r requirements.txt ## Usage +### GUI version To play the game yourself, run the following command: ```bash @@ -58,6 +59,14 @@ To train the genetic agent, run the following command: python main.py train ``` +### Web version +To run the web version of the game, run the following command: + +```bash +docker compose up --build +``` +Then go to `http://localhost:80` in your browser. + ## Testing To run the test suite, run the following command from the root directory of the project: From 0e2a8efad1e0df85d51319708ec959c29736d2e1 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Mon, 16 Sep 2024 23:29:13 +0200 Subject: [PATCH 10/24] refactor: Improve TetrisWebGameManager code structure and variable naming --- src/game/TetrisWebGameManager.py | 46 ++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 97b8367..5ed166c 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -14,18 +14,16 @@ def __init__(self, board: Tetris, websocket): self.board = board # Ensure board is of type Tetris self.websocket = websocket # WebSocket connection for real-time communication self.score = 0 - self.currentTime = int(round(time.time() * 1000)) - self.updateTimer = 1 # Timer to control piece dropping - self.base_fall_delay = ( - 1 # Base delay for blocks to fall automatically (in seconds) - ) - self.fall_delay = self.base_fall_delay # The actual delay for the current speed - self.last_fall_time = time.time() # Track the last time the block fell + self.current_time = int(round(time.time() * 1000)) + self.update_timer = 1 # Timer to control piece dropping + self.start_fall_delay_in_seconds = 1 + self.current_fall_delay = self.start_fall_delay_in_seconds + self.last_fall_time = time.time() async def movePiece(self, direction: Action): """Move the Tetris block in a given direction and send updated game state via WebSocket.""" self.board.doAction(direction) - await self.send_game_state() # Send updated state after action + await self.send_game_state() def isGameOver(self): """Check if the game is over.""" @@ -35,18 +33,21 @@ def update_fall_delay(self): """Update the fall delay based on the score.""" # For every 10 rows removed (or points scored), decrease the fall delay # Ensure it does not go below a minimum fall delay (e.g., 0.1 seconds) - self.fall_delay = max(self.base_fall_delay - (self.score // 10) * 0.1, 0.1) - print(f"Updated fall delay: {self.fall_delay}") + self.current_fall_delay = max( + self.start_fall_delay_in_seconds - (self.score // 10) * 0.1, 0.1 + ) + print(f"Updated fall delay: {self.current_fall_delay}") async def startGame(self): """Start the game loop for a normal game, receiving inputs and sending game state via WebSocket.""" - await self.send_game_state() # Send initial game state + # Send initial game state + await self.send_game_state() while not self.board.gameOver: try: # Track the time and automatically move the block down if enough time has passed current_time = time.time() - if current_time - self.last_fall_time >= self.fall_delay: + if current_time - self.last_fall_time >= self.current_fall_delay: await self.movePiece(Action.SOFT_DROP) self.last_fall_time = current_time @@ -57,15 +58,16 @@ async def startGame(self): ) await self.handle_input(input_action) except asyncio.TimeoutError: - pass # No input received within 0.1 seconds, keep the block falling + # No input received within 0.1 seconds, keep the block falling + pass # Update the board after block lands if self.board.blockHasLanded: self.board.updateBoard() - self.score += 1 # Increase score each time a block lands - self.update_fall_delay() # Adjust fall speed based on the new score + self.score += 1 + self.update_fall_delay() - await self.send_game_state() # Send updated state + await self.send_game_state() except Exception as e: print(f"Error in game loop: {e}") @@ -75,11 +77,12 @@ async def startGame(self): async def startDemo(self, agent: Agent): """Start the game loop for a demo game with an agent, sending updates via WebSocket.""" - await self.send_game_state() # Send game state to client + # Send game state to client + await self.send_game_state() while not self.board.gameOver: - playGameDemoStepByStep(agent, self.board) # Agent plays step by step + playGameDemoStepByStep(agent, self.board) await asyncio.sleep(0.1) # Small delay to simulate gameplay - await self.send_game_state() # Send updated state + await self.send_game_state() await self.stopGame() @@ -99,7 +102,10 @@ async def handle_input(self, input_action): async def send_game_state(self): """Send the current game state to the client via WebSocket.""" temp = deepcopy(self.board) - temp_board = temp.board[3:] # Skip the top hidden rows + + # Skip the top hidden rows + temp_board = temp.board[3:] + game_state = { "board": temp_board, "score": self.score, From 9c6d28075e532e8018b3d9f4bf297fd3ca2fe6b9 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Tue, 17 Sep 2024 00:57:38 +0200 Subject: [PATCH 11/24] refactor: Rename WebSocket variables in agentplayer.js for clarity --- frontend/agentplayer.js | 21 ++++++++++++--------- frontend/index.html | 17 +++++------------ frontend/routes.js | 0 frontend/singleplayer.js | 22 +++++++++++----------- 4 files changed, 28 insertions(+), 32 deletions(-) create mode 100644 frontend/routes.js diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js index 3ac9cda..054216d 100644 --- a/frontend/agentplayer.js +++ b/frontend/agentplayer.js @@ -1,6 +1,6 @@ const agentSelect = document.getElementById("agent-select"); const startDemoBtn = document.getElementById("start-demo"); -let wsAgent = null; // WebSocket connection for agent demo +let agentWebSocket = null; // Fetch available agents from the server and populate the dropdown async function loadAgents() { @@ -20,27 +20,30 @@ function startDemo() { const selectedAgent = agentSelect.value; // Close the existing WebSocket connection if any - if (wsAgent) { - wsAgent.close(); + if (agentWebSocket) { + agentWebSocket.close(); } - wsAgent = new WebSocket(`ws://127.0.0.1:8000/ws/demo/${selectedAgent}`); + agentWebSocket = new WebSocket( + `ws://127.0.0.1:8000/ws/demo/${selectedAgent}` + ); - wsAgent.onopen = function () { + agentWebSocket.onopen = function () { console.log(`WebSocket connection established with ${selectedAgent} agent`); }; - wsAgent.onmessage = function (event) { + agentWebSocket.onmessage = function (event) { const gameState = JSON.parse(event.data); console.log("Game state received from server:", gameState); - drawBoard(gameState.board); // Render the updated board using shared function + // Render the updated board using shared function + drawBoard(gameState.board); }; - wsAgent.onclose = function () { + agentWebSocket.onclose = function () { console.log("WebSocket connection closed"); }; - wsAgent.onerror = function (error) { + agentWebSocket.onerror = function (error) { console.error("WebSocket error:", error); }; } diff --git a/frontend/index.html b/frontend/index.html index 30257b4..0ea3635 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,26 +1,19 @@ - Tetris AI Demo + TetrisAI -

Tetris AI Demo

+

TetrisAI

- + + - - - + - - - - diff --git a/frontend/routes.js b/frontend/routes.js new file mode 100644 index 0000000..e69de29 diff --git a/frontend/singleplayer.js b/frontend/singleplayer.js index 4d96d5f..9046d80 100644 --- a/frontend/singleplayer.js +++ b/frontend/singleplayer.js @@ -1,35 +1,35 @@ // WebSocket connection for single-player mode -const wsSingleplayer = new WebSocket("ws://127.0.0.1:8000/ws/game"); +const singleplayerWebSocket = new WebSocket("ws://127.0.0.1:8000/ws/game"); -wsSingleplayer.onopen = function () { +singleplayerWebSocket.onopen = () => { console.log("Single-player WebSocket connection established"); }; -wsSingleplayer.onmessage = function (event) { +singleplayerWebSocket.onmessage = (event) => { const gameState = JSON.parse(event.data); console.log("Game state received from server:", gameState); drawBoard(gameState.board); // Render the updated board using shared function }; -wsSingleplayer.onclose = function () { +singleplayerWebSocket.onclose = () => { console.log("Single-player WebSocket connection closed"); }; -wsSingleplayer.onerror = function (error) { +singleplayerWebSocket.onerror = (error) => { console.error("WebSocket error:", error); }; // Sending input events to the server -window.addEventListener("keydown", function (e) { +window.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { - wsSingleplayer.send("SOFT_DROP"); + singleplayerWebSocket.send("SOFT_DROP"); } else if (e.key === "ArrowLeft") { - wsSingleplayer.send("MOVE_LEFT"); + singleplayerWebSocket.send("MOVE_LEFT"); } else if (e.key === "ArrowRight") { - wsSingleplayer.send("MOVE_RIGHT"); + singleplayerWebSocket.send("MOVE_RIGHT"); } else if (e.key === " ") { - wsSingleplayer.send("HARD_DROP"); + singleplayerWebSocket.send("HARD_DROP"); } else if (e.key === "ArrowUp") { - wsSingleplayer.send("ROTATE_CLOCKWISE"); + singleplayerWebSocket.send("ROTATE_CLOCKWISE"); } }); From 46268384ec2751d897e3813052bc2022018f0426 Mon Sep 17 00:00:00 2001 From: Sverre Nystad <89105607+SverreNystad@users.noreply.github.com> Date: Tue, 17 Sep 2024 01:07:55 +0200 Subject: [PATCH 12/24] refactor: Delete frontend/routes.js --- frontend/routes.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 frontend/routes.js diff --git a/frontend/routes.js b/frontend/routes.js deleted file mode 100644 index e69de29..0000000 From 1452e63d33bab27df46fc1cf6def87a4b9da1255 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Tue, 17 Sep 2024 01:49:26 +0200 Subject: [PATCH 13/24] refactor: Improve TetrisWebGameManager code structure and variable naming --- src/game/TetrisWebGameManager.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 5ed166c..8d36f6b 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -1,24 +1,26 @@ -from copy import deepcopy import time import asyncio import json +from fastapi import WebSocket + from src.agents.agent import Agent, playGameDemoStepByStep from src.game.tetris import Action, Tetris class TetrisGameManager: - def __init__(self, board: Tetris, websocket): + def __init__(self, board: Tetris, websocket: WebSocket): """ Initialize the game manager with a board of type Tetris and a WebSocket connection. """ - self.board = board # Ensure board is of type Tetris + self.board = board self.websocket = websocket # WebSocket connection for real-time communication - self.score = 0 self.current_time = int(round(time.time() * 1000)) self.update_timer = 1 # Timer to control piece dropping + self.start_fall_delay_in_seconds = 1 self.current_fall_delay = self.start_fall_delay_in_seconds self.last_fall_time = time.time() + self.fastest_fall_delay = 0.1 async def movePiece(self, direction: Action): """Move the Tetris block in a given direction and send updated game state via WebSocket.""" @@ -31,10 +33,14 @@ def isGameOver(self): def update_fall_delay(self): """Update the fall delay based on the score.""" - # For every 10 rows removed (or points scored), decrease the fall delay - # Ensure it does not go below a minimum fall delay (e.g., 0.1 seconds) + # Speed up the block falling speed based on the number of lines cleared + LINES_CLEAR_FOR_LEVEL = 10 + LEVEL_SPEEDUP_FACTOR = 0.1 + self.current_fall_delay = max( - self.start_fall_delay_in_seconds - (self.score // 10) * 0.1, 0.1 + self.start_fall_delay_in_seconds + - (self.board.rowsRemoved // LINES_CLEAR_FOR_LEVEL) * LEVEL_SPEEDUP_FACTOR, + self.fastest_fall_delay, ) print(f"Updated fall delay: {self.current_fall_delay}") @@ -64,7 +70,6 @@ async def startGame(self): # Update the board after block lands if self.board.blockHasLanded: self.board.updateBoard() - self.score += 1 self.update_fall_delay() await self.send_game_state() @@ -101,14 +106,12 @@ async def handle_input(self, input_action): async def send_game_state(self): """Send the current game state to the client via WebSocket.""" - temp = deepcopy(self.board) # Skip the top hidden rows - temp_board = temp.board[3:] - + visible_board = self.board.board[3:] game_state = { - "board": temp_board, - "score": self.score, + "board": visible_board, + "score": self.board.rowsRemoved, "gameOver": self.isGameOver(), } await self.websocket.send_text(json.dumps(game_state)) @@ -117,4 +120,4 @@ async def stopGame(self): """Handle game over logic.""" await self.websocket.close() print("Game Over") - print(self.board.board) + print(f"Final Score: {self.board.rowsRemoved}") From a0a344a9fb9fee2e981fac3a0d0069835a9a9c77 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Tue, 17 Sep 2024 01:50:07 +0200 Subject: [PATCH 14/24] feat: add second canvas element for agent and allow for parallel play --- frontend/agentplayer.js | 4 +++- frontend/index.html | 3 ++- frontend/singleplayer.js | 3 ++- frontend/tetris-common.js | 21 +++++++++++++-------- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js index 054216d..c08eb1a 100644 --- a/frontend/agentplayer.js +++ b/frontend/agentplayer.js @@ -2,6 +2,8 @@ const agentSelect = document.getElementById("agent-select"); const startDemoBtn = document.getElementById("start-demo"); let agentWebSocket = null; +const canvasAgentId = "agentplayer-canvas"; + // Fetch available agents from the server and populate the dropdown async function loadAgents() { const response = await fetch("http://127.0.0.1:8000/agents"); @@ -36,7 +38,7 @@ function startDemo() { const gameState = JSON.parse(event.data); console.log("Game state received from server:", gameState); // Render the updated board using shared function - drawBoard(gameState.board); + drawBoard(gameState.board, canvasAgentId); }; agentWebSocket.onclose = function () { diff --git a/frontend/index.html b/frontend/index.html index 0ea3635..db87eca 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,8 @@

TetrisAI

- + + diff --git a/frontend/singleplayer.js b/frontend/singleplayer.js index 9046d80..6d8c564 100644 --- a/frontend/singleplayer.js +++ b/frontend/singleplayer.js @@ -1,5 +1,6 @@ // WebSocket connection for single-player mode const singleplayerWebSocket = new WebSocket("ws://127.0.0.1:8000/ws/game"); +const canvasSinglePlayerId = "singleplayer-canvas"; singleplayerWebSocket.onopen = () => { console.log("Single-player WebSocket connection established"); @@ -8,7 +9,7 @@ singleplayerWebSocket.onopen = () => { singleplayerWebSocket.onmessage = (event) => { const gameState = JSON.parse(event.data); console.log("Game state received from server:", gameState); - drawBoard(gameState.board); // Render the updated board using shared function + drawBoard(gameState.board, canvasSinglePlayerId); }; singleplayerWebSocket.onclose = () => { diff --git a/frontend/tetris-common.js b/frontend/tetris-common.js index d22ee19..600b126 100644 --- a/frontend/tetris-common.js +++ b/frontend/tetris-common.js @@ -1,6 +1,3 @@ -const canvas = document.getElementById("game-canvas"); -const ctx = canvas.getContext("2d"); - // Define block colors const COLORS = [ "rgba(0, 0, 0, 0)", // No color (transparent) @@ -14,17 +11,25 @@ const COLORS = [ ]; // Shared function to draw the Tetris board with the correct colors -function drawBoard(board) { - const blockSize = 40; // Size of each block - ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas +/** + * + * @param {number[][]} board - The Tetris board represented as a 2D array + */ +function drawBoard(board, canvasId) { + const canvas = document.getElementById(canvasId); + const ctx = canvas.getContext("2d"); + const blockSize = 40; + + // Clear the canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); for (let y = 0; y < board.length; y++) { for (let x = 0; x < board[y].length; x++) { const blockType = board[y][x]; - const color = COLORS[blockType]; // Get the color based on the block type + const color = COLORS[blockType]; + // Only draw the block if it's not an empty block if (blockType !== 0) { - // Don't draw for empty spaces ctx.fillStyle = color; ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize); } From f1486544c16b2d5ea78db3d0cc9daea5590c3722 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Tue, 17 Sep 2024 02:09:33 +0200 Subject: [PATCH 15/24] refactor: Update WebSocket URLs to use BASE_URL and WS_BASE_URL constants --- frontend/agentplayer.js | 10 ++++++---- frontend/index.html | 9 +++++---- frontend/routes.js | 2 ++ frontend/singleplayer.js | 8 ++++++-- frontend/tetris-common.js | 8 +++++--- 5 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 frontend/routes.js diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js index c08eb1a..0ddfae1 100644 --- a/frontend/agentplayer.js +++ b/frontend/agentplayer.js @@ -1,3 +1,7 @@ +import { BASE_URL, WS_BASE_URL } from "./routes.js"; +import { drawBoard } from "./tetris-common.js"; + +// DOM elements const agentSelect = document.getElementById("agent-select"); const startDemoBtn = document.getElementById("start-demo"); let agentWebSocket = null; @@ -6,7 +10,7 @@ const canvasAgentId = "agentplayer-canvas"; // Fetch available agents from the server and populate the dropdown async function loadAgents() { - const response = await fetch("http://127.0.0.1:8000/agents"); + const response = await fetch(`${BASE_URL}/agents`); const agents = await response.json(); agents.forEach((agent) => { @@ -26,9 +30,7 @@ function startDemo() { agentWebSocket.close(); } - agentWebSocket = new WebSocket( - `ws://127.0.0.1:8000/ws/demo/${selectedAgent}` - ); + agentWebSocket = new WebSocket(`${WS_BASE_URL}/demo/${selectedAgent}`); agentWebSocket.onopen = function () { console.log(`WebSocket connection established with ${selectedAgent} agent`); diff --git a/frontend/index.html b/frontend/index.html index db87eca..311fdb3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,11 +10,12 @@

TetrisAI

- + - - - + + + + diff --git a/frontend/routes.js b/frontend/routes.js new file mode 100644 index 0000000..50a6ff6 --- /dev/null +++ b/frontend/routes.js @@ -0,0 +1,2 @@ +export const BASE_URL = "http://127.0.0.1:8000"; +export const WS_BASE_URL = "ws://127.0.0.1:8000/ws"; diff --git a/frontend/singleplayer.js b/frontend/singleplayer.js index 6d8c564..28ed13b 100644 --- a/frontend/singleplayer.js +++ b/frontend/singleplayer.js @@ -1,5 +1,9 @@ -// WebSocket connection for single-player mode -const singleplayerWebSocket = new WebSocket("ws://127.0.0.1:8000/ws/game"); +import { WS_BASE_URL } from "./routes.js"; +import { drawBoard } from "./tetris-common.js"; + +const singleplayerWebSocket = new WebSocket(`${WS_BASE_URL}/game`); +console.log(WS_BASE_URL); + const canvasSinglePlayerId = "singleplayer-canvas"; singleplayerWebSocket.onopen = () => { diff --git a/frontend/tetris-common.js b/frontend/tetris-common.js index 600b126..f07b9a2 100644 --- a/frontend/tetris-common.js +++ b/frontend/tetris-common.js @@ -1,4 +1,4 @@ -// Define block colors +/** Define block colors */ const COLORS = [ "rgba(0, 0, 0, 0)", // No color (transparent) "rgb(0, 255, 255)", // I block (cyan) @@ -10,10 +10,10 @@ const COLORS = [ "rgb(255, 255, 0)", // O block (yellow) ]; -// Shared function to draw the Tetris board with the correct colors /** - * + * Draw the Tetris board with the correct colors * @param {number[][]} board - The Tetris board represented as a 2D array + * @param {string} canvasId - The ID of the canvas element to draw the board on */ function drawBoard(board, canvasId) { const canvas = document.getElementById(canvasId); @@ -36,3 +36,5 @@ function drawBoard(board, canvasId) { } } } + +export { drawBoard }; From 6f80ad3fb73644de55a6ca74f79ddf1b1d0fea7d Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Tue, 17 Sep 2024 02:09:44 +0200 Subject: [PATCH 16/24] fix: Prevent default spacebar scrolling in agentplayer.js --- frontend/agentplayer.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js index 0ddfae1..4304cca 100644 --- a/frontend/agentplayer.js +++ b/frontend/agentplayer.js @@ -52,6 +52,11 @@ function startDemo() { }; } +window.addEventListener("keydown", (e) => { + if (e.key === " ") { + e.preventDefault(); // Prevent default spacebar scrolling + } +}); // Load the agents when the page is loaded window.addEventListener("load", loadAgents); From b430f74507308bab4508de519551c560ec591208 Mon Sep 17 00:00:00 2001 From: Sverre Nystad Date: Tue, 17 Sep 2024 13:35:11 +0200 Subject: [PATCH 17/24] refactor: Update agentplayer.js to use arrow functions for WebSocket event handlers --- frontend/agentplayer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/agentplayer.js b/frontend/agentplayer.js index 4304cca..167b929 100644 --- a/frontend/agentplayer.js +++ b/frontend/agentplayer.js @@ -32,22 +32,22 @@ function startDemo() { agentWebSocket = new WebSocket(`${WS_BASE_URL}/demo/${selectedAgent}`); - agentWebSocket.onopen = function () { + agentWebSocket.onopen = () => { console.log(`WebSocket connection established with ${selectedAgent} agent`); }; - agentWebSocket.onmessage = function (event) { + agentWebSocket.onmessage = (event) => { const gameState = JSON.parse(event.data); console.log("Game state received from server:", gameState); // Render the updated board using shared function drawBoard(gameState.board, canvasAgentId); }; - agentWebSocket.onclose = function () { + agentWebSocket.onclose = () => { console.log("WebSocket connection closed"); }; - agentWebSocket.onerror = function (error) { + agentWebSocket.onerror = (error) => { console.error("WebSocket error:", error); }; } From 2f2db07d27fc2b8a732ed80b53e5e39d02ef67cb Mon Sep 17 00:00:00 2001 From: Sverre Nystad Date: Tue, 17 Sep 2024 14:43:26 +0200 Subject: [PATCH 18/24] feat: Add "nextPiece" property to json sent to client --- src/game/TetrisWebGameManager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 8d36f6b..7774f9e 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -113,6 +113,7 @@ async def send_game_state(self): "board": visible_board, "score": self.board.rowsRemoved, "gameOver": self.isGameOver(), + "nextPiece": self.board.nextBlock.type, } await self.websocket.send_text(json.dumps(game_state)) From a78ac262e737046f5875796052b6cf6ab5734523 Mon Sep 17 00:00:00 2001 From: Sverre Nystad Date: Tue, 17 Sep 2024 15:08:04 +0200 Subject: [PATCH 19/24] chore: add node to gitignore --- .gitignore | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5e8f6be..050ad2a 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,136 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.vscode/ \ No newline at end of file +.vscode/ + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file From 6a9197d0d8690cac60d366a48d0776485496f7b0 Mon Sep 17 00:00:00 2001 From: Sverre Nystad Date: Tue, 17 Sep 2024 16:21:12 +0200 Subject: [PATCH 20/24] chore: Update .gitignore to include package-lock.json --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 050ad2a..4bbd8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -291,4 +291,6 @@ dist .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz -.pnp.* \ No newline at end of file +.pnp.* + +**package-lock.json \ No newline at end of file From 5bcc5eb68e2340c07828f53c3072adb29f9a2077 Mon Sep 17 00:00:00 2001 From: Sverre Nystad Date: Tue, 17 Sep 2024 16:54:39 +0200 Subject: [PATCH 21/24] feat: add highscores post and get endpoints --- web_app.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/web_app.py b/web_app.py index 29ec1af..61d0c75 100644 --- a/web_app.py +++ b/web_app.py @@ -1,10 +1,17 @@ -from fastapi import FastAPI, WebSocket +import os +import json +from dataclasses import asdict, dataclass + +from fastapi import FastAPI, WebSocket, HTTPException from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder from fastapi.middleware.cors import CORSMiddleware + from src.game.tetris import Tetris from src.game.TetrisWebGameManager import TetrisGameManager from src.agents.agent_factory import create_agent, AVAILABLE_AGENTS + app = FastAPI( title="Cogito TetrisAI API", description="This API provides a WebSocket interface to play Tetris and watch a Tetris AI agent play the game.", @@ -28,6 +35,64 @@ async def get_available_agents(): return AVAILABLE_AGENTS +@dataclass +class Score: + name: str + score: int + date: str + + +HIGHSCORES_FILE = "highscores.json" + + +def initialize_file(): + """Ensure the highscores file exists and contains valid JSON.""" + if not os.path.isfile(HIGHSCORES_FILE): + with open(HIGHSCORES_FILE, "w") as file: + json.dump([], file) + else: + # Check if the file is empty or invalid + try: + with open(HIGHSCORES_FILE, "r") as file: + if not file.read().strip(): + # If the file is empty, write an empty list + with open(HIGHSCORES_FILE, "w") as file: + json.dump([], file) + except json.JSONDecodeError: + # If the file contains invalid JSON, reset it + with open(HIGHSCORES_FILE, "w") as file: + json.dump([], file) + + +@app.get("/highscores", response_model=list[Score]) +async def get_highscores(): + initialize_file() + + with open(HIGHSCORES_FILE, "r") as file: + scores_data = json.load(file) + scores = [Score(**score) for score in scores_data] + return scores + + +@app.post("/highscores", response_model=Score) +async def add_highscore(raw_score: dict): + initialize_file() + + # Read existing scores + with open(HIGHSCORES_FILE, "r") as file: + scores_data = json.load(file) + + # Create new score instance + score = Score(**raw_score) + scores_data.append(asdict(score)) + + # Write the updated scores back to the file + with open(HIGHSCORES_FILE, "w") as file: + json.dump(scores_data, file) + + return score + + @app.websocket("/ws/game") async def websocket_endpoint(websocket: WebSocket): board = Tetris() # Initialize the game board From 18262dbcec57fc1fe370b1d9dda4308b8fdc1443 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Sun, 22 Sep 2024 21:18:17 +0200 Subject: [PATCH 22/24] feat: mark landing place with sentinel value when sending game state --- src/game/TetrisWebGameManager.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 7774f9e..352603d 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -107,14 +107,31 @@ async def handle_input(self, input_action): async def send_game_state(self): """Send the current game state to the client via WebSocket.""" - # Skip the top hidden rows - visible_board = self.board.board[3:] + # Determine the landing position of the block + simulated_board = self.board.copy() + while simulated_board.isValidBlockPosition(simulated_board.block): + simulated_board.block.moveDown() + simulated_board.block.moveUp() + + # Mark the landing position of the block on the board + LANDING_BLOCK_COLOR = -1 + for i in range(4): + for j in range(4): + if i * 4 + j in simulated_board.block.image(): + simulated_board.board[i + simulated_board.block.y][ + j + simulated_board.block.x + ] = LANDING_BLOCK_COLOR + + # Skip the top hidden rows for the visible board + visible_board = simulated_board.board[3:] + game_state = { "board": visible_board, "score": self.board.rowsRemoved, "gameOver": self.isGameOver(), "nextPiece": self.board.nextBlock.type, } + await self.websocket.send_text(json.dumps(game_state)) async def stopGame(self): From 5a6054e0dff4b82a85dcd59298acc4f990b99d37 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Sun, 22 Sep 2024 21:18:31 +0200 Subject: [PATCH 23/24] feat: Include "nextBlock" property in game state JSON --- src/game/TetrisWebGameManager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 352603d..99e7c30 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -126,6 +126,7 @@ async def send_game_state(self): visible_board = simulated_board.board[3:] game_state = { + "nextBlock": self.board.nextBlock.type, "board": visible_board, "score": self.board.rowsRemoved, "gameOver": self.isGameOver(), From dcca97e90ed76e1975e3c662d985cec55ec4fc52 Mon Sep 17 00:00:00 2001 From: SverreNystad Date: Sun, 22 Sep 2024 21:20:37 +0200 Subject: [PATCH 24/24] feat: Remove unused "nextPiece" property from game state JSON --- src/game/TetrisWebGameManager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py index 99e7c30..601b82a 100644 --- a/src/game/TetrisWebGameManager.py +++ b/src/game/TetrisWebGameManager.py @@ -130,7 +130,6 @@ async def send_game_state(self): "board": visible_board, "score": self.board.rowsRemoved, "gameOver": self.isGameOver(), - "nextPiece": self.board.nextBlock.type, } await self.websocket.send_text(json.dumps(game_state))