feat: add browser support and Docker deployment
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
*
|
||||||
|
!Dockerfile
|
||||||
|
!main.py
|
||||||
|
!src/
|
||||||
|
!src/*.py
|
||||||
|
!assets/
|
||||||
|
!assets/**
|
||||||
|
**/__pycache__/
|
||||||
|
**/.DS_Store
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
+15
@@ -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
|
||||||
@@ -18,6 +18,45 @@ source .venv/bin/activate or source .venv/bin/activate.fish
|
|||||||
uv run main.py
|
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:
|
## Usage of the game:
|
||||||
|
|
||||||
- Use WASD or Arrow Keys to move around
|
- Use WASD or Arrow Keys to move around
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pygame # Pygbag scans the entry point for native dependencies.
|
||||||
|
|
||||||
from src.game import Game
|
from src.game import Game
|
||||||
|
|
||||||
|
|
||||||
def main():
|
async def main():
|
||||||
game = Game()
|
game = Game()
|
||||||
game.run()
|
await game.run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
asyncio.run(main())
|
||||||
|
|||||||
+6
-2
@@ -8,6 +8,8 @@ Controls:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -89,10 +91,11 @@ class Game:
|
|||||||
self.monsters = MonsterManager(monster_sides, self.map)
|
self.monsters = MonsterManager(monster_sides, self.map)
|
||||||
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
|
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
|
frame_count = 0
|
||||||
while self.running and (max_frames is None or frame_count < max_frames):
|
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":
|
if self.state == "startscreen":
|
||||||
next_state = self.startscreen.run()
|
next_state = self.startscreen.run()
|
||||||
@@ -119,6 +122,7 @@ class Game:
|
|||||||
|
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
pygame.quit()
|
pygame.quit()
|
||||||
|
|
||||||
|
|||||||
+24
-7
@@ -1,4 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.platform == "emscripten":
|
||||||
|
from platform import window
|
||||||
|
|
||||||
import pygame
|
import pygame
|
||||||
from src.settings import HIGHSCORE_COLOR_FONT, HIGHSCORE_COLOR_TABLE
|
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_title = pygame.font.SysFont("Arial", 28, bold=True)
|
||||||
self.font_score = pygame.font.SysFont("Arial", 18, 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):
|
def load_scores(self):
|
||||||
"""
|
"""
|
||||||
Will load the highscore json file.
|
Will load the highscore json file.
|
||||||
:return: All scores form 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:
|
with open(self.path, "r") as f:
|
||||||
scores = json.load(f)
|
scores = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
return scores if isinstance(scores, list) else []
|
return scores if isinstance(scores, list) else []
|
||||||
|
|
||||||
@@ -37,6 +48,12 @@ class Highscore:
|
|||||||
with open(self.path, "w") as file:
|
with open(self.path, "w") as file:
|
||||||
json.dump(scores, file, indent=4)
|
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):
|
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
|
Add a new score to the list of existing scores from highscore file. The
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user