diff --git a/.gitignore b/.gitignore
index 5e8f6be..4bbd8bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,4 +159,138 @@ 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.*
+
+**package-lock.json
\ No newline at end of file
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/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:
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..167b929
--- /dev/null
+++ b/frontend/agentplayer.js
@@ -0,0 +1,64 @@
+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;
+
+const canvasAgentId = "agentplayer-canvas";
+
+// Fetch available agents from the server and populate the dropdown
+async function loadAgents() {
+ const response = await fetch(`${BASE_URL}/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 (agentWebSocket) {
+ agentWebSocket.close();
+ }
+
+ agentWebSocket = new WebSocket(`${WS_BASE_URL}/demo/${selectedAgent}`);
+
+ agentWebSocket.onopen = () => {
+ console.log(`WebSocket connection established with ${selectedAgent} agent`);
+ };
+
+ 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 = () => {
+ console.log("WebSocket connection closed");
+ };
+
+ agentWebSocket.onerror = (error) => {
+ console.error("WebSocket error:", error);
+ };
+}
+
+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);
+
+// 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..311fdb3
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,21 @@
+
+
+
+ TetrisAI
+
+
+
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
new file mode 100644
index 0000000..28ed13b
--- /dev/null
+++ b/frontend/singleplayer.js
@@ -0,0 +1,40 @@
+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 = () => {
+ console.log("Single-player WebSocket connection established");
+};
+
+singleplayerWebSocket.onmessage = (event) => {
+ const gameState = JSON.parse(event.data);
+ console.log("Game state received from server:", gameState);
+ drawBoard(gameState.board, canvasSinglePlayerId);
+};
+
+singleplayerWebSocket.onclose = () => {
+ console.log("Single-player WebSocket connection closed");
+};
+
+singleplayerWebSocket.onerror = (error) => {
+ console.error("WebSocket error:", error);
+};
+
+// Sending input events to the server
+window.addEventListener("keydown", (e) => {
+ if (e.key === "ArrowDown") {
+ singleplayerWebSocket.send("SOFT_DROP");
+ } else if (e.key === "ArrowLeft") {
+ singleplayerWebSocket.send("MOVE_LEFT");
+ } else if (e.key === "ArrowRight") {
+ singleplayerWebSocket.send("MOVE_RIGHT");
+ } else if (e.key === " ") {
+ singleplayerWebSocket.send("HARD_DROP");
+ } else if (e.key === "ArrowUp") {
+ singleplayerWebSocket.send("ROTATE_CLOCKWISE");
+ }
+});
diff --git a/frontend/tetris-common.js b/frontend/tetris-common.js
new file mode 100644
index 0000000..f07b9a2
--- /dev/null
+++ b/frontend/tetris-common.js
@@ -0,0 +1,40 @@
+/** 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)
+];
+
+/**
+ * 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);
+ 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];
+
+ // Only draw the block if it's not an empty block
+ if (blockType !== 0) {
+ ctx.fillStyle = color;
+ ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize);
+ }
+ }
+ }
+}
+
+export { drawBoard };
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/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/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
diff --git a/src/game/TetrisWebGameManager.py b/src/game/TetrisWebGameManager.py
new file mode 100644
index 0000000..601b82a
--- /dev/null
+++ b/src/game/TetrisWebGameManager.py
@@ -0,0 +1,141 @@
+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: WebSocket):
+ """
+ Initialize the game manager with a board of type Tetris and a WebSocket connection.
+ """
+ self.board = board
+ self.websocket = websocket # WebSocket connection for real-time communication
+ 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."""
+ self.board.doAction(direction)
+ await self.send_game_state()
+
+ 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."""
+ # 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.board.rowsRemoved // LINES_CLEAR_FOR_LEVEL) * LEVEL_SPEEDUP_FACTOR,
+ self.fastest_fall_delay,
+ )
+ 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."""
+ # 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.current_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:
+ # 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.update_fall_delay()
+
+ await self.send_game_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."""
+ # Send game state to client
+ await self.send_game_state()
+ while not self.board.gameOver:
+ playGameDemoStepByStep(agent, self.board)
+ await asyncio.sleep(0.1) # Small delay to simulate gameplay
+ await self.send_game_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)
+
+ async def send_game_state(self):
+ """Send the current game state to the client via WebSocket."""
+
+ # 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 = {
+ "nextBlock": self.board.nextBlock.type,
+ "board": visible_board,
+ "score": self.board.rowsRemoved,
+ "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(f"Final Score: {self.board.rowsRemoved}")
diff --git a/web_app.py b/web_app.py
new file mode 100644
index 0000000..61d0c75
--- /dev/null
+++ b/web_app.py
@@ -0,0 +1,164 @@
+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.",
+ version="1.0.0",
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # Adjust this to allow specific origins as needed
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+@app.get("/agents", response_model=list)
+async def get_available_agents():
+ """
+ Returns a list of available agents for playing the game.
+ """
+ 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
+ manager = TetrisGameManager(board, websocket)
+ await websocket.accept()
+ print("WebSocket connection established")
+
+ try:
+ await manager.startGame() # Start the game loop with automatic block dropping
+ 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 demo error: {e}")
+ 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."}
+ )