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
+38
View File
@@ -0,0 +1,38 @@
{
"name": "mobile-tag-server",
"private": true,
"version": "0.1.0",
"description": "Mobile Tag game server (Node.js + Socket.io)",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tensorflow-models/pose-detection": "^2.1.3",
"@tensorflow/tfjs": "^4.20.0",
"@tensorflow/tfjs-backend-cpu": "^4.20.0",
"@turf/bearing": "^7.0.0",
"@turf/distance": "^7.0.0",
"@turf/helpers": "^7.0.0",
"canvas": "^2.11.2",
"cors": "^2.8.5",
"express": "^4.19.2",
"socket.io": "^4.7.5",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.12",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"eslint": "^8.57.0",
"nodemon": "^3.1.4",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}
+207
View File
@@ -0,0 +1,207 @@
import type { Match, Player, Shot, HitEvent, BodyPart } from './types';
import { getDistanceM, getBearing, angleDifference, isInsideBoundary, isPositionPlausible } from './geo';
import { validateShotImage } from './pose';
export function startMatch(match: Match): void {
if (match.status !== 'lobby') return;
match.status = 'running';
match.startTime = Date.now();
for (const player of match.players.values()) {
player.health = match.settings.startHealth;
player.alive = true;
player.cooldownUntil = 0;
player.score = 0;
player.kills = 0;
player.deaths = 0;
}
// End match after duration
setTimeout(() => {
if (match.status === 'running') endMatch(match);
}, match.settings.matchDurationMs);
}
export function endMatch(match: Match): void {
match.status = 'ended';
match.endTime = Date.now();
}
export function updatePlayerPosition(
match: Match,
playerId: string,
pos: { lat: number; lng: number },
heading: number
): void {
const player = match.players.get(playerId);
if (!player || !player.alive) return;
console.log('[updatePlayerPosition]', playerId, 'pos:', pos, 'heading:', heading, 'currentPos:', player.position);
// Always accept the first real position (starting from 0,0 default)
const isDefaultPosition = player.position.lat === 0 && player.position.lng === 0;
if (isDefaultPosition) {
player.position = pos;
} else {
// Plausibility check: allow 500 m jump over 1 s (generous, but blocks teleport cheating)
const now = Date.now();
const lastUpdate = (player as Player & { lastUpdate?: number }).lastUpdate ?? now - 1000;
const dt = now - lastUpdate;
if (isPositionPlausible(player.position, pos, dt, 500)) {
player.position = pos;
} else {
console.log('[updatePlayerPosition] rejected implausible jump');
}
}
player.heading = heading;
(player as Player & { lastUpdate?: number }).lastUpdate = Date.now();
}
export async function handleShot(match: Match, shot: Shot): Promise<HitEvent | null> {
console.log('[handleShot] match players:', match.players.size, 'match status:', match.status);
for (const p of match.players.values()) {
console.log(' player:', p.id, p.name, 'alive:', p.alive, 'pos:', p.position, 'socket:', p.socketId);
}
const shooter = match.players.get(shot.shooterId);
console.log('[handleShot] shooter:', shot.shooterId, 'alive:', shooter?.alive, 'pos:', shot.shooterPos, 'heading:', shot.heading, 'bodyPart:', shot.bodyPart);
if (!shooter || !shooter.alive) {
console.log('[handleShot] rejected: shooter dead or missing');
return null;
}
// Server authoritative time: use server timestamp when the shot arrived
const now = shot.serverTimestamp;
if (now < shooter.cooldownUntil) {
console.log('[handleShot] rejected: cooldown active', now, shooter.cooldownUntil);
return null;
}
// Validate reported shooter position against last known position (allow 10 m tolerance)
const reportedDist = getDistanceM(shooter.position, shot.shooterPos);
if (reportedDist > 10) return null;
// Find best target in cone
let bestTarget: Player | null = null;
let bestDistance = Infinity;
for (const target of match.players.values()) {
if (target.id === shooter.id || !target.alive) continue;
if (match.mode === 'teams' && target.team === shooter.team) continue;
const d = getDistanceM(shot.shooterPos, target.position);
const b = getBearing(shot.shooterPos, target.position);
const diff = angleDifference(shot.heading, b);
console.log('[handleShot] checking target', target.id, 'dist:', d, 'bearing:', b, 'angleDiff:', diff, 'range:', match.settings.rangeM, 'tolerance:', match.settings.angleTolerance);
if (d > match.settings.rangeM) continue;
// Konsistente Winkeltoleranz für alle Entfernungen innerhalb der Reichweite
// Kein Proximity-Bypass mehr: auch nahe Gegner müssen anvisiert werden
if (diff > match.settings.angleTolerance) continue;
if (d < bestDistance) {
bestDistance = d;
bestTarget = target;
}
}
if (!bestTarget) {
console.log('[handleShot] rejected: no target in range/cone');
return null;
}
// Validate body part
const validParts: BodyPart[] = ['head', 'chest', 'arm', 'leg'];
if (!validParts.includes(shot.bodyPart)) return null;
// --- KI-Validierung VOR Schadensanwendung ---
// Wenn ein Bild mitgesendet wurde, validiere es serverseitig
let effectiveBodyPart: BodyPart = shot.bodyPart;
let poseValidation = null;
if (shot.imageData) {
poseValidation = await validateShotImage(shot.imageData, shot.bodyPart);
if (!poseValidation.personDetected) {
// Keine Person im Bild erkannt → Schuss ist ein Miss
console.log('[handleShot] rejected: no person detected in image');
return null;
}
if (poseValidation.bodyPartDetected) {
// KI bestätigt den BodyPart → alles gut, behalte shot.bodyPart
console.log('[handleShot] pose validation OK, bodyPart:', shot.bodyPart);
} else {
// KI erkennt Person, aber der gemeldete BodyPart ist nicht in der Bildmitte
// Wir überschreiben mit dem, was die KI tatsächlich sieht
// Fallback: wenn kein spezifischer BodyPart bestätigt, nimm 'chest' als Default
console.log('[handleShot] pose validation: bodyPart not centered, using chest as fallback');
effectiveBodyPart = 'chest';
}
} else {
// Kein Bild gesendet → Schuss ablehnen (Sicherheit)
console.log('[handleShot] rejected: no image data');
return null;
}
const damage = match.settings.damageByBodyPart[effectiveBodyPart];
// Apply damage
bestTarget.health -= damage;
shooter.cooldownUntil = now + match.settings.reloadMs;
if (bestTarget.health <= 0) {
bestTarget.health = 0;
bestTarget.alive = false;
bestTarget.deaths += 1;
shooter.kills += 1;
shooter.score += 100;
// Respawn
setTimeout(() => {
bestTarget.health = match.settings.startHealth;
bestTarget.alive = true;
}, match.settings.respawnMs);
} else {
shooter.score += damage;
}
const hit: HitEvent = {
shooterId: shooter.id,
shooterName: shooter.name,
targetId: bestTarget.id,
targetName: bestTarget.name,
bodyPart: effectiveBodyPart,
damage,
targetHealth: bestTarget.health,
imageData: shot.imageData,
poseValidation,
};
return hit;
}
export function checkWinCondition(match: Match): boolean {
if (match.mode === 'ffa') {
const alive = Array.from(match.players.values()).filter((p) => p.alive);
if (alive.length <= 1 && match.players.size > 1) {
endMatch(match);
return true;
}
}
return false;
}
export function serializeMatch(match: Match): Record<string, unknown> {
return {
id: match.id,
code: match.code,
mode: match.mode,
status: match.status,
boundary: match.boundary,
settings: match.settings,
players: Object.fromEntries(match.players),
startTime: match.startTime,
endTime: match.endTime,
};
}
+58
View File
@@ -0,0 +1,58 @@
import distance from '@turf/distance';
import bearing from '@turf/bearing';
import { point } from '@turf/helpers';
import type { GeoPosition, Boundary } from './types';
export function getDistanceM(a: GeoPosition, b: GeoPosition): number {
return distance(point([a.lng, a.lat]), point([b.lng, b.lat]), { units: 'meters' });
}
export function getBearing(a: GeoPosition, b: GeoPosition): number {
return bearing(point([a.lng, a.lat]), point([b.lng, b.lat]));
}
export function normalizeAngle(angle: number): number {
return ((angle + 180) % 360) - 180;
}
export function angleDifference(a: number, b: number): number {
return Math.abs(normalizeAngle(a - b));
}
export function isInCone(
from: GeoPosition,
to: GeoPosition,
heading: number,
toleranceDeg: number
): boolean {
const targetBearing = getBearing(from, to);
return angleDifference(heading, targetBearing) <= toleranceDeg;
}
export function isInsideBoundary(pos: GeoPosition, boundary?: Boundary): boolean {
if (!boundary) return true;
if (boundary.type === 'circle') {
const d = getDistanceM(pos, boundary.center);
return d <= boundary.radiusM;
}
// Polygon: simple bounding box check for MVP (proper point-in-polygon can be added later)
const lats = boundary.coordinates.map((c) => c.lat);
const lngs = boundary.coordinates.map((c) => c.lng);
return (
pos.lat >= Math.min(...lats) &&
pos.lat <= Math.max(...lats) &&
pos.lng >= Math.min(...lngs) &&
pos.lng <= Math.max(...lngs)
);
}
export function isPositionPlausible(
oldPos: GeoPosition,
newPos: GeoPosition,
dtMs: number,
maxSpeedMs: number = 15
): boolean {
const d = getDistanceM(oldPos, newPos);
const maxDistance = (maxSpeedMs * dtMs) / 1000;
return d <= maxDistance;
}
+138
View File
@@ -0,0 +1,138 @@
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import {
createMatch,
joinMatch,
leaveMatch,
getMatchBySocketId,
setMatchSettings,
} from './rooms';
import { startMatch, handleShot, updatePlayerPosition, serializeMatch, checkWinCondition } from './game';
import type { Shot, GameMode, MatchSettings } from './types';
const app = express();
app.use(cors());
app.use(express.json());
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
transports: ['websocket'],
});
const socketToPlayer = new Map<string, { matchId: string; playerId: string }>();
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
socket.on('room:create', ({ name }: { name: string }) => {
const { match, player } = createMatch(name, socket.id);
socketToPlayer.set(socket.id, { matchId: match.id, playerId: player.id });
socket.join(match.id);
socket.emit('room:created', { code: match.code, matchId: match.id, playerId: player.id, isHost: player.isHost });
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('room:join', ({ code, name }: { code: string; name: string }) => {
const result = joinMatch(code, name, socket.id);
if (!result) {
socket.emit('error:message', 'Raum nicht gefunden oder voll');
return;
}
const { match, player } = result;
socketToPlayer.set(socket.id, { matchId: match.id, playerId: player.id });
socket.join(match.id);
socket.emit('room:joined', { code: match.code, matchId: match.id, playerId: player.id, isHost: player.isHost });
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('room:leave', () => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = leaveMatch(socket.id);
socketToPlayer.delete(socket.id);
socket.leave(info.matchId);
if (match) {
io.to(match.id).emit('room:state', serializeMatch(match));
}
});
socket.on('match:start', ({ settings }: { settings: Partial<MatchSettings> & { mode?: GameMode } }) => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = getMatchBySocketId(socket.id);
if (!match) return;
const player = match.players.get(info.playerId);
if (!player?.isHost) {
socket.emit('error:message', 'Nur der Host kann das Spiel starten');
return;
}
if (settings.mode) setMatchSettings(match, settings, settings.mode);
else setMatchSettings(match, settings);
startMatch(match);
io.to(match.id).emit('match:started', { startTime: match.startTime, settings: match.settings });
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('player:update', ({ pos, heading }: { pos: { lat: number; lng: number }; heading: number }) => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = getMatchBySocketId(socket.id);
if (!match || match.status !== 'running') return;
updatePlayerPosition(match, info.playerId, pos, heading);
// Broadcast positions to all players in match (throttled ideally)
io.to(match.id).emit('state:sync', Array.from(match.players.values()));
});
socket.on('player:shoot', async (shot: Omit<Shot, 'shooterId' | 'serverTimestamp'>) => {
const info = socketToPlayer.get(socket.id);
if (!info) return;
const match = getMatchBySocketId(socket.id);
if (!match || match.status !== 'running') return;
const fullShot: Shot = {
...shot,
shooterId: info.playerId,
serverTimestamp: Date.now(),
};
const hit = await handleShot(match, fullShot);
if (hit) {
io.to(match.id).emit('player:hit', hit);
if (!match.players.get(hit.targetId)?.alive) {
io.to(match.id).emit('player:death', { playerId: hit.targetId, killerId: hit.shooterId });
}
checkWinCondition(match);
}
io.to(match.id).emit('room:state', serializeMatch(match));
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
const info = socketToPlayer.get(socket.id);
if (info) {
const match = leaveMatch(socket.id);
socketToPlayer.delete(socket.id);
if (match) {
io.to(match.id).emit('room:state', serializeMatch(match));
}
}
});
});
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`Mobile Tag server listening on port ${PORT}`);
});
export default httpServer;
+110
View File
@@ -0,0 +1,110 @@
import * as poseDetection from '@tensorflow-models/pose-detection';
import * as tf from '@tensorflow/tfjs-core';
import '@tensorflow/tfjs-backend-cpu';
import { createCanvas, Image } from 'canvas';
import type { BodyPart } from './types';
let detector: poseDetection.PoseDetector | null = null;
export async function initPoseDetector(): Promise<void> {
await tf.setBackend('cpu');
await tf.ready();
if (detector) return;
const model = poseDetection.SupportedModels.MoveNet;
detector = await poseDetection.createDetector(model, {
modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING,
});
}
export interface PoseValidationResult {
personDetected: boolean;
bodyPartDetected: boolean;
confidence: number;
}
function keypointNameForBodyPart(bodyPart: BodyPart): string {
switch (bodyPart) {
case 'head':
return 'nose';
case 'chest':
return 'left_shoulder'; // center of chest approximated
case 'arm':
return 'left_elbow';
case 'leg':
return 'left_knee';
default:
return 'nose';
}
}
function isCenterScreen(x: number, y: number): boolean {
// Consider center 40% of the image
return x >= 0.3 && x <= 0.7 && y >= 0.2 && y <= 0.8;
}
export async function validateShotImage(
imageDataUrl: string | undefined,
bodyPart: BodyPart
): Promise<PoseValidationResult> {
if (!imageDataUrl) {
return { personDetected: false, bodyPartDetected: false, confidence: 0 };
}
if (!detector) {
await initPoseDetector();
}
try {
const base64 = imageDataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64, 'base64');
const img = new Image();
img.src = buffer;
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// Konvertiere Canvas-Pixel-Daten in einen tf.Tensor3D
// (node-canvas ist kein HTMLCanvasElement, daher manuelle Tensor-Erstellung)
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = tf.tensor3d(
new Float32Array(imageData.data),
[img.height, img.width, 4]
);
// RGBA -> RGB: ersten 3 Kanäle nehmen
const rgb = pixels.slice([0, 0, 0], [img.height, img.width, 3]);
const poses = await detector!.estimatePoses(rgb);
// Tensor-Speicher freigeben
pixels.dispose();
rgb.dispose();
if (!poses || poses.length === 0) {
return { personDetected: false, bodyPartDetected: false, confidence: 0 };
}
const pose = poses[0];
const keypointName = keypointNameForBodyPart(bodyPart);
const keypoint = pose.keypoints.find((k) => k.name === keypointName);
if (!keypoint || keypoint.score == null || keypoint.score < 0.3) {
return { personDetected: true, bodyPartDetected: false, confidence: pose.score ?? 0 };
}
const normalizedX = keypoint.x / img.width;
const normalizedY = keypoint.y / img.height;
const center = isCenterScreen(normalizedX, normalizedY);
return {
personDetected: true,
bodyPartDetected: center,
confidence: keypoint.score,
};
} catch (err) {
console.error('[validateShotImage] error:', err);
return { personDetected: false, bodyPartDetected: false, confidence: 0 };
}
}
+130
View File
@@ -0,0 +1,130 @@
import { v4 as uuidv4 } from 'uuid';
import type { Match, Player, MatchSettings, GameMode } from './types';
import { DEFAULT_SETTINGS } from './types';
const matches = new Map<string, Match>();
const playerToMatch = new Map<string, string>(); // socketId -> matchId
function generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export function createMatch(hostName: string, hostSocketId: string): { match: Match; player: Player } {
const matchId = uuidv4();
const playerId = uuidv4();
const player: Player = {
id: playerId,
name: hostName,
socketId: hostSocketId,
position: { lat: 0, lng: 0 },
heading: 0,
health: DEFAULT_SETTINGS.startHealth,
alive: true,
cooldownUntil: 0,
score: 0,
kills: 0,
deaths: 0,
isHost: true,
};
const match: Match = {
id: matchId,
code: generateCode(),
mode: 'ffa',
status: 'lobby',
settings: { ...DEFAULT_SETTINGS },
players: new Map([[playerId, player]]),
createdAt: Date.now(),
};
matches.set(matchId, match);
playerToMatch.set(hostSocketId, matchId);
return { match, player };
}
export function joinMatch(code: string, name: string, socketId: string): { match: Match; player: Player } | null {
const match = Array.from(matches.values()).find((m) => m.code === code.toUpperCase());
if (!match || match.status !== 'lobby' || match.players.size >= match.settings.maxPlayers) {
return null;
}
const playerId = uuidv4();
const player: Player = {
id: playerId,
name,
socketId,
position: { lat: 0, lng: 0 },
heading: 0,
health: match.settings.startHealth,
alive: true,
cooldownUntil: 0,
score: 0,
kills: 0,
deaths: 0,
isHost: false,
};
match.players.set(playerId, player);
playerToMatch.set(socketId, match.id);
return { match, player };
}
export function leaveMatch(socketId: string): Match | null {
const matchId = playerToMatch.get(socketId);
if (!matchId) return null;
const match = matches.get(matchId);
if (!match) return null;
const player = Array.from(match.players.values()).find((p) => p.socketId === socketId);
if (player) {
match.players.delete(player.id);
}
playerToMatch.delete(socketId);
if (match.players.size === 0) {
matches.delete(matchId);
return null;
}
// Promote new host if host left
if (player?.isHost && match.players.size > 0) {
const nextHost = match.players.values().next().value;
if (nextHost) nextHost.isHost = true;
}
return match;
}
export function getMatchBySocketId(socketId: string): Match | null {
const matchId = playerToMatch.get(socketId);
if (!matchId) return null;
return matches.get(matchId) ?? null;
}
export function getMatch(matchId: string): Match | null {
return matches.get(matchId) ?? null;
}
export function setMatchSettings(
match: Match,
settings: Partial<MatchSettings>,
mode?: GameMode
): void {
match.settings = { ...match.settings, ...settings };
if (mode) match.mode = mode;
}
export function cleanupOldMatches(maxAgeMs: number = 3600000): void {
const now = Date.now();
for (const [matchId, match] of matches.entries()) {
if (now - match.createdAt > maxAgeMs && match.status !== 'running') {
matches.delete(matchId);
for (const player of match.players.values()) {
playerToMatch.delete(player.socketId);
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
export type BodyPart = 'head' | 'chest' | 'arm' | 'leg';
export type Team = 'A' | 'B';
export type GameMode = 'ffa' | 'teams';
export type MatchStatus = 'lobby' | 'running' | 'ended';
export interface BodyPartDamage {
head: number;
chest: number;
leg: number;
arm: number;
}
export interface GeoPosition {
lat: number;
lng: number;
}
export interface Player {
id: string;
name: string;
team?: Team;
socketId: string;
position: GeoPosition;
heading: number;
health: number;
alive: boolean;
cooldownUntil: number;
score: number;
kills: number;
deaths: number;
isHost: boolean;
}
export interface CircleBoundary {
type: 'circle';
center: GeoPosition;
radiusM: number;
}
export interface PolygonBoundary {
type: 'polygon';
coordinates: GeoPosition[];
}
export type Boundary = CircleBoundary | PolygonBoundary;
export interface MatchSettings {
maxPlayers: number;
reloadMs: number;
rangeM: number;
angleTolerance: number;
damageByBodyPart: BodyPartDamage;
respawnMs: number;
matchDurationMs: number;
startHealth: number;
powerSaveDimMs: number;
powerSaveSleepMs: number;
}
export interface Match {
id: string;
code: string;
mode: GameMode;
status: MatchStatus;
boundary?: Boundary;
settings: MatchSettings;
players: Map<string, Player>;
startTime?: number;
endTime?: number;
createdAt: number;
}
export interface Shot {
shooterId: string;
clientTimestamp: number;
serverTimestamp: number;
shooterPos: GeoPosition;
heading: number;
bodyPart: BodyPart;
imageData?: string;
}
export interface PoseValidationResult {
personDetected: boolean;
bodyPartDetected: boolean;
confidence: number;
}
export interface HitEvent {
shooterId: string;
shooterName?: string;
targetId: string;
targetName?: string;
bodyPart: BodyPart;
damage: number;
targetHealth: number;
imageData?: string;
poseValidation?: PoseValidationResult;
}
export const DEFAULT_DAMAGE: BodyPartDamage = {
head: 100,
chest: 40,
leg: 25,
arm: 15,
};
export const DEFAULT_SETTINGS: MatchSettings = {
maxPlayers: 10,
reloadMs: 3000,
rangeM: 50,
angleTolerance: 30,
damageByBodyPart: DEFAULT_DAMAGE,
respawnMs: 5000,
matchDurationMs: 600000,
startHealth: 100,
powerSaveDimMs: 10000,
powerSaveSleepMs: 30000,
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"types": ["node"],
"sourceMap": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}