Initial commit: Mobil_Tag project setup

This commit is contained in:
topsith
2026-08-05 20:29:18 +02:00
commit 9db3812cf8
46 changed files with 3888 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# URL des Mobile Tag Game Servers
VITE_SERVER_URL=http://localhost:3001
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<script>
// Force clear old service workers and caches on deploy update
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((r) => r.unregister());
});
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach((name) => caches.delete(name));
});
}
}
</script>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="GPS-based outdoor lasertag for every smartphone" />
<title>Mobile Tag</title>
</head>
<body class="bg-slate-950 text-white overflow-hidden">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "mobile-tag-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tensorflow-models/pose-detection": "^2.1.3",
"@tensorflow/tfjs": "^4.20.0",
"@tensorflow/tfjs-backend-webgl": "^4.20.0",
"@turf/bearing": "^7.0.0",
"@turf/distance": "^7.0.0",
"@turf/helpers": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.8",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-plugin-pwa": "^0.20.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0f172a"/>
<circle cx="256" cy="256" r="160" fill="none" stroke="#3b82f6" stroke-width="24"/>
<line x1="256" y1="96" x2="256" y2="176" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<line x1="256" y1="336" x2="256" y2="416" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<line x1="96" y1="256" x2="176" y2="256" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<line x1="336" y1="256" x2="416" y2="256" stroke="#ef4444" stroke-width="24" stroke-linecap="round"/>
<circle cx="256" cy="256" r="20" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

