12 Commits

Author SHA1 Message Date
988878502c Hide inactive players from board dump 2023-03-28 09:45:49 +02:00
0e8775bd08 FE tweak 2023-03-27 14:28:15 +02:00
b5a49fb53b Add fairhopper-sdk as module 2023-03-27 13:55:52 +02:00
9acaf0c2c0 Remove SDK 2023-03-27 09:53:29 +02:00
870e2deb79 FE tweak 2023-03-27 09:52:51 +02:00
fa2aee881d Cleanups 2023-03-26 23:59:15 +02:00
f74bc9b52e FE tweaks 2023-03-26 20:00:35 +02:00
63e7e0d21c Update readme 2023-03-26 14:58:57 +02:00
4831f1e393 Update readme 2023-03-26 14:42:26 +02:00
806a379253 Demo SDK 2023-03-26 14:37:39 +02:00
f54344a17f Tweak frontend and game logic 2023-03-26 00:37:58 +01:00
3ac07f3072 Optimize views 2023-03-25 18:59:35 +01:00
14 changed files with 263 additions and 118 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "fairhopper-sdk"]
path = fairhopper-sdk
url = git@gitea.ekirin.com:Intis/fairhopper-sdk.git

View File

@ -75,46 +75,52 @@ actor "Player 1" as P1
actor "Player 2" as P2
actor "Player 3" as P3
package Masterpiece {
usecase Game as "FairHopper\nGame Server"
usecase WS as "WS Server"
package Masterpiece #seashell {
rectangle "FairHopper Game Server" #lightcyan {
usecase API as "API Server"
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService"
}
P1 -left-> Game: REST API
P2 -left-> Game: REST API
P3 -left-> Game: REST API
Game --> WS: WebSockets
WS --> Vis: WebSockets
usecase ExtVis1 as "Visualisation\nService"
usecase ExtVis2 as "Visualisation\nService"
P1 -left-> API: REST API
P2 -left-> API: REST API
P3 -left-> API: REST API
API --> Game
Game --> WS: Game State
WS --> Vis: WS Game State
WS --> ExtVis1: WS Game State
WS --> ExtVis2: WS Game State
```
### WebSockets
```plantuml
participant Game as "FairHopper\nGame Server"
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
endbox
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
Game ->o WS: Server Connect
activate WS #coral
WS -> Game: Get game state
activate Game #yellow
Game -> WS: Game state
deactivate
deactivate
Game ->o WS: Send initial state
Client1 ->o WS: Client Connect
activate WS #coral
WS -> Client1: Game state
deactivate
Client1 ->o WS: Client connect
activate WS #coral
WS -> Client1: Game state
deactivate
Client2 ->o WS: Client Connect
activate WS #coral
WS -> Client2: Game state
deactivate
Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
loop #lightyellow On game state change
loop #lightyellow On game state change
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state

1
fairhopper-sdk Submodule

Submodule fairhopper-sdk added at 10290dba54

View File

