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