+63
View File
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { useGameStore } from './store/gameStore';
import { useSocket } from './hooks/useSocket';
import { useGeolocation } from './hooks/useGeolocation';
import { useDeviceOrientation } from './hooks/useDeviceOrientation';
import { usePowerSaving } from './hooks/usePowerSaving';
import { Lobby } from './components/Lobby';
import { CameraView } from './components/CameraView';
import { HUD } from './components/HUD';
import { Scoreboard } from './components/Scoreboard';
import { PowerSavingOverlay } from './components/PowerSavingOverlay';
import { HitFeed } from './components/HitFeed';
export default function App() {
const screen = useGameStore((s) => s.screen);
const playerId = useGameStore((s) => s.playerId);
const error = useGameStore((s) => s.error);
const match = useGameStore((s) => s.match);
const { updatePlayer } = useSocket();
const geo = useGeolocation(screen === 'game');
const orientation = useDeviceOrientation(screen === 'game');
const power = usePowerSaving({
enabled: screen === 'game',
dimMs: match?.settings.powerSaveDimMs ?? 10000,
sleepMs: match?.settings.powerSaveSleepMs ?? 30000,
});
// Send position + heading to server while in game
useEffect(() => {
if (screen !== 'game' || !playerId) return;
const interval = setInterval(() => {
if (!geo.error) {
// Always send position; fallback heading 0 if compass unavailable
updatePlayer({ lat: geo.lat, lng: geo.lng }, orientation.heading ?? 0);
}
}, 100);
return () => clearInterval(interval);
}, [screen, playerId, geo, orientation, updatePlayer]);
return (
<div className="relative h-full w-full">
{error && (
<div className="absolute left-0 right-0 top-0 z-50 bg-danger/90 p-2 text-center text-sm font-semibold text-white">
{error}
</div>
)}
{screen === 'lobby' && <Lobby />}
{screen === 'game' && (
<>
<CameraView active={power.isActive} />
<HitFeed />
<HUD geo={geo} orientation={orientation} />
<PowerSavingOverlay state={power.state} />
</>
)}
{screen === 'scoreboard' && <Scoreboard />}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { useCamera } from '../hooks/useCamera';
import { usePoseDetection } from '../hooks/usePoseDetection';
import { Crosshair } from './Crosshair';
import { useGameStore } from '../store/gameStore';
import { bodyPartLabel } from '../game/bodyParts';
interface CameraViewProps {
active: boolean;
}
export function CameraView({ active }: CameraViewProps) {
const { setVideoElement, videoRef, ready, error } = useCamera(active);
const pose = usePoseDetection(active && ready, videoRef);
const setTargetBodyPart = useGameStore((s) => s.setTargetBodyPart);
useEffect(() => {
// Only set target body part when pose detection actually found a person
setTargetBodyPart(pose.currentBodyPart?.part ?? null);
}, [pose.currentBodyPart, setTargetBodyPart]);
return (
<div className="absolute inset-0 bg-black">
<video
ref={setVideoElement}
autoPlay
playsInline
muted
className="h-full w-full object-cover"
/>
<Crosshair bodyPart={pose.currentBodyPart} />
<div className="pointer-events-none absolute left-4 top-4 rounded bg-black/50 px-2 py-1 text-xs text-white">
{pose.currentBodyPart ? bodyPartLabel(pose.currentBodyPart.part) : 'Keine Person erkannt'}
</div>
{!ready && !error && active && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 text-white">
Kamera wird gestartet...
</div>
)}
{!active && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black text-white">
<span className="opacity-50">Kamera pausiert (Energiesparmodus)</span>
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/90 p-6 text-center text-white">
<p className="mb-2 text-danger">{error}</p>
<p className="text-sm text-slate-400">Bitte erlaube Kamera-Zugriff in den Browser-Einstellungen.</p>
</div>
)}
{pose.loading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-center text-white">
<div>
<div className="mb-2 text-lg font-bold">Körpererkennung lädt...</div>
<div className="text-sm text-slate-400">Das KI-Modell wird initialisiert</div>
</div>
</div>
)}
{pose.error && (
<div className="absolute bottom-20 left-0 right-0 bg-danger/80 p-2 text-center text-sm text-white">
{pose.error}
</div>
)}
{!pose.loading && !pose.error && pose.ready && (
<div className="absolute left-4 top-20 rounded bg-black/50 px-2 py-1 text-xs text-white">
Backend: {pose.backendName ?? '?'}
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { bodyPartLabel, bodyPartColor, type BodyPartResult } from '../game/bodyParts';
interface CrosshairProps {
bodyPart: BodyPartResult | null;
position?: { x: number; y: number };
}
export function Crosshair({ bodyPart, position = { x: 50, y: 50 } }: CrosshairProps) {
const hasBodyPart = bodyPart != null;
const color = hasBodyPart ? bodyPartColor(bodyPart.part) : 'rgba(255,255,255,0.8)';
return (
<div
className="pointer-events-none absolute"
style={{
left: `${position.x}%`,
top: `${position.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
<svg width="200" height="200" viewBox="0 0 200 200" className="overflow-visible">
{/* Outer circle */}
<circle
cx="100"
cy="100"
r="70"
fill="none"
stroke={color}
strokeWidth="2"
strokeDasharray="8 4"
opacity={bodyPart ? 1 : 0.6}
/>
{/* Crosshair lines */}
<line x1="100" y1="40" x2="100" y2="80" stroke={color} strokeWidth="3" />
<line x1="100" y1="120" x2="100" y2="160" stroke={color} strokeWidth="3" />
<line x1="40" y1="100" x2="80" y2="100" stroke={color} strokeWidth="3" />
<line x1="120" y1="100" x2="160" y2="100" stroke={color} strokeWidth="3" />
{/* Center dot */}
<circle cx="100" cy="100" r="4" fill={color} />
</svg>
{bodyPart && (
<div
className="absolute mt-24 rounded-full px-4 py-1 text-sm font-black tracking-widest"
style={{
backgroundColor: color,
color: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
}}
>
{bodyPartLabel(bodyPart.part)}
</div>
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useMemo } from 'react';
import { useGameStore } from '../store/gameStore';
import { useSocket } from '../hooks/useSocket';
import { findTargetInCone, isReadyToShoot } from '../game/targeting';
import { bodyPartLabel } from '../game/bodyParts';
import type { GeoPosition } from '../types/game';
interface HUDProps {
geo: {
lat: number;
lng: number;
accuracy: number;
heading: number | null;
error: string | null;
};
orientation: {
heading: number | null;
error: string | null;
permissionNeeded: boolean;
requestPermission: () => Promise<boolean>;
};
}
export function HUD({ geo, orientation }: HUDProps) {
const match = useGameStore((s) => s.match);
const playerId = useGameStore((s) => s.playerId);
const lastShotAt = useGameStore((s) => s.lastShotAt);
const targetBodyPart = useGameStore((s) => s.targetBodyPart);
const lastHit = useGameStore((s) => s.lastHit);
const setLastShotAt = useGameStore((s) => s.setLastShotAt);
const { shoot } = useSocket();
const self = match?.players[playerId ?? ''];
const enemies = useMemo(() => {
if (!match || !self) return [];
return Object.values(match.players).filter((p) => {
if (p.id === self.id) return false;
if (match.mode === 'teams' && p.team === self.team) return false;
return true;
});
}, [match, self]);
const heading = orientation.heading ?? geo.heading ?? 0;
const shooterPos: GeoPosition = { lat: geo.lat, lng: geo.lng };
const targetCandidate = useMemo(() => {
if (!self || !match) return null;
return findTargetInCone(
shooterPos,
heading,
enemies,
match.settings.rangeM,
match.settings.angleTolerance
);
}, [shooterPos, heading, enemies, match, self]);
const { ready: canShoot, reason } = isReadyToShoot(
targetBodyPart,
targetCandidate,
lastShotAt,
match?.settings.reloadMs ?? 3000
);
const reloadProgress = Math.min(1, (Date.now() - lastShotAt) / (match?.settings.reloadMs ?? 3000));
const captureSnapshot = (): string | undefined => {
const video = document.querySelector('video');
if (!video || video.videoWidth === 0) return undefined;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return undefined;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Compress to JPEG at 60% quality, max 640px width
const scale = Math.min(1, 640 / canvas.width);
if (scale < 1) {
const small = document.createElement('canvas');
small.width = canvas.width * scale;
small.height = canvas.height * scale;
const sctx = small.getContext('2d');
if (!sctx) return undefined;
sctx.drawImage(canvas, 0, 0, small.width, small.height);
return small.toDataURL('image/jpeg', 0.6);
}
return canvas.toDataURL('image/jpeg', 0.6);
};
const handleShoot = () => {
if (!canShoot || !self || !match) return;
const imageData = captureSnapshot();
setLastShotAt(Date.now());
shoot({
shooterPos,
heading,
bodyPart: targetBodyPart ?? 'chest',
clientTimestamp: Date.now(),
imageData,
});
};
if (!self || !match) return null;
return (
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-4">
{/* Top HUD */}
<div className="flex items-start justify-between gap-2">
<div className="hud-panel min-w-[120px]">
<div className="text-xs text-slate-400">Leben</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-800">
<div
className="h-full transition-all duration-300"
style={{
width: `${self.health}%`,
backgroundColor: self.health > 50 ? '#22c55e' : self.health > 25 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
<div className="mt-1 text-right text-sm font-bold">{Math.ceil(self.health)}%</div>
</div>
<div className="hud-panel text-center">
<div className="text-xs text-slate-400">Score</div>
<div className="text-xl font-black">{self.score}</div>
</div>
<div className="hud-panel text-right">
<div className="text-xs text-slate-400">GPS</div>
<div className="text-sm font-semibold">{geo.error ? 'Fehler' : `±${Math.round(geo.accuracy)}m`}</div>
<div className="text-xs text-slate-500">{Math.round(heading)}°</div>
</div>
</div>
{/* Center hit feedback (own hits only) */}
{lastHit && lastHit.shooterId === playerId && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-xl bg-success/90 px-6 py-3 text-center font-black text-white shadow-lg">
<div className="text-2xl">TREFFER!</div>
<div className="text-sm">{bodyPartLabel(lastHit.bodyPart)} · {lastHit.damage} Schaden</div>
</div>
</div>
)}
{/* Bottom HUD */}
<div className="flex flex-col items-center gap-3">
{orientation.permissionNeeded && (
<button
className="pointer-events-auto btn-primary"
onClick={orientation.requestPermission}
>
Kompass aktivieren
</button>
)}
{targetCandidate && (
<div className="hud-panel text-center">
<div className="text-xs text-slate-400">Ziel</div>
<div className="font-bold">{targetCandidate.player.name}</div>
<div className="text-sm">{Math.round(targetCandidate.distanceM)}m</div>
</div>
)}
{!canShoot && reason && (
<div className="text-sm font-semibold text-slate-300">{reason}</div>
)}
<button
className="pointer-events-auto relative flex h-24 w-24 items-center justify-center rounded-full border-4 border-white/20 bg-danger font-black text-white shadow-lg transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canShoot}
onClick={handleShoot}
>
<span className="z-10">FEUER</span>
{reloadProgress < 1 && (
<svg className="absolute inset-0 h-full w-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth="8"
strokeDasharray="289"
strokeDashoffset={289 * (1 - reloadProgress)}
/>
</svg>
)}
</button>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { useGameStore } from '../store/gameStore';
import { bodyPartLabel } from '../game/bodyParts';
import type { HitFeedItem, Player } from '../types/game';
export function HitFeed() {
const hitFeed = useGameStore((s) => s.hitFeed);
const match = useGameStore((s) => s.match);
const playerId = useGameStore((s) => s.playerId);
// Vibrate on new feed items involving the current player
useEffect(() => {
if (!navigator.vibrate || hitFeed.length === 0) return;
const latest = hitFeed[hitFeed.length - 1];
if (latest.shooterId === playerId || latest.targetId === playerId) {
navigator.vibrate([50, 50, 100]);
}
}, [hitFeed, playerId]);
if (!match || hitFeed.length === 0) return null;
return (
<div className="pointer-events-none absolute left-4 right-4 top-20 z-30 flex flex-col items-center gap-1">
{hitFeed.slice(-3).map((item: HitFeedItem) => {
const rowKey = item.id;
const row = (
<HitFeedRow item={item} players={match.players as Record<string, Player>} />
);
return <div key={rowKey}>{row}</div>;
})}
</div>
);
}
interface HitFeedRowProps {
item: HitFeedItem;
players: Record<string, Player>;
}
function HitFeedRow({ item, players }: HitFeedRowProps) {
const shooterName = item.shooterName || players[item.shooterId]?.name || 'Unbekannt';
const targetName = item.targetName || players[item.targetId]?.name || 'Unbekannt';
return (
<div className="rounded-full bg-slate-900/90 px-4 py-1.5 text-center text-sm font-semibold text-white shadow backdrop-blur-sm">
{shooterName} traf {targetName} · {bodyPartLabel(item.bodyPart)} · {item.damage}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useState } from 'react';
import { useGameStore } from '../store/gameStore';
import { useSocket } from '../hooks/useSocket';
import { DEFAULT_SETTINGS, type GameMode } from '../types/game';
export function Lobby() {
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [tab, setTab] = useState<'create' | 'join'>('create');
const match = useGameStore((s) => s.match);
const isHost = useGameStore((s) => s.isHost);
const playerId = useGameStore((s) => s.playerId);
const { createRoom, joinRoom, startMatch, leaveRoom } = useSocket();
const [mode, setMode] = useState<GameMode>('ffa');
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
if (!match || !playerId) {
return (
<div className="flex h-full flex-col items-center justify-center p-6">
<h1 className="mb-2 text-4xl font-black tracking-tight text-primary-500">MOBILE TAG</h1>
<p className="mb-8 text-center text-slate-400">GPS-Lasertag mit jedem Handy</p>
<div className="mb-6 flex w-full max-w-sm gap-2 rounded-lg bg-slate-900 p-1">
<button
className={`flex-1 rounded-md py-2 text-sm font-semibold ${tab === 'create' ? 'bg-primary-600 text-white' : 'text-slate-400'}`}
onClick={() => setTab('create')}
>
Erstellen
</button>
<button
className={`flex-1 rounded-md py-2 text-sm font-semibold ${tab === 'join' ? 'bg-primary-600 text-white' : 'text-slate-400'}`}
onClick={() => setTab('join')}
>
Beitreten
</button>
</div>
<div className="w-full max-w-sm space-y-4">
<input
type="text"
placeholder="Dein Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-white placeholder-slate-500 focus:border-primary-500 focus:outline-none"
/>
{tab === 'join' && (
<input
type="text"
placeholder="Raumcode (6 Ziffern)"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-white placeholder-slate-500 focus:border-primary-500 focus:outline-none"
/>
)}
<button
className="btn-primary w-full"
disabled={!name || (tab === 'join' && code.length !== 6)}
onClick={() => {
if (tab === 'create') createRoom(name);
else joinRoom(code, name);
}}
>
{tab === 'create' ? 'Raum erstellen' : 'Raum beitreten'}
</button>
</div>
</div>
);
}
const players = Object.values(match.players);
return (
<div className="flex h-full flex-col p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-bold">Lobby</h2>
<span className="rounded-lg bg-slate-800 px-3 py-1 font-mono text-lg tracking-widest text-primary-500">
{match.code}
</span>
</div>
<div className="mb-4 rounded-xl bg-slate-900 p-4">
<h3 className="mb-2 font-semibold text-slate-300">Spieler</h3>
<ul className="space-y-2">
{players.map((p) => (
<li key={p.id} className="flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2">
<span>{p.name}</span>
{p.id === playerId && <span className="text-xs text-slate-500">Du</span>}
</li>
))}
</ul>
</div>
{isHost && (
<div className="mb-4 space-y-3 rounded-xl bg-slate-900 p-4">
<h3 className="font-semibold text-slate-300">Einstellungen</h3>
<div>
<label className="mb-1 block text-sm text-slate-400">Modus</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value as GameMode)}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
>
<option value="ffa">Alle gegen Alle</option>
<option value="teams">Teams</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm text-slate-400">Reichweite (m)</label>
<input
type="number"
value={settings.rangeM}
onChange={(e) => setSettings({ ...settings, rangeM: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm text-slate-400">Nachladen (ms)</label>
<input
type="number"
value={settings.reloadMs}
onChange={(e) => setSettings({ ...settings, reloadMs: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm text-slate-400">Dimmen nach (ms)</label>
<input
type="number"
value={settings.powerSaveDimMs}
onChange={(e) => setSettings({ ...settings, powerSaveDimMs: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm text-slate-400">Tiefschlaf nach (ms)</label>
<input
type="number"
value={settings.powerSaveSleepMs}
onChange={(e) => setSettings({ ...settings, powerSaveSleepMs: Number(e.target.value) })}
className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-white"
/>
</div>
</div>
<button
className="btn-primary w-full"
disabled={players.length < 1}
onClick={() =>
startMatch({
...settings,
mode,
maxPlayers: settings.maxPlayers,
})
}
>
Spiel starten
</button>
</div>
)}
{!isHost && <p className="text-center text-slate-400">Warte auf den Host...</p>}
<button className="btn-secondary mt-auto" onClick={leaveRoom}>
Lobby verlassen
</button>
</div>
);
}
@@ -0,0 +1,29 @@
import type { PowerState } from '../hooks/usePowerSaving';
interface PowerSavingOverlayProps {
state: PowerState;
}
export function PowerSavingOverlay({ state }: PowerSavingOverlayProps) {
if (state === 'active') return null;
const isSleeping = state === 'sleeping';
return (
<div
className={`fixed inset-0 z-40 flex items-center justify-center transition-opacity duration-700 ${
isSleeping ? 'bg-black/95' : 'bg-black/80'
}`}
>
<div className="text-center">
<div className={`mb-3 text-5xl ${isSleeping ? 'opacity-30' : 'opacity-70'}`}>💤</div>
<p className={`font-semibold ${isSleeping ? 'text-slate-500' : 'text-slate-300'}`}>
{isSleeping ? 'Tippen, um fortzufahren' : 'Bildschirm wird gedimmt'}
</p>
{!isSleeping && (
<p className="mt-1 text-sm text-slate-500">Berühre den Bildschirm, um aktiv zu bleiben</p>
)}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useGameStore } from '../store/gameStore';
import { getBearing, getDistanceM } from '../game/geo';
interface RadarProps {
selfHeading: number;
}
export function Radar({ selfHeading }: RadarProps) {
const match = useGameStore((s) => s.match);
const playerId = useGameStore((s) => s.playerId);
if (!match || !playerId) return null;
const self = match.players[playerId];
if (!self) return null;
const enemies = Object.values(match.players).filter((p) => {
if (p.id === self.id) return false;
if (match.mode === 'teams' && p.team === self.team) return false;
return p.alive;
});
const size = 120;
const center = size / 2;
const maxRange = match.settings.rangeM * 2;
return (
<div className="hud-panel absolute bottom-4 left-4 flex h-[140px] w-[140px] items-center justify-center rounded-full">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={center} cy={center} r={size / 2 - 2} fill="#0f172a" stroke="#334155" strokeWidth="2" />
<circle cx={center} cy={center} r={size / 4} fill="none" stroke="#334155" strokeWidth="1" />
{/* North indicator rotated by heading */}
<g transform={`rotate(${selfHeading} ${center} ${center})`}>
<polygon points={`${center},${4} ${center - 4},${14} ${center + 4},${14}`} fill="#3b82f6" />
</g>
{enemies.map((p) => {
const d = getDistanceM(self.position, p.position);
if (d > maxRange) return null;
const b = getBearing(self.position, p.position);
const relAngle = b - selfHeading;
const rad = (relAngle * Math.PI) / 180;
const r = (d / maxRange) * (size / 2 - 4);
const x = center + r * Math.sin(rad);
const y = center - r * Math.cos(rad);
return <circle key={p.id} cx={x} cy={y} r={5} fill="#ef4444" />;
})}
</svg>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useGameStore } from '../store/gameStore';
import { useSocket } from '../hooks/useSocket';
import type { Player } from '../types/game';
export function Scoreboard() {
const match = useGameStore((s) => s.match);
const { leaveRoom } = useSocket();
if (!match) return null;
const players = (Object.values(match.players) as Player[]).sort((a, b) => b.score - a.score);
return (
<div className="flex h-full flex-col bg-slate-950 p-6">
<h2 className="mb-6 text-center text-3xl font-black text-primary-500">SPIELENDE</h2>
<div className="mb-6 rounded-xl bg-slate-900 p-4">
<table className="w-full text-left">
<thead>
<tr className="text-sm text-slate-400">
<th className="pb-2">#</th>
<th className="pb-2">Spieler</th>
<th className="pb-2 text-right">Kills</th>
<th className="pb-2 text-right">Score</th>
</tr>
</thead>
<tbody>
{players.map((p, index) => (
<tr key={p.id} className="border-t border-slate-800">
<td className="py-3 font-bold">{index + 1}</td>
<td className="py-3">{p.name}</td>
<td className="py-3 text-right">{p.kills}</td>
<td className="py-3 text-right font-bold text-primary-500">{p.score}</td>
</tr>
))}
</tbody>
</table>
</div>
<button className="btn-primary mt-auto" onClick={leaveRoom}>
Zurück zur Lobby
</button>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import type { Keypoint } from '@tensorflow-models/pose-detection';
import type { BodyPart } from '../types/game';
export interface BodyPartResult {
part: BodyPart;
score: number;
}
interface Box {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}
function getKeypoint(name: string, keypoints: Keypoint[]): Keypoint | undefined {
return keypoints.find((k) => k.name === name);
}
function makeBox(points: Keypoint[], padding: number = 0): Box | null {
const valid = points.filter((p) => p && p.score && p.score > 0.2);
if (valid.length === 0) return null;
return {
xMin: Math.min(...valid.map((p) => p.x)) - padding,
xMax: Math.max(...valid.map((p) => p.x)) + padding,
yMin: Math.min(...valid.map((p) => p.y)) - padding,
yMax: Math.max(...valid.map((p) => p.y)) + padding,
};
}
function pointInBox(p: { x: number; y: number }, box: Box): boolean {
return p.x >= box.xMin && p.x <= box.xMax && p.y >= box.yMin && p.y <= box.yMax;
}
export function getBodyPartByScreenPosition(
x: number,
y: number,
width: number,
height: number
): BodyPartResult | null {
if (width === 0 || height === 0) return null;
const relX = x / width;
const relY = y / height;
// Left/right edges of screen count as arms
if (relX < 0.2 || relX > 0.8) return { part: 'arm', score: 1 };
// Vertical zones: head / chest / leg
if (relY < 0.33) return { part: 'head', score: 1 };
if (relY < 0.66) return { part: 'chest', score: 1 };
return { part: 'leg', score: 1 };
}
export function getBodyPartAtCrosshair(
pose: { keypoints: Keypoint[] },
crosshair: { x: number; y: number }
): BodyPartResult | null {
const kps = pose.keypoints;
const head = makeBox([
getKeypoint('nose', kps),
getKeypoint('left_eye', kps),
getKeypoint('right_eye', kps),
getKeypoint('left_ear', kps),
getKeypoint('right_ear', kps),
].filter(Boolean) as Keypoint[]);
const chest = makeBox([
getKeypoint('left_shoulder', kps),
getKeypoint('right_shoulder', kps),
getKeypoint('left_hip', kps),
getKeypoint('right_hip', kps),
].filter(Boolean) as Keypoint[]);
const arms = makeBox([
getKeypoint('left_elbow', kps),
getKeypoint('right_elbow', kps),
getKeypoint('left_wrist', kps),
getKeypoint('right_wrist', kps),
].filter(Boolean) as Keypoint[]);
// Large padding around wrists to detect a phone held in the hand as an arm hit
const hands = makeBox([
getKeypoint('left_wrist', kps),
getKeypoint('right_wrist', kps),
].filter(Boolean) as Keypoint[], 80);
const legs = makeBox([
getKeypoint('left_knee', kps),
getKeypoint('right_knee', kps),
getKeypoint('left_ankle', kps),
getKeypoint('right_ankle', kps),
].filter(Boolean) as Keypoint[]);
// Priority: head > chest > hands (phone in hand = arm) > arms > legs
if (head && pointInBox(crosshair, head)) return { part: 'head', score: 1 };
if (chest && pointInBox(crosshair, chest)) return { part: 'chest', score: 1 };
if (hands && pointInBox(crosshair, hands)) return { part: 'arm', score: 1 };
if (arms && pointInBox(crosshair, arms)) return { part: 'arm', score: 1 };
if (legs && pointInBox(crosshair, legs)) return { part: 'leg', score: 1 };
return null;
}
export function bodyPartLabel(part: BodyPart): string {
switch (part) {
case 'head':
return 'KOPF';
case 'chest':
return 'BRUST';
case 'arm':
return 'ARM';
case 'leg':
return 'BEIN';
}
}
export function bodyPartColor(part: BodyPart): string {
switch (part) {
case 'head':
return '#ef4444'; // red-500
case 'chest':
return '#f97316'; // orange-500
case 'arm':
return '#eab308'; // yellow-500
case 'leg':
return '#3b82f6'; // blue-500
}
}
+41
View File
@@ -0,0 +1,41 @@
import distance from '@turf/distance';
import bearing from '@turf/bearing';
import { point } from '@turf/helpers';
import type { GeoPosition } from '../types/game';
export function getDistanceM(a: GeoPosition, b: GeoPosition): number {
return distance(point([a.lng, a.lat]), point([b.lng, b.lat]), { units: 'meters' });
}
export function getBearing(a: GeoPosition, b: GeoPosition): number {
return bearing(point([a.lng, a.lat]), point([b.lng, b.lat]));
}
export function normalizeAngle(angle: number): number {
return ((angle + 180) % 360) - 180;
}
export function angleDifference(a: number, b: number): number {
return Math.abs(normalizeAngle(a - b));
}
export function isInCone(
from: GeoPosition,
to: GeoPosition,
heading: number,
toleranceDeg: number
): boolean {
const targetBearing = getBearing(from, to);
return angleDifference(heading, targetBearing) <= toleranceDeg;
}
export function isPositionPlausible(
oldPos: GeoPosition,
newPos: GeoPosition,
dtMs: number,
maxSpeedMs: number = 15
): boolean {
const d = getDistanceM(oldPos, newPos);
const maxDistance = (maxSpeedMs * dtMs) / 1000;
return d <= maxDistance;
}
+1
View File
@@ -0,0 +1 @@
"import { getDistanceM, getBearing, angleDifference } from './geo';"
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
interface CameraState {
stream: MediaStream | null;
error: string | null;
ready: boolean;
}
export function useCamera(enabled: boolean) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [state, setState] = useState<CameraState>({
stream: null,
error: null,
ready: false,
});
const setCameraReady = useGameStore((s) => s.setCameraReady);
useEffect(() => {
if (!enabled) {
if (state.stream) {
state.stream.getTracks().forEach((t) => t.stop());
setState({ stream: null, error: null, ready: false });
setCameraReady(false);
}
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setState((s) => ({ ...s, error: 'Kamera nicht unterstützt' }));
setCameraReady(false);
return;
}
let active = true;
navigator.mediaDevices
.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
.then((stream) => {
if (!active) {
stream.getTracks().forEach((t) => t.stop());
return;
}
setState({ stream, error: null, ready: true });
setCameraReady(true);
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play().catch(() => {});
}
})
.catch((err) => {
console.error('Camera error:', err);
setState({ stream: null, error: 'Kamera-Berechtigung verweigert oder nicht verfügbar', ready: false });
setCameraReady(false);
});
return () => {
active = false;
state.stream?.getTracks().forEach((t) => t.stop());
};
}, [enabled, setCameraReady]);
const setVideoElement = useCallback((el: HTMLVideoElement | null) => {
videoRef.current = el;
if (el && state.stream) {
el.srcObject = state.stream;
el.play().catch(() => {});
}
}, [state.stream]);
return { ...state, setVideoElement, videoRef };
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState, useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
interface OrientationState {
alpha: number | null; // Z-Axis (0-360)
beta: number | null; // X-Axis (-180 to 180)
gamma: number | null; // Y-Axis (-90 to 90)
heading: number | null; // 0-360 compass heading
error: string | null;
}
// iOS 13+ permission request
async function requestOrientationPermission(): Promise<boolean> {
const DeviceOrientationEvent = window.DeviceOrientationEvent as unknown as {
requestPermission?: () => Promise<'granted' | 'denied'>;
};
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const response = await DeviceOrientationEvent.requestPermission();
return response === 'granted';
} catch {
return false;
}
}
return true;
}
export function useDeviceOrientation(enabled: boolean) {
const [state, setState] = useState<OrientationState>({
alpha: null,
beta: null,
gamma: null,
heading: null,
error: null,
});
const [permissionNeeded, setPermissionNeeded] = useState(false);
const setOrientationReady = useGameStore((s) => s.setOrientationReady);
const requestPermission = useCallback(async () => {
const granted = await requestOrientationPermission();
if (granted) {
setPermissionNeeded(false);
setOrientationReady(true);
} else {
setState((s) => ({ ...s, error: 'Orientierungs-Berechtigung verweigert' }));
setOrientationReady(false);
}
return granted;
}, [setOrientationReady]);
useEffect(() => {
if (!enabled) return;
if (typeof DeviceOrientationEvent !== 'undefined' && 'requestPermission' in DeviceOrientationEvent) {
setPermissionNeeded(true);
return;
}
const handleOrientation = (event: DeviceOrientationEvent) => {
let heading: number | null = null;
if ((event as unknown as { webkitCompassHeading?: number }).webkitCompassHeading !== undefined) {
heading = (event as unknown as { webkitCompassHeading: number }).webkitCompassHeading;
} else if (event.alpha !== null) {
heading = 360 - event.alpha;
}
if (heading !== null) heading = (heading + 360) % 360;
setState({
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
heading,
error: null,
});
setOrientationReady(true);
};
window.addEventListener('deviceorientation', handleOrientation);
return () => window.removeEventListener('deviceorientation', handleOrientation);
}, [enabled, setOrientationReady]);
return { ...state, permissionNeeded, requestPermission };
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useGameStore } from '../store/gameStore';
interface GeoState {
lat: number;
lng: number;
accuracy: number;
heading: number | null;
speed: number | null;
timestamp: number;
error: string | null;
}
const DEFAULT_OPTIONS: PositionOptions = {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000,
};
export function useGeolocation(enabled: boolean) {
const [state, setState] = useState<GeoState>({
lat: 0,
lng: 0,
accuracy: Infinity,
heading: null,
speed: null,
timestamp: 0,
error: null,
});
const historyRef = useRef<{ lat: number; lng: number }[]>([]);
const setGeolocationReady = useGameStore((s) => s.setGeolocationReady);
const smoothPosition = useCallback((lat: number, lng: number) => {
historyRef.current.push({ lat, lng });
if (historyRef.current.length > 3) historyRef.current.shift();
const avgLat = historyRef.current.reduce((s, p) => s + p.lat, 0) / historyRef.current.length;
const avgLng = historyRef.current.reduce((s, p) => s + p.lng, 0) / historyRef.current.length;
return { lat: avgLat, lng: avgLng };
}, []);
useEffect(() => {
if (!enabled || !navigator.geolocation) {
if (!navigator.geolocation) setState((s) => ({ ...s, error: 'GPS nicht verfügbar' }));
return;
}
const watchId = navigator.geolocation.watchPosition(
(pos) => {
const smoothed = smoothPosition(pos.coords.latitude, pos.coords.longitude);
setState({
lat: smoothed.lat,
lng: smoothed.lng,
accuracy: pos.coords.accuracy,
heading: pos.coords.heading,
speed: pos.coords.speed,
timestamp: pos.timestamp,
error: null,
});
setGeolocationReady(true);
},
(err) => {
let message = 'GPS-Fehler';
switch (err.code) {
case err.PERMISSION_DENIED:
message = 'GPS-Berechtigung verweigert';
break;
case err.POSITION_UNAVAILABLE:
message = 'GPS-Position nicht verfügbar';
break;
case err.TIMEOUT:
message = 'GPS-Timeout';
break;
}
setState((s) => ({ ...s, error: message }));
setGeolocationReady(false);
},
DEFAULT_OPTIONS
);
return () => navigator.geolocation.clearWatch(watchId);
}, [enabled, smoothPosition, setGeolocationReady]);
return state;
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import * as poseDetection from '@tensorflow-models/pose-detection';
import * as tf from '@tensorflow/tfjs-core';
import '@tensorflow/tfjs-backend-webgl';
import '@tensorflow/tfjs-backend-cpu';
import { getBodyPartAtCrosshair, BodyPartResult } from '../game/bodyParts';
interface PoseState {
detector: poseDetection.PoseDetector | null;
poses: poseDetection.Pose[];
currentBodyPart: BodyPartResult | null;
error: string | null;
ready: boolean;
loading: boolean;
backendName: string | null;
}
export function usePoseDetection(enabled: boolean, videoRef: React.RefObject<HTMLVideoElement>) {
const [state, setState] = useState<PoseState>({
detector: null,
poses: [],
currentBodyPart: null,
error: null,
ready: false,
loading: false,
backendName: null,
});
const rafRef = useRef<number | null>(null);
const detectorRef = useRef<poseDetection.PoseDetector | null>(null);
const lastDetectTimeRef = useRef<number>(0);
useEffect(() => {
if (!enabled) return;
let active = true;
setState((s) => ({ ...s, loading: true, error: null }));
const init = async () => {
try {
// Try WebGL first, fall back to CPU
let backend = 'webgl';
try {
await tf.setBackend('webgl');
await tf.ready();
console.log('[PoseDetection] WebGL backend ready');
} catch (err) {
console.warn('[PoseDetection] WebGL failed, trying CPU:', err);
await tf.setBackend('cpu');
await tf.ready();
backend = 'cpu';
console.log('[PoseDetection] CPU backend ready');
}
if (!active) return;
console.log('[PoseDetection] Creating MoveNet detector...');
const detector = await poseDetection.createDetector(
poseDetection.SupportedModels.MoveNet,
{
modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING,
}
);
if (!active) {
detector.dispose();
return;
}
detectorRef.current = detector;
console.log('[PoseDetection] Detector created');
setState((s) => ({ ...s, detector, ready: true, loading: false, backendName: backend }));
} catch (err) {
console.error('Pose detection init error:', err);
if (!active) return;
setState((s) => ({
...s,
error: 'Körpererkennung konnte nicht gestartet werden: ' + (err instanceof Error ? err.message : String(err)),
ready: false,
loading: false,
backendName: null,
}));
}
};
init();
return () => {
active = false;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [enabled]);
const detect = useCallback(async () => {
const video = videoRef.current;
const detector = detectorRef.current;
if (!video || !detector) {
rafRef.current = requestAnimationFrame(detect);
return;
}
if (video.readyState < 2) {
// Video not ready yet, wait a bit
rafRef.current = requestAnimationFrame(detect);
return;
}
// Throttle detection to ~10 FPS to save battery and allow model to load
const now = performance.now();
if (now - lastDetectTimeRef.current < 100) {
rafRef.current = requestAnimationFrame(detect);
return;
}
lastDetectTimeRef.current = now;
try {
const poses = await detector.estimatePoses(video);
const center = { x: video.videoWidth / 2, y: video.videoHeight / 2 };
const bodyPart = poses.length > 0 ? getBodyPartAtCrosshair(poses[0], center) : null;
if (poses.length > 0) {
console.log('[PoseDetection] Poses found:', poses.length, 'keypoints:', poses[0].keypoints.length, 'bodyPart:', bodyPart?.part ?? 'none');
}
setState((s) => ({ ...s, poses, currentBodyPart: bodyPart }));
} catch (err) {
console.error('Pose detection error:', err);
}
rafRef.current = requestAnimationFrame(detect);
}, [videoRef]);
useEffect(() => {
if (state.ready && enabled) {
console.log('[PoseDetection] Starting detection loop');
rafRef.current = requestAnimationFrame(detect);
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [state.ready, enabled, detect]);
return state;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState, useCallback } from 'react';
export type PowerState = 'active' | 'dimmed' | 'sleeping';
interface PowerSavingOptions {
enabled: boolean;
dimMs: number;
sleepMs: number;
}
export function usePowerSaving({ enabled, dimMs, sleepMs }: PowerSavingOptions) {
const [state, setState] = useState<PowerState>('active');
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const lastActivityRef = useRef<number>(Date.now());
const dimTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sleepTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dimTimerRef.current) clearTimeout(dimTimerRef.current);
if (sleepTimerRef.current) clearTimeout(sleepTimerRef.current);
dimTimerRef.current = null;
sleepTimerRef.current = null;
}, []);
const requestWakeLock = useCallback(async () => {
if (!('wakeLock' in navigator)) return;
try {
wakeLockRef.current = await navigator.wakeLock.request('screen');
} catch (err) {
console.warn('Wake Lock failed:', err);
}
}, []);
const releaseWakeLock = useCallback(() => {
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
}, []);
const setActive = useCallback(() => {
lastActivityRef.current = Date.now();
clearTimers();
if (state !== 'active') {
setState('active');
requestWakeLock();
}
dimTimerRef.current = setTimeout(() => {
setState('dimmed');
releaseWakeLock();
}, dimMs);
sleepTimerRef.current = setTimeout(() => {
setState('sleeping');
releaseWakeLock();
}, sleepMs);
}, [dimMs, sleepMs, state, clearTimers, requestWakeLock, releaseWakeLock]);
useEffect(() => {
if (!enabled) {
clearTimers();
releaseWakeLock();
setState('active');
return;
}
const handleActivity = () => setActive();
// Initial set timers
setActive();
window.addEventListener('touchstart', handleActivity, { passive: true });
window.addEventListener('click', handleActivity, { passive: true });
window.addEventListener('pointerdown', handleActivity, { passive: true });
return () => {
clearTimers();
releaseWakeLock();
window.removeEventListener('touchstart', handleActivity);
window.removeEventListener('click', handleActivity);
window.removeEventListener('pointerdown', handleActivity);
};
}, [enabled, setActive, clearTimers, releaseWakeLock]);
return { state, isActive: state === 'active', isDimmed: state === 'dimmed', isSleeping: state === 'sleeping' };
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useGameStore } from '../store/gameStore';
import type { Match, HitEvent, HitFeedItem } from '../types/game';
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3001';
let socketInstance: Socket | null = null;
let listenersAttached = false;
function getSocket(): Socket {
if (!socketInstance) {
socketInstance = io(SERVER_URL, {
transports: ['websocket'],
reconnection: true,
});
}
return socketInstance;
}
export function useSocket() {
const setMatch = useGameStore((s) => s.setMatch);
const setError = useGameStore((s) => s.setError);
const setScreen = useGameStore((s) => s.setScreen);
const setLastHit = useGameStore((s) => s.setLastHit);
const addHitFeedItem = useGameStore((s) => s.addHitFeedItem);
const clearHitFeed = useGameStore((s) => s.clearHitFeed);
const setPlayerId = useGameStore((s) => s.setPlayerId);
const setIsHost = useGameStore((s) => s.setIsHost);
useEffect(() => {
clearHitFeed();
const socket = getSocket();
if (!listenersAttached) {
listenersAttached = true;
socket.on('connect', () => {
console.log('Socket connected:', socket.id);
setError(null);
});
socket.on('disconnect', () => {
setError('Verbindung zum Server verloren');
});
socket.on('connect_error', (err) => {
console.error('Socket error:', err);
setError(`Verbindungsfehler: ${err.message}`);
});
socket.on('room:created', ({ code, matchId, playerId, isHost }: { code: string; matchId: string; playerId: string; isHost: boolean }) => {
console.log('Room created:', code, matchId, playerId, isHost);
setPlayerId(playerId);
setIsHost(isHost);
});
socket.on('room:joined', ({ code, matchId, playerId, isHost }: { code: string; matchId: string; playerId: string; isHost: boolean }) => {
console.log('Room joined:', code, matchId, playerId, isHost);
setPlayerId(playerId);
setIsHost(isHost);
});
socket.on('room:state', (match: Match) => {
setMatch(match);
if (match.status === 'running') setScreen('game');
if (match.status === 'ended') setScreen('scoreboard');
});
socket.on('match:started', () => {
setScreen('game');
});
socket.on('player:hit', (hit: HitEvent) => {
setLastHit(hit);
const feedItem: HitFeedItem = {
...hit,
id: `${hit.shooterId}-${hit.targetId}-${Date.now()}`,
createdAt: Date.now(),
};
addHitFeedItem(feedItem);
setTimeout(() => setLastHit(null), 2000);
});
socket.on('match:end', () => {
setScreen('scoreboard');
});
socket.on('error:message', (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 5000);
});
}
// Singleton socket stays connected across component mounts/unmounts
return () => {};
}, [setMatch, setError, setScreen, setLastHit, addHitFeedItem, clearHitFeed, setPlayerId, setIsHost]);
const createRoom = useCallback((name: string) => {
getSocket().emit('room:create', { name });
}, []);
const joinRoom = useCallback((code: string, name: string) => {
getSocket().emit('room:join', { code, name });
}, []);
const startMatch = useCallback((settings: Record<string, unknown>) => {
getSocket().emit('match:start', { settings });
}, []);
const updatePlayer = useCallback((pos: { lat: number; lng: number }, heading: number) => {
getSocket().emit('player:update', { pos, heading });
}, []);
const shoot = useCallback((shot: { shooterPos: { lat: number; lng: number }; heading: number; bodyPart: string; clientTimestamp: number; imageData?: string }) => {
getSocket().emit('player:shoot', shot);
}, []);
const leaveRoom = useCallback(() => {
getSocket().emit('room:leave');
}, []);
return {
socket: getSocket(),
createRoom,
joinRoom,
startMatch,
updatePlayer,
shoot,
leaveRoom,
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand';
import type { Match, Player, GameMode, MatchSettings, HitEvent, HitFeedItem, BodyPart } from '../types/game';
export type AppScreen = 'lobby' | 'game' | 'scoreboard';
interface GameState {
playerId: string | null;
playerName: string;
match: Match | null;
isHost: boolean;
screen: AppScreen;
error: string | null;
lastHit: HitEvent | null;
lastShotAt: number;
targetBodyPart: BodyPart | null;
hitFeed: HitFeedItem[];
cameraReady: boolean;
geolocationReady: boolean;
orientationReady: boolean;
setPlayerName: (name: string) => void;
setMatch: (match: Match | null) => void;
setPlayerId: (id: string | null) => void;
setIsHost: (isHost: boolean) => void;
setScreen: (screen: AppScreen) => void;
setError: (error: string | null) => void;
setLastHit: (hit: HitEvent | null) => void;
setLastShotAt: (time: number) => void;
setTargetBodyPart: (part: BodyPart | null) => void;
addHitFeedItem: (item: HitFeedItem) => void;
clearHitFeed: () => void;
setCameraReady: (ready: boolean) => void;
setGeolocationReady: (ready: boolean) => void;
setOrientationReady: (ready: boolean) => void;
}
export const useGameStore = create<GameState>((set) => ({
playerId: null,
playerName: '',
match: null,
isHost: false,
screen: 'lobby',
error: null,
lastHit: null,
lastShotAt: 0,
targetBodyPart: null,
hitFeed: [],
cameraReady: false,
geolocationReady: false,
orientationReady: false,
setPlayerName: (name) => set({ playerName: name }),
setMatch: (match) => set({ match }),
setPlayerId: (id) => set({ playerId: id }),
setIsHost: (isHost) => set({ isHost }),
setScreen: (screen) => set({ screen }),
setError: (error) => set({ error }),
setLastHit: (lastHit) => set({ lastHit }),
setLastShotAt: (lastShotAt) => set({ lastShotAt }),
setTargetBodyPart: (targetBodyPart) => set({ targetBodyPart }),
addHitFeedItem: (item) =>
set((state) => ({ hitFeed: [...state.hitFeed.slice(-9), item] })),
clearHitFeed: () => set({ hitFeed: [] }),
setCameraReady: (cameraReady) => set({ cameraReady }),
setGeolocationReady: (geolocationReady) => set({ geolocationReady }),
setOrientationReady: (orientationReady) => set({ orientationReady }),
}));
+31
View File
@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply h-full w-full overflow-hidden bg-slate-950 text-white;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
* {
@apply box-border;
}
}
@layer components {
.hud-panel {
@apply rounded-xl border border-slate-700/50 bg-slate-900/80 p-3 shadow-lg backdrop-blur-sm;
}
.btn-primary {
@apply flex items-center justify-center rounded-lg bg-primary-600 px-4 py-3 font-semibold text-white shadow transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-secondary {
@apply flex items-center justify-center rounded-lg border border-slate-600 bg-slate-800 px-4 py-3 font-semibold text-white shadow transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50;
}
}
+121
View File
@@ -0,0 +1,121 @@
export type BodyPart = 'head' | 'chest' | 'arm' | 'leg';
export type Team = 'A' | 'B';
export type GameMode = 'ffa' | 'teams';
export type MatchStatus = 'lobby' | 'running' | 'ended';
export interface BodyPartDamage {
head: number;
chest: number;
leg: number;
arm: number;
}
export interface GeoPosition {
lat: number;
lng: number;
}
export interface Player {
id: string;
name: string;
team?: Team;
position: GeoPosition;
heading: number;
health: number;
alive: boolean;
cooldownUntil: number;
score: number;
kills: number;
deaths: number;
}
export interface CircleBoundary {
type: 'circle';
center: GeoPosition;
radiusM: number;
}
export interface PolygonBoundary {
type: 'polygon';
coordinates: GeoPosition[];
}
export type Boundary = CircleBoundary | PolygonBoundary;
export interface MatchSettings {
maxPlayers: number;
reloadMs: number;
rangeM: number;
angleTolerance: number;
damageByBodyPart: BodyPartDamage;
respawnMs: number;
matchDurationMs: number;
startHealth: number;
powerSaveDimMs: number;
powerSaveSleepMs: number;
}
export interface Match {
id: string;
code: string;
mode: GameMode;
status: MatchStatus;
boundary?: Boundary;
settings: MatchSettings;
players: Record<string, Player>;
startTime?: number;
endTime?: number;
}
export interface Shot {
shooterId: string;
timestamp: number;
shooterPos: GeoPosition;
heading: number;
bodyPart: BodyPart;
imageData?: string;
}
export interface HitEvent {
shooterId: string;
shooterName?: string;
targetId: string;
targetName?: string;
bodyPart: BodyPart;
damage: number;
targetHealth: number;
imageData?: string;
}
export interface HitFeedItem extends HitEvent {
id: string;
createdAt: number;
}
export interface ShotResult {
hit: boolean;
bodyPart?: BodyPart;
targetId?: string;
damage?: number;
message: string;
}
export const DEFAULT_DAMAGE: BodyPartDamage = {
head: 100,
chest: 40,
leg: 25,
arm: 15,
};
export const DEFAULT_SETTINGS: MatchSettings = {
maxPlayers: 10,
reloadMs: 3000,
rangeM: 20,
angleTolerance: 5,
damageByBodyPart: DEFAULT_DAMAGE,
respawnMs: 5000,
matchDurationMs: 600000,
startHealth: 100,
powerSaveDimMs: 10000,
powerSaveSleepMs: 30000,
};
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
500: '#3b82f6',
600: '#2563eb',
},
danger: '#ef4444',
success: '#22c55e',
warning: '#f59e0b',
},
animation: {
'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],
};
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
selfDestroying: true,
devOptions: {
enabled: true,
},
manifest: {
name: 'Mobile Tag',
short_name: 'MobileTag',
description: 'GPS-based outdoor lasertag for every smartphone',
theme_color: '#0f172a',
background_color: '#020617',
display: 'fullscreen',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: '/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
},
],
},
}),
],
server: {
host: true,
port: 5173,
},
});