@ -8,13 +8,15 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css">
<title>Document</title>
<title>FairHopper Visualisation Client</title>
</head>
<body>
<div class="container-fluid">
<h1>FairHopper WS Client</h1>
<h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
<div class="row">
<div class="col-10">
<div class="board-container">
@ -22,14 +24,23 @@
</div>
</div>
<div class="col-2">
<h3>Players</h3>
<ul id="players-content"></ul>
<h3 class="pb-2 border-bottom">
Players
</h3>
<ul class="players" id="players-content"></ul>
</div>
</div>
</div>
</body>
<script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
@ -46,25 +57,40 @@
document.getElementById("board-content").innerHTML = html;
}
function renderCellContent(x, y, content) {
const cell = document.getElementById(`cell-${x}-${y}`);
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.map((player) => {
const html = players.filter(player => player.active).map((player) => {
return `
<li>${player.name} (${player.move_count})</li>
<li class="${player.reached_destination ? "text-success" : ""}">
${player.name} (${player.move_count})
${player.reached_destination ? "✅" : ""}
</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.forEach(player => {
renderCellContent(player.position.x, player.position.y, "😎");
players.filter(player => player.active).forEach(player => {
const cell = findCell(player.position);
const playerIcon = player.reached_destination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
const html = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
cell.innerHTML = html;
}
});
}
@ -79,41 +105,47 @@
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => {
renderCellContent(obj.position.x, obj.position.y, "🔥");
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position.x, position.y, "🏠");
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
window.onload = function () {
const ws = new WebSocket('ws://localhost:8011/bla-tra');
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log("message received:", data)
function wsConnect() {
let ws = new WebSocket('ws://localhost:8011');
ws.onopen = () => {
console.log("WS connected")
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log("WS message received:", data)
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
};
ws.onopen = function () {
console.log("open");
}
ws.onclose = (e) => {
setTimeout(function () {
wsConnect();
}, 1000);
};
ws.onclose = function () {
console.log("close");
}
ws.onerror = function () {
console.log("error");
}
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
}
</script>

View File

@ -13,8 +13,43 @@ body {
grid-gap: 2px;
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0px;
}
.cell {
flex: 1;
text-align: center;
aspect-ratio: 1;
background-color: beige;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
ul.players {
list-style-type: none;
padding-left: 0;
}
.player-tooltip {
position: absolute;
margin-bottom: 50px;
font-size: 8pt;
padding: 2px 10px;
color: white;
background-color: darkred;
border-radius: 5px;
z-index: 1000;
}
.player-tooltip::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: darkred transparent transparent transparent;
}

View File

@ -15,10 +15,25 @@ from hopper.api.dto import (
from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.models.player import Player
router = APIRouter()
def get_player(uuid: str, engine: GameEngine = Depends(get_game_engine)) -> Player:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Player kicked out due to inactivity",
)
return player
@router.get("/ping", response_model=PingResponse)
async def ping() -> PingResponse:
return PingResponse(
@ -38,7 +53,9 @@ async def get_game_info(
)
@router.post("/game", response_model=StartGameResponseDto)
@router.post(
"/game", response_model=StartGameResponseDto, status_code=status.HTTP_201_CREATED
)
async def start_game(
body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine),
@ -57,22 +74,20 @@ async def start_game(
@router.get(
"/player/{uuid}",
response_model=PlayerInfoResponseDto,
status_code=status.HTTP_201_CREATED,
responses={
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
},
},
)
async def get_player_info(
uuid: str,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> MovePlayerResponseDto:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity",
)
return PlayerInfoResponseDto(player=player)
@ -87,7 +102,11 @@ async def get_player_info(
},
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player uuid not valid, probably due to inactivity",
"description": " Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
},
status.HTTP_409_CONFLICT: {
"model": ErrorResponseDto,
@ -96,22 +115,11 @@ async def get_player_info(
},
)
async def move_player(
uuid: str,
direction: Direction,
response: Response,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> MovePlayerResponseDto:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity",
)
try:
move_result = await engine.move_player(player, direction)
except PositionOutOfBounds:

View File

@ -1,9 +1,11 @@
import asyncio
import logging
import random
from typing import Optional
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.interfaces import SendGameStateInterface
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
@ -16,12 +18,13 @@ from hopper.models.board import (
)
from hopper.models.player import Player, PlayerList, Position
from hopper.watchdog import InactivityWatchdog
from hopper.ws_server import WSServer
from settings import settings
class GameEngine:
def __init__(self, board: GameBoard, ws_server: Optional[WSServer] = None) -> None:
def __init__(
self, board: GameBoard, ws_server: Optional[SendGameStateInterface] = None
) -> None:
self.board = board
self.ws_server = ws_server
self.players = PlayerList()
@ -32,9 +35,15 @@ class GameEngine:
dump = self.board.dump()
for player in self.players:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER
]
show_player = (
player.active
and player.position.y < len(dump)
and player.position.x < len(dump[player.position.y])
)
if show_player:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER
]
return dump
@ -57,7 +66,7 @@ class GameEngine:
self._start_inactivity_watchdog()
player = Player(
name=player_name,
position=Position(0, 0),
position=self._create_player_start_position(),
)
self.players.append(player)
@ -67,16 +76,26 @@ class GameEngine:
if self.ws_server:
await self.ws_server.send_game_state()
await asyncio.sleep(settings.game.MOVE_DELAY)
return player
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
def _create_player_start_position(self) -> Position:
"""Create random position somewhere on the board border"""
border_len = (self.board.width + self.board.height) * 2
rnd_position = random.randint(0, border_len - 1)
new_position = Position(player.position.x, player.position.y)
logging.info(f"Player {player} move to {direction}")
if rnd_position < self.board.width * 2:
x = rnd_position % self.board.width
y = 0 if rnd_position < self.board.width else self.board.height - 1
else:
rnd_position -= 2 * self.board.width
x = 0 if rnd_position < self.board.height else self.board.width - 1
y = rnd_position % self.board.height
return Position(x=x, y=y)
def _move_position(self, position: Position, direction: Direction) -> Position:
new_position = Position(position.x, position.y)
if direction == Direction.LEFT:
new_position.x -= 1
elif direction == Direction.RIGHT:
@ -87,39 +106,56 @@ class GameEngine:
new_position.y += 1
else:
raise ValueError(f"Unhandled direction: {direction}")
return new_position
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
# player will not be able to move once they reach the destination
if player.reached_destination:
return PlayerMoveResult.DESTINATION_REACHED
logging.info(f"Player {player} move to {direction}")
new_position = self._move_position(player.position, direction)
player.move_attempt_count += 1
if not self.position_in_board_bounds(new_position):
if not self._position_in_board_bounds(new_position):
raise PositionOutOfBounds()
if self.colided_with_obstacle(new_position):
if self._colided_with_obstacle(new_position):
raise Collision()
player.position = new_position
player.move_count += 1
if self._is_player_on_destination(player):
player.reached_destination = True
logging.info(f"Player {player} reached destination!")
if self.ws_server:
await self.ws_server.send_game_state()
if self.is_player_on_destination(player):
logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board()
if player.reached_destination:
return PlayerMoveResult.DESTINATION_REACHED
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK
def is_player_on_destination(self, player: Player) -> bool:
def _is_player_on_destination(self, player: Player) -> bool:
return player.position == self.board.destination.position
def position_in_board_bounds(self, position: Position) -> bool:
def _position_in_board_bounds(self, position: Position) -> bool:
return (
0 <= position.x < self.board.width and 0 <= position.y < self.board.height
)
def colided_with_obstacle(self, position: Position) -> bool:
def _colided_with_obstacle(self, position: Position) -> bool:
return self.board.get_object_at_position(position) is not None
def get_board_layout(self) -> BoardLayout:
@ -132,12 +168,12 @@ class GameEngineFactory:
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: Optional[WSServer] = None,
ws_server: Optional[SendGameStateInterface] = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
height=board_height,
destination=Destination(Position(board_height // 2, board_height // 2)),
destination=Destination(Position(board_width // 2, board_height // 2)),
)
obstacle_layer = Layer(name="obstacles")
for _ in range(obstacle_count):
@ -157,7 +193,9 @@ class GameEngineFactory:
return game
@staticmethod
def create_default(ws_server: Optional[WSServer] = None) -> GameEngine:
def create_default(
ws_server: Optional[SendGameStateInterface] = None,
) -> GameEngine:
return GameEngineFactory.create(
board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT,
@ -173,7 +211,10 @@ class GameEngineFactory:
player = Player(
name="Pero",
uuid="test-player-id",
position=Position(2, 2),
position=Position(
settings.debug.TEST_PLAYER_START_X,
settings.debug.TEST_PLAYER_START_Y,
),
can_be_deactivated=False,
)
players.append(player)

6
hopper/interfaces.py Normal file
View File

@ -0,0 +1,6 @@
from typing import Protocol
class SendGameStateInterface(Protocol):
async def send_game_state(self) -> None:
...

View File

@ -4,7 +4,7 @@ from typing import Optional
@dataclass
class GameSettings:
MOVE_DELAY: int = 0.5 # seconds
MOVE_DELAY: float = 0.5 # seconds
@dataclass
class BoardSettings:
@ -30,6 +30,8 @@ class WSServerSettings:
class DebugSettings:
PRINT_BOARD: bool = False
CREATE_TEST_PLAYER: bool = False
TEST_PLAYER_START_X: int = 0
TEST_PLAYER_START_Y: int = 0
@dataclass

View File

@ -22,6 +22,7 @@ class Player:
)
active: bool = True
can_be_deactivated: bool = True
reached_destination: bool = False
def reset_timeout(self) -> None:
self.last_seen = datetime.datetime.now()

View File

@ -15,9 +15,12 @@ class LayerDto(BaseModel):
name: str
objects: list[LayerObjectDto]
class GameStatePlayerDto(PlayerDto):
reached_destination: bool
class GameStateDto(BaseModel):
board: BoardDto
destination: DestinationDto
players: list[PlayerDto]
players: list[GameStatePlayerDto]
layers: list[LayerDto]

View File

@ -5,14 +5,18 @@ import time
from threading import Thread
from typing import Optional
from hopper.interfaces import SendGameStateInterface
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings
class InactivityWatchdog(Thread):
def __init__(
self, players: PlayerList, ws_server: Optional[WSServer] = None, *args, **kwargs
self,
players: PlayerList,
ws_server: Optional[SendGameStateInterface] = None,
*args,
**kwargs,
) -> None:
self.players = players
self.ws_server = ws_server

View File

@ -12,20 +12,20 @@ from settings import settings
class WSServer(Thread):
def __init__(self, *args, **kwargs) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
super().__init__(*args, **kwargs)
async def handler(self, websocket: WebSocketServerProtocol) -> None:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
# send initial game state to connected client
await self.send_game_state_to_client(websocket)
# loop and do nothing while client is connected
connected = True
while connected:
try:
message = await websocket.recv()
# we're expecting nothing from client, but read if client sends a message
await websocket.recv()
except ConnectionClosedOK:
connected = False
finally:
@ -49,11 +49,13 @@ class WSServer(Thread):
async def send_game_state_to_client(
self, websocket: WebSocketServerProtocol
) -> None:
"""Send game state to the client"""
message = self._create_game_state_message()
logging.debug(f"Sending game state to client: {websocket.id}")
await websocket.send(message)
async def send_game_state(self) -> None:
"""Broadcast game state to all connected clients"""
if not self.connected_clients:
return
@ -77,4 +79,5 @@ class WSServer(Thread):
await asyncio.Future() # run forever
def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async())