Compare commits

...

3 Commits

Author SHA1 Message Date
425660f139 feat: confirm dialog for changing decks 2025-07-08 14:59:24 +02:00
d5a744652d feat: better colab 2025-07-08 14:57:44 +02:00
e9d8a1a818 feat: live collaboration 2025-07-08 14:48:07 +02:00
2 changed files with 257 additions and 82 deletions

View File

@ -8,9 +8,36 @@ const app = express();
const server = http.createServer(app);
const io = new Server(server);
const eventTimestamps = {};
const EVENT_LIMIT = 100; // Max events per second per user
const TIME_FRAME = 1000; // 1 second in milliseconds
io.on('connection', (socket) => {
console.log('a user connected');
// Middleware to check for event spamming
socket.use((packet, next) => {
const userId = socket.id;
const eventName = packet[0];
const now = Date.now();
if (!eventTimestamps[userId]) {
eventTimestamps[userId] = [];
}
// Remove timestamps older than the time frame
eventTimestamps[userId] = eventTimestamps[userId].filter(ts => now - ts < TIME_FRAME);
// Check if the user has exceeded the event limit
if (eventTimestamps[userId].length >= EVENT_LIMIT) {
console.warn(`User ${userId} is sending too many events (${eventName}). Throttling.`);
return next(new Error('Too many events'));
}
eventTimestamps[userId].push(now);
next();
});
socket.on('join', (room) => {
socket.join(room);
console.log(`User joined room: ${room}`);
@ -72,6 +99,14 @@ io.on('connection', (socket) => {
socket.to(data.room).emit('flip-all-cards', data);
});
socket.on('shuffle-mode-change', (data) => {
socket.to(data.room).emit('shuffle-mode-change', data);
});
socket.on('import-canvas', (data) => {
socket.to(data.room).emit('import-canvas', data);
});
socket.on('disconnect', () => {
console.log('user disconnected');
});

View File

@ -1,7 +1,8 @@
<script lang="ts">
import Card from '$lib/Card.svelte';
import StickyNote from '$lib/StickyNote.svelte';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { io, Socket } from 'socket.io-client';
interface CardData {
id: number;
@ -21,7 +22,7 @@
riskCategories?: string[];
}
interface StickyNote {
interface StickyNoteData {
id: number;
text: string;
x: number;
@ -111,11 +112,153 @@
let cards = $state<CardData[]>([]);
let maxZIndex = $state(0);
let stickyNotes = $state<StickyNote[]>([]);
let stickyNotes = $state<StickyNoteData[]>([]);
let nextNoteId = $state(1);
let noteColors = ['#ffeb3b', '#4caf50', '#2196f3', '#ff9800', '#e91e63', '#9c27b0'];
function initializeDeck() {
let socket: Socket;
let room: string;
let isStateSynced = false;
let lastEmitTime = 0;
onMount(() => {
const urlParams = new URLSearchParams(window.location.search);
room = urlParams.get('sessid') || `room-${Math.random().toString(36).substr(2, 9)}`;
if (!urlParams.has('sessid')) {
window.history.replaceState({}, '', `?sessid=${room}`);
}
socket = io();
socket.on('connect', () => {
socket.emit('join', room);
// Request state from other clients, but only if we haven't synced yet
if (!isStateSynced) {
socket.emit('request-state', { room });
}
});
// If no one sends state within 2 seconds, initialize the deck
setTimeout(() => {
if (!isStateSynced) {
initializeDeck();
isStateSynced = true;
}
}, 2000);
socket.on('request-state', (data) => {
const fullState = {
cards,
stickyNotes,
selectedDeck,
shuffleMode,
deckPosition,
maxZIndex,
nextNoteId
};
socket.emit('sync-state', { to: data.from, room, state: fullState });
});
socket.on('sync-state', (data) => {
if (!isStateSynced) {
restoreCanvasState(data.state);
isStateSynced = true;
}
});
socket.on('card-move', (data) => {
const card = cards.find(c => c.id === data.id);
if (card) {
card.x = data.x;
card.y = data.y;
card.zIndex = data.zIndex;
if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
}
});
socket.on('card-click', (data) => {
const card = cards.find(c => c.id === data.id);
if (card) {
card.flipped = !card.flipped;
card.zIndex = data.zIndex;
if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
}
});
socket.on('card-drop', (data) => {
const card = cards.find(c => c.id === data.id);
if (card) {
Object.assign(card, data.cardState);
}
});
socket.on('sticky-note-add', (data) => {
stickyNotes.push(data.note);
if (data.note.id >= nextNoteId) nextNoteId = data.note.id + 1;
if (data.note.zIndex > maxZIndex) maxZIndex = data.note.zIndex;
});
socket.on('sticky-note-move', (data) => {
const note = stickyNotes.find(n => n.id === data.id);
if (note) {
note.x = data.x;
note.y = data.y;
note.zIndex = data.zIndex;
if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
}
});
socket.on('sticky-note-update', (data) => {
const note = stickyNotes.find(n => n.id === data.id);
if (note) {
note.text = data.text;
}
});
socket.on('sticky-note-delete', (data) => {
const index = stickyNotes.findIndex(n => n.id === data.id);
if (index >= 0) {
stickyNotes.splice(index, 1);
}
});
socket.on('deck-change', (data) => {
selectedDeck = data.selectedDeck;
initializeDeck(false); // Don't broadcast, just init
});
socket.on('shuffle-deck', (data) => {
cards = data.cards;
});
socket.on('collect-to-deck', (data) => {
cards = data.cards;
});
socket.on('deal-cards', (data) => {
cards = data.cards;
});
socket.on('flip-all-cards', () => {
cards.forEach(card => card.flipped = !card.flipped);
});
socket.on('shuffle-mode-change', (data) => {
shuffleMode = data.shuffleMode;
});
socket.on('import-canvas', (data) => {
restoreCanvasState(data.state);
});
return () => {
socket.disconnect();
};
});
function initializeDeck(broadcast = true) {
const config = deckConfigs[selectedDeck];
cards = config.cards.map((image, index) => ({
id: index + 1,
@ -128,13 +271,21 @@
isRiskCategory: selectedDeck === 1 && image.includes('1.png')
}));
maxZIndex = cards.length;
stickyNotes = [];
nextNoteId = 1;
if (broadcast && socket) {
socket.emit('deck-change', { room, selectedDeck });
}
}
onMount(() => {
initializeDeck();
});
function handleDeckChange() {
if (!confirm('Are you sure you want to change the deck? This will reset the current session.')) {
// If the user cancels, revert the selection
const previousDeck = cards.length > 0 ? deckConfigs.findIndex(d => d.cards[0] === cards[0].frontImage) : 0;
selectedDeck = previousDeck;
return;
}
initializeDeck();
}
@ -144,17 +295,21 @@
card.x = x;
card.y = y;
card.zIndex = ++maxZIndex;
const now = Date.now();
if (now - lastEmitTime > 16) { // Throttle to ~60fps
socket.emit('card-move', { room, id, x, y, zIndex: card.zIndex });
lastEmitTime = now;
}
}
}
function handleCardDrop(id: number, x: number, y: number) {
const card = cards.find(c => c.id === id);
if (card) {
// Calculate card center position (card is 220x300px)
const cardCenterX = x + 110;
const cardCenterY = y + 150;
// Check if card center is dropped on deck area
if (
cardCenterX >= deckPosition.x &&
cardCenterX <= deckPosition.x + 220 &&
@ -162,7 +317,6 @@
cardCenterY <= deckPosition.y + 300 &&
!card.inDeck
) {
// Move card to top of deck
const deckCards = cards.filter(c => c.inDeck);
const topZ = deckCards.length > 0 ? Math.max(...deckCards.map(c => c.zIndex)) + 1 : 0;
@ -172,9 +326,9 @@
card.flipped = true;
card.inDeck = true;
} else if (card.inDeck) {
// Card was moved out of deck
card.inDeck = false;
}
socket.emit('card-drop', { room, id, cardState: { ...card } });
}
}
@ -182,6 +336,7 @@
const card = cards.find(c => c.id === id);
if (card) {
card.zIndex = ++maxZIndex;
socket.emit('card-click', { room, id, zIndex: card.zIndex });
}
}
@ -200,6 +355,7 @@
} else {
normalShuffle(deckCards);
}
socket.emit('shuffle-deck', { room, cards });
}
function normalShuffle(deckCards: CardData[]) {
@ -281,6 +437,7 @@
}
function collectToDeck() {
if (!confirm('Are you sure you want to collect all cards to the deck?')) return;
cards.forEach((card, index) => {
card.x = deckPosition.x + index * 2;
card.y = deckPosition.y + index * 2;
@ -288,9 +445,11 @@
card.flipped = true;
card.inDeck = true;
});
socket.emit('collect-to-deck', { room, cards });
}
function dealCards() {
if (!confirm('Are you sure you want to deal all cards?')) return;
cards.forEach((card, index) => {
card.x = 50 + 330 + (index % 6) * 240;
card.y = 50 + Math.floor(index / 6) * 320;
@ -298,45 +457,34 @@
card.flipped = false;
card.inDeck = false;
});
socket.emit('deal-cards', { room, cards });
}
function flipAllCards() {
cards.forEach(card => {
card.flipped = !card.flipped;
});
socket.emit('flip-all-cards', { room });
}
function handleCanvasWheel(event: WheelEvent) {
event.preventDefault();
if (event.ctrlKey || event.metaKey) {
// Zoom with mouse wheel + ctrl/cmd towards viewport center
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
// Get viewport center
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Convert viewport center to canvas coordinates before zoom
const canvasCenterX = (centerX - canvasOffset.x) / canvasZoom;
const canvasCenterY = (centerY - canvasOffset.y) / canvasZoom;
// Update zoom
canvasZoom = newZoom;
// Adjust offset to keep the viewport center fixed
canvasOffset.x = centerX - canvasCenterX * canvasZoom;
canvasOffset.y = centerY - canvasCenterY * canvasZoom;
} else {
// Pan with mouse wheel
const scrollSpeed = 1 / canvasZoom; // Adjust pan speed for zoom level
const scrollSpeed = 1;
canvasOffset.x -= event.deltaX * scrollSpeed;
canvasOffset.y -= event.deltaY * scrollSpeed;
canvasOffset.x = Math.max(-2000, Math.min(2000, canvasOffset.x));
canvasOffset.y = Math.max(-2000, Math.min(2000, canvasOffset.y));
}
}
@ -356,9 +504,8 @@
event.preventDefault();
const deltaX = event.clientX - dragStart.x;
const deltaY = event.clientY - dragStart.y;
canvasOffset.x = Math.max(-2000, Math.min(2000, dragStartOffset.x + deltaX));
canvasOffset.y = Math.max(-2000, Math.min(2000, dragStartOffset.y + deltaY));
canvasOffset.x = dragStartOffset.x + deltaX;
canvasOffset.y = dragStartOffset.y + deltaY;
}
}
@ -373,7 +520,6 @@
}
function getTouchDistance(touches: TouchList) {
if (touches.length < 2) return 0;
const touch1 = touches[0];
const touch2 = touches[1];
return Math.sqrt(
@ -383,7 +529,6 @@
}
function getTouchCenter(touches: TouchList) {
if (touches.length < 2) return { x: 0, y: 0 };
const touch1 = touches[0];
const touch2 = touches[1];
return {
@ -393,19 +538,12 @@
}
function handleCanvasTouchStart(event: TouchEvent) {
isDragging = true;
if (event.touches.length >= 2) {
// Two or more touches - pinch zoom
isPinching = true;
lastPinchDistance = getTouchDistance(event.touches);
pinchCenter = getTouchCenter(event.touches);
// Convert pinch center to canvas coordinates
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
pinchCenter.x -= rect.left;
pinchCenter.y -= rect.top;
} else if (event.touches.length === 1) {
// Single touch - pan
isDragging = true;
const touch = event.touches[0];
dragStart.x = touch.clientX;
dragStart.y = touch.clientY;
@ -417,29 +555,27 @@
function handleCanvasTouchMove(event: TouchEvent) {
if (event.touches.length === 1 && isDragging && !isPinching) {
// Single touch pan
const touch = event.touches[0];
const deltaX = touch.clientX - dragStart.x;
const deltaY = touch.clientY - dragStart.y;
canvasOffset.x = Math.max(-2000, Math.min(2000, dragStartOffset.x + deltaX));
canvasOffset.y = Math.max(-2000, Math.min(2000, dragStartOffset.y + deltaY));
canvasOffset.x = dragStartOffset.x + deltaX;
canvasOffset.y = dragStartOffset.y + deltaY;
} else if (event.touches.length === 2 && isPinching) {
// Pinch zoom
const currentDistance = getTouchDistance(event.touches);
const zoomFactor = currentDistance / lastPinchDistance;
const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
// Convert pinch center to canvas coordinates before zoom
const canvasCenterX = (pinchCenter.x - canvasOffset.x) / canvasZoom;
const canvasCenterY = (pinchCenter.y - canvasOffset.y) / canvasZoom;
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const pinchCenterX = pinchCenter.x - rect.left;
const pinchCenterY = pinchCenter.y - rect.top;
const canvasCenterX = (pinchCenterX - canvasOffset.x) / canvasZoom;
const canvasCenterY = (pinchCenterY - canvasOffset.y) / canvasZoom;
// Update zoom
canvasZoom = newZoom;
// Adjust offset to keep the pinch center fixed
canvasOffset.x = pinchCenter.x - canvasCenterX * canvasZoom;
canvasOffset.y = pinchCenter.y - canvasCenterY * canvasZoom;
canvasOffset.x = pinchCenterX - canvasCenterX * canvasZoom;
canvasOffset.y = pinchCenterY - canvasCenterY * canvasZoom;
lastPinchDistance = currentDistance;
}
@ -447,38 +583,29 @@
}
function handleCanvasTouchEnd(event: TouchEvent) {
if (event.touches.length === 0) {
isDragging = false;
isPinching = false;
} else if (event.touches.length === 1) {
isPinching = false;
// A pinch ended. Reset pan state for the remaining finger to prevent a jump.
const touch = event.touches[0];
dragStart.x = touch.clientX;
dragStart.y = touch.clientY;
dragStartOffset.x = canvasOffset.x;
dragStartOffset.y = canvasOffset.y;
}
event.preventDefault();
if (event.touches.length < 2) isPinching = false;
if (event.touches.length < 1) isDragging = false;
}
function addStickyNote() {
const randomColor = noteColors[Math.floor(Math.random() * noteColors.length)];
const newNote: StickyNote = {
const newNote: StickyNoteData = {
id: nextNoteId++,
text: 'New note',
x: 400 - canvasOffset.x,
y: 200 - canvasOffset.y,
text: '',
x: (400 - canvasOffset.x) / canvasZoom,
y: (200 - canvasOffset.y) / canvasZoom,
zIndex: ++maxZIndex,
color: randomColor
};
stickyNotes.push(newNote);
socket.emit('sticky-note-add', { room, note: newNote });
}
function updateStickyNote(id: number, text: string) {
const note = stickyNotes.find(n => n.id === id);
if (note) {
note.text = text;
socket.emit('sticky-note-update', { room, id, text });
}
}
@ -488,6 +615,11 @@
note.x = x;
note.y = y;
note.zIndex = ++maxZIndex;
const now = Date.now();
if (now - lastEmitTime > 16) { // Throttle to ~60fps
socket.emit('sticky-note-move', { room, id, x, y, zIndex: note.zIndex });
lastEmitTime = now;
}
}
}
@ -495,6 +627,7 @@
const index = stickyNotes.findIndex(n => n.id === id);
if (index >= 0) {
stickyNotes.splice(index, 1);
socket.emit('sticky-note-delete', { room, id });
}
}
@ -504,8 +637,6 @@
shuffleMode,
cards: cards.map(card => ({...card})),
stickyNotes: stickyNotes.map(note => ({...note})),
canvasOffset,
canvasZoom,
deckPosition,
maxZIndex,
nextNoteId,
@ -525,6 +656,16 @@
URL.revokeObjectURL(url);
}
function restoreCanvasState(state: any) {
selectedDeck = state.selectedDeck ?? 0;
shuffleMode = state.shuffleMode ?? 'normal';
cards = state.cards ?? [];
stickyNotes = state.stickyNotes ?? [];
deckPosition = state.deckPosition ?? { x: 50, y: 300 };
maxZIndex = state.maxZIndex ?? (cards.length + (stickyNotes.length || 0));
nextNoteId = state.nextNoteId ?? 1;
}
function importCanvas() {
const input = document.createElement('input');
input.type = 'file';
@ -538,22 +679,15 @@
try {
const canvasState = JSON.parse(e.target?.result as string);
// Validate the imported data structure
if (!canvasState.cards || !Array.isArray(canvasState.cards)) {
alert('Invalid canvas file format');
return;
}
// Restore state
selectedDeck = canvasState.selectedDeck || 0;
shuffleMode = canvasState.shuffleMode || 'normal';
cards = canvasState.cards || [];
stickyNotes = canvasState.stickyNotes || [];
canvasOffset = canvasState.canvasOffset || { x: 0, y: 0 };
canvasZoom = canvasState.canvasZoom || 1;
deckPosition = canvasState.deckPosition || { x: 50, y: 300 };
maxZIndex = canvasState.maxZIndex || cards.length;
nextNoteId = canvasState.nextNoteId || 1;
restoreCanvasState(canvasState);
// Broadcast imported state to others
socket.emit('import-canvas', { room, state: canvasState });
} catch (error) {
alert('Error reading canvas file: ' + error);
@ -565,6 +699,12 @@
input.click();
document.body.removeChild(input);
}
$effect(() => {
if (socket) {
socket.emit('shuffle-mode-change', { room, shuffleMode });
}
});
</script>
<div class="game-container">
@ -772,4 +912,4 @@
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
</style>
</style>