Initial commit: Mobil_Tag project setup
This commit is contained in:
+41
@@ -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/
|
||||
@@ -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*
|
||||
@@ -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:** 2–10 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*
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
# URL des Mobile Tag Game Servers
|
||||
VITE_SERVER_URL=http://localhost:3001
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"import { getDistanceM, getBearing, angleDifference } from './geo';"
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user