Initial commit: Mobil_Tag project setup
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user