diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ca20ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* +!Dockerfile +!main.py +!src/ +!src/*.py +!assets/ +!assets/** +**/__pycache__/ +**/.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6658921 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.py[cod] +build/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa055ab --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 2eef7b1..05bf477 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.py b/main.py index 82a129b..88278eb 100644 --- a/main.py +++ b/main.py @@ -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()) diff --git a/src/game.py b/src/game.py index ed05e95..f1db65e 100644 --- a/src/game.py +++ b/src/game.py @@ -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() diff --git a/src/highscore.py b/src/highscore.py index 6aa0d53..5e9ae6c 100644 --- a/src/highscore.py +++ b/src/highscore.py @@ -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. """ - with open(self.path, "r") as f: - scores = json.load(f) + 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 diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..7796754 --- /dev/null +++ b/tests/test_web.py @@ -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()