Compare commits

11 Commits
Author SHA1 Message Date
niklasandClaude Opus 5.5 97348f884a feat(web): fill browser window and fix localhost loading
Use a custom pygbag template: canvas uses the full window (gui_divider 1),
black page background, and redirect localhost to 127.0.0.1 because the
pygbag runtime otherwise fetches pygame from a local :8000 dev proxy and
the game never starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:11:40 +02:00
niklas e70ee0a01d feat: add browser support and Docker deployment 2026-09-25 17:05:20 +02:00
Niklas Haiden 1d82aed1c4 add documentation file 2026-06-16 21:16:12 +02:00
Niklas Haiden c562985664 update docs a bit 2026-05-08 20:11:35 +02:00
Niklas Haiden b002998adb refactor: fix formatting and set game window title to "Silent FH" 2026-05-08 19:56:32 +02:00
Niklas Haiden bce77b816d feat: add timer-based tower spawning mechanic triggered by player desk interaction 2026-05-08 19:49:38 +02:00
Niklas Haiden 7266f7b569 increase music sound 2026-05-08 19:43:00 +02:00
Niklas Haiden d2b42c5b0e decrease projectile damage further to 2 2026-05-08 19:39:46 +02:00
Niklas Haiden 514962321c fix highscore implementation and decrease projectile damage 2026-05-08 19:37:57 +02:00
Niklas Haiden 3fcbd8ce51 refactor: replace static door dimensions with dynamic sizing and add rect property 2026-05-08 19:34:10 +02:00
Niklas Haiden 4faad9dd4b feat: implement encounter reset mechanics for rooms and doors 2026-05-08 19:32:07 +02:00
16 changed files with 1006 additions and 91 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
+17
View File
@@ -0,0 +1,17 @@
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/
# Full-window canvas template; kept outside /app so it is not packed into the game.
COPY web/pygbag.tmpl /pygbag.tmpl
# 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" --template /pygbag.tmpl .
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
+217
View File
@@ -0,0 +1,217 @@
# Silent FH - Projektdokumentation
<div align="center">
```text
SSSSS III L EEEEE N N TTTTT FFFFF H H
S I L E NN N T F H H
SSSSS I L EEEE N N N T FFFF HHHHH
S I L E N NN T F H H
SSSSS III LLLLL EEEEE N N T F H H
```
**Study. Survive. Submit your ECTS.**
</div>
## Inhaltsverzeichnis
- [Projektübersicht](#projektübersicht)
- [Gruppenmitglieder](#gruppenmitglieder)
- [Repository und Installation](#repository-und-installation)
- [Corporate Identity](#corporate-identity)
- [Kurzbeschreibung](#kurzbeschreibung)
- [Features und technische Lösungen](#features-und-technische-lösungen)
- [Dokumentiertes Bugfixing](#dokumentiertes-bugfixing)
- [Problembeschreibung und Learnings](#problembeschreibung-und-learnings)
- [Hyperlinks und Referenzen](#hyperlinks-und-referenzen)
## Projektübersicht
**Silent FH** ist ein 2D Survival-Spiel, in dem eine Studentin oder ein Student über Nacht in der FH Joanneum eingeschlossen ist. Während der Nacht verwandeln sich Professoren in Monster und greifen die Räume an. Ziel ist es, möglichst lange zu überleben, ECTS zu sammeln und am Ende einen hohen Score im Leaderboard zu erreichen. Das Spiel wurde mit der Python Bibliothek PyGame geschrieben und es wurde der Package Manager "uv" benutzt.
Der Einstieg erfolgt über [`main.py`](main.py). Die zentrale Spielsteuerung liegt in [`src/game.py`](src/game.py). Von dort werden Startscreen, Endscreen, Karte, Player, Monster, Items, Shop, Tower, Sound, HUD und Highscore-System koordiniert.
## Gruppenmitglieder
Die Namen und Rollen wurden aus der bestehenden [`README.md`](README.md) übernommen.
- **Baumgartner Kevin**
- UI
- Startscreen
- Endscreen
- Highscore Display
- **Berger Lena**
- Sound Effects
- HUD
- ECTS-System
- Türen
- Items: Beer und Coffee
- **Kammerhofer Lukas**
- Tower
- Map
- Game Design
- **Haiden Niklas**
- Merge Requests
- Sprites
- Zombies / Monster
- Game Loop Logic, Zusammenführen des Spiels
- Dokumentation
## Repository und Installation
Das Projekt liegt im Git-Repository:
- **Repository:** [`git@git-iit.fh-joanneum.at:swd25-bootcamp/silent-fh.git`](git@git-iit.fh-joanneum.at:swd25-bootcamp/silent-fh.git)
- **Branch-Konzept:** Feature-Branches nach dem Muster `feat/feature-name`
- **Code-Review:** Pull Requests mit Merge-Prozess
- **Commit-Stil:** semantische Commits, z. B. `feat(...)`, `fix(...)`, `docs(...)`, `chore(...)`
Installation und Start:
```bash
uv sync
source .venv/bin/activate
uv run main.py
```
Die Abhängigkeiten stehen in [`pyproject.toml`](pyproject.toml), welches die Convention für den "uv" Package Manager ist.
## Corporate Identity
Silent FH nutzt eine dunkle, leicht unheimliche FH-Nacht-Atmosphäre. Bilder und Audio sind KI generiert.
### Farbwelt
Die Farben orientieren sich an den Konstanten in [`src/settings.py`](src/settings.py).
| Einsatz | Farbe | RGB | Wirkung |
| --- | --- | --- | --- |
| Hintergrund / Void | `#0A0A0F` | `(10, 10, 15)` | dunkle Nacht, Gefahr |
| Wände | `#1E1428` | `(30, 20, 40)` | FH-Gebäude, Begrenzung |
| Räume | `#4B2837` | `(75, 40, 55)` | Seminar- und Lernräume |
| Gang | `#23412D` | `(35, 65, 45)` | Fluchtweg, Orientierung |
| Türen | `#285A6E` | `(40, 90, 110)` | Übergang zwischen Raum und Gang |
| Startscreen-Akzent | `#00FF00` | `(0, 255, 0)` | Retro-Game-UI |
| Warnung / Lose | `#FF0000` | `(255, 0, 0)` | Gefahr und Scheitern |
### Tonalität
- **Atmosphäre:** dunkel, campusnah, leicht ironisch
- **Sprache:** FH- und Studienbegriffe wie `Matriculate`, `Exmatriculate`, `ECTS`, `Study`, `Coffee`
- **UI-Stil:** klare Buttons, Highscore-Tabelle, HUD mit Statusleisten
- **Game-Fantasy:** Lernen bringt Ressourcen, Kaffee rettet die Tür, Bier steigert die ECTS-Produktion
## Kurzbeschreibung
Der Spieler startet am Startscreen, gibt einen Namen oder eine ID ein und beginnt danach eine Survival-Runde. Die Spielfigur bewegt sich mit `WASD` oder den Pfeiltasten durch eine tilebasierte FH-Karte. Die Karte besteht aus Räumen, Gängen, Wänden, Startposition und Türen. In jedem Raum steht ein Tisch.
Sobald der Spieler an einem Tisch lernt, werden ECTS generiert. Gleichzeitig kann dadurch ein Raum-Encounter aktiviert werden: Die Tür schließt sich, Monster werden wach und bewegen sich im Gang zur passenden Raumtür. Die Monster greifen die geschlossene Tür in Intervallen an. Fällt die Tür-HP auf `0`, ist das Spiel verloren und der Endscreen wird angezeigt.
Der Spieler kann länger überleben, indem er Items kauft und Ressourcen sinnvoll verwendet. `Coffee` heilt die aktive Tür, `Beer` erhöht die ECTS-Generierung. Nach ausreichend langer Tisch-Interaktion kann ein Tower entstehen, der Projektile auf das Monster schießt und es beschädigt.
## Features und technische Lösungen
### Steuerung und Screens
- `WASD` oder Pfeiltasten: Spielfigur bewegen
- `E`: Item kaufen, wenn der Spieler ein Item berührt und genug ECTS besitzt
- `Space`: Spielerzustand umschalten und Transformationssound abspielen
- `ESC` oder `Q`: Spiel beenden
- `ENTER`: Spiel starten oder neu starten
Der Startscreen in [`src/startscreen.py`](src/startscreen.py) bietet Namenseingabe, Startbutton, Quitbutton, Validierung und Highscore-Anzeige. Der Endscreen in [`src/endscreen.py`](src/endscreen.py) zeigt Ergebnis, gesammelte ECTS, überlebte Zeit, Restart-Option und Leaderboard.
### Karte und Räume
Die Map in [`src/map.py`](src/map.py) wird als Liste von Strings modelliert. Jedes Zeichen steht für einen Tile-Typ:
- `W`: Wall
- `R`: Room
- `S`: Start
- `H`: Hall
- `D`: Door
Diese Struktur ist einfach lesbar und gut für PyGame geeignet. Zusätzlich speichert die Map Raumrechtecke für `Room 102`, `Room 103` und `Room 105`. Pro Raum wird automatisch ein Tisch in der Mitte erzeugt. Die Funktion `is_accessible()` legt fest, welche Tiles betreten werden dürfen.
### Player, Sprites und Monster
Die Actor-Logik in [`src/actors.py`](src/actors.py) kapselt Position, Blickrichtung, Animation, `good`-/`bad`-Status und Kollisions-Rollback. Sprite-Sets werden automatisch aus Asset-Ordnern geladen und müssen `sprite_01.png` bis `sprite_12.png` enthalten. Die Frames werden nach Bewegungsrichtungen gruppiert.
Die Monsterlogik in [`src/monster.py`](src/monster.py) spawnt Monster auf Gang-Tiles (`H`). Wenn ein Raum getriggert wird, wechseln die Monster in den angry-Zustand, suchen das Gang-Tile vor der Tür und bewegen sich über eine Breadth-First Search dorthin. Türen (`D`) sind für Monster kein begehbarer Pfad. Dadurch bleiben sie logisch im Gang und greifen nur die Tür an.
### Türen, ECTS, Items und Tower
[`src/door.py`](src/door.py) beschreibt Türen mit Position, Größe, HP, `is_closed`, `take_damage()`, `reset()` und `rect`-Property. Die Game-Logik erstellt Türen dynamisch passend zu den `D`-Tiles eines Raums.
Das ECTS-System in [`src/ects.py`](src/ects.py) ist die zentrale Ressource. ECTS werden nur generiert, wenn der Spieler an einem Tisch steht. Beer erhöht die ECTS-Rate, Shop und Tower können ECTS abziehen. Dadurch entsteht ein Risiko-Nutzen-Verhältnis: Am Tisch verdient man Ressourcen, aktiviert aber auch Gefahr.
Items werden in [`src/item.py`](src/item.py) verwaltet, Käufe in [`src/shop.py`](src/shop.py). Items spawnen in Räumen, laufen nach einem Timer ab und werden gegen Blocker-Rechtecke geprüft. `Coffee` heilt die aktive Tür um `20` HP, `Beer` erhöht die ECTS-Generierung um `2`. Erfolgreiche und fehlgeschlagene Käufe haben Soundfeedback.
Der Tower in [`src/tower.py`](src/tower.py) ist eine Verteidigungsmechanik:
- Spawnkosten: `10` ECTS
- Aktivierung nach `5000 ms` Tischkontakt
- Lebensdauer: `30000 ms`
- Projektilschaden: `2`
- Schussintervall: `1000 ms`
Der Tower erscheint nur einmal pro Raum-Encounter, schießt auf das Monster und wird beim Raumwechsel zurückgesetzt.
### HUD, Highscore und Sound
Das HUD in [`src/hud.py`](src/hud.py) zeigt Spielername, Door HP, Zombie HP, ECTS, ECTS pro Sekunde und Überlebenszeit. Der Highscore in [`src/highscore.py`](src/highscore.py) speichert Ergebnisse in [`src/highscore.json`](src/highscore.json). Sortiert wird nach gewonnenen Runs, höheren ECTS, längerer Überlebenszeit und Name als Tie-Breaker.
[`src/sounds.py`](src/sounds.py) kapselt Soundeffekte und Hintergrundmusik. Geladen werden u. a. Tower-Schuss, Monster-Hit, Kauf erfolgreich, Kauf fehlgeschlagen, Transformation sowie normale und gruselige Hintergrundmusik. Falls eine Sound-Datei fehlt, stürzt das Spiel nicht ab.
### Architektur
Die zentrale Game Loop in [`src/game.py`](src/game.py) arbeitet mit den Zuständen `startscreen`, `game` und `endscreen`. Dadurch bleiben Menü, Gameplay und Ergebnisanzeige getrennt. Mehrere Systeme nutzen `pygame.Rect` für Kollisionen und Blocker-Listen. Zeitabhängige Ereignisse basieren auf `pygame.time.get_ticks()`, z. B. ECTS-Generierung, Item-Spawns, Monster-Angriffe, Tower-Aktivierung, Tower-Lebensdauer und Überlebenszeit.
## Dokumentiertes Bugfixing
Die Bugfixes wurden aus Code und Git-Historie abgeleitet.
| Commit | Problem | Lösung |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dd1773e fix: fix buying logic for shop` | Die Shop-Logik gab bei ausreichendem ECTS-Guthaben fälschlich `False` zurück. | Die Bedingung wurde auf `if not self.ects.is_over_balance(cost): return False` geändert. Danach wird der Betrag abgezogen und `True` zurückgegeben. |
| `a218527 fix: readd missing file` | [`src/ects.py`](src/ects.py) fehlte, wodurch Imports des ECTS-Systems brechen konnten. | Die Datei mit ECTS-Kontostand, Rate, Timer und `upgrade_rate()` wurde wieder hinzugefügt. |
| `810a44c fix: import error` und `ba0bb49 feat: fix endscreen imports` | Imports wie `from settings import ...` passten nicht zur Projektstruktur. | Die Imports wurden auf `from src.settings import ...` und `from src.button import Button` geändert. |
| `8c454ed feat(startscreen): change ui texts from german to english and fix button click event` | Button-Klicks und leere Namenseingaben waren nicht sauber behandelt. | Der Button setzt nun einen internen `clicked`-Status, und der Startscreen validiert leere Namen. |
| `ac1085f bugfixes/troubleshooting` und `164e314 bugfixing` | Im Tower gab es falsche Attributnamen wie `self.field.rect`, einen falschen Methodennamen und eine falsch eingerückte `clear()`-Methode. | Die Attribute wurden auf `field_rect` und `tower_rect` korrigiert, `field_spawn()` wird richtig aufgerufen, `clear()` ist wieder eine Instanzmethode. |
| `80f6868 bugfix` | Beim Fehlerladen des Projektilsprites wurde versehentlich `tower_image` statt `projectile_image` auf `None` gesetzt. | Nur `projectile_image` wird zurückgesetzt, der Tower-Sprite bleibt erhalten. |
| `6fbd750 feat(endscreen): fix position of highscore table in endscreen` | Die Highscore-Tabelle überlagerte Inhalte am Endscreen. | Die Position wurde von `(560, 90)` auf `(800, 300)` verschoben. |
| `5149623 fix highscore implementation and decrease projectile damage` | Highscore-Speicherung und Sortierung waren nicht robust genug. | Scores werden beim Endscreen gespeichert, JSON-Daten geprüft, Zeiten formatiert und Werte über `safe_int()` / `parse_time()` abgesichert. |
| `3fcbd8c refactor: replace static door dimensions with dynamic sizing and add rect property` | Statische Türgrößen waren unflexibel. | Türen werden aus den `D`-Tiles berechnet und stellen eine `rect`-Property bereit. |
| `4faad9d feat: implement encounter reset mechanics for rooms and doors` | Beim Raumwechsel konnten alte Monster-, Tür- oder Tower-Zustände weiterlaufen. | `handle_room_change()` setzt Items, Tower, Tür und Monsterzustand zurück. |
| `bce77b8`, `d2b42c5`, `d6bdf22` | Der Tower musste technisch und spielerisch ausbalanciert werden. | Aktivierungszeit, Lebensdauer, Ablaufverhalten und Projektilschaden wurden angepasst. |
## Problembeschreibung und Learnings
Silent FH besteht aus vielen voneinander abhängigen Systemen. Wenn der Spieler am Tisch steht, laufen gleichzeitig ECTS-Generierung, Monster-Encounter-Logik, Tower-Timer, Monsterbewegung und HUD-Updates. Die wichtigste technische Lösung ist deshalb eine zentrale Orchestrierung in [`src/game.py`](src/game.py), während die Einzelsysteme in eigenen Klassen bleiben.
Eine vollständige Monster-KI wäre für den Projektumfang zu groß gewesen. Stattdessen nutzt das Spiel den BFS Algorithmus um den kürzesten Pfad zum Spieler zu finden. Spawnlogik wurde so gelöst: Items, Monster und Tower prüfen Blocker-Rechtecke, damit sie nicht auf Tischen, Türen oder anderen Objekten erscheinen.
Balancing hatte mehrere Iterationen erfordert. Viele Werte in [`src/settings.py`](src/settings.py) steuern Gameplay: Monster-HP, Tür-HP, Tower-Schaden, ECTS-Rate, Itemkosten, Spawnintervalle und Angriffsgeschwindigkeit. Die Git Historie zeigt, dass die Werte im Laufe der Entwicklung immer wieder angepasst werden mussten, um das Spiel fair und enjoyable zu machen.
## Hyperlinks und Referenzen
### Externe Referenzen
- [PyGame Dokumentation](https://www.pygame.org/docs/)
- [uv Dokumentation](https://docs.astral.sh/uv/)
- https://docs.aws.amazon.com/neptune-analytics/latest/userguide/bfs-algorithms.html
### Assets im Projekt
- `assets/niklas_sprites`: Player-Sprites
- `assets/normal_knoll`, `assets/angry_knoll`: Monster-Sprites
- `assets/normal_krainz`, `assets/angry_krainz`: Monster-Sprites
- `assets/normal_schwab`, `assets/angry_schwab`: Monster-Spritepaar
- `assets/items`: Beer, Coffee, Tower, Book
- `assets/furniture`: Tischgrafik-Sprites
- `assets/sounds`: Soundeffekte und Hintergrundmusik
Für die korrekte Ausformulierung und Überarbeitung wurde KI (ChatGPT 5.5) verwendet.
+67 -6
View File
@@ -1,6 +1,7 @@
# Silent FH
In Silent FH, you play as a student trapped overnight in the FH Joanneum. During the night the professors turn into monsters and hunt down the trapped students.
In Silent FH, you play as a student trapped overnight in the FH Joanneum. During the night the professors turn into
monsters and hunt down the trapped students.
Your goal is to survive.
This project uses uv and PyGame as its main libraries.
@@ -9,20 +10,80 @@ To get uv, please see here: https://docs.astral.sh/uv/getting-started/installati
To sync the dependencies, please run:
## Usage
```
uv sync
source .venv/bin/activate or source .venv/bin/activate.fish
uv run main.py
```
Coding Guidelines:
## 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** (it redirects to `127.0.0.1`, because the
Pygbag runtime treats `localhost` as its dev server). The game scales to fill
the browser window while keeping its aspect ratio. 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
- Use E to buy items (if you have enough ECTS)
- Survive or die
- Coffee heals the door
- Beer increases your ETCS generation
Live long enough and get the highest amount of ETCS you can!
### Coding Guidelines:
- Using branches for features, named like so: feat/feature-name
- Using pull requests and merging for code review
- Using semantic commits for a good overview of the changes, for example:
- feat(component): added new component
- fix(bug): fixed a bug
- docs(readme): updated documentation
- chore(ci): automated build and deployment
- feat(component): added new component
- fix(bug): fixed a bug
- docs(readme): updated documentation
- chore(ci): automated build and deployment
Game coded up by Baumgartner Kevin, Kammerhofer Lukas, Berger Lena and Haiden Niklas.
## Responsibilities
- Baumgartner Kevin: UI, Startscreen, Endscreen, Highscore Display
- Berger Lena: Sound Effects, HUD, ECTS System, Doors, Items (Beer and Coffee)
- Kammerhofer Lukas: Tower, Map, Design of the game
- Haiden Niklas: Merge Requests, Sprites, Zombies, Game Loop Logic, Docs
+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())
+13 -5
View File
@@ -1,8 +1,6 @@
import pygame
from src.settings import (
DOOR_WIDTH,
DOOR_HEIGHT,
DOOR_BORDER_WIDTH,
COLOR_DOOR_BEIGE,
COLOR_DOOR_BORDER
@@ -11,10 +9,12 @@ from src.settings import (
# using class to be able to reuse it
# as we will have multiple rooms to go in
class Door:
def __init__(self, x, y, hp_value):
def __init__(self, x, y, width, height, hp_value):
# for each door we can set our own x/y position
self.x = x
self.y = y
self.width = width
self.height = height
self.max_hp = hp_value
self.current_hp = hp_value
@@ -25,10 +25,14 @@ class Door:
def draw(self, screen):
# door
pygame.draw.rect(screen, self.door_color, (self.x, self.y, DOOR_WIDTH, DOOR_HEIGHT))
pygame.draw.rect(screen, self.door_color, self.rect)
# doorframe
pygame.draw.rect(screen, self.door_border_color, (self.x, self.y, DOOR_WIDTH, DOOR_HEIGHT), DOOR_BORDER_WIDTH)
pygame.draw.rect(screen, self.door_border_color, self.rect, DOOR_BORDER_WIDTH)
@property
def rect(self):
return pygame.Rect(self.x, self.y, self.width, self.height)
# when attacked and gets dmg reduces the health (if closed), door "breaks" when health = 0
def take_damage(self, amount):
@@ -37,3 +41,7 @@ class Door:
if self.current_hp <= 0:
self.current_hp = 0
self.is_closed = False
def reset(self):
self.current_hp = self.max_hp
self.is_closed = False
+1
View File
@@ -55,6 +55,7 @@ class Endscreen:
)
self.highscore = Highscore()
self.highscore.add_score(self.player_name or "Student", self.score_ects, self.time_survived, self.won)
def run(self):
# get events
+65 -21
View File
@@ -8,6 +8,8 @@ Controls:
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from typing import Optional
@@ -25,11 +27,10 @@ from src.settings import (
BEER_BOTTLE_ECTS_VALUE,
COFFEE_HEAL_VALUE,
DEMO_ACTOR_HEIGHT,
DOOR_HEIGHT,
DOOR_HP,
DOOR_WIDTH,
FPS,
PLAYER_SPEED,
TOWER_TABLE_ACTIVATION_TIME,
)
from src.shop import Shop
from src.startscreen import Startscreen
@@ -47,6 +48,7 @@ class Game:
pygame.init()
self.screen = pygame.display.set_mode((screen_w, screen_h))
self.clock = pygame.time.Clock()
pygame.display.set_caption("Silent FH")
self.font = pygame.font.SysFont("monospace", 22, bold=True)
self.item_font = pygame.font.SysFont("Arial", 16, bold=True)
self.running = True
@@ -67,6 +69,7 @@ class Game:
self.game_start_time = 0
self.endscreen: Optional[Endscreen] = None
self.current_room_key: Optional[tuple[int, int, int, int]] = None
self.table_touch_start_time: Optional[int] = None
self.map = Map()
self.active_door: Optional[Door] = None
@@ -88,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()
@@ -118,6 +122,7 @@ class Game:
pygame.display.flip()
frame_count += 1
await asyncio.sleep(0)
pygame.quit()
@@ -149,10 +154,11 @@ class Game:
self.player.hit_wall()
self.player.update_animation(dt)
self.ects.update(self.is_player_at_desk())
self.handle_room_table_collision()
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
is_at_desk = self.is_player_at_desk(player_rect, room_rect)
self.ects.update(is_at_desk)
self.handle_room_table_collision()
self.handle_room_change(room_rect)
self.update_items(player_rect, room_rect)
self.monsters.update(
@@ -161,7 +167,7 @@ class Game:
self.is_position_walkable,
self.get_monster_spawn_blockers(),
)
self.update_tower()
self.update_tower(room_rect, is_at_desk)
self.check_end_conditions()
def get_player_movement(self, dt: float) -> pygame.Vector2:
@@ -171,7 +177,7 @@ class Game:
- (keys[pygame.K_LEFT] or keys[pygame.K_a]),
(keys[pygame.K_DOWN] or keys[pygame.K_s])
- (keys[pygame.K_UP] or keys[pygame.K_w]),
)
)
if movement.length_squared() == 0:
return movement
@@ -188,44 +194,79 @@ class Game:
row = int(position.y // tile_size)
return self.map.is_accessible(column, row)
def is_player_at_desk(self) -> bool:
def is_player_at_desk(
self,
player_rect: Optional[pygame.Rect] = None,
room_rect: Optional[pygame.Rect] = None,
) -> bool:
"""Return True while the player's hitbox touches a desk in the current room."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
if player_rect is None:
player_rect = self.get_actor_rect(self.player)
if room_rect is None:
room_rect = self.map.get_current_room(player_rect)
if room_rect is None:
return False
return any(desk.check_collision(player_rect) for desk in self.map.get_desks_for_room(room_rect))
def update_tower(self) -> None:
"""Spawn a tower when enough ECTS are available and let it attack the zombie."""
def update_tower(
self,
room_rect: Optional[pygame.Rect],
is_at_desk: bool,
) -> None:
"""Spawn a tower after the player studies at a table, then let it attack."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
monster = self.monsters.monsters[0] if self.monsters.monsters else None
if monster is None or not monster.is_alive:
self.tower.expire_if_needed()
self.update_table_touch_timer(is_at_desk)
return
can_spawn_tower = self.update_table_touch_timer(is_at_desk)
damage = self.tower.update(
room_rect,
self.get_tower_spawn_blockers(),
self.ects,
monster.rect,
can_spawn=can_spawn_tower,
)
if monster is not None and damage > 0:
monster.take_damage(damage)
def update_table_touch_timer(self, is_at_desk: bool) -> bool:
now = pygame.time.get_ticks()
if not is_at_desk:
self.table_touch_start_time = None
return False
if self.table_touch_start_time is None:
self.table_touch_start_time = now
return False
return now - self.table_touch_start_time >= TOWER_TABLE_ACTIVATION_TIME
def handle_room_change(self, room_rect: Optional[pygame.Rect]) -> None:
room_key = self.get_room_key(room_rect) if room_rect is not None else None
if room_key == self.current_room_key:
return
if self.current_room_key is not None:
self.reset_active_encounter()
self.item_manager.clear()
self.tower.clear(reset_spawned=True)
self.table_touch_start_time = None
self.current_room_key = room_key
def reset_active_encounter(self) -> None:
"""Reset the room attack once the player exits through the door."""
if self.active_door is not None:
self.active_door.reset()
self.active_door = None
self.monsters.reset_encounter()
def update_items(self, player_rect: pygame.Rect, room_rect: Optional[pygame.Rect]) -> None:
"""Spawn room items and process one player purchase request."""
@@ -272,6 +313,7 @@ class Game:
self.shop = Shop(self.ects, self.item_manager.items, self.sound_manager)
self.item_manager.clear()
self.current_room_key = None
self.table_touch_start_time = None
self.tower.clear(reset_spawned=True)
self.active_door = None
self.doors_by_room = {}
@@ -321,21 +363,23 @@ class Game:
tile_size,
)
if (
room_rect.left <= tile_rect.centerx <= room_rect.right
and room_rect.bottom <= tile_rect.centery <= room_rect.bottom + 2 * tile_size
room_rect.left <= tile_rect.centerx <= room_rect.right
and room_rect.bottom <= tile_rect.centery <= room_rect.bottom + 2 * tile_size
):
door_tiles.append(tile_rect)
if not door_tiles:
return Door(room_rect.centerx - DOOR_WIDTH // 2, room_rect.bottom, DOOR_HP)
return Door(room_rect.centerx - tile_size, room_rect.bottom, tile_size * 2, 10, DOOR_HP)
door_area = door_tiles[0].copy()
for tile_rect in door_tiles[1:]:
door_area.union_ip(tile_rect)
return Door(
door_area.centerx - DOOR_WIDTH // 2,
door_area.centery - DOOR_HEIGHT // 2,
door_area.x,
door_area.y,
door_area.width,
door_area.height,
DOOR_HP,
)
@@ -352,7 +396,7 @@ class Game:
blockers = self.get_monster_spawn_blockers()
if self.active_door is not None:
blockers.append(pygame.Rect(self.active_door.x, self.active_door.y, DOOR_WIDTH, DOOR_HEIGHT))
blockers.append(self.active_door.rect)
for monster in self.monsters.monsters:
blockers.append(monster.rect)
blockers.extend(item.rect for item in self.item_manager.items)
-30
View File
@@ -4,35 +4,5 @@
"ects": 55,
"time": "02:00",
"won": true
},
{
"name": "Anna",
"ects": 48,
"time": "01:35",
"won": true
},
{
"name": "Kevin",
"ects": 42,
"time": "01:58",
"won": true
},
{
"name": "Lena",
"ects": 35,
"time": "01:44",
"won": false
},
{
"name": "Lukas",
"ects": 20,
"time": "00:58",
"won": false
},
{
"name": "Max",
"ects": 12,
"time": "00:30",
"won": false
}
]
+74 -15
View File
@@ -1,29 +1,42 @@
import json
import sys
if sys.platform == "emscripten":
from platform import window
import pygame
from src.settings import HIGHSCORE_COLOR_FONT, HIGHSCORE_COLOR_TABLE
class Highscore:
def __init__(self):
def __init__(self, path="src/highscore.json"):
# fix path to json file with highscores
self.path = "src/highscore.json"
self.path = path
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:
return 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 []
def save_scores(self, scores):
"""
@@ -35,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
@@ -57,8 +76,7 @@ class Highscore:
}
scores.append(new_score)
# sort by ects descending
scores.sort(key=lambda score: score["ects"], reverse=True)
self.sort_scores(scores)
# keep only top 50 elements
scores = scores[:50]
@@ -71,9 +89,50 @@ class Highscore:
:return: Return top 5 scores as list.
"""
scores = self.load_scores()
scores.sort(key=lambda score: score["ects"], reverse=True)
self.sort_scores(scores)
return scores[:5]
@classmethod
def sort_scores(cls, scores):
scores.sort(key=cls.get_rank_key)
@staticmethod
def get_rank_key(score):
return (
not bool(score.get("won", False)),
-Highscore.safe_int(score.get("ects", 0)),
-Highscore.parse_time(score.get("time", 0)),
str(score.get("name", "")).lower(),
)
@staticmethod
def safe_int(value):
try:
return int(value)
except (TypeError, ValueError):
return 0
@staticmethod
def parse_time(value):
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
parts = value.split(":")
try:
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
return int(value)
except ValueError:
return 0
return 0
@staticmethod
def format_time(value):
total_seconds = Highscore.parse_time(value)
minutes = total_seconds // 60
seconds = total_seconds % 60
return f"{minutes:02}:{seconds:02}"
def get_top_scores_formatted(self):
"""
Get top 5 scores from highscore file in nice format.
@@ -88,7 +147,7 @@ class Highscore:
line = (
f"{index + 1}. {score['name']} | "
f"ECTS: {score['ects']} | "
f"Time: {score['time']} | "
f"Time: {self.format_time(score['time'])} | "
f"Result: {result}"
)
+8
View File
@@ -180,6 +180,14 @@ class MonsterManager:
for monster in self.monsters:
monster.wake_up()
def reset_encounter(self) -> None:
"""Stop the room attack and put all active monsters back into normal state."""
self.active_target_tile = None
self.active_door = None
for monster in self.monsters:
monster.sleep()
def update(
self,
dt: float,
+6 -5
View File
@@ -64,9 +64,10 @@ COFFEE_HEAL_VALUE = 20
# --- TOWER ---
TOWER_COST = 10
PROJECTILE_DMG = 7
PROJECTILE_DMG = 2
PROJECTILE_SPEED = 7
COLOR_PROJECTILE = (200, 200, 0)
TOWER_TABLE_ACTIVATION_TIME = 5000 # ms the player must stay at a table before tower spawn
TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears
# --- ITEM SPAWN TIMER ---
@@ -90,7 +91,7 @@ MONSTER_MAX_COUNT = 1
MONSTER_SPAWN_INTERVAL = 8000 # ms until a replacement monster spawns
MONSTER_SPEED = 90.0
MONSTER_DOOR_DAMAGE = ZOMBIE_MEDIUM_DMG
MONSTER_ATTACK_INTERVAL = 1000 # ms between door attacks
MONSTER_ATTACK_INTERVAL = 2000 # ms between door attacks
# --- HUD ---
HUD_FONT = "Arial"
@@ -149,8 +150,8 @@ SFX_BUY_FAIL = f"{SOUND_DIR}/buy_fail.wav"
SFX_MONSTER_HEALED = f"{SOUND_DIR}/monster_healed.wav"
SFX_MONSTER_TRANSFORM = f"{SOUND_DIR}/monster_transform.wav"
BGM_NORMAL = f"{SOUND_DIR}/bgm_normal.wav"
BGM_NORMAL = f"{SOUND_DIR}/bgm_creepy.wav"
BGM_CREEPY = f"{SOUND_DIR}/bgm_creepy.wav"
VOLUME_SFX = 0.5
VOLUME_BGM = 0.3
VOLUME_SFX = 0.6
VOLUME_BGM = 0.7
+3 -1
View File
@@ -71,13 +71,15 @@ class Tower:
blocked_rects: list[pygame.Rect],
ects,
monster_rect: Optional[pygame.Rect],
can_spawn: bool = True,
) -> int:
"""Update tower spawning/shooting and return damage dealt to the monster."""
now = pygame.time.get_ticks()
total_damage = 0
self.try_spawn(room_rect, blocked_rects, ects)
if can_spawn:
self.try_spawn(room_rect, blocked_rects, ects)
if self.tower_rect is not None and monster_rect is not None:
if now - self.last_shot >= fire_interval:
+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()
+443
View File
@@ -0,0 +1,443 @@
<html lang="en-us"><script src="{{cookiecutter.cdn}}pythons.js" type=module id="site" data-python="python{{cookiecutter.PYBUILD}}" data-LINES=42 data-COLUMNS=132 data-os="vtx,snd,gui" async defer>#<!--
print("""
Loading {{cookiecutter.title}} from {{cookiecutter.archive}}.apk
Pygbag Version : {{cookiecutter.version}}
Template Version : 0.9.3
Python : {{cookiecutter.PYBUILD}}
CDN URL : {{cookiecutter.cdn}}
Screen : {{cookiecutter.width}}x{{cookiecutter.height}}
Title : {{cookiecutter.title}}
Folder : {{cookiecutter.directory}}
Authors : {{cookiecutter.authors}}
SPDX-License-Identifier: {{cookiecutter.spdx}}
""")
import sys
import asyncio
import platform
import json
from pathlib import Path
# do not rename
async def custom_site():
import embed
platform.document.body.style.background = "#000"
platform.window.transfer.hidden = true
platform.window.canvas.style.visibility = "visible"
bundle = "{{cookiecutter.archive}}"
# the C or js loader could do that but be explicit.
appdir = Path(f"/data/data/{bundle}") # /data/data/{{cookiecutter.archive}}
appdir.mkdir()
# unpack filesystem from compressed archive into work dir
if platform.window.location.host.find('.itch.zone')>0:
import zipfile
async with platform.fopen("{{cookiecutter.archive}}.apk", "rb") as archive:
with zipfile.ZipFile(archive) as zip_ref:
zip_ref.extractall(appdir.as_posix())
else:
import tarfile
async with platform.fopen("{{cookiecutter.archive}}.tar.gz", "rb") as archive:
tar = tarfile.open(fileobj=archive, mode="r:gz")
tar.extractall(path=appdir.as_posix(), filter='tar')
tar.close()
# preloader will change to work dir and prepend it to sys.path
platform.run_main(PyConfig, loaderhome= appdir / "assets", loadermain=None)
# wait preloading complete : that includes images and wasm compilation of bundled modules
while embed.counter()<0:
await asyncio.sleep(.1)
main = appdir / "assets" / "main.py"
# TODO: test for window.webkitAudioContext and block aio loop until gesture if accessing media manager play
# before a gesture is recorded ( touch or click )
# test/wait user media interaction
if not platform.window.MM.UME:
# now that apk is mounted we have access to font cache
# but we need to fill __file__ that is not yet set
__import__(__name__).__file__ = main
# now make a prompt
msg = "Ready to start ! Please click/touch page"
platform.window.infobox.innerText = msg
print("\n"*4, f" * Waiting for media user engagement. {msg} *" , "\n"*4)
while not platform.window.MM.UME:
await asyncio.sleep(.1)
# start async top level machinery if not started and add a console in any case if requested.
await TopLevel_async_handler.start_toplevel(platform.shell, console=window.python.config.debug)
# now that apk is mounted we have access to font cache
# but we need to fill __file__/__name__ that are not yet set
__import__(__name__).__file__ = main
def ui_callback(pkg):
platform.window.infobox.innerText = f"installing {pkg}"
await shell.source(main, callback=ui_callback)
# if you don't reach that step
# your main.py has an infinite sync loop somewhere !
platform.window.infobox.style.display = "none"
platform.window.config.gui_divider = 1
platform.window.window_resize()
print("default.tmpl: done")
shell.interactive()
asyncio.run( custom_site() )
# BEGIN BLOCK
#
# now this is the html part you can (and should) customize
# It is not mandatory : pygame-script when it reads the first line (also called
# shebang ) of above code create absolute minimal widget set
# required for running with default rules
#
# do not alter that comment block it is separating python code from html code
# =============================================================================
# --></script><head><!--
//=============================================================================
//
//
//
//
//
//
//
{%- if cookiecutter.comment != "" -%}
{{cookiecutter.comment}}
{% endif %}
--><script type="application/javascript">
// END BLOCK
// this dict is available under PyConfig.config from __main__
config = {
xtermjs : "{{cookiecutter.xtermjs}}" ,
_sdl2 : "canvas",
user_canvas : 0,
user_canvas_managed : 0,
gui_divider : 1,
ume_block : {{cookiecutter.ume_block}},
can_close : {{cookiecutter.can_close}},
archive : "{{cookiecutter.archive}}",
gui_debug : 2,
cdn : "{{cookiecutter.cdn}}",
autorun : {{cookiecutter.autorun}},
PYBUILD : "{{cookiecutter.PYBUILD}}",
fb_ar : 1.77,
fb_width : "{{cookiecutter.width}}",
fb_height : "{{cookiecutter.height}}"
}
function show_infobox() {
infobox.style.display = "block";
// Measure box
const w = infobox.offsetWidth;
const h = infobox.offsetHeight;
// Center in viewport
const left = (window.innerWidth - w) / 2;
const top = (window.innerHeight - h) / 2;
infobox.style.left = left + "px";
infobox.style.top = top + "px";
}
</script>
<title>{{cookiecutter.title}}</title>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="height=device-height, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"/>
<link rel="icon" type="image/png" href="favicon.png" sizes="16x16">
<style>
#status {
display: inline-block;
vertical-align: top;
margin-top: 20px;
margin-left: 30px;
font-weight: bold;
color: rgb(120, 120, 120);
}
#progress {
height: 20px;
width: 300px;
}
#infobox {
position: fixed; /* center relative to viewport */
background: green;
color: blue;
font-weight: bold;
padding: 12px 24px;
/* display: none; */
z-index: 999999;
}
div.emscripten { text-align: center; }
div.emscripten_border { border: 1px solid black; }
div.thick_border { border: 4px solid black; }
/* the canvas *must not* have any border or padding, or mouse coords will be wrong */
/* average size of droid screen 470dp x 320dp */
canvas.emscripten {
border: 0px none;
background-color: transparent;
width: 100%;
height: 100%;
z-index: 5;
padding: 0;
margin: 0 auto;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
body {
font-family: arial;
margin: 0;
padding: none;
background-color: #000;
overflow: hidden;
}
.topright{
position:absolute;
top:0px;
right:0px;
}
.bottomright {
position:absolute;
top: 40%;
right: 0px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.trinfo{
position: relative;
right: 0px;
border: 1px solid black;
}
.framed{
position: relative;
top: 150px;
right: 10px;
border: 1px solid black;
}
</style>
<script>
// On "localhost" the pygbag runtime expects its dev proxy on :8000 and
// fails to fetch pygame. 127.0.0.1 uses the public CDN instead.
if (location.hostname === "localhost")
location.replace(location.href.replace("//localhost", "//127.0.0.1"))
/*
if (navigator.serviceWorker)
navigator.serviceWorker.register("{{cookiecutter.cdn}}pygbag{{cookiecutter.version}}.js")
else
console.warn("Service workers not supported")
*/
</script>
<script src="{{cookiecutter.cdn}}/browserfs.min.js"></script>
</head>
<body>
<div id="transfer" align=center>
<!-- <div class="spinner" id='spinner'></div> -->
<div class="emscripten" id="status">Downloading...</div>
<div class="emscripten">
<progress value="0" max="100" id="progress"></progress>
</div>
</div>
<canvas class="emscripten" id="canvas"
width="1px"
height="1px"
oncontextmenu="event.preventDefault()" tabindex=1>
</canvas>
<canvas class="emscripten" id="canvas3d"
width="{{cookiecutter.width}}px"
height="{{cookiecutter.height}}px"
oncontextmenu="event.preventDefault()" tabindex=1 hidden>
</canvas>
<div id="infobox">Loading, please wait ...</div>
<div id=html></div>
<div id=crt class=bottomright >
<div id="system" hidden>
<div class="button-container">
<button id="aiostop" disabled>AIO ⏏︎</button>
<button id="aiopaused_true" disabled>AIO ■</button>
<button id="aiopaused_false" disabled>AIO ▶</button>
<button id="pygame_mixer_music_pause" disabled>Music ■</button>
</div>
<div class="button-container">
<div id=load_min>min</div>
<div id=load_avg>avg</div>
<div id=load_max>max</div>
<button id="load_rst" disabled>RESET</button>
</div>
<div id="level">(battery level unknown)</div>
<div id="stateBattery">(charging state unknown)</div>
</div>
<div id=box class="emscripten_border" hidden=true>
<div id="info" class="trinfo"></div>
<iframe id="iframe" class="framed" name="iframe"
width="470px" height="90%"
allowtransparency="true"
style="z-index: 10;"
style="background: #FFFFFF;"
frameborder="1"
allowfullscreen="true"
webkitallowfullscreen="true"
msallowfullscreen="true"
mozallowfullscreen="true"
sandbox="allow-same-origin allow-top-navigation allow-scripts allow-pointer-lock"
allow="autoplay; fullscreen *; geolocation; microphone; camera; midi; monetization; xr-spatial-tracking; gamepad; gyroscope; accelerometer; xr; cross-origin-isolated"
src="{{cookiecutter.cdn}}empty.html"
scrolling="yes">
</iframe>
</div>
</div>
<div id="dlg" hidden>
<input type="file" id="dlg_multifile" multiple accept="image/*">
<label for="dlg_multifile">Select files</label>
</div>
<div id="pyconsole">
<div id="terminal" tabIndex=1 align="left"></div>
</div>
<script type="application/javascript">
globalThis.__canvas_resized = (self, ecw, ech) => {
console.warn("TODO: panda3d canvas monitor", self, ecw, ech)
}
async function custom_onload(debug_hidden) {
// this is called before anything python is loaded
// make your js customization here
console.log(__FILE__, "custom_onload")
pyconsole.hidden = debug_hidden
system.hidden = debug_hidden
transfer.hidden = debug_hidden
info.hidden = debug_hidden
box.hidden = debug_hidden
show_infobox()
}
function custom_prerun(){
// no python main and no (MEMFS + VFS) yet.
console.log(__FILE__, "custom_prerun")
}
function custom_postrun(){
// python main and no VFS filesystem yet.
console.log(__FILE__, "custom_postrun")
}
function debug() {
// allow to gain access to dev tools from js console
// but only on desktop. difficult to reach when in iframe
python.config.debug = true
custom_onload(false)
Module.PyRun_SimpleString("shell.uptime()")
window_resize()
}
function info_inline(data){
document.getElementById("info").innerHTML = data
}
function info_online(url) {
// display info about current APK
fetch( url /*, options */)
.then((response) => response.text())
.then((html) => {
info_inline(html);
})
.catch((error) => {
console.warn(error);
});
}
function frame_online(url) {
window.frames["iframe"].location = url;
}
</script>
</body>
</html>