Initial
This commit is contained in:
commit
7f26a85369
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"python.formatting.provider": "none"
|
||||
}
|
||||
11
Pipfile
Normal file
11
Pipfile
Normal file
@ -0,0 +1,11 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.11"
|
||||
20
Pipfile.lock
generated
Normal file
20
Pipfile.lock
generated
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "ed6d5d614626ae28e274e453164affb26694755170ccab3aa5866f093d51d3e4"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.11"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {},
|
||||
"develop": {}
|
||||
}
|
||||
BIN
__pycache__/connect4.cpython-311.pyc
Normal file
BIN
__pycache__/connect4.cpython-311.pyc
Normal file
Binary file not shown.
202
app.py
Normal file
202
app.py
Normal file
@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
import secrets
|
||||
import websockets
|
||||
|
||||
from connect4 import PLAYER1, PLAYER2, Connect4
|
||||
|
||||
JOIN = {}
|
||||
WATCH = {}
|
||||
|
||||
|
||||
async def replay(websocket, game):
|
||||
# Make a copy to avoid an exception if game.moves changes while iteration
|
||||
# is in progress. If a move is played while replay is running, moves will
|
||||
# be sent out of order but each move will be sent once and eventually the
|
||||
# UI will be consistent
|
||||
for player, column, row in game.moves.copy():
|
||||
event = {
|
||||
"type": "play",
|
||||
"player": player,
|
||||
"column": column,
|
||||
"row": row,
|
||||
}
|
||||
await websocket.send(json.dumps(event))
|
||||
|
||||
|
||||
async def play(websocket, game, player, connected):
|
||||
async for message in websocket:
|
||||
event = json.loads(message)
|
||||
assert event["type"] == "play"
|
||||
column = event["column"]
|
||||
|
||||
try:
|
||||
# Play the move
|
||||
row = game.play(player, column)
|
||||
except RuntimeError as exc:
|
||||
await error(websocket, str(exc))
|
||||
continue
|
||||
|
||||
event = {
|
||||
"type": "play",
|
||||
"player": player,
|
||||
"column": column,
|
||||
"row": row,
|
||||
}
|
||||
websockets.broadcast(connected, json.dumps(event))
|
||||
|
||||
if game.winner is not None:
|
||||
event = {
|
||||
"type": "win",
|
||||
"player": game.winner,
|
||||
}
|
||||
websockets.broadcast(connected, json.dumps(event))
|
||||
|
||||
|
||||
async def start(websocket):
|
||||
# Initialise a Connect Four game, the set of WebSocket connections
|
||||
# recieving moves from this game, and secret access token.
|
||||
game = Connect4()
|
||||
connected = {websocket}
|
||||
|
||||
join_key = secrets.token_urlsafe(12)
|
||||
JOIN[join_key] = game, connected
|
||||
|
||||
watch_key = secrets.token_urlsafe(12)
|
||||
WATCH[watch_key] = game, connected
|
||||
|
||||
try:
|
||||
# Send the secret access token to the browser of the first player.
|
||||
# where it'll be used for building a "join" link.
|
||||
event = {
|
||||
"type": "init",
|
||||
"join": join_key,
|
||||
"watch": watch_key,
|
||||
}
|
||||
await websocket.send(json.dumps(event))
|
||||
|
||||
await play(websocket, game, PLAYER1, connected)
|
||||
finally:
|
||||
del JOIN[join_key]
|
||||
del WATCH[watch_key]
|
||||
|
||||
|
||||
async def error(websocket, message):
|
||||
event = {
|
||||
"type": "error",
|
||||
"message": message,
|
||||
}
|
||||
await websocket.send(json.dumps(event))
|
||||
|
||||
|
||||
async def join(websocket, join_key):
|
||||
# Find the connect four game
|
||||
try:
|
||||
game, connected = JOIN[join_key]
|
||||
except KeyError:
|
||||
await error(websocket, "Game not found.")
|
||||
return
|
||||
|
||||
# Register to receive moves from this game
|
||||
connected.add(websocket)
|
||||
try:
|
||||
# Send the first move, in case the first player already played it
|
||||
await replay(websocket, game)
|
||||
# Recieve and process the moves from the second player
|
||||
await play(websocket, game, PLAYER2, connected)
|
||||
|
||||
finally:
|
||||
connected.remove(websocket)
|
||||
|
||||
|
||||
async def watch(websocket, watch_key):
|
||||
# Find the connect4 game
|
||||
try:
|
||||
game, connected = WATCH[watch_key]
|
||||
except KeyError:
|
||||
await error(websocket, "Game not found.")
|
||||
|
||||
connected.add(websocket)
|
||||
try:
|
||||
# Send previous moves, In case the game has already started
|
||||
await replay(websocket, game)
|
||||
# Keep the connection open but don't recieve any messages
|
||||
await websocket.wait_closed()
|
||||
finally:
|
||||
connected.remove(websocket)
|
||||
|
||||
|
||||
async def handler(websocket):
|
||||
# Receive and parse the "init" event from the UI.
|
||||
message = await websocket.recv()
|
||||
event = json.loads(message)
|
||||
assert event["type"] == "init"
|
||||
|
||||
if "join" in event:
|
||||
# Second player joins an existing game.
|
||||
await join(websocket, event["join"])
|
||||
elif "watch" in event:
|
||||
# Spectator watches an existing game
|
||||
await watch(websocket, event["watch"])
|
||||
else:
|
||||
# First player starts a new game
|
||||
await start(websocket)
|
||||
|
||||
|
||||
# async def handler(websocket):
|
||||
# # Initialize a Connect Four game.
|
||||
# game = Connect4()
|
||||
|
||||
# # Players take alternate turns, using the same browser.
|
||||
# turns = itertools.cycle([PLAYER1, PLAYER2])
|
||||
# player = next(turns)
|
||||
|
||||
# async for message in websocket:
|
||||
# # Parse a "play" event from the UI.
|
||||
# event = json.loads(message)
|
||||
# assert event["type"] == "play"
|
||||
# column = event["column"]
|
||||
|
||||
# try:
|
||||
# # Play the move.
|
||||
# row = game.play(player, column)
|
||||
# except RuntimeError as exc:
|
||||
# # Send an "error" event if the move was illegal.
|
||||
# event = {
|
||||
# "type": "error",
|
||||
# "message": str(exc),
|
||||
# }
|
||||
# await websocket.send(json.dumps(event))
|
||||
# continue
|
||||
|
||||
# # Send a "play" event to update the UI.
|
||||
# event = {
|
||||
# "type": "play",
|
||||
# "player": player,
|
||||
# "column": column,
|
||||
# "row": row,
|
||||
# }
|
||||
# await websocket.send(json.dumps(event))
|
||||
|
||||
# # If move is winning, send a "win" event.
|
||||
# if game.winner is not None:
|
||||
# event = {
|
||||
# "type": "win",
|
||||
# "player": game.winner,
|
||||
# }
|
||||
# await websocket.send(json.dumps(event))
|
||||
|
||||
# # Alternate turns.
|
||||
# player = next(turns)
|
||||
|
||||
|
||||
async def main():
|
||||
async with websockets.serve(handler, "", 8001):
|
||||
await asyncio.Future() # run forever
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
105
connect4.css
Normal file
105
connect4.css
Normal file
@ -0,0 +1,105 @@
|
||||
/* General layout */
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
align-items: flex-end;
|
||||
width: 720px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.action {
|
||||
color: darkgray;
|
||||
font-family: "Helvetica Neue", sans-serif;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
padding: 20px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background-color: darkgray;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action[href=""] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Connect Four board */
|
||||
|
||||
.board {
|
||||
background-color: blue;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.board::before,
|
||||
.board::after {
|
||||
background-color: blue;
|
||||
content: "";
|
||||
height: 720px;
|
||||
width: 20px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.board::before {
|
||||
left: -20px;
|
||||
}
|
||||
|
||||
.board::after {
|
||||
right: -20px;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.column:hover .empty {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.column:hover .empty ~ .empty {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.red {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background-color: yellow;
|
||||
}
|
||||
45
connect4.js
Normal file
45
connect4.js
Normal file
@ -0,0 +1,45 @@
|
||||
const PLAYER1 = "red";
|
||||
|
||||
const PLAYER2 = "yellow";
|
||||
|
||||
function createBoard(board) {
|
||||
// Inject stylesheet.
|
||||
const linkElement = document.createElement("link");
|
||||
linkElement.href = import.meta.url.replace(".js", ".css");
|
||||
linkElement.rel = "stylesheet";
|
||||
document.head.append(linkElement);
|
||||
// Generate board.
|
||||
for (let column = 0; column < 7; column++) {
|
||||
const columnElement = document.createElement("div");
|
||||
columnElement.className = "column";
|
||||
columnElement.dataset.column = column;
|
||||
for (let row = 0; row < 6; row++) {
|
||||
const cellElement = document.createElement("div");
|
||||
cellElement.className = "cell empty";
|
||||
cellElement.dataset.column = column;
|
||||
columnElement.append(cellElement);
|
||||
}
|
||||
board.append(columnElement);
|
||||
}
|
||||
}
|
||||
|
||||
function playMove(board, player, column, row) {
|
||||
// Check values of arguments.
|
||||
if (player !== PLAYER1 && player !== PLAYER2) {
|
||||
throw new Error(`player must be ${PLAYER1} or ${PLAYER2}.`);
|
||||
}
|
||||
const columnElement = board.querySelectorAll(".column")[column];
|
||||
if (columnElement === undefined) {
|
||||
throw new RangeError("column must be between 0 and 6.");
|
||||
}
|
||||
const cellElement = columnElement.querySelectorAll(".cell")[row];
|
||||
if (cellElement === undefined) {
|
||||
throw new RangeError("row must be between 0 and 5.");
|
||||
}
|
||||
// Place checker in cell.
|
||||
if (!cellElement.classList.replace("empty", player)) {
|
||||
throw new Error("cell must be empty.");
|
||||
}
|
||||
}
|
||||
|
||||
export { PLAYER1, PLAYER2, createBoard, playMove };
|
||||
62
connect4.py
Normal file
62
connect4.py
Normal file
@ -0,0 +1,62 @@
|
||||
__all__ = ["PLAYER1", "PLAYER2", "Connect4"]
|
||||
|
||||
PLAYER1, PLAYER2 = "red", "yellow"
|
||||
|
||||
|
||||
class Connect4:
|
||||
"""
|
||||
A Connect Four game.
|
||||
|
||||
Play moves with :meth:`play`.
|
||||
|
||||
Get past moves with :attr:`moves`.
|
||||
|
||||
Check for a victory with :attr:`winner`.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.moves = []
|
||||
self.top = [0 for _ in range(7)]
|
||||
self.winner = None
|
||||
|
||||
@property
|
||||
def last_player(self):
|
||||
"""
|
||||
Player who played the last move.
|
||||
|
||||
"""
|
||||
return PLAYER1 if len(self.moves) % 2 else PLAYER2
|
||||
|
||||
@property
|
||||
def last_player_won(self):
|
||||
"""
|
||||
Whether the last move is winning.
|
||||
|
||||
"""
|
||||
b = sum(1 << (8 * column + row) for _, column, row in self.moves[::-2])
|
||||
return any(b & b >> v & b >> 2 * v & b >> 3 * v for v in [1, 7, 8, 9])
|
||||
|
||||
def play(self, player, column):
|
||||
"""
|
||||
Play a move in a column.
|
||||
|
||||
Returns the row where the checker lands.
|
||||
|
||||
Raises :exc:`RuntimeError` if the move is illegal.
|
||||
|
||||
"""
|
||||
if player == self.last_player:
|
||||
raise RuntimeError("It isn't your turn.")
|
||||
|
||||
row = self.top[column]
|
||||
if row == 6:
|
||||
raise RuntimeError("This slot is full.")
|
||||
|
||||
self.moves.append((player, column, row))
|
||||
self.top[column] += 1
|
||||
|
||||
if self.winner is None and self.last_player_won:
|
||||
self.winner = self.last_player
|
||||
|
||||
return row
|
||||
18
index.html
Normal file
18
index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Connect Four</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="actions">
|
||||
<a class="action new" href="/">New</a>
|
||||
<a class="action join" href="">Join</a>
|
||||
<a class="action watch" href="">Watch</a>
|
||||
</div>
|
||||
<div class="board"></div>
|
||||
<script src="main.js" type="module"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
82
main.js
Normal file
82
main.js
Normal file
@ -0,0 +1,82 @@
|
||||
import { createBoard, playMove } from "./connect4.js";
|
||||
|
||||
function initGame(websocket) {
|
||||
websocket.addEventListener("open", () => {
|
||||
// Send an "init" event according to who is connecting.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let event = { type: "init" };
|
||||
if (params.has("join")) {
|
||||
// Second player joins an existing game.
|
||||
event.join = params.get("join");
|
||||
} else if (params.has("watch")) {
|
||||
// Spectator watches an existing game.
|
||||
event.watch = params.get("watch");
|
||||
} else {
|
||||
// First player starts a new game.
|
||||
}
|
||||
websocket.send(JSON.stringify(event));
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(message) {
|
||||
window.setTimeout(() => window.alert(message), 50);
|
||||
}
|
||||
|
||||
function receiveMoves(board, websocket) {
|
||||
websocket.addEventListener("message", ({ data }) => {
|
||||
const event = JSON.parse(data);
|
||||
switch (event.type) {
|
||||
case "init":
|
||||
// Create links for inviting the second player and spectators.
|
||||
document.querySelector(".join").href = "?join=" + event.join;
|
||||
document.querySelector(".watch").href = "?watch=" + event.watch;
|
||||
break;
|
||||
case "play":
|
||||
// Update the UI with the move.
|
||||
playMove(board, event.player, event.column, event.row);
|
||||
break;
|
||||
case "win":
|
||||
showMessage(`Player ${event.player} wins!`);
|
||||
// No further messages are expected; close the WebSocket connection.
|
||||
websocket.close(1000);
|
||||
break;
|
||||
case "error":
|
||||
showMessage(event.message);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported event type: ${event.type}.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendMoves(board, websocket) {
|
||||
// Don't send moves for a spectator watching a game.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("watch")) {
|
||||
return;
|
||||
}
|
||||
// When clicking a column, send a "play" event for a move in that column.
|
||||
board.addEventListener("click", ({ target }) => {
|
||||
const column = target.dataset.column;
|
||||
// Ignore clicks outside a column.
|
||||
if (column === undefined) {
|
||||
return;
|
||||
}
|
||||
const event = {
|
||||
type: "play",
|
||||
column: parseInt(column, 10),
|
||||
};
|
||||
websocket.send(JSON.stringify(event));
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// Initialize the UI.
|
||||
const board = document.querySelector(".board");
|
||||
createBoard(board);
|
||||
// Open the WebSocket connection and register event handlers.
|
||||
const websocket = new WebSocket("ws://localhost:8001/");
|
||||
initGame(websocket);
|
||||
receiveMoves(board, websocket);
|
||||
sendMoves(board, websocket);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user