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