Initial commit: Mobil_Tag project setup

This commit is contained in:
topsith
2026-08-05 20:29:18 +02:00
commit 9db3812cf8
46 changed files with 3888 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Dependencies
node_modules/
*/node_modules/
# Build outputs
dist/
build/
*/dist/
*/build/
# Environment variables (but keep .env.example)
.env
.env.local
.env.*.local
!.env.example
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage/
# TypeScript
*.tsbuildinfo
# Misc
.cache/
+395
View File
@@ -0,0 +1,395 @@
# Deployment-Anleitung: Mobile Tag auf Ubuntu-Server
Diese Anleitung beschreibt, wie du das Mobile Tag Projekt auf deinen bestehenden Ubuntu-Webserver mit PHP bringst. Das Backend benötigt Node.js (siehe `UBUNTU_SETUP.md`), das Frontend ist eine statische PWA.
---
## Übersicht
| Komponente | Technologie | Pfad auf Server | Auslieferung |
|---|---|---|---|
| Backend | Node.js + Socket.io | `/var/www/mobile-tag/server/dist` | PM2 auf Port 3001 |
| Frontend (PWA) | Vite-Build | `/var/www/mobile-tag/client/dist` | Apache oder Nginx |
| WebSocket | Socket.io | `:3001` | Nginx Reverse Proxy (empfohlen) |
---
## Voraussetzungen
- Ubuntu-Server mit SSH-Zugriff
- Node.js ≥ 18 installiert (`node -v` und `npm -v` prüfen)
- Apache oder Nginx als Webserver
- Domain oder Server-IP
- Ports 80, 443 und optional 3001 freigegeben
---
## Schritt 1: Projekt auf den Server kopieren
### Vom Windows-Rechner (mit Git Bash, WSL oder PowerShell)
```bash
# Mit scp (Pfad anpassen!)
scp -r "c:/Users/thebi/OneDrive/KI Projekte/Mobil_Tag" root@dein-server:/var/www/mobile-tag
```
### Alternativ als ZIP
1. Auf dem Windows-Rechner den `Mobil_Tag`-Ordner als ZIP packen
2. ZIP per FTP/SFTP in `/var/www/` hochladen
3. Auf dem Server entpacken:
```bash
cd /var/www
sudo apt install unzip -y
sudo unzip mobile-tag.zip
sudo mv Mobil_Tag mobile-tag
```
**Wichtig:** Falls du schon einmal `npm install` lokal gemacht hast, lösche vor dem Kopieren die Ordner `node_modules`:
```bash
# Auf Windows, bevor du kopierst:
Remove-Item -Recurse -Force "c:/Users/thebi/OneDrive/KI Projekte/Mobil_Tag/node_modules"
Remove-Item -Recurse -Force "c:/Users/thebi/OneDrive/KI Projekte/Mobil_Tag/client/node_modules"
Remove-Item -Recurse -Force "c:/Users/thebi/OneDrive/KI Projekte/Mobil_Tag/server/node_modules"
```
---
## Schritt 2: Server vorbereiten
```bash
# Ins Projektverzeichnis wechseln
cd /var/www/mobile-tag
# Rechte setzen (falls nötig)
sudo chown -R www-data:www-data /var/www/mobile-tag
sudo chmod -R 755 /var/www/mobile-tag
# Node-Version prüfen
node -v # Sollte v20.x oder höher anzeigen
npm -v
```
---
## Schritt 3: Abhängigkeiten installieren
```bash
cd /var/www/mobile-tag
npm run install:all
```
Das installiert automatisch:
- Root-Abhängigkeiten (`concurrently`)
- Client-Abhängigkeiten (React, Vite, TensorFlow.js, Tailwind, ...)
- Server-Abhängigkeiten (Express, Socket.io, ...)
---
## Schritt 4: Server-URL konfigurieren
Der Client muss wissen, wo das Backend läuft.
```bash
cd /var/www/mobile-tag/client
cp .env.example .env
```
Dann `.env` bearbeiten:
```bash
# Variante A: Backend läuft auf eigener Subdomain (empfohlen mit Nginx)
VITE_SERVER_URL=https://api.dein-domain.de
# Variante B: Backend läuft direkt auf Port 3001
VITE_SERVER_URL=http://dein-server-ip:3001
# Variante C: Beides auf derselben Domain mit Nginx Reverse Proxy
VITE_SERVER_URL=https://dein-domain.de
```
**Wichtig:** Wenn du HTTPS nutzt, muss auch `VITE_SERVER_URL` mit `https://` beginnen.
---
## Schritt 5: Projekt bauen
```bash
cd /var/www/mobile-tag
npm run build
```
Ergebnis:
- `server/dist/` kompilierter Node.js-Server
- `client/dist/` fertige PWA-Dateien
---
## Schritt 6: Backend starten
### Variante A: Mit PM2 (empfohlen für Produktion)
```bash
# PM2 global installieren
sudo npm install -g pm2
# Server als Dienst starten
cd /var/www/mobile-tag/server
pm2 start dist/index.js --name mobile-tag-server
# PM2 so einrichten, dass es nach Neustart automatisch startet
pm2 save
pm2 startup
# → Den angezeigten Befehl kopieren und ausführen (meist mit sudo)
```
**Nützliche PM2-Befehle:**
```bash
pm2 status # Status anzeigen
pm2 logs mobile-tag-server # Logs anzeigen
pm2 restart mobile-tag-server
pm2 stop mobile-tag-server
pm2 delete mobile-tag-server
```
### Variante B: Einfacher Test ohne PM2
```bash
cd /var/www/mobile-tag/server
npm start
```
Läuft dann solange das Terminal offen ist.
---
## Schritt 7: Frontend ausliefern
### Mit Apache
1. `client/dist/` in dein Webverzeichnis kopieren:
```bash
sudo mkdir -p /var/www/html/mobile-tag
sudo cp -r /var/www/mobile-tag/client/dist/* /var/www/html/mobile-tag/
```
2. Falls nötig, Apache konfigurieren (Single-Page-App-Routing):
```bash
sudo nano /etc/apache2/sites-available/mobile-tag.conf
```
```apache
<VirtualHost *:80>
ServerName dein-domain.de
DocumentRoot /var/www/html/mobile-tag
<Directory /var/www/html/mobile-tag>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
</VirtualHost>
```
Aktivieren:
```bash
sudo a2enmod rewrite
sudo a2ensite mobile-tag.conf
sudo systemctl reload apache2
```
### Mit Nginx (empfohlen)
```bash
sudo nano /etc/nginx/sites-available/mobile-tag
```
```nginx
server {
listen 80;
server_name dein-domain.de;
root /var/www/html/mobile-tag;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# WebSocket-Verkehr an Node.js weiterleiten
location /socket.io/ {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
Aktivieren:
```bash
sudo ln -s /etc/nginx/sites-available/mobile-tag /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
**Hinweis:** Wenn du Apache statt Nginx verwendest und den Node-Server direkt auf Port 3001 erreichbar machen willst, musst du Port 3001 in der Firewall freigeben (siehe Schritt 9).
---
## Schritt 8: HTTPS mit Let's Encrypt einrichten
Dieser Schritt ist **zwingend erforderlich**, da Kamera, GPS und Orientierung in Browsern nur mit HTTPS funktionieren.
### Mit Nginx
```bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d dein-domain.de
```
### Mit Apache
```bash
sudo apt install certbot python3-certbot-apache -y
sudo certbot --apache -d dein-domain.de
```
Danach ist die App unter `https://dein-domain.de` erreichbar.
---
## Schritt 9: Firewall konfigurieren
### Wenn Nginx als Reverse Proxy verwendet wird
Port 3001 muss **nicht** öffentlich erreichbar sein, da Nginx intern weiterleitet:
```bash
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable
```
### Wenn der Node-Server direkt auf Port 3001 erreichbar sein soll
```bash
sudo ufw allow 3001/tcp
sudo ufw allow 'Apache Full' # oder 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable
```
---
## Schritt 10: App testen
1. Öffne im Handy-Browser: `https://dein-domain.de`
2. Erlaube Kamera, Standort und Orientierung.
3. Erstelle einen Raum und teile den Code.
4. Ein zweiter Spieler tritt dem Raum bei.
5. Host startet das Spiel.
---
## Updates einspielen
Wenn du Änderungen am Code machst:
```bash
cd /var/www/mobile-tag
# Neue Dateien vom lokalen Rechner auf den Server kopieren (Schritt 1 wiederholen)
# Dann auf dem Server:
npm run install:all
npm run build
pm2 restart mobile-tag-server
# Bei Apache:
sudo cp -r /var/www/mobile-tag/client/dist/* /var/www/html/mobile-tag/
# Bei Nginx (falls DocumentRoot direkt auf /var/www/mobile-tag/client/dist zeigt, nicht nötig):
sudo cp -r /var/www/mobile-tag/client/dist/* /var/www/html/mobile-tag/
```
---
## Fehlerbehebung
### „npm: Befehl nicht gefunden"
Node.js ist nicht installiert oder nicht im PATH. Siehe `UBUNTU_SETUP.md`.
### „Port 3001 bereits belegt"
```bash
sudo lsof -i :3001
# Prozess-ID merken und beenden:
sudo kill -9 <PID>
pm2 restart mobile-tag-server
```
### „WebSocket-Verbindung fehlgeschlagen"
- Prüfe, ob der Server läuft: `pm2 status`
- Prüfe die Firewall: `sudo ufw status`
- Prüfe, ob `VITE_SERVER_URL` korrekt ist
- Prüfe die Nginx-Konfiguration auf das `/socket.io/`-Location-Block
### „Kamera/GPS funktioniert nicht"
- HTTPS erforderlich!
- Browser-Berechtigungen prüfen
- Auf iOS: nur Safari unterstützt DeviceOrientation vollständig
### „Permission denied" beim Kopieren
```bash
sudo chown -R $USER:$USER /var/www/mobile-tag
```
---
## Zusammenfassung der wichtigsten Befehle
```bash
# Nach dem Kopieren auf den Server:
cd /var/www/mobile-tag
npm run install:all
npm run build
cd client
cp .env.example .env
# .env bearbeiten: VITE_SERVER_URL anpassen
cd /var/www/mobile-tag
npm run build
cd server
pm2 start dist/index.js --name mobile-tag-server
pm2 save
pm2 startup
# Nginx-Konfiguration anlegen und HTTPS einrichten
```
---
*Stand: 26.07.2026*
+292
View File
@@ -0,0 +1,292 @@
# Mobile Tag Projektplan
GPS-basiertes Outdoor-Lasertag für jedes Smartphone als Progressive Web App (PWA).
---
## 1. Konzept & Spielprinzip
- **Plattform:** Webanwendung (PWA), installierbar auf iOS/Android, keine App-Store-Downloads nötig.
- **Spielort:** Überall im Freien. Das Spielfeld wird vor dem Match definiert.
- **Treffermodell:** Hybrid aus GPS/Kompass (Grob-Check) und Kamera-Computer-Vision (Fein-Check).
- GPS + Kompass prüfen, ob ein Gegner in Reichweite und Blickrichtung liegt.
- Die Kamera zeigt ein Fadenkreuz über dem Live-Bild.
- TensorFlow.js (MoveNet) erkennt Körperteile. Das Fadenkreuz entscheidet über Kopf, Brust, Arm oder Bein.
- Jeder Körperteil verursacht unterschiedlichen Schaden.
- **Nachlade-Mechanik:** Kein Dauerfeuer. Cooldown einstellbar, Standard ~3 s.
- **Spielmodi:** Free-for-All und Teams.
- **Spieleranzahl:** 210 Spieler pro Match (MVP).
---
## 2. Architektur
```
┌─────────────────┐ ┌──────────────────────┐
│ Client PWA │◄───────►│ Node.js Game Server │
│ React + Vite │ WebSocket│ Express + Socket.io │
└─────────────────┘ └──────────────────────┘
Handy-Sensoren:
- GPS (Geolocation API)
- Kompass (DeviceOrientationEvent)
- Kamera (getUserMedia)
- TensorFlow.js MoveNet (Pose Estimation)
```
---
## 3. Tech-Stack
| Schicht | Technologie |
|---|---|
| Frontend | React 18 + Vite + TypeScript |
| PWA | `vite-plugin-pwa` (Workbox) |
| Styling | Tailwind CSS |
| Geo-Logik | Turf.js |
| Kamera | `getUserMedia` + `<video>` + Canvas/SVG-Overlay |
| Computer Vision | TensorFlow.js + MoveNet (`@tensorflow-models/pose-detection`) |
| Backend | Node.js + Express + Socket.io |
| Hosting Frontend | Vercel / Netlify |
| Hosting Backend | Render / Railway / Fly.io |
---
## 4. Projektstruktur
```
Mobil_Tag/
├── README.md
├── PLAN.md
├── package.json
├── client/ # Frontend PWA
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── index.html
│ ├── public/
│ │ ├── manifest.webmanifest
│ │ └── icons/
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── components/
│ │ ├── CameraView.tsx
│ │ ├── Crosshair.tsx
│ │ ├── HUD.tsx
│ │ ├── Lobby.tsx
│ │ ├── Radar.tsx
│ │ └── Scoreboard.tsx
│ ├── hooks/
│ │ ├── useGeolocation.ts
│ │ ├── useDeviceOrientation.ts
│ │ ├── useCamera.ts
│ │ ├── usePoseDetection.ts
│ │ └── useSocket.ts
│ ├── game/
│ │ ├── targeting.ts
│ │ ├── bodyParts.ts
│ │ └── geo.ts
│ ├── store/
│ │ └── gameStore.ts
│ ├── types/
│ │ └── game.ts
│ └── styles/
│ └── index.css
└── server/ # Game Server
├── package.json
├── tsconfig.json
└── src/
├── index.ts
├── rooms.ts
├── game.ts
├── geo.ts
└── types.ts
```
---
## 5. Datenmodell
```typescript
type BodyPart = 'head' | 'chest' | 'arm' | 'leg';
interface BodyPartDamage {
head: number; // 100
chest: number; // 40
leg: number; // 25
arm: number; // 15
}
interface Player {
id: string;
name: string;
team?: 'A' | 'B';
position: { lat: number; lng: number };
heading: number; // 0-359 Grad
health: number; // 0-100
alive: boolean;
cooldownUntil: number; // Timestamp
score: number;
kills: number;
deaths: number;
}
interface Match {
id: string;
code: string; // 6-stelliger Raumcode
mode: 'ffa' | 'teams';
status: 'lobby' | 'running' | 'ended';
boundary: Circle | Polygon;
settings: MatchSettings;
players: Map<string, Player>;
startTime: number;
endTime?: number;
}
interface MatchSettings {
maxPlayers: number; // 2-10
reloadMs: number; // Standard 3000
rangeM: number; // Standard 20
angleTolerance: number; // Standard 5
damageByBodyPart: BodyPartDamage;
respawnMs: number; // Standard 5000
matchDurationMs: number; // Standard 600000
startHealth: number; // Standard 100
powerSaveDimMs: number; // Standard 10000
powerSaveSleepMs: number; // Standard 30000
}
interface Shot {
shooterId: string;
timestamp: number;
shooterPos: { lat: number; lng: number };
heading: number;
bodyPart: BodyPart;
}
interface HitEvent {
shooterId: string;
shooterName?: string;
targetId: string;
targetName?: string;
bodyPart: BodyPart;
damage: number;
targetHealth: number;
}
```
---
## 6. Trefferlogik
### 6.1 Zwei-Stufen-Validierung
1. **Grob-Check (GPS + Kompass):**
- Gegner innerhalb `rangeM`?
- Gegner innerhalb `±angleTolerance` der Blickrichtung?
- Gegner am Leben?
- Nicht im selben Team?
- Cooldown abgelaufen?
2. **Fein-Check (Kamera-CV):**
- MoveNet erkennt mindestens eine Person im Kamerabild.
- Das Fadenkreuz liegt innerhalb der erkannten Körperregion.
- Bestimmtes Körperteil wird ermittelt.
### 6.2 Body-Part-Mapping (MoveNet)
MoveNet liefert 17 Keypoints. Daraus werden Regionen gebildet:
| Körperteil | Keypoints | Schaden |
|---|---|---|
| Kopf | nose, eyes, ears | 100 |
| Brust | shoulders, hips | 40 |
| Arm | elbows, wrists | 15 |
| Bein | knees, ankles | 25 |
### 6.3 Power-Saving
Während des Spiels dimmt sich der Bildschirm nach `powerSaveDimMs` (Standard 10 s) und geht nach `powerSaveSleepMs` (Standard 30 s) in den Tiefschlaf. Kamera und MoveNet werden pausiert, GPS und Kompass laufen weiter. Ein Touch reaktiviert den aktiven Modus sofort.
### 6.4 Server-Validierung
Der Server empfängt `shot` und validiert:
- Spieler am Leben und Cooldown abgelaufen.
- GPS-Plausibilität (keine unrealistischen Positionssprünge).
- Gegner innerhalb Reichweite + Blickkegel.
- `bodyPart` ist gültig.
- Trefferziel ist der GPS-nächste Gegner in Blickrichtung (Konsistenzcheck).
Bei Treffer: Schaden anwenden, Score aktualisieren, `hit`-Event broadcasten.
---
## 7. Spielablauf
```
Lobby (Code teilen) → Spieler joinen → Host konfiguriert Match →
Spielstart → 10 Hz Positions-Updates → Clients zeigen Visier →
Spieler feuert → Server validiert → Hit/Miss → Respawn →
Spielende (Timer oder letzter Spieler) → Scoreboard
```
---
## 8. Wichtige Socket.io-Events
| Event | Richtung | Beschreibung |
|---|---|---|
| `room:create` | C→S | Raum erstellen |
| `room:created` | S→C | Raumcode zurück |
| `room:join` | C→S | Raum beitreten |
| `room:state` | S→C | Aktueller Raumzustand |
| `match:start` | C→S | Spiel starten (Host) |
| `match:started` | S→C | Spiel gestartet |
| `player:update` | C→S | Position + Heading (10 Hz) |
| `state:sync` | S→C | Spieler-Positionen broadcast |
| `player:shoot` | C→S | Schussabgabe mit BodyPart |
| `player:hit` | S→C | Treffer mit Körperteil + Schaden |
| `player:death` | S→C | Spieler ausgeschaltet |
| `player:respawn` | S→C | Spieler wiederbelebt |
| `match:end` | S→C | Match beendet + Scoreboard |
---
## 9. PWA-Setup
- Manifest mit Name, Icons, `display: fullscreen`, Theme.
- Service Worker via `vite-plugin-pwa`.
- Install-Prompt auf Home-Screen.
- HTTPS erforderlich (Kamera, Geolocation, Orientation).
---
## 10. Risiken & Mitigation
| Risiko | Mitigation |
|---|---|
| GPS-Ungenauigkeit | Glättung, großzügige Toleranzen, serverseitige Plausibilität |
| Kompass-Kalibrierung | Anleitung, Live-Heading-Anzeige |
| Akkuverbrauch | Bildschirm an lassen, einstellbare Update-Raten |
| iOS-Safari-Quirks | HTTPS, Permission-Requests, fallbacks |
| Kamera-CV-Performance | MoveNet Lightning, WebGL-Backend |
| Netz im Freien | Reconnect-Logik, Positionscache |
| GPS-Spoofing | Server-Plausibilität, Casual-Play akzeptabel |
---
## 11. Implementierungsphasen
1. **Phase 1 Scaffolding:** Projektstruktur, Vite + React + TS, Tailwind, Socket.io-Server, TensorFlow.js.
2. **Phase 2 Geo & Orientierung:** GPS-Tracking, Kompass, Turf.js.
3. **Phase 3 Backend & Lobby:** Räume, Socket.io-Events, Match-State.
4. **Phase 4 Kamera-CV & Visier:** MoveNet, Body-Part-Erkennung, Fadenkreuz.
5. **Phase 5 Trefferlogik & Gameplay:** Server-autoritative Treffervalidierung, Schaden, Respawn, Score.
6. **Phase 6 Spielablauf & UI:** Lobby, Settings, Spielgebiet, Timer, Scoreboard.
7. **Phase 7 PWA & Polish:** Manifest, Service Worker, Permissions, Performance, Power-Saving, Hit-Feed.
---
*Stand: 26.07.2026*
+114
View File
@@ -0,0 +1,114 @@
# Mobile Tag
GPS-basiertes Outdoor-Lasertag für jedes Smartphone als Progressive Web App (PWA).
## Was ist das?
Ein Spiel, das überall im Freien gespielt werden kann. Jeder Spieler nutzt sein eigenes Smartphone:
- **GPS** für Position und Spielgebiet
- **Kamera** mit Fadenkreuz als Visier
- **TensorFlow.js MoveNet** erkennt Körperteile (Kopf, Brust, Arm, Bein)
- **Kompass** für die Blickrichtung
- **Node.js + Socket.io** als Multiplayer-Server
## Projektstruktur
```
Mobil_Tag/
├── client/ # React + Vite + TypeScript PWA
├── server/ # Node.js + Socket.io Game Server
└── PLAN.md # Ausführlicher Projektplan
```
## Voraussetzungen
- [Node.js](https://nodejs.org/) ≥ 18
- npm (wird mit Node.js installiert)
## Schnellstart
```bash
# 1. Abhängigkeiten installieren
npm run install:all
# 2. Server und Client gleichzeitig starten
npm run dev
```
Danach ist die App unter `http://localhost:5173` erreichbar und der Server unter `http://localhost:3001`.
## Entwicklung einzeln starten
```bash
# Nur Server
cd server
npm run dev
# Nur Client (in einem neuen Terminal)
cd client
npm run dev
```
## Wichtige Hinweise für das Testen
- **HTTPS erforderlich:** Kamera, GPS und Orientierung erfordern in vielen Browsern HTTPS. Für lokale Tests (`localhost`) machen die Browser meist eine Ausnahme.
- **Mobilgerät empfohlen:** Die App ist für Smartphones ausgelegt. Auf dem Desktop funktioniert sie eingeschränkt (keine GPS/Orientierungs-Sensoren).
- **Kompass kalibrieren:** Vor Spielbeginn das Handy in einer 8-förmigen Bewegung bewegen, damit der Kompass korrekt arbeitet.
- **Power-Saving:** Der Bildschirm dimmt sich nach 10 Sekunden Inaktivität und geht nach 30 Sekunden in den Tiefschlaf. Ein Tippen reaktiviert sofort.
## Schadensmodell
| Körperteil | Schaden |
|---|---|
| Kopf | 100 (Instant-Kill) |
| Brust | 40 |
| Bein | 25 |
| Arm | 15 |
## Treffer-Benachrichtigungen
Alle Spieler sehen sofort, wenn jemand getroffen wird („Spieler A traf Spieler B · KOPF · 100“). Bei eigenen Treffern oder Treffern gegen sich selbst vibriert das Handy kurz.
## Backend-Hosting
Das Backend benötigt **Node.js**, da PHP keine Echtzeit-WebSocket-Verbindungen für Multiplayer-Spiele halten kann.
Falls du bereits einen PHP-Webserver hast, hast du zwei Möglichkeiten:
1. **Node.js neben PHP installieren** siehe [`UBUNTU_SETUP.md`](./UBUNTU_SETUP.md) für die Schritt-für-Schritt-Anleitung.
2. **Backend in der Cloud hosten** empfohlen für den einfachsten Start. Kostenlose Optionen: Render, Railway, Fly.io.
## Deployment
### Frontend (PWA)
Auf [Vercel](https://vercel.com/) oder [Netlify](https://www.netlify.com/) deployen. Die PWA-Dateien werden mit `npm run build:client` erzeugt.
### Backend
Auf [Render](https://render.com/), [Railway](https://railway.app/) oder [Fly.io](https://fly.io/) deployen. Der Server startet mit `npm run build:server && npm start`.
### Umgebungsvariablen
Im Client kann die Server-URL über `.env` angepasst werden:
```bash
# client/.env
VITE_SERVER_URL=http://localhost:3001
```
## Nächste Schritte / MVP-Status
- [x] Projektplan
- [x] Scaffolding (Client + Server)
- [ ] Geo-/Orientierungs-Module testen
- [x] Kamera + MoveNet-Performance optimieren (Power-Saving)
- [ ] Spielgebiet-Konfiguration in der Lobby
- [ ] Radar/Minimap integrieren
- [ ] PWA-Icons und Install-Prompt
- [x] Akku-/Performance-Optimierung (Power-Saving)
## Lizenz
MIT
+135
View File
@@ -0,0 +1,135 @@
# Mobile Tag - Task Status
## Aktueller Stand (27.07.2026 ~20:20)
### Bereits umgesetzt
- Deployment auf Ubuntu-Server (194.164.201.75) via PM2 + Apache Reverse-Proxy + HTTPS
- Service Worker auf `selfDestroying: true` gesetzt (alte Caches werden gelöscht)
- `useSocket` ist Module-Level-Singleton (keine doppelten Verbindungen mehr)
- Lobby zeigt sich korrekt an, `playerId` und `isHost` werden gesetzt
- Feuer-Button ist immer aktiv (außer während Nachladezeit)
- Server-autoritative Schussauswertung:
- Client sendet `shooterPos`, `heading`, `bodyPart`, `clientTimestamp`
- Server prüft Cooldown, Lebensstatus, Positions-Plausibilität, Reichweite, Blickrichtung
- Keine Treffer auf tote Spieler
- Reichweite auf **50m** und Winkeltoleranz auf **30°** erhöht
- Bei Distanz < 3m wird der Winkel ignoriert (für Tests nebeneinander)
- Fallback-Targeting entfernt: Keine fake Körperteile mehr nur echt erkannte Personen zeigen ein Label
### Server-Log Analyse (27.07.2026 ~20:25) - ZWEITER ECHTER TEST
**Ergebnis:** Zwei echte Spieler (Tobi, Niko) im gleichen laufenden Match.
**Gefundenes Problem:** Die Spielerpositionen auf dem Server blieben bei `{ lat: 0, lng: 0 }`, obwohl der Schütze seine echte GPS-Position sendete.
**Ursache:** In `client/src/App.tsx` wurde `player:update` nur gesendet, wenn `orientation.heading !== null`. Wenn der Kompass keine Berechtigung hatte oder nicht funktionierte, wurden die GPS-Positionen **nie** an den Server gesendet.
**Fix:**
- `App.tsx`: Position wird jetzt immer gesendet, auch wenn der Kompass null ist (heading fallback = 0)
- `server/src/game.ts`: `isPositionPlausible` von 50m/s auf 500m/s erhöht, damit der erste große Sprung von (0,0) akzeptiert wird
- `CameraView.tsx`: Fadenkreuz ist nicht mehr durch Klicken/Tippen verschiebbar
### Weitere Änderungen (27.07.2026 ~21:10)
- **Hybrid-Targeting implementiert:**
- Distanz < 10m: Proximity-Hit (kein Kompass nötig)
- Distanz 10-50m: Kompass mit ±60° Toleranz
- **Bild-Snapshot beim Feuern:** Client erstellt JPEG-Shot und sendet ihn mit dem Schuss an den Server
- **Server-seitige KI-Bildauswertung:** TensorFlow.js MoveNet läuft auf dem Server und prüft jeden Schuss
- Erkennt Person im Bild?
- Ist das gewählte Körperteil (`head`/`chest`/`arm`/`leg`) in der Bildmitte?
- Ergebnis steht im `HitEvent` unter `poseValidation`
- **Erste GPS-Position wird akzeptiert:** Der Sprung von (0,0) zur echten Position wird nicht mehr als Teleport abgelehnt
### Noch offen / nächste Schritte
1. **Testen mit zwei echten Spielern im gleichen Raum**
2. **Server-Logs weiter beobachten** (`pm2 logs mobile-tag-server`)
3. **Client-seitiges Feedback verbessern**, wenn kein Gegner in Reichweite
4. **Snapshot-Validierung in Gameplay einbinden** aktuell wird das Ergebnis nur zurückgegeben, hat aber keine Auswirkung auf Schaden
---
## Deploy-Handbuch für neue Tasks
### Server-Zugang
- **Server-IP:** 194.164.201.75
- **User:** root
- **SSH-Key:** `%USERPROFILE%\.ssh\id_ed25519_mobiletag` (Windows) bzw. `~/.ssh/id_ed25519_mobiletag` (Linux/Mac)
- **SSH-Befehl (Windows CMD):**
```cmd
ssh -i "%USERPROFILE%\.ssh\id_ed25519_mobiletag" root@194.164.201.75
```
### Projekt-Pfade
- **Lokal:** `c:\Users\thebi\OneDrive\KI Projekte\Mobil_Tag`
- **Server:** `/var/www/mobile-tag`
### Wichtige Dateien
| Datei | Zweck |
|-------|-------|
| `server/src/game.ts` | `handleShot` Server-Logik mit Debug-Logging |
| `server/src/types.ts` | `DEFAULT_SETTINGS` (rangeM: 50, angleTolerance: 30) |
| `server/src/index.ts` | Socket.io Event-Handler (`player:shoot`, etc.) |
| `server/src/rooms.ts` | Raum-Management (create/join/leave) |
| `client/src/components/HUD.tsx` | Feuer-Button + Schuss senden |
| `client/src/components/CameraView.tsx` | Kamera + Pose-Erkennung + Fadenkreuz |
| `client/src/hooks/useSocket.ts` | Socket.io-Client-Singleton |
| `client/src/store/gameStore.ts` | Globaler Zustand |
### Komplette Deploy-Prozedur
1. **Lokale Änderungen vornehmen** (im Ordner `c:\Users\thebi\OneDrive\KI Projekte\Mobil_Tag`)
2. **Änderungen auf den Server kopieren** (nur geänderte Dateien, Beispiel):
```cmd
scp -i "%USERPROFILE%\.ssh\id_ed25519_mobiletag" "c:/Users/thebi/OneDrive/KI Projekte/Mobil_Tag/server/src/game.ts" root@194.164.201.75:/var/www/mobile-tag/server/src/game.ts
scp -i "%USERPROFILE%\.ssh\id_ed25519_mobiletag" "c:/Users/thebi/OneDrive/KI Projekte/Mobil_Tag/client/src/components/HUD.tsx" root@194.164.201.75:/var/www/mobile-tag/client/src/components/HUD.tsx
```
3. **Auf dem Server bauen und neustarten:**
```bash
cd /var/www/mobile-tag
npm run build
pm2 restart mobile-tag-server
systemctl reload apache2
```
4. **Logs prüfen:**
```bash
pm2 logs mobile-tag-server --lines 100
```
### Alles-in-einem Deploy-Befehl (nach Datei-Änderungen)
```cmd
ssh -i "%USERPROFILE%\.ssh\id_ed25519_mobiletag" root@194.164.201.75 "cd /var/www/mobile-tag && npm run build && pm2 restart mobile-tag-server && systemctl reload apache2"
```
### Rollback
Falls etwas schiefgeht:
```bash
cd /var/www/mobile-tag
git checkout -- .
# oder manuell die letzte funktionierende Version wiederherstellen
npm run build
pm2 restart mobile-tag-server
systemctl reload apache2
```
### Server-Status prüfen
```bash
pm2 status
pm2 logs mobile-tag-server --lines 50
systemctl status apache2
```
### Test-Skript
`/tmp/socket-test-shoot.js` auf dem Server simuliert zwei Spieler und einen Treffer:
```bash
node /tmp/socket-test-shoot.js
```
### Troubleshooting
| Problem | Lösung |
|---------|--------|
| Client lädt alte Version | Hard Refresh (Strg+Shift+R) oder privater Tab |
| "Verbindungsfehler" | Prüfen, ob `mobile-tag-server` läuft (`pm2 status`) |
| Keine Treffer | Logs prüfen; sicherstellen, dass beide Spieler im gleichen Raum sind |
| 502 Bad Gateway | Apache/PM2 neu starten (`systemctl reload apache2`, `pm2 restart mobile-tag-server`) |
---
Letzte Aktualisierung: 27.07.2026 ~20:20
+139
View File
@@ -0,0 +1,139 @@
# Ubuntu-Server Setup für Mobile Tag
Diese Anleitung zeigt, wie du Node.js neben PHP auf einem Ubuntu-Server installierst und das Mobile Tag Backend betreibst.
## 1. Node.js installieren (Methode A: NodeSource)
```bash
# System aktualisieren
sudo apt update && sudo apt upgrade -y
# NodeSource Repository für Node.js 20 hinzufügen
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Node.js + npm installieren
sudo apt-get install -y nodejs
# Version prüfen
node -v
npm -v
```
## Alternative: Node.js mit nvm installieren (empfohlen)
```bash
# nvm installieren
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# nvm in aktueller Session verfügbar machen
source ~/.bashrc
# Node.js 20 installieren
nvm install 20
nvm use 20
nvm alias default 20
# Version prüfen
node -v
npm -v
```
## 2. Projekt auf den Server kopieren
Per `git clone`, `scp`, `rsync` oder FTP in ein Verzeichnis wie `/var/www/mobile-tag` kopieren.
```bash
cd /var/www/mobile-tag
```
## 3. Abhängigkeiten installieren
```bash
npm run install:all
```
## 4. Build
```bash
npm run build
```
## 5. Server starten
### Einfacher Start (für Tests)
```bash
cd server
npm start
```
Der Server läuft dann auf Port `3001`.
### Produktiver Start mit PM2 (empfohlen)
```bash
# PM2 global installieren
sudo npm install -g pm2
# Server als Dienst starten
cd /var/www/mobile-tag/server
pm2 start dist/index.js --name mobile-tag-server
# PM2 so konfigurieren, dass es nach Neustart automatisch startet
pm2 save
pm2 startup
# → Den ausgegebenen Befehl kopieren und ausführen
```
## 6. Reverse Proxy mit Nginx (empfohlen)
Damit das Backend über HTTPS und Port 443 erreichbar ist, kannst du Nginx als Reverse Proxy nutzen:
```nginx
server {
listen 80;
server_name api.dein-domain.de;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
Anschließend Let's Encrypt für HTTPS einrichten:
```bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d api.dein-domain.de
```
## 7. Frontend deployen
Das Frontend (`client/dist` nach `npm run build`) kannst du wie gewohnt über deinen PHP-Webserver ausliefern.
Im Client muss die Server-URL angepasst werden (`client/.env`):
```bash
VITE_SERVER_URL=https://api.dein-domain.de
```
## 8. Firewall
Falls UFW aktiv ist, Port 3001 nur für den internen Reverse Proxy freigeben (oder für Tests direkt):
```bash
sudo ufw allow 3001/tcp
```
Bei Verwendung von Nginx Reverse Proxy ist das nicht nötig.
## Wichtige Hinweise
- Das Backend braucht **Node.js**, da PHP keine WebSocket-Verbindungen für Echtzeit-Spiele halten kann.
- Dein PHP-Server kann weiterhin parallel laufen.
- Für HTTPS ist Let's Encrypt empfohlen, da Kamera/GPS/Orientierung in Browsern HTTPS erfordern.
+2
View File
@@ -0,0 +1,2 @@
# URL des Mobile Tag Game Servers
VITE_SERVER_URL=http://localhost:3001
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<script>
// Force clear old service workers and caches on deploy update
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((r) => r.unregister());
});
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach((name) => caches.delete(name));
});
}
}
</script>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="GPS-based outdoor lasertag for every smartphone" />
<title>Mobile Tag</title>
</head>
<body class="bg-slate-950 text-white overflow-hidden">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "mobile-tag-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tensorflow-models/pose-detection": "^2.1.3",
"@tensorflow/tfjs": "^4.20.0",
"@tensorflow/tfjs-backend-webgl": "^4.20.0",
"@turf/bearing": "^7.0.0",
"@turf/distance": "^7.0.0",
"@turf/helpers": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.8",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-plugin-pwa": "^0.20.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0f172a"/>
<circle cx="256" cy="256" r="160" fill="none" stroke="#3b82f6" stroke-width="24"/>
<line x1="256" y1="96" x2="256" y2="176" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<line x1="256" y1="336" x2="256" y2="416" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<line x1="96" y1="256" x2="176" y2="256" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<line x1="336" y1="256" x2="416" y2="256" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<circle cx="256" cy="256" r="20" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

+63
View File
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { useGameStore } from './store/gameStore';
import { useSocket } from './hooks/useSocket';
import { useGeolocation } from './hooks/useGeolocation';
import { useDeviceOrientation } from './hooks/useDeviceOrientation';
import { usePowerSaving } from './hooks/usePowerSaving';
import { Lobby } from './components/Lobby';
import { CameraView } from './components/CameraView';
import { HUD } from './components/HUD';
import { Scoreboard } from './components/Scoreboard';
import { PowerSavingOverlay } from './components/PowerSavingOverlay';
import { HitFeed } from './components/HitFeed';
export default function App() {
const screen = useGameStore((s) => s.screen);
const playerId = useGameStore((s) => s.playerId);
const error = useGameStore((s) => s.error);
const match = useGameStore((s) => s.match);
const { updatePlayer } = useSocket();
const geo = useGeolocation(screen === 'game');
const orientation = useDeviceOrientation(screen === 'game');
const power = usePowerSaving({
enabled: screen === 'game',
dimMs: match?.settings.powerSaveDimMs ?? 10000,
sleepMs: match?.settings.powerSaveSleepMs ?? 30000,
});
// Send position + heading to server while in game
useEffect(() => {
if (screen !== 'game' || !playerId) return;
const interval = setInterval(() => {
if (!geo.error) {
// Always send position; fallback heading 0 if compass unavailable
updatePlayer({ lat: geo.lat, lng: geo.lng }, orientation.heading ?? 0);
}
}, 100);
return () => clearInterval(interval);
}, [screen, playerId, geo, orientation, updatePlayer]);
return (
<div className="relative h-full w-full">
{error && (
<div className="absolute left-0 right-0 top-0 z-50 bg-danger/90 p-2 text-center text-sm font-semibold text-white">
{error}
</div>
)}
{screen === 'lobby' && <Lobby />}
{screen === 'game' && (
<>
<CameraView active={power.isActive} />
<HitFeed />
<HUD geo={geo} orientation={orientation} />
<PowerSavingOverlay state={power.state} />
</>
)}
{screen === 'scoreboard' && <Scoreboard />}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { useCamera } from '../hooks/useCamera';
import { usePoseDetection } from '../hooks/usePoseDetection';
import { Crosshair } from './Crosshair';
import { useGameStore } from '../store/gameStore';
import { bodyPartLabel } from '../game/bodyParts';
interface CameraViewProps {
active: boolean;
}
export function CameraView({ active }: CameraViewProps) {
const { setVideoElement, videoRef, ready, error } = useCamera(active);
const pose = usePoseDetection(active && ready, videoRef);
const setTargetBodyPart = useGameStore((s) => s.setTargetBodyPart);
useEffect(() => {
// Only set target body part when pose detection actually found a person
setTargetBodyPart(pose.currentBodyPart?.part ?? null);
}, [pose.currentBodyPart, setTargetBodyPart]);
return (
<div className="absolute inset-0 bg-black">
<video
ref={setVideoElement}
autoPlay
playsInline
muted
className="h-full w-full object-cover"
/>
<Crosshair bodyPart={pose.currentBodyPart} />
<div className="pointer-events-none absolute left-4 top-4 rounded bg-black/50 px-2 py-1 text-xs text-white">
{pose.currentBodyPart ? bodyPartLabel(pose.currentBodyPart.part) : 'Keine Person erkannt'}
</div>
{!ready && !error && active && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 text-white">
Kamera wird gestartet...
</div>
)}
{!active && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black text-white">
<span className="opacity-50">Kamera pausiert (Energiesparmodus)</span>
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/90 p-6 text-center text-white">
<p className="mb-2 text-danger">{error}</p>
<p className="text-sm text-slate-400">Bitte erlaube Kamera-Zugriff in den Browser-Einstellungen.</p>
</div>
)}
{pose.loading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-center text-white">
<div>
<div className="mb-2 text-lg font-bold">Körpererkennung lädt...</div>
<div className="text-sm text-slate-400">Das KI-Modell wird initialisiert</div>
</div>
</div>
)}
{pose.error && (
<div className="absolute bottom-20 left-0 right-0 bg-danger/80 p-2 text-center text-sm text-white">
{pose.error}
</div>
)}
{!pose.loading && !pose.error && pose.ready && (
<div className="absolute left-4 top-20 rounded bg-black/50 px-2 py-1 text-xs text-white">
Backend: {pose.backendName ?? '?'}
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { bodyPartLabel, bodyPartColor, type BodyPartResult } from '../game/bodyParts';
interface CrosshairProps {
bodyPart: BodyPartResult | null;
position?: { x: number; y: number };
}
export function Crosshair({ bodyPart, position = { x: 50, y: 50 } }: CrosshairProps) {
const hasBodyPart = bodyPart != null;
const color = hasBodyPart ? bodyPartColor(bodyPart.part) : 'rgba(255,255,255,0.8)';
return (
<div
className="pointer-events-none absolute"
style={{
left: `${position.x}%`,
top: `${position.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
<svg width="200" height="200" viewBox="0 0 200 200" className="overflow-visible">
{/* Outer circle */}
<circle
cx="100"
cy="100"
r="70"
fill="none"
stroke={color}
strokeWidth="2"
strokeDasharray="8 4"
opacity={bodyPart ? 1 : 0.6}
/>
{/* Crosshair lines */}
<line x1="100" y1="40" x2="100" y2="80" stroke={color} strokeWidth="3" />
<line x1="100" y1="120" x2="100" y2="160" stroke={color} strokeWidth="3" />
<line x1="40" y1="100" x2="80" y2="100" stroke={color} strokeWidth="3" />
<line x1="120" y1="100" x2="160" y2="100" stroke={color} strokeWidth="3" />
{/* Center dot */}
<circle cx="100" cy="100" r="4" fill={color} />
</svg>
{bodyPart && (
<div
className="absolute mt-24 rounded-full px-4 py-1 text-sm font-black tracking-widest"
style={{
backgroundColor: color,
color: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
}}
>
{bodyPartLabel(bodyPart.part)}
</div>
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useMemo } from 'react';
import { useGameStore } from '../store/gameStore';
import { useSocket } from '../hooks/useSocket';
import { findTargetInCone, isReadyToShoot } from '../game/targeting';
import { bodyPartLabel } from '../game/bodyParts';
import type { GeoPosition } from '../types/game';
interface HUDProps {
geo: {
lat: number;
lng: number;
accuracy: number;
heading: number | null;
error: string | null;
};
orientation: {
heading: number | null;
error: string | null;
permissionNeeded: boolean;
requestPermission: () => Promise<boolean>;
};
}
export function HUD({ geo, orientation }: HUDProps) {
const match = useGameStore((s) => s.match);
const playerId = useGameStore((s) => s.playerId);
const lastShotAt = useGameStore((s) => s.lastShotAt);
const targetBodyPart = useGameStore((s) => s.targetBodyPart);
const lastHit = useGameStore((s) => s.lastHit);
const setLastShotAt = useGameStore((s) => s.setLastShotAt);
const { shoot } = useSocket();
const self = match?.players[playerId ?? ''];
const enemies = useMemo(() => {
if (!match || !self) return [];
return Object.values(match.players).filter((p) => {
if (p.id === self.id) return false;
if (match.mode === 'teams' && p.team === self.team) return false;
return true;
});
}, [match, self]);
const heading = orientation.heading ?? geo.heading ?? 0;
const shooterPos: GeoPosition = { lat: geo.lat, lng: geo.lng };
const targetCandidate = useMemo(() => {
if (!self || !match) return null;
return findTargetInCone(
shooterPos,
heading,
enemies,
match.settings.rangeM,
match.settings.angleTolerance
);
}, [shooterPos, heading, enemies, match, self]);
const { ready: canShoot, reason } = isReadyToShoot(
targetBodyPart,
targetCandidate,
lastShotAt,
match?.settings.reloadMs ?? 3000
);
const reloadProgress = Math.min(1, (Date.now() - lastShotAt) / (match?.settings.reloadMs ?? 3000));
const captureSnapshot = (): string | undefined => {
const video = document.querySelector('video');
if (!video || video.videoWidth === 0) return undefined;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return undefined;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Compress to JPEG at 60% quality, max 640px width
const scale = Math.min(1, 640 / canvas.width);
if (scale < 1) {
const small = document.createElement('canvas');
small.width = canvas.width * scale;
small.height = canvas.height * scale;
const sctx = small.getContext('2d');
if (!sctx) return undefined;
sctx.drawImage(canvas, 0, 0, small.width, small.height);
return small.toDataURL('image/jpeg', 0.6);
}
return canvas.toDataURL('image/jpeg', 0.6);
};
const handleShoot = () => {
if (!canShoot || !self || !match) return;
const imageData = captureSnapshot();
setLastShotAt(Date.now());
shoot({
shooterPos,
heading,
bodyPart: targetBodyPart ?? 'chest',
clientTimestamp: Date.now(),
imageData,
});
};
if (!self || !match) return null;
return (
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-4">
{/* Top HUD */}
<div className="flex items-start justify-between gap-2">
<div className="hud-panel min-w-[120px]">
<div className="text-xs text-slate-400">Leben</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-800">
<div
className="h-full transition-all duration-300"
style={{
width: `${self.health}%`,
backgroundColor: self.health > 50 ? '#22c55e' : self.health > 25 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
<div className="mt-1 text-right text-sm font-bold">{Math.ceil(self.health)}%</div>
</div>
<div className="hud-panel text-center">
<div className="text-xs text-slate-400">Score</div>
<div className="text-xl font-black">{self.score}</div>
</div>
<div className="hud-panel text-right">
<div className="text-xs text-slate-400">GPS</div>
<div className="text-sm font-semibold">{geo.error ? 'Fehler' : `±${Math.round(geo.accuracy)}m`}</div>
<div className="text-xs text-slate-500">{Math.round(heading)}°</div>
</div>
</div>
{/* Center hit feedback (own hits only) */}
{lastHit && lastHit.shooterId === playerId && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-xl bg-success/90 px-6 py-3 text-center font-black text-white shadow-lg">
<div className="text-2xl">TREFFER!</div>
<div className="text-sm">{bodyPartLabel(lastHit.bodyPart)} · {lastHit.damage} Schaden</div>
</div>
</div>
)}
{/* Bottom HUD */}
<div className="flex flex-col items-center gap-3">
{orientation.permissionNeeded && (
<button
className="pointer-events-auto btn-primary"
onClick={orientation.requestPermission}
>
Kompass aktivieren
</button>
)}
{targetCandidate && (
<div className="hud-panel text-center">
<div className="text-xs text-slate-400">Ziel</div>
<div className="font-bold">{targetCandidate.player.name}</div>
<div className="text-sm">{Math.round(targetCandidate.distanceM)}m</div>
</div>
)}
{!canShoot && reason && (
<div className="text-sm font-semibold text-slate-300">{reason}</div>
)}
<button
className="pointer-events-auto relative flex h-24 w-24 items-center justify-center rounded-full border-4 border-white/20 bg-danger font-black text-white shadow-lg transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canShoot}
onClick={handleShoot}
>
<span className="z-10">FEUER</span>
{reloadProgress < 1 && (
<svg className="absolute inset-0 h-full w-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="8"
strokeDasharray="289"
strokeDashoffset={289 * (1 - reloadProgress)}
/>
</svg>
)}
</button>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { useGameStore } from '../store/gameStore';
import { bodyPartLabel } from '../game/bodyParts';
import type { HitFeedItem, Player } from '../types/game';
export function HitFeed() {
const hitFeed = useGameStore((s) => s.hitFeed);
const match = useGameStore((s) => s.match);
const playerId = useGameStore((s) => s.playerId);
// Vibrate on new feed items involving the current player
useEffect(() => {
if (!navigator.vibrate || hitFeed.length === 0) return;
const latest = hitFeed[hitFeed.length - 1];
if (latest.shooterId === playerId || latest.targetId === playerId) {
navigator.vibrate([50, 50, 100]);
}
}, [hitFeed, playerId]);
if (!match || hitFeed.length === 0) return null;
return (
<div className="pointer-events-none absolute left-4 right-4 top-20 z-30 flex flex-col items-center gap-1">
{hitFeed.slice(-3).map((item: HitFeedItem) => {
const rowKey = item.id;
const row = (
<HitFeedRow item={item} players={match.players as Record<string, Player>} />
);
return <div key={rowKey}>{row}</div>;
})}
</div>
);
}
interface HitFeedRowProps {
item: HitFeedItem;
players: Record<string, Player>;
}
function HitFeedRow({ item, players }: HitFeedRowProps) {
const shooterName = item.shooterName || players[item.shooterId]?.name || 'Unbekannt';
const targetName = item.targetName || players[item.targetId]?.name || 'Unbekannt';
return (
<div className="rounded-full bg-slate-900/90 px-4 py-1.5 text-center text-sm font-semibold text-white shadow backdrop-blur-sm">
{shooterName} traf {targetName} · {bodyPartLabel(item.bodyPart)} · {item.damage}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useState } from 'react';
import { useGameStore } from '../store/gameStore';
import { useSocket } from '../hooks/useSocket';
import { DEFAULT_SETTINGS, type GameMode } from '../types/game';
export function Lobby() {
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [tab, setTab] = useState<'create' | 'join'>('create');
const match = useGameStore((s) => s.match);
const isHost = useGameStore((s) => s.isHost);
const playerId = useGameStore((s) => s.playerId);
const { createRoom, joinRoom, startMatch, leaveRoom } = useSocket();
const [mode, setMode] = useState<GameMode>('ffa');
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
if (!match || !playerId) {
return (
<div className="flex h-full flex-col items-center justify-center p-6">
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary-500">MOBILE TAG</h1>
<p className="mb-8 text-center text-slate-400">GPS-Lasertag mit jedem Handy</p>
<div className="mb-6 flex w-full max-w-sm gap-2 rounded-lg bg-slate-900 p-1">
<button
className={`flex-1 rounded-md py-2 text-sm font-semibold ${tab === 'create' ? 'bg-primary-600 text-white' : 'text-slate-400'}`}
onClick={() => setTab('create')}
>
Erstellen
</button>
<button
className={`flex-1 rounded-md py-2 text-sm font-semibold ${tab === 'join' ? 'bg-primary-600 text-white' : 'text-slate-400'}`}
onClick={() => setTab('join')}
>
Beitreten
</button>
</div>
<div className="w-full max-w-sm space-y-4">
<input
type="text"
placeholder="Dein Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-white placeholder-slate-500 focus:border-primary-500 focus:outline-none"
/>
{tab === 'join' && (
<input
type="text"
placeholder="Raumcode (6 Ziffern)"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-white placeholder-slate-500 focus:border-primary-500 focus:outline-none"
/>
)}
<button
className="btn-primary w-full"
disabled={!name || (tab === 'join' && code.length !== 6)}
onClick={() => {
if (tab === 'create') createRoom(name);
else joinRoom(code, name);
}}
>
{tab === 'create' ? 'Raum erstellen' : 'Raum beitreten'}
</button>
</div>
</div>
);
}
const players = Object.values(match.players);
return (
<div className="flex h-full flex-col p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-bold">Lobby</h2>
<span className="rounded-lg bg-slate-800 px-3 py-1 font-mono text-lg tracking-widest text-primary-500">
{match.code}
</span>
</div>
<div className="mb-4 rounded-xl bg-slate-900 p-4">
<h3 className="mb-2 font-semibold text-slate-300">Spieler</h3>
<ul className="space-y-2">
{players.map((p) => (
<li key={p.id} className="flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2">
<span>{p.name}</span>
{p.id === playerId && <span className="text-xs text-slate-500">Du</span>}
</li>
))}
</ul>
</div>
{isHost && (
<div className="mb-4 space-y-3 rounded-xl bg-slate-900 p-4">
<h3 className="font-semibold text-slate-300">Einstellungen</h3>
<div>
<label className="mb-1 block text-sm text-slate-400">Modus</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value as GameMode)}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
>
<option value="ffa">Alle gegen Alle</option>
<option value="teams">Teams</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm text-slate-400">Reichweite (m)</label>
<input
type="number"
value={settings.rangeM}
onChange={(e) => setSettings({ ...settings, rangeM: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm text-slate-400">Nachladen (ms)</label>
<input
type="number"
value={settings.reloadMs}
onChange={(e) => setSettings({ ...settings, reloadMs: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm text-slate-400">Dimmen nach (ms)</label>
<input
type="number"
value={settings.powerSaveDimMs}
onChange={(e) => setSettings({ ...settings, powerSaveDimMs: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm text-slate-400">Tiefschlaf nach (ms)</label>
<input
type="number"
value={settings.powerSaveSleepMs}
onChange={(e) => setSettings({ ...settings, powerSaveSleepMs: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
</div>
<button
className="btn-primary w-full"
disabled={players.length < 1}
onClick={() =>
startMatch({
...settings,
mode,
maxPlayers: settings.maxPlayers,
})
}
>
Spiel starten
</button>
</div>
)}
{!isHost && <p className="text-center text-slate-400">Warte auf den Host...</p>}
<button className="btn-secondary mt-auto" onClick={leaveRoom}>
Lobby verlassen
</button>
</div>
);
}
@@ -0,0 +1,29 @@
import type { PowerState } from '../hooks/usePowerSaving';
interface PowerSavingOverlayProps {
state: PowerState;
}
export function PowerSavingOverlay({ state }: PowerSavingOverlayProps) {
if (state === 'active') return null;
const isSleeping = state === 'sleeping';
return (
<div
className={`fixed inset-0 z-40 flex items-center justify-center transition-opacity duration-700 ${
isSleeping ? 'bg-black/95' : 'bg-black/80'
}`}
>
<div className="text-center">
<div className={`mb-3 text-5xl ${isSleeping ? 'opacity-30' : 'opacity-70'}`}>💤</div>
<p className={`font-semibold ${isSleeping ? 'text-slate-500' : 'text-slate-300'}`}>
{isSleeping ? 'Tippen, um fortzufahren' : 'Bildschirm wird gedimmt'}
</p>
{!isSleeping && (
<p className="mt-1 text-sm text-slate-500">Berühre den Bildschirm, um aktiv zu bleiben</p>
)}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useGameStore } from '../store/gameStore';
import { getBearing, getDistanceM } from '../game/geo';
interface RadarProps {
selfHeading: number;
}
export function Radar({ selfHeading }: RadarProps) {
const match = useGameStore((s) => s.match);
const playerId = useGameStore((s) => s.playerId);
if (!match || !playerId) return null;
const self = match.players[playerId];
if (!self) return null;
const enemies = Object.values(match.players).filter((p) => {
if (p.id === self.id) return false;
if (match.mode === 'teams' && p.team === self.team) return false;
return p.alive;
});
const size = 120;
const center = size / 2;
const maxRange = match.settings.rangeM * 2;
return (
<div className="hud-panel absolute bottom-4 left-4 flex h-[140px] w-[140px] items-center justify-center rounded-full">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={center} cy={center} r={size / 2 - 2} fill="#0f172a" stroke="#334155" strokeWidth="2" />
<circle cx={center} cy={center} r={size / 4} fill="none" stroke="#334155" strokeWidth="1" />
{/* North indicator rotated by heading */}
<g transform={`rotate(${selfHeading} ${center} ${center})`}>
<polygon points={`${center},${4} ${center - 4},${14} ${center + 4},${14}`} fill="#3b82f6" />
</g>
{enemies.map((p) => {
const d = getDistanceM(self.position, p.position);
if (d > maxRange) return null;
const b = getBearing(self.position, p.position);
const relAngle = b - selfHeading;
const rad = (relAngle * Math.PI) / 180;
const r = (d / maxRange) * (size / 2 - 4);
const x = center + r * Math.sin(rad);
const y = center - r * Math.cos(rad);
return <circle key={p.id} cx={x} cy={y} r={5} fill="#ef4444" />;
})}
</svg>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useGameStore } from '../store/gameStore';
import { useSocket } from '../hooks/useSocket';
import type { Player } from '../types/game';
export function Scoreboard() {
const match = useGameStore((s) => s.match);
const { leaveRoom } = useSocket();
if (!match) return null;
const players = (Object.values(match.players) as Player[]).sort((a, b) => b.score - a.score);
return (
<div className="flex h-full flex-col bg-slate-950 p-6">
<h2 className="mb-6 text-center text-3xl font-black text-primary-500">SPIELENDE</h2>
<div className="mb-6 rounded-xl bg-slate-900 p-4">
<table className="w-full text-left">
<thead>
<tr className="text-sm text-slate-400">
<th className="pb-2">#</th>
<th className="pb-2">Spieler</th>
<th className="pb-2 text-right">Kills</th>
<th className="pb-2 text-right">Score</th>
</tr>
</thead>
<tbody>
{players.map((p, index) => (
<tr key={p.id} className="border-t border-slate-800">
<td className="py-3 font-bold">{index + 1}</td>
<td className="py-3">{p.name}</td>
<td className="py-3 text-right">{p.kills}</td>
<td className="py-3 text-right font-bold text-primary-500">{p.score}</td>
</tr>
))}
</tbody>
</table>
</div>
<button className="btn-primary mt-auto" onClick={leaveRoom}>
Zurück zur Lobby
</button>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import type { Keypoint } from '@tensorflow-models/pose-detection';
import type { BodyPart } from '../types/game';
export interface BodyPartResult {
part: BodyPart;
score: number;
}
interface Box {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}
function getKeypoint(name: string, keypoints: Keypoint[]): Keypoint | undefined {
return keypoints.find((k) => k.name === name);
}
function makeBox(points: Keypoint[], padding: number = 0): Box | null {
const valid = points.filter((p) => p && p.score && p.score > 0.2);
if (valid.length === 0) return null;
return {
xMin: Math.min(...valid.map((p) => p.x)) - padding,
xMax: Math.max(...valid.map((p) => p.x)) + padding,
yMin: Math.min(...valid.map((p) => p.y)) - padding,
yMax: Math.max(...valid.map((p) => p.y)) + padding,
};
}
function pointInBox(p: { x: number; y: number }, box: Box): boolean {
return p.x >= box.xMin && p.x <= box.xMax && p.y >= box.yMin && p.y <= box.yMax;
}
export function getBodyPartByScreenPosition(
x: number,
y: number,
width: number,
height: number
): BodyPartResult | null {
if (width === 0 || height === 0) return null;
const relX = x / width;
const relY = y / height;
// Left/right edges of screen count as arms
if (relX < 0.2 || relX > 0.8) return { part: 'arm', score: 1 };
// Vertical zones: head / chest / leg
if (relY < 0.33) return { part: 'head', score: 1 };
if (relY < 0.66) return { part: 'chest', score: 1 };
return { part: 'leg', score: 1 };
}
export function getBodyPartAtCrosshair(
pose: { keypoints: Keypoint[] },
crosshair: { x: number; y: number }
): BodyPartResult | null {
const kps = pose.keypoints;
const head = makeBox([
getKeypoint('nose', kps),
getKeypoint('left_eye', kps),
getKeypoint('right_eye', kps),
getKeypoint('left_ear', kps),
getKeypoint('right_ear', kps),
].filter(Boolean) as Keypoint[]);
const chest = makeBox([
getKeypoint('left_shoulder', kps),
getKeypoint('right_shoulder', kps),
getKeypoint('left_hip', kps),
getKeypoint('right_hip', kps),
].filter(Boolean) as Keypoint[]);
const arms = makeBox([
getKeypoint('left_elbow', kps),
getKeypoint('right_elbow', kps),
getKeypoint('left_wrist', kps),
getKeypoint('right_wrist', kps),
].filter(Boolean) as Keypoint[]);
// Large padding around wrists to detect a phone held in the hand as an arm hit
const hands = makeBox([
getKeypoint('left_wrist', kps),
getKeypoint('right_wrist', kps),
].filter(Boolean) as Keypoint[], 80);
const legs = makeBox([
getKeypoint('left_knee', kps),
getKeypoint('right_knee', kps),
getKeypoint('left_ankle', kps),
getKeypoint('right_ankle', kps),
].filter(Boolean) as Keypoint[]);
// Priority: head > chest > hands (phone in hand = arm) > arms > legs
if (head && pointInBox(crosshair, head)) return { part: 'head', score: 1 };
if (chest && pointInBox(crosshair, chest)) return { part: 'chest', score: 1 };
if (hands && pointInBox(crosshair, hands)) return { part: 'arm', score: 1 };
if (arms && pointInBox(crosshair, arms)) return { part: 'arm', score: 1 };
if (legs && pointInBox(crosshair, legs)) return { part: 'leg', score: 1 };
return null;
}
export function bodyPartLabel(part: BodyPart): string {
switch (part) {
case 'head':
return 'KOPF';
case 'chest':
return 'BRUST';
case 'arm':
return 'ARM';
case 'leg':
return 'BEIN';
}
}
export function bodyPartColor(part: BodyPart): string {
switch (part) {
case 'head':
return '#ef4444'; // red-500
case 'chest':
return '#f97316'; // orange-500
case 'arm':
return '#eab308'; // yellow-500
case 'leg':
return '#3b82f6'; // blue-500
}
}
+41
View File
@@ -0,0 +1,41 @@
import distance from '@turf/distance';
import bearing from '@turf/bearing';
import { point } from '@turf/helpers';
import type { GeoPosition } from '../types/game';
export function getDistanceM(a: GeoPosition, b: GeoPosition): number {
return distance(point([a.lng, a.lat]), point([b.lng, b.lat]), { units: 'meters' });
}
export function getBearing(a: GeoPosition, b: GeoPosition): number {
return bearing(point([a.lng, a.lat]), point([b.lng, b.lat]));
}
export function normalizeAngle(angle: number): number {
return ((angle + 180) % 360) - 180;
}
export function angleDifference(a: number, b: number): number {
return Math.abs(normalizeAngle(a - b));
}
export function isInCone(
from: GeoPosition,
to: GeoPosition,
heading: number,
toleranceDeg: number
): boolean {
const targetBearing = getBearing(from, to);
return angleDifference(heading, targetBearing) <= toleranceDeg;
}
export function isPositionPlausible(
oldPos: GeoPosition,
newPos: GeoPosition,
dtMs: number,
maxSpeedMs: number = 15
): boolean {
const d = getDistanceM(oldPos, newPos);
const maxDistance = (maxSpeedMs * dtMs) / 1000;
return d <= maxDistance;
}
+1
View File
@@ -0,0 +1 @@
"import { getDistanceM, getBearing, angleDifference } from './geo';"
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
interface CameraState {
stream: MediaStream | null;
error: string | null;
ready: boolean;
}
export function useCamera(enabled: boolean) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [state, setState] = useState<CameraState>({
stream: null,
error: null,
ready: false,
});
const setCameraReady = useGameStore((s) => s.setCameraReady);
useEffect(() => {
if (!enabled) {
if (state.stream) {
state.stream.getTracks().forEach((t) => t.stop());
setState({ stream: null, error: null, ready: false });
setCameraReady(false);
}
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setState((s) => ({ ...s, error: 'Kamera nicht unterstützt' }));
setCameraReady(false);
return;
}
let active = true;
navigator.mediaDevices
.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
.then((stream) => {
if (!active) {
stream.getTracks().forEach((t) => t.stop());
return;
}
setState({ stream, error: null, ready: true });
setCameraReady(true);
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play().catch(() => {});
}
})
.catch((err) => {
console.error('Camera error:', err);
setState({ stream: null, error: 'Kamera-Berechtigung verweigert oder nicht verfügbar', ready: false });
setCameraReady(false);
});
return () => {
active = false;
state.stream?.getTracks().forEach((t) => t.stop());
};
}, [enabled, setCameraReady]);
const setVideoElement = useCallback((el: HTMLVideoElement | null) => {
videoRef.current = el;
if (el && state.stream) {
el.srcObject = state.stream;
el.play().catch(() => {});
}
}, [state.stream]);
return { ...state, setVideoElement, videoRef };
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState, useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
interface OrientationState {
alpha: number | null; // Z-Axis (0-360)
beta: number | null; // X-Axis (-180 to 180)
gamma: number | null; // Y-Axis (-90 to 90)
heading: number | null; // 0-360 compass heading
error: string | null;
}
// iOS 13+ permission request
async function requestOrientationPermission(): Promise<boolean> {
const DeviceOrientationEvent = window.DeviceOrientationEvent as unknown as {
requestPermission?: () => Promise<'granted' | 'denied'>;
};
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const response = await DeviceOrientationEvent.requestPermission();
return response === 'granted';
} catch {
return false;
}
}
return true;
}
export function useDeviceOrientation(enabled: boolean) {
const [state, setState] = useState<OrientationState>({
alpha: null,
beta: null,
gamma: null,
heading: null,
error: null,
});
const [permissionNeeded, setPermissionNeeded] = useState(false);
const setOrientationReady = useGameStore((s) => s.setOrientationReady);
const requestPermission = useCallback(async () => {
const granted = await requestOrientationPermission();
if (granted) {
setPermissionNeeded(false);
setOrientationReady(true);
} else {
setState((s) => ({ ...s, error: 'Orientierungs-Berechtigung verweigert' }));
setOrientationReady(false);
}
return granted;
}, [setOrientationReady]);
useEffect(() => {
if (!enabled) return;
if (typeof DeviceOrientationEvent !== 'undefined' && 'requestPermission' in DeviceOrientationEvent) {
setPermissionNeeded(true);
return;
}
const handleOrientation = (event: DeviceOrientationEvent) => {
let heading: number | null = null;
if ((event as unknown as { webkitCompassHeading?: number }).webkitCompassHeading !== undefined) {
heading = (event as unknown as { webkitCompassHeading: number }).webkitCompassHeading;
} else if (event.alpha !== null) {
heading = 360 - event.alpha;
}
if (heading !== null) heading = (heading + 360) % 360;
setState({
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
heading,
error: null,
});
setOrientationReady(true);
};
window.addEventListener('deviceorientation', handleOrientation);
return () => window.removeEventListener('deviceorientation', handleOrientation);
}, [enabled, setOrientationReady]);
return { ...state, permissionNeeded, requestPermission };
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
interface GeoState {
lat: number;
lng: number;
accuracy: number;
heading: number | null;
speed: number | null;
timestamp: number;
error: string | null;
}
const DEFAULT_OPTIONS: PositionOptions = {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000,
};
export function useGeolocation(enabled: boolean) {
const [state, setState] = useState<GeoState>({
lat: 0,
lng: 0,
accuracy: Infinity,
heading: null,
speed: null,
timestamp: 0,
error: null,
});
const historyRef = useRef<{ lat: number; lng: number }[]>([]);
const setGeolocationReady = useGameStore((s) => s.setGeolocationReady);
const smoothPosition = useCallback((lat: number, lng: number) => {
historyRef.current.push({ lat, lng });
if (historyRef.current.length > 3) historyRef.current.shift();
const avgLat = historyRef.current.reduce((s, p) => s + p.lat, 0) / historyRef.current.length;
const avgLng = historyRef.current.reduce((s, p) => s + p.lng, 0) / historyRef.current.length;
return { lat: avgLat, lng: avgLng };
}, []);
useEffect(() => {
if (!enabled || !navigator.geolocation) {
if (!navigator.geolocation) setState((s) => ({ ...s, error: 'GPS nicht verfügbar' }));
return;
}
const watchId = navigator.geolocation.watchPosition(
(pos) => {
const smoothed = smoothPosition(pos.coords.latitude, pos.coords.longitude);
setState({
lat: smoothed.lat,
lng: smoothed.lng,
accuracy: pos.coords.accuracy,
heading: pos.coords.heading,
speed: pos.coords.speed,
timestamp: pos.timestamp,
error: null,
});
setGeolocationReady(true);
},
(err) => {
let message = 'GPS-Fehler';
switch (err.code) {
case err.PERMISSION_DENIED:
message = 'GPS-Berechtigung verweigert';
break;
case err.POSITION_UNAVAILABLE:
message = 'GPS-Position nicht verfügbar';
break;
case err.TIMEOUT:
message = 'GPS-Timeout';
break;
}
setState((s) => ({ ...s, error: message }));
setGeolocationReady(false);
},
DEFAULT_OPTIONS
);
return () => navigator.geolocation.clearWatch(watchId);
}, [enabled, smoothPosition, setGeolocationReady]);
return state;
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import * as poseDetection from '@tensorflow-models/pose-detection';
import * as tf from '@tensorflow/tfjs-core';
import '@tensorflow/tfjs-backend-webgl';
import '@tensorflow/tfjs-backend-cpu';
import { getBodyPartAtCrosshair, BodyPartResult } from '../game/bodyParts';
interface PoseState {
detector: poseDetection.PoseDetector | null;
poses: poseDetection.Pose[];
currentBodyPart: BodyPartResult | null;
error: string | null;
ready: boolean;
loading: boolean;
backendName: string | null;
}
export function usePoseDetection(enabled: boolean, videoRef: React.RefObject<HTMLVideoElement>) {
const [state, setState] = useState<PoseState>({
detector: null,
poses: [],
currentBodyPart: null,
error: null,
ready: false,
loading: false,
backendName: null,
});
const rafRef = useRef<number | null>(null);
const detectorRef = useRef<poseDetection.PoseDetector | null>(null);
const lastDetectTimeRef = useRef<number>(0);
useEffect(() => {
if (!enabled) return;
let active = true;
setState((s) => ({ ...s, loading: true, error: null }));
const init = async () => {
try {
// Try WebGL first, fall back to CPU
let backend = 'webgl';
try {
await tf.setBackend('webgl');
await tf.ready();
console.log('[PoseDetection] WebGL backend ready');
} catch (err) {
console.warn('[PoseDetection] WebGL failed, trying CPU:', err);
await tf.setBackend('cpu');
await tf.ready();
backend = 'cpu';
console.log('[PoseDetection] CPU backend ready');
}
if (!active) return;
console.log('[PoseDetection] Creating MoveNet detector...');
const detector = await poseDetection.createDetector(
poseDetection.SupportedModels.MoveNet,
{
modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING,
}
);
if (!active) {
detector.dispose();
return;
}
detectorRef.current = detector;
console.log('[PoseDetection] Detector created');
setState((s) => ({ ...s, detector, ready: true, loading: false, backendName: backend }));
} catch (err) {
console.error('Pose detection init error:', err);
if (!active) return;
setState((s) => ({
...s,
error: 'Körpererkennung konnte nicht gestartet werden: ' + (err instanceof Error ? err.message : String(err)),
ready: false,
loading: false,
backendName: null,
}));
}
};
init();
return () => {
active = false;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [enabled]);
const detect = useCallback(async () => {
const video = videoRef.current;
const detector = detectorRef.current;
if (!video || !detector) {
rafRef.current = requestAnimationFrame(detect);
return;
}
if (video.readyState < 2) {
// Video not ready yet, wait a bit
rafRef.current = requestAnimationFrame(detect);
return;
}
// Throttle detection to ~10 FPS to save battery and allow model to load
const now = performance.now();
if (now - lastDetectTimeRef.current < 100) {
rafRef.current = requestAnimationFrame(detect);
return;
}
lastDetectTimeRef.current = now;
try {
const poses = await detector.estimatePoses(video);
const center = { x: video.videoWidth / 2, y: video.videoHeight / 2 };
const bodyPart = poses.length > 0 ? getBodyPartAtCrosshair(poses[0], center) : null;
if (poses.length > 0) {
console.log('[PoseDetection] Poses found:', poses.length, 'keypoints:', poses[0].keypoints.length, 'bodyPart:', bodyPart?.part ?? 'none');
}
setState((s) => ({ ...s, poses, currentBodyPart: bodyPart }));
} catch (err) {
console.error('Pose detection error:', err);
}
rafRef.current = requestAnimationFrame(detect);
}, [videoRef]);
useEffect(() => {
if (state.ready && enabled) {
console.log('[PoseDetection] Starting detection loop');
rafRef.current = requestAnimationFrame(detect);
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [state.ready, enabled, detect]);
return state;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState, useCallback } from 'react';
export type PowerState = 'active' | 'dimmed' | 'sleeping';
interface PowerSavingOptions {
enabled: boolean;
dimMs: number;
sleepMs: number;
}
export function usePowerSaving({ enabled, dimMs, sleepMs }: PowerSavingOptions) {
const [state, setState] = useState<PowerState>('active');
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const lastActivityRef = useRef<number>(Date.now());
const dimTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sleepTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dimTimerRef.current) clearTimeout(dimTimerRef.current);
if (sleepTimerRef.current) clearTimeout(sleepTimerRef.current);
dimTimerRef.current = null;
sleepTimerRef.current = null;
}, []);
const requestWakeLock = useCallback(async () => {
if (!('wakeLock' in navigator)) return;
try {
wakeLockRef.current = await navigator.wakeLock.request('screen');
} catch (err) {
console.warn('Wake Lock failed:', err);
}
}, []);
const releaseWakeLock = useCallback(() => {
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
}, []);
const setActive = useCallback(() => {
lastActivityRef.current = Date.now();
clearTimers();
if (state !== 'active') {
setState('active');
requestWakeLock();
}
dimTimerRef.current = setTimeout(() => {
setState('dimmed');
releaseWakeLock();
}, dimMs);
sleepTimerRef.current = setTimeout(() => {
setState('sleeping');
releaseWakeLock();
}, sleepMs);
}, [dimMs, sleepMs, state, clearTimers, requestWakeLock, releaseWakeLock]);
useEffect(() => {
if (!enabled) {
clearTimers();
releaseWakeLock();
setState('active');
return;
}
const handleActivity = () => setActive();
// Initial set timers
setActive();
window.addEventListener('touchstart', handleActivity, { passive: true });
window.addEventListener('click', handleActivity, { passive: true });
window.addEventListener('pointerdown', handleActivity, { passive: true });
return () => {
clearTimers();
releaseWakeLock();
window.removeEventListener('touchstart', handleActivity);
window.removeEventListener('click', handleActivity);
window.removeEventListener('pointerdown', handleActivity);
};
}, [enabled, setActive, clearTimers, releaseWakeLock]);
return { state, isActive: state === 'active', isDimmed: state === 'dimmed', isSleeping: state === 'sleeping' };
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useGameStore } from '../store/gameStore';
import type { Match, HitEvent, HitFeedItem } from '../types/game';
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3001';
let socketInstance: Socket | null = null;
let listenersAttached = false;
function getSocket(): Socket {
if (!socketInstance) {
socketInstance = io(SERVER_URL, {
transports: ['websocket'],
reconnection: true,
});
}
return socketInstance;
}
export function useSocket() {
const setMatch = useGameStore((s) => s.setMatch);
const setError = useGameStore((s) => s.setError);
const setScreen = useGameStore((s) => s.setScreen);
const setLastHit = useGameStore((s) => s.setLastHit);
const addHitFeedItem = useGameStore((s) => s.addHitFeedItem);
const clearHitFeed = useGameStore((s) => s.clearHitFeed);
const setPlayerId = useGameStore((s) => s.setPlayerId);
const setIsHost = useGameStore((s) => s.setIsHost);
useEffect(() => {
clearHitFeed();
const socket = getSocket();
if (!listenersAttached) {
listenersAttached = true;
socket.on('connect', () => {
console.log('Socket connected:', socket.id);
setError(null);
});
socket.on('disconnect', () => {
setError('Verbindung zum Server verloren');
});
socket.on('connect_error', (err) => {
console.error('Socket error:', err);
setError(`Verbindungsfehler: ${err.message}`);
});
socket.on('room:created', ({ code, matchId, playerId, isHost }: { code: string; matchId: string; playerId: string; isHost: boolean }) => {
console.log('Room created:', code, matchId, playerId, isHost);
setPlayerId(playerId);
setIsHost(isHost);
});
socket.on('room:joined', ({ code, matchId, playerId, isHost }: { code: string; matchId: string; playerId: string; isHost: boolean }) => {
console.log('Room joined:', code, matchId, playerId, isHost);
setPlayerId(playerId);
setIsHost(isHost);
});
socket.on('room:state', (match: Match) => {
setMatch(match);
if (match.status === 'running') setScreen('game');
if (match.status === 'ended') setScreen('scoreboard');
});
socket.on('match:started', () => {
setScreen('game');
});
socket.on('player:hit', (hit: HitEvent) => {
setLastHit(hit);
const feedItem: HitFeedItem = {
...hit,
id: `${hit.shooterId}-${hit.targetId}-${Date.now()}`,
createdAt: Date.now(),
};
addHitFeedItem(feedItem);
setTimeout(() => setLastHit(null), 2000);
});
socket.on('match:end', () => {
setScreen('scoreboard');
});
socket.on('error:message', (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 5000);
});
}
// Singleton socket stays connected across component mounts/unmounts
return () => {};
}, [setMatch, setError, setScreen, setLastHit, addHitFeedItem, clearHitFeed, setPlayerId, setIsHost]);
const createRoom = useCallback((name: string) => {
getSocket().emit('room:create', { name });
}, []);
const joinRoom = useCallback((code: string, name: string) => {
getSocket().emit('room:join', { code, name });
}, []);
const startMatch = useCallback((settings: Record<string, unknown>) => {
getSocket().emit('match:start', { settings });
}, []);
const updatePlayer = useCallback((pos: { lat: number; lng: number }, heading: number) => {
getSocket().emit('player:update', { pos, heading });
}, []);
const shoot = useCallback((shot: { shooterPos: { lat: number; lng: number }; heading: number; bodyPart: string; clientTimestamp: number; imageData?: string }) => {
getSocket().emit('player:shoot', shot);
}, []);
const leaveRoom = useCallback(() => {
getSocket().emit('room:leave');
}, []);
return {
socket: getSocket(),
createRoom,
joinRoom,
startMatch,
updatePlayer,
shoot,
leaveRoom,
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand';
import type { Match, Player, GameMode, MatchSettings, HitEvent, HitFeedItem, BodyPart } from '../types/game';
export type AppScreen = 'lobby' | 'game' | 'scoreboard';
interface GameState {
playerId: string | null;
playerName: string;
match: Match | null;
isHost: boolean;
screen: AppScreen;
error: string | null;
lastHit: HitEvent | null;
lastShotAt: number;
targetBodyPart: BodyPart | null;
hitFeed: HitFeedItem[];
cameraReady: boolean;
geolocationReady: boolean;
orientationReady: boolean;
setPlayerName: (name: string) => void;
setMatch: (match: Match | null) => void;
setPlayerId: (id: string | null) => void;
setIsHost: (isHost: boolean) => void;
setScreen: (screen: AppScreen) => void;
setError: (error: string | null) => void;
setLastHit: (hit: HitEvent | null) => void;
setLastShotAt: (time: number) => void;
setTargetBodyPart: (part: BodyPart | null) => void;
addHitFeedItem: (item: HitFeedItem) => void;
clearHitFeed: () => void;
setCameraReady: (ready: boolean) => void;
setGeolocationReady: (ready: boolean) => void;
setOrientationReady: (ready: boolean) => void;
}
export const useGameStore = create<GameState>((set) => ({
playerId: null,
playerName: '',
match: null,
isHost: false,
screen: 'lobby',
error: null,
lastHit: null,
lastShotAt: 0,
targetBodyPart: null,
hitFeed: [],
cameraReady: false,
geolocationReady: false,
orientationReady: false,
setPlayerName: (name) => set({ playerName: name }),
setMatch: (match) => set({ match }),
setPlayerId: (id) => set({ playerId: id }),
setIsHost: (isHost) => set({ isHost }),
setScreen: (screen) => set({ screen }),
setError: (error) => set({ error }),
setLastHit: (lastHit) => set({ lastHit }),
setLastShotAt: (lastShotAt) => set({ lastShotAt }),
setTargetBodyPart: (targetBodyPart) => set({ targetBodyPart }),
addHitFeedItem: (item) =>
set((state) => ({ hitFeed: [...state.hitFeed.slice(-9), item] })),
clearHitFeed: () => set({ hitFeed: [] }),
setCameraReady: (cameraReady) => set({ cameraReady }),
setGeolocationReady: (geolocationReady) => set({ geolocationReady }),
setOrientationReady: (orientationReady) => set({ orientationReady }),
}));
+31
View File
@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply h-full w-full overflow-hidden bg-slate-950 text-white;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
* {
@apply box-border;
}
}
@layer components {
.hud-panel {
@apply rounded-xl border border-slate-700/50 bg-slate-900/80 p-3 shadow-lg backdrop-blur-sm;
}
.btn-primary {
@apply flex items-center justify-center rounded-lg bg-primary-600 px-4 py-3 font-semibold text-white shadow transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-secondary {
@apply flex items-center justify-center rounded-lg border border-slate-600 bg-slate-800 px-4 py-3 font-semibold text-white shadow transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50;
}
}
+121
View File
@@ -0,0 +1,121 @@
export type BodyPart = 'head' | 'chest' | 'arm' | 'leg';
export type Team = 'A' | 'B';
export type GameMode = 'ffa' | 'teams';
export type MatchStatus = 'lobby' | 'running' | 'ended';
export interface BodyPartDamage {
head: number;
chest: number;
leg: number;
arm: number;
}
export interface GeoPosition {
lat: number;
lng: number;
}
export interface Player {
id: string;
name: string;
team?: Team;
position: GeoPosition;
heading: number;
health: number;
alive: boolean;
cooldownUntil: number;
score: number;
kills: number;
deaths: number;
}
export interface CircleBoundary {
type: 'circle';
center: GeoPosition;
radiusM: number;
}
export interface PolygonBoundary {
type: 'polygon';
coordinates: GeoPosition[];
}
export type Boundary = CircleBoundary | PolygonBoundary;
export interface MatchSettings {
maxPlayers: number;
reloadMs: number;
rangeM: number;
angleTolerance: number;
damageByBodyPart: BodyPartDamage;
respawnMs: number;
matchDurationMs: number;
startHealth: number;
powerSaveDimMs: number;
powerSaveSleepMs: number;
}
export interface Match {
id: string;
code: string;
mode: GameMode;
status: MatchStatus;
boundary?: Boundary;
settings: MatchSettings;
players: Record<string, Player>;
startTime?: number;
endTime?: number;
}
export interface Shot {
shooterId: string;
timestamp: number;
shooterPos: GeoPosition;
heading: number;
bodyPart: BodyPart;
imageData?: string;
}
export interface HitEvent {
shooterId: string;
shooterName?: string;
targetId: string;
targetName?: string;
bodyPart: BodyPart;
damage: number;
targetHealth: number;
imageData?: string;
}
export interface HitFeedItem extends HitEvent {
id: string;
createdAt: number;
}
export interface ShotResult {
hit: boolean;
bodyPart?: BodyPart;
targetId?: string;
damage?: number;
message: string;
}
export const DEFAULT_DAMAGE: BodyPartDamage = {
head: 100,
chest: 40,
leg: 25,
arm: 15,
};
export const DEFAULT_SETTINGS: MatchSettings = {
maxPlayers: 10,
reloadMs: 3000,
rangeM: 20,
angleTolerance: 5,
damageByBodyPart: DEFAULT_DAMAGE,
respawnMs: 5000,
matchDurationMs: 600000,
startHealth: 100,
powerSaveDimMs: 10000,
powerSaveSleepMs: 30000,
};
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
500: '#3b82f6',
600: '#2563eb',
},
danger: '#ef4444',
success: '#22c55e',
warning: '#f59e0b',
},
animation: {
'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],
};
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
selfDestroying: true,
devOptions: {
enabled: true,
},
manifest: {
name: 'Mobile Tag',
short_name: 'MobileTag',
description: 'GPS-based outdoor lasertag for every smartphone',
theme_color: '#0f172a',
background_color: '#020617',
display: 'fullscreen',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: '/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
},
],
},
}),
],
server: {
host: true,
port: 5173,
},
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "mobile-tag",
"version": "0.1.0",
"private": true,
"description": "GPS-based outdoor lasertag for every smartphone as PWA",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "cd client && npm run dev",
"dev:server": "cd server && npm run dev",
"build": "npm run build:server && npm run build:client",
"build:client": "cd client && npm run build",
"build:server": "cd server && npm run build",
"install:all": "npm install && cd client && npm install && cd ../server && npm install"
},
"devDependencies": {
"concurrently": "^8.2.2"
},
"engines": {
"node": ">=18.0.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "mobile-tag-server",
"private": true,
"version": "0.1.0",
"description": "Mobile Tag game server (Node.js + Socket.io)",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tensorflow-models/pose-detection": "^2.1.3",
"@tensorflow/tfjs": "^4.20.0",
"@tensorflow/tfjs-backend-cpu": "^4.20.0",
"@turf/bearing": "^7.0.0",
"@turf/distance": "^7.0.0",
"@turf/helpers": "^7.0.0",
"canvas": "^2.11.2",
"cors": "^2.8.5",
"express": "^4.19.2",
"socket.io": "^4.7.5",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.12",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"eslint": "^8.57.0",
"nodemon": "^3.1.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}
+207
View File
@@ -0,0 +1,207 @@
import type { Match, Player, Shot, HitEvent, BodyPart } from './types';
import { getDistanceM, getBearing, angleDifference, isInsideBoundary, isPositionPlausible } from './geo';
import { validateShotImage } from './pose';
export function startMatch(match: Match): void {
if (match.status !== 'lobby') return;
match.status = 'running';
match.startTime = Date.now();
for (const player of match.players.values()) {
player.health = match.settings.startHealth;
player.alive = true;
player.cooldownUntil = 0;
player.score = 0;
player.kills = 0;
player.deaths = 0;
}
// End match after duration
setTimeout(() => {
if (match.status === 'running') endMatch(match);
}, match.settings.matchDurationMs);
}
export function endMatch(match: Match): void {
match.status = 'ended';
match.endTime = Date.now();
}
export function updatePlayerPosition(
match: Match,
playerId: string,
pos: { lat: number; lng: number },
heading: number
): void {
const player = match.players.get(playerId);
if (!player || !player.alive) return;
console.log('[updatePlayerPosition]', playerId, 'pos:', pos, 'heading:', heading, 'currentPos:', player.position);
// Always accept the first real position (starting from 0,0 default)
const isDefaultPosition = player.position.lat === 0 && player.position.lng === 0;
if (isDefaultPosition) {
player.position = pos;
} else {
// Plausibility check: allow 500 m jump over 1 s (generous, but blocks teleport cheating)
const now = Date.now();
const lastUpdate = (player as Player & { lastUpdate?: number }).lastUpdate ?? now - 1000;
const dt = now - lastUpdate;
if (isPositionPlausible(player.position, pos, dt, 500)) {
player.position = pos;
} else {
console.log('[updatePlayerPosition] rejected implausible jump');
}
}
player.heading = heading;
(player as Player & { lastUpdate?: number }).lastUpdate = Date.now();
}
export async function handleShot(match: Match, shot: Shot): Promise<HitEvent | null> {
console.log('[handleShot] match players:', match.players.size, 'match status:', match.status);
for (const p of match.players.values()) {
console.log(' player:', p.id, p.name, 'alive:', p.alive, 'pos:', p.position, 'socket:', p.socketId);
}
const shooter = match.players.get(shot.shooterId);
console.log('[handleShot] shooter:', shot.shooterId, 'alive:', shooter?.alive, 'pos:', shot.shooterPos, 'heading:', shot.heading, 'bodyPart:', shot.bodyPart);
if (!shooter || !shooter.alive) {
console.log('[handleShot] rejected: shooter dead or missing');
return null;
}
// Server authoritative time: use server timestamp when the shot arrived
const now = shot.serverTimestamp;
if (now < shooter.cooldownUntil) {
console.log('[handleShot] rejected: cooldown active', now, shooter.cooldownUntil);
return null;
}
// Validate reported shooter position against last known position (allow 10 m tolerance)
const reportedDist = getDistanceM(shooter.position, shot.shooterPos);
if (reportedDist > 10) return null;
// Find best target in cone
let bestTarget: Player | null = null;
let bestDistance = Infinity;
for (const target of match.players.values()) {
if (target.id === shooter.id || !target.alive) continue;
if (match.mode === 'teams' && target.team === shooter.team) continue;
const d = getDistanceM(shot.shooterPos, target.position);
const b = getBearing(shot.shooterPos, target.position);
const diff = angleDifference(shot.heading, b);
console.log('[handleShot] checking target', target.id, 'dist:', d, 'bearing:', b, 'angleDiff:', diff, 'range:', match.settings.rangeM, 'tolerance:', match.settings.angleTolerance);
if (d > match.settings.rangeM) continue;
// Konsistente Winkeltoleranz für alle Entfernungen innerhalb der Reichweite
// Kein Proximity-Bypass mehr: auch nahe Gegner müssen anvisiert werden
if (diff > match.settings.angleTolerance) continue;
if (d < bestDistance) {
bestDistance = d;
bestTarget = target;
}
}
if (!bestTarget) {
console.log('[handleShot] rejected: no target in range/cone');
return null;
}
// Validate body part
const validParts: BodyPart[] = ['head', 'chest', 'arm', 'leg'];
if (!validParts.includes(shot.bodyPart)) return null;
// --- KI-Validierung VOR Schadensanwendung ---
// Wenn ein Bild mitgesendet wurde, validiere es serverseitig
let effectiveBodyPart: BodyPart = shot.bodyPart;
let poseValidation = null;
if (shot.imageData) {
poseValidation = await validateShotImage(shot.imageData, shot.bodyPart);
if (!poseValidation.personDetected) {
// Keine Person im Bild erkannt → Schuss ist ein Miss
console.log('[handleShot] rejected: no person detected in image');
return null;
}
if (poseValidation.bodyPartDetected) {
// KI bestätigt den BodyPart → alles gut, behalte shot.bodyPart
console.log('[handleShot] pose validation OK, bodyPart:', shot.bodyPart);
} else {
// KI erkennt Person, aber der gemeldete BodyPart ist nicht in der Bildmitte
// Wir überschreiben mit dem, was die KI tatsächlich sieht
// Fallback: wenn kein spezifischer BodyPart bestätigt, nimm 'chest' als Default
console.log('[handleShot] pose validation: bodyPart not centered, using chest as fallback');
effectiveBodyPart = 'chest';
}
} else {
// Kein Bild gesendet → Schuss ablehnen (Sicherheit)
console.log('[handleShot] rejected: no image data');
return null;
}
const damage = match.settings.damageByBodyPart[effectiveBodyPart];
// Apply damage
bestTarget.health -= damage;
shooter.cooldownUntil = now + match.settings.reloadMs;
if (bestTarget.health <= 0) {
bestTarget.health = 0;
bestTarget.alive = false;
bestTarget.deaths += 1;
shooter.kills += 1;
shooter.score += 100;
// Respawn
setTimeout(() => {
bestTarget.health = match.settings.startHealth;
bestTarget.alive = true;
}, match.settings.respawnMs);
} else {
shooter.score += damage;
}
const hit: HitEvent = {
shooterId: shooter.id,
shooterName: shooter.name,
targetId: bestTarget.id,
targetName: bestTarget.name,
bodyPart: effectiveBodyPart,
damage,
targetHealth: bestTarget.health,
imageData: shot.imageData,
poseValidation,
};
return hit;
}
export function checkWinCondition(match: Match): boolean {
if (match.mode === 'ffa') {
const alive = Array.from(match.players.values()).filter((p) => p.alive);
if (alive.length <= 1 && match.players.size > 1) {
endMatch(match);
return true;
}
}
return false;
}
export function serializeMatch(match: Match): Record<string, unknown> {
return {
id: match.id,
code: match.code,
mode: match.mode,
status: match.status,
boundary: match.boundary,
settings: match.settings,
players: Object.fromEntries(match.players),
startTime: match.startTime,
endTime: match.endTime,
};
}
+58
View File
@@ -0,0 +1,58 @@
import distance from '@turf/distance';
import bearing from '@turf/bearing';
import { point } from '@turf/helpers';
import type { GeoPosition, Boundary } from './types';
export function getDistanceM(a: GeoPosition, b: GeoPosition): number {
return distance(point([a.lng, a.lat]), point([b.lng, b.lat]), { units: 'meters' });
}
export function getBearing(a: GeoPosition, b: GeoPosition): number {
return bearing(point([a.lng, a.lat]), point([b.lng, b.lat]));
}
export function normalizeAngle(angle: number): number {
return ((angle + 180) % 360) - 180;
}
export function angleDifference(a: number, b: number): number {
return Math.abs(normalizeAngle(a - b));
}
export function isInCone(
from: GeoPosition,
to: GeoPosition,
heading: number,
toleranceDeg: number
): boolean {
const targetBearing = getBearing(from, to);
return angleDifference(heading, targetBearing) <= toleranceDeg;
}
export function isInsideBoundary(pos: GeoPosition, boundary?: Boundary): boolean {
if (!boundary) return true;
if (boundary.type === 'circle') {
const d = getDistanceM(pos, boundary.center);
return d <= boundary.radiusM;
}
// Polygon: simple bounding box check for MVP (proper point-in-polygon can be added later)
const lats = boundary.coordinates.map((c) => c.lat);
const lngs = boundary.coordinates.map((c) => c.lng);
return (
pos.lat >= Math.min(...lats) &&
pos.lat <= Math.max(...lats) &&
pos.lng >= Math.min(...lngs) &&
pos.lng <= Math.max(...lngs)
);
}
export function isPositionPlausible(
oldPos: GeoPosition,
newPos: GeoPosition,
dtMs: number,
maxSpeedMs: number = 15
): boolean {
const d = getDistanceM(oldPos, newPos);
const maxDistance = (maxSpeedMs * dtMs) / 1000;
return d <= maxDistance;
}
+138
View File
@@ -0,0 +1,138 @@
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import {
createMatch,
joinMatch,
leaveMatch,
getMatchBySocketId,
setMatchSettings,
} from './rooms';
import { startMatch, handleShot, updatePlayerPosition, serializeMatch, checkWinCondition } from './game';
import type { Shot, GameMode, MatchSettings } from './types';
const app = express();
app.use(cors());
app.use(express.json());
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
transports: ['websocket'],
});
const socketToPlayer = new Map<string, { matchId: string; playerId: string }>();
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
socket.on('room:create', ({ name }: { name: string }) => {
const { match, player } = createMatch(name, socket.id);
socketToPlayer.set(socket.id, { matchId: match.id, playerId: player.id });
socket.join(match.id);
socket.emit('room:created', { code: match.code, matchId: match.id, playerId: player.id, isHost: player.isHost });
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('room:join', ({ code, name }: { code: string; name: string }) => {
const result = joinMatch(code, name, socket.id);
if (!result) {
socket.emit('error:message', 'Raum nicht gefunden oder voll');
return;
}
const { match, player } = result;
socketToPlayer.set(socket.id, { matchId: match.id, playerId: player.id });
socket.join(match.id);
socket.emit('room:joined', { code: match.code, matchId: match.id, playerId: player.id, isHost: player.isHost });
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('room:leave', () => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = leaveMatch(socket.id);
socketToPlayer.delete(socket.id);
socket.leave(info.matchId);
if (match) {
io.to(match.id).emit('room:state', serializeMatch(match));
}
});
socket.on('match:start', ({ settings }: { settings: Partial<MatchSettings> & { mode?: GameMode } }) => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = getMatchBySocketId(socket.id);
if (!match) return;
const player = match.players.get(info.playerId);
if (!player?.isHost) {
socket.emit('error:message', 'Nur der Host kann das Spiel starten');
return;
}
if (settings.mode) setMatchSettings(match, settings, settings.mode);
else setMatchSettings(match, settings);
startMatch(match);
io.to(match.id).emit('match:started', { startTime: match.startTime, settings: match.settings });
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('player:update', ({ pos, heading }: { pos: { lat: number; lng: number }; heading: number }) => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = getMatchBySocketId(socket.id);
if (!match || match.status !== 'running') return;
updatePlayerPosition(match, info.playerId, pos, heading);
// Broadcast positions to all players in match (throttled ideally)
io.to(match.id).emit('state:sync', Array.from(match.players.values()));
});
socket.on('player:shoot', async (shot: Omit<Shot, 'shooterId' | 'serverTimestamp'>) => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = getMatchBySocketId(socket.id);
if (!match || match.status !== 'running') return;
const fullShot: Shot = {
...shot,
shooterId: info.playerId,
serverTimestamp: Date.now(),
};
const hit = await handleShot(match, fullShot);
if (hit) {
io.to(match.id).emit('player:hit', hit);
if (!match.players.get(hit.targetId)?.alive) {
io.to(match.id).emit('player:death', { playerId: hit.targetId, killerId: hit.shooterId });
}
checkWinCondition(match);
}
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
const info = socketToPlayer.get(socket.id);
if (info) {
const match = leaveMatch(socket.id);
socketToPlayer.delete(socket.id);
if (match) {
io.to(match.id).emit('room:state', serializeMatch(match));
}
}
});
});
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`Mobile Tag server listening on port ${PORT}`);
});
export default httpServer;
+110
View File
@@ -0,0 +1,110 @@
import * as poseDetection from '@tensorflow-models/pose-detection';
import * as tf from '@tensorflow/tfjs-core';
import '@tensorflow/tfjs-backend-cpu';
import { createCanvas, Image } from 'canvas';
import type { BodyPart } from './types';
let detector: poseDetection.PoseDetector | null = null;
export async function initPoseDetector(): Promise<void> {
await tf.setBackend('cpu');
await tf.ready();
if (detector) return;
const model = poseDetection.SupportedModels.MoveNet;
detector = await poseDetection.createDetector(model, {
modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING,
});
}
export interface PoseValidationResult {
personDetected: boolean;
bodyPartDetected: boolean;
confidence: number;
}
function keypointNameForBodyPart(bodyPart: BodyPart): string {
switch (bodyPart) {
case 'head':
return 'nose';
case 'chest':
return 'left_shoulder'; // center of chest approximated
case 'arm':
return 'left_elbow';
case 'leg':
return 'left_knee';
default:
return 'nose';
}
}
function isCenterScreen(x: number, y: number): boolean {
// Consider center 40% of the image
return x >= 0.3 && x <= 0.7 && y >= 0.2 && y <= 0.8;
}
export async function validateShotImage(
imageDataUrl: string | undefined,
bodyPart: BodyPart
): Promise<PoseValidationResult> {
if (!imageDataUrl) {
return { personDetected: false, bodyPartDetected: false, confidence: 0 };
}
if (!detector) {
await initPoseDetector();
}
try {
const base64 = imageDataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64, 'base64');
const img = new Image();
img.src = buffer;
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// Konvertiere Canvas-Pixel-Daten in einen tf.Tensor3D
// (node-canvas ist kein HTMLCanvasElement, daher manuelle Tensor-Erstellung)
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = tf.tensor3d(
new Float32Array(imageData.data),
[img.height, img.width, 4]
);
// RGBA -> RGB: ersten 3 Kanäle nehmen
const rgb = pixels.slice([0, 0, 0], [img.height, img.width, 3]);
const poses = await detector!.estimatePoses(rgb);
// Tensor-Speicher freigeben
pixels.dispose();
rgb.dispose();
if (!poses || poses.length === 0) {
return { personDetected: false, bodyPartDetected: false, confidence: 0 };
}
const pose = poses[0];
const keypointName = keypointNameForBodyPart(bodyPart);
const keypoint = pose.keypoints.find((k) => k.name === keypointName);
if (!keypoint || keypoint.score == null || keypoint.score < 0.3) {
return { personDetected: true, bodyPartDetected: false, confidence: pose.score ?? 0 };
}
const normalizedX = keypoint.x / img.width;
const normalizedY = keypoint.y / img.height;
const center = isCenterScreen(normalizedX, normalizedY);
return {
personDetected: true,
bodyPartDetected: center,
confidence: keypoint.score,
};
} catch (err) {
console.error('[validateShotImage] error:', err);
return { personDetected: false, bodyPartDetected: false, confidence: 0 };
}
}
+130
View File
@@ -0,0 +1,130 @@
import { v4 as uuidv4 } from 'uuid';
import type { Match, Player, MatchSettings, GameMode } from './types';
import { DEFAULT_SETTINGS } from './types';
const matches = new Map<string, Match>();
const playerToMatch = new Map<string, string>(); // socketId -> matchId
function generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export function createMatch(hostName: string, hostSocketId: string): { match: Match; player: Player } {
const matchId = uuidv4();
const playerId = uuidv4();
const player: Player = {
id: playerId,
name: hostName,
socketId: hostSocketId,
position: { lat: 0, lng: 0 },
heading: 0,
health: DEFAULT_SETTINGS.startHealth,
alive: true,
cooldownUntil: 0,
score: 0,
kills: 0,
deaths: 0,
isHost: true,
};
const match: Match = {
id: matchId,
code: generateCode(),
mode: 'ffa',
status: 'lobby',
settings: { ...DEFAULT_SETTINGS },
players: new Map([[playerId, player]]),
createdAt: Date.now(),
};
matches.set(matchId, match);
playerToMatch.set(hostSocketId, matchId);
return { match, player };
}
export function joinMatch(code: string, name: string, socketId: string): { match: Match; player: Player } | null {
const match = Array.from(matches.values()).find((m) => m.code === code.toUpperCase());
if (!match || match.status !== 'lobby' || match.players.size >= match.settings.maxPlayers) {
return null;
}
const playerId = uuidv4();
const player: Player = {
id: playerId,
name,
socketId,
position: { lat: 0, lng: 0 },
heading: 0,
health: match.settings.startHealth,
alive: true,
cooldownUntil: 0,
score: 0,
kills: 0,
deaths: 0,
isHost: false,
};
match.players.set(playerId, player);
playerToMatch.set(socketId, match.id);
return { match, player };
}
export function leaveMatch(socketId: string): Match | null {
const matchId = playerToMatch.get(socketId);
if (!matchId) return null;
const match = matches.get(matchId);
if (!match) return null;
const player = Array.from(match.players.values()).find((p) => p.socketId === socketId);
if (player) {
match.players.delete(player.id);
}
playerToMatch.delete(socketId);
if (match.players.size === 0) {
matches.delete(matchId);
return null;
}
// Promote new host if host left
if (player?.isHost && match.players.size > 0) {
const nextHost = match.players.values().next().value;
if (nextHost) nextHost.isHost = true;
}
return match;
}
export function getMatchBySocketId(socketId: string): Match | null {
const matchId = playerToMatch.get(socketId);
if (!matchId) return null;
return matches.get(matchId) ?? null;
}
export function getMatch(matchId: string): Match | null {
return matches.get(matchId) ?? null;
}
export function setMatchSettings(
match: Match,
settings: Partial<MatchSettings>,
mode?: GameMode
): void {
match.settings = { ...match.settings, ...settings };
if (mode) match.mode = mode;
}
export function cleanupOldMatches(maxAgeMs: number = 3600000): void {
const now = Date.now();
for (const [matchId, match] of matches.entries()) {
if (now - match.createdAt > maxAgeMs && match.status !== 'running') {
matches.delete(matchId);
for (const player of match.players.values()) {
playerToMatch.delete(player.socketId);
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
export type BodyPart = 'head' | 'chest' | 'arm' | 'leg';
export type Team = 'A' | 'B';
export type GameMode = 'ffa' | 'teams';
export type MatchStatus = 'lobby' | 'running' | 'ended';
export interface BodyPartDamage {
head: number;
chest: number;
leg: number;
arm: number;
}
export interface GeoPosition {
lat: number;
lng: number;
}
export interface Player {
id: string;
name: string;
team?: Team;
socketId: string;
position: GeoPosition;
heading: number;
health: number;
alive: boolean;
cooldownUntil: number;
score: number;
kills: number;
deaths: number;
isHost: boolean;
}
export interface CircleBoundary {
type: 'circle';
center: GeoPosition;
radiusM: number;
}
export interface PolygonBoundary {
type: 'polygon';
coordinates: GeoPosition[];
}
export type Boundary = CircleBoundary | PolygonBoundary;
export interface MatchSettings {
maxPlayers: number;
reloadMs: number;
rangeM: number;
angleTolerance: number;
damageByBodyPart: BodyPartDamage;
respawnMs: number;
matchDurationMs: number;
startHealth: number;
powerSaveDimMs: number;
powerSaveSleepMs: number;
}
export interface Match {
id: string;
code: string;
mode: GameMode;
status: MatchStatus;
boundary?: Boundary;
settings: MatchSettings;
players: Map<string, Player>;
startTime?: number;
endTime?: number;
createdAt: number;
}
export interface Shot {
shooterId: string;
clientTimestamp: number;
serverTimestamp: number;
shooterPos: GeoPosition;
heading: number;
bodyPart: BodyPart;
imageData?: string;
}
export interface PoseValidationResult {
personDetected: boolean;
bodyPartDetected: boolean;
confidence: number;
}
export interface HitEvent {
shooterId: string;
shooterName?: string;
targetId: string;
targetName?: string;
bodyPart: BodyPart;
damage: number;
targetHealth: number;
imageData?: string;
poseValidation?: PoseValidationResult;
}
export const DEFAULT_DAMAGE: BodyPartDamage = {
head: 100,
chest: 40,
leg: 25,
arm: 15,
};
export const DEFAULT_SETTINGS: MatchSettings = {
maxPlayers: 10,
reloadMs: 3000,
rangeM: 50,
angleTolerance: 30,
damageByBodyPart: DEFAULT_DAMAGE,
respawnMs: 5000,
matchDurationMs: 600000,
startHealth: 100,
powerSaveDimMs: 10000,
powerSaveSleepMs: 30000,
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"types": ["node"],
"sourceMap": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}