feat: add browser support and Docker deployment

This commit is contained in:
2026-09-25 17:05:20 +02:00
parent 1d82aed1c4
commit e70ee0a01d
8 changed files with 173 additions and 14 deletions
+9
View File
@@ -0,0 +1,9 @@
*
!Dockerfile
!main.py
!src/
!src/*.py
!assets/
!assets/**
**/__pycache__/
**/.DS_Store
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.py[cod]
build/
.DS_Store
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim AS build
WORKDIR /app
RUN pip install --no-cache-dir pygbag==0.9.3
COPY main.py ./
COPY src/ ./src/
COPY assets/ ./assets/
# Do not publish the desktop leaderboard or developer files.
RUN printf '[]\n' > src/highscore.json \
&& python -m pygbag --build --no_opt --disable-sound-format-error --title "Silent FH" .
FROM nginxinc/nginx-unprivileged:1.28-alpine
COPY --from=build /app/build/web/ /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
+39
View File
@@ -18,6 +18,45 @@ source .venv/bin/activate or source .venv/bin/activate.fish
uv run main.py
```
## Browser / Docker deployment
The game runs client-side through [Pygbag](https://pygame-web.github.io/)
(Python WebAssembly + pygame-ce). Docker builds the web bundle and serves it
with non-root Nginx; players do not share a game session.
```bash
docker build -t silent-fh:web .
docker run -d --name silent-fh --restart unless-stopped -p 8080:8080 silent-fh:web
```
Open **http://localhost:8080**. Allow the initial runtime download to finish,
click the game/name field, enter a name, and press Enter. A desktop browser
with a keyboard is recommended; touch controls are not implemented.
Reload the page to start again after quitting the game.
Deployment notes:
- The container listens on **8080** and includes an HTTP healthcheck. Put it
behind your usual HTTPS reverse proxy for public hosting. No volumes needed.
- Both the image build and players' browsers need internet access. Pygbag's
versioned runtime and pygame-ce wheels are fetched from
`https://pygame-web.github.io/cdn/`; this is **not an offline/self-contained
runtime**. The container serves the game code and assets only.
- Browser highscores persist in `localStorage` per browser and site origin,
not on the server. Clearing site data clears scores; blocked storage falls
back to session-only scores. The desktop leaderboard is not published.
- Browser system fonts fall back to Pygame's bundled font. Audio may require
a click/key press before the browser permits playback.
- The native `uv run main.py` command still works. `Game.run()` is now async;
Python callers must await it.
Smoke checks (stdlib unittest, no extra test dependencies):
```bash
uv run python -m unittest discover -s tests -v
curl --fail http://localhost:8080/ > /dev/null
```
## Usage of the game:
- Use WASD or Arrow Keys to move around
+7 -3
View File
@@ -1,10 +1,14 @@
import asyncio
import pygame # Pygbag scans the entry point for native dependencies.
from src.game import Game
def main():
async def main():
game = Game()
game.run()
await game.run()
if __name__ == "__main__":
main()
asyncio.run(main())
+6 -2
View File
@@ -8,6 +8,8 @@ Controls:
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from typing import Optional
@@ -89,10 +91,11 @@ class Game:
self.monsters = MonsterManager(monster_sides, self.map)
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
def run(self, max_frames: Optional[int] = None) -> None:
async def run(self, max_frames: Optional[int] = None) -> None:
frame_count = 0
while self.running and (max_frames is None or frame_count < max_frames):
dt = min(self.clock.tick(FPS) / 1000.0, 0.05)
# The browser schedules frames; only throttle the desktop loop.
dt = min(self.clock.tick(0 if sys.platform == "emscripten" else FPS) / 1000.0, 0.05)
if self.state == "startscreen":
next_state = self.startscreen.run()
@@ -119,6 +122,7 @@ class Game:
pygame.display.flip()
frame_count += 1
await asyncio.sleep(0)
pygame.quit()
+24 -7
View File
@@ -1,4 +1,9 @@
import json
import sys
if sys.platform == "emscripten":
from platform import window
import pygame
from src.settings import HIGHSCORE_COLOR_FONT, HIGHSCORE_COLOR_TABLE
@@ -10,20 +15,26 @@ class Highscore:
self.font_title = pygame.font.SysFont("Arial", 28, bold=True)
self.font_score = pygame.font.SysFont("Arial", 18, bold=True)
# check if file already exists, otherwise create it
try:
with open(self.path, "r") as f:
pass
except FileNotFoundError:
self.save_scores([])
def load_scores(self):
"""
Will load the highscore json file.
:return: All scores form json file.
"""
if sys.platform == "emscripten":
try:
stored = window.localStorage.getItem(f"silent-fh:{self.path}")
if stored:
scores = json.loads(stored)
return scores if isinstance(scores, list) else []
except Exception as exc:
# Storage may be blocked by browser privacy settings.
print(f"Could not load browser highscores: {exc}")
try:
with open(self.path, "r") as f:
scores = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
return scores if isinstance(scores, list) else []
@@ -37,6 +48,12 @@ class Highscore:
with open(self.path, "w") as file:
json.dump(scores, file, indent=4)
if sys.platform == "emscripten":
try:
window.localStorage.setItem(f"silent-fh:{self.path}", json.dumps(scores))
except Exception as exc:
print(f"Could not persist browser highscores: {exc}")
def add_score(self, player_name, score_ects, time_survived, won):
"""
Add a new score to the list of existing scores from highscore file. The
+66
View File
@@ -0,0 +1,66 @@
"""Run with: uv run python -m unittest discover -s tests -v"""
import asyncio
import json
import os
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
import pygame
from src.game import Game
from src.highscore import Highscore
class WebCompatibilityTests(unittest.TestCase):
def test_loop_yields_and_renders_all_screens(self):
async def exercise(state):
game = Game()
game.player_name = "Test Student"
game.state = state
if state == "endscreen":
game.show_endscreen(won=False)
yielded = []
asyncio.get_running_loop().call_soon(yielded.append, True)
await game.run(max_frames=2)
self.assertTrue(yielded, "Game loop must yield to the browser")
with patch.object(Highscore, "load_scores", return_value=[]), patch.object(Highscore, "save_scores"):
for state in ("startscreen", "game", "endscreen"):
asyncio.run(exercise(state))
def test_highscore_storage(self):
pygame.font.init()
with tempfile.TemporaryDirectory() as directory:
path = str(Path(directory) / "scores.json")
scores = Highscore(path)
self.assertEqual(scores.load_scores(), [])
scores.add_score("Desktop", 5, 12, False)
self.assertEqual(Highscore(path).load_scores()[0]["name"], "Desktop")
storage = {}
window = SimpleNamespace(localStorage=SimpleNamespace(
getItem=storage.get,
setItem=storage.__setitem__,
))
with patch("src.highscore.sys.platform", "emscripten"), patch("src.highscore.window", window, create=True):
scores.add_score("Browser", 10, 20, False)
Path(path).unlink() # Simulate a fresh browser virtual filesystem.
self.assertEqual(Highscore(path).get_top_scores()[0]["name"], "Browser")
self.assertEqual(len(json.loads(storage[f"silent-fh:{path}"])), 2)
storage[f"silent-fh:{path}"] = "invalid json"
self.assertEqual(scores.load_scores(), [])
scores.save_scores([])
window.localStorage.getItem = Mock(side_effect=RuntimeError("blocked"))
window.localStorage.setItem = Mock(side_effect=RuntimeError("blocked"))
scores.add_score("Fallback", 1, 1, False)
self.assertEqual(scores.load_scores()[0]["name"], "Fallback")
pygame.quit()
if __name__ == "__main__":
unittest.main()