Feature: public rooms, mobile UX, reconnection, and gameplay fixes
Rooms & lobby
- Rename docker-compose.yml → compose.yml
- Public/Private toggle on room creation; public rooms assign random seats
to prevent team collusion
- GET /api/rooms API — lists open public rooms; Join tab shows live list
with one-tap join
- Room creator: swap any two seats by tapping (select-to-swap UI); ▶ Start
Game button force-starts with bots filling empty seats
Reconnection
- Session moved from sessionStorage → localStorage (survives browser close)
- Socket handlers split: socket.once for one-shot callbacks, persistent
socket.on('connect') for auto-rejoin on network drops
- Server rejoin accepts userId match as fallback (cross-device rejoin for
authenticated users); re-issues token on success
- Server emits hasActiveGame on connect so auth'd users on a new device are
pulled back into their game automatically
- Explicit leave nulls seat/token/userIds so hasActiveGame never re-drags a
player back in after they chose to leave
Mobile UX
- Remove all opponent/partner card backs; replace with compact card-count
badge — frees ~120px of vertical space on small phones
- Screen height: 100dvh (dynamic viewport) instead of 100vh — fixes the
"only top 1/5 visible" issue on phones with browser chrome
- Table grid side columns shrunk to 36px on touch devices; player names
rotated vertically
- Bidding overlay: transparent non-blocking top panel on touch; hand stays
visible and interactive; auto fan-mode during bidding
- touch-action: pan-x on hand scroll, none in fan/drag mode — suppresses
Android back-gesture and Google Gemini conflicts
- user-select: none on game screen prevents long-press selection menus
Gameplay & notifications
- Center trick area now shows whose turn it is instead of trump (trump is
already in the info bar); flashes gold when it's the player's turn
- Turn reminder after 5 s of inaction: gold glow pulse on hand area
+ Android vibration OR two-note Web Audio chime on iOS (vibrate API not
supported by Apple)
- Fix: turn reminder was never triggered after winning a trick — justWon
branch blocked myTurnNow from being set even when currentTurn === mySeat
- Waiting room ☰ menu: Reload and Exit accessible without entering the game
- Prevent duplicate room joins (same socket, same userId, or same name)
Service worker
- Bump to shelem-v2; pre-cache all 55 card SVGs at install time so cards
are available instantly from the very first hand, including offline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,4 @@ data/
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
|
||||
+255
-44
@@ -14,13 +14,69 @@ let lastState = null;
|
||||
// Lobby selections
|
||||
let selectedJoker = true;
|
||||
let selectedScore = 505;
|
||||
let selectedPublic = false;
|
||||
let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
|
||||
|
||||
// Widow discard state
|
||||
let widowSelected = [];
|
||||
|
||||
// Bid UI state
|
||||
let currentBidAmount = 85;
|
||||
let lastBidHandNumber = -1; // detect new hands so we can reset the bid amount
|
||||
let lastBidHandNumber = -1;
|
||||
|
||||
// Turn reminder state
|
||||
let turnReminderTimer = null;
|
||||
let turnReminderActive = false;
|
||||
|
||||
// Unlock Web Audio on first user gesture (required by iOS)
|
||||
let _audioCtx = null;
|
||||
function getAudioCtx() {
|
||||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (_audioCtx.state === 'suspended') _audioCtx.resume();
|
||||
return _audioCtx;
|
||||
}
|
||||
document.addEventListener('pointerdown', () => getAudioCtx(), { once: true });
|
||||
|
||||
function playTurnChime() {
|
||||
try {
|
||||
const ctx = getAudioCtx();
|
||||
const now = ctx.currentTime;
|
||||
// Two-note chime: G5 then B5
|
||||
[[784, 0], [988, 0.18]].forEach(([freq, delay]) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0, now + delay);
|
||||
gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.5);
|
||||
});
|
||||
} catch (e) { /* audio not available */ }
|
||||
}
|
||||
|
||||
function handleTurnReminder(isMyTurn) {
|
||||
if (isMyTurn && !turnReminderActive) {
|
||||
turnReminderActive = true;
|
||||
$('my-area')?.classList.remove('turn-urgent');
|
||||
turnReminderTimer = setTimeout(() => {
|
||||
$('my-area')?.classList.add('turn-urgent');
|
||||
// Vibrate on Android; chime on iOS (vibrate not supported there)
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate([300, 100, 300]);
|
||||
} else {
|
||||
playTurnChime();
|
||||
}
|
||||
}, 5000);
|
||||
} else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) {
|
||||
clearTimeout(turnReminderTimer);
|
||||
turnReminderTimer = null;
|
||||
turnReminderActive = false;
|
||||
$('my-area')?.classList.remove('turn-urgent');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Play / hand-display mode (mirror of Hearts) ──────────────
|
||||
function isTouchDevice() {
|
||||
@@ -261,6 +317,7 @@ function initLobby() {
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
$('tab-' + btn.dataset.tab)?.classList.add('active');
|
||||
if (btn.dataset.tab === 'join') loadPublicRooms();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,9 +339,19 @@ function initLobby() {
|
||||
});
|
||||
});
|
||||
|
||||
// Visibility toggle
|
||||
document.querySelectorAll('.visibility-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
selectedPublic = btn.dataset.public === 'true';
|
||||
});
|
||||
});
|
||||
|
||||
$('btn-create').addEventListener('click', createGame);
|
||||
$('btn-join').addEventListener('click', joinGame);
|
||||
$('btn-spectate').addEventListener('click', spectateGame);
|
||||
$('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
|
||||
|
||||
$('btn-show-auth').addEventListener('click', () => {
|
||||
show('overlay-auth');
|
||||
@@ -316,10 +383,45 @@ function createGame() {
|
||||
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
connectSocket(() => {
|
||||
socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore });
|
||||
socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore, isPublic: selectedPublic });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPublicRooms() {
|
||||
const listEl = $('public-rooms-list');
|
||||
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
||||
try {
|
||||
const r = await fetch('/api/rooms');
|
||||
const list = await r.json();
|
||||
if (list.length === 0) {
|
||||
listEl.innerHTML = '<p class="hint">No public rooms right now.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
list.forEach(room => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'public-room-item';
|
||||
row.innerHTML = `
|
||||
<div class="public-room-info">
|
||||
<span class="public-room-host">${escHtml(room.hostName)}'s room</span>
|
||||
<span class="public-room-meta">${room.playerCount}/4 · ${room.jokerMode ? '🃏' : '♠'} ${room.winScore}</span>
|
||||
</div>
|
||||
<button class="btn-join-pub btn-primary" data-room="${escHtml(room.id)}">Join</button>`;
|
||||
row.querySelector('.btn-join-pub').addEventListener('click', () => {
|
||||
$('input-code').value = room.id;
|
||||
joinGame();
|
||||
});
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
} catch {
|
||||
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function joinGame() {
|
||||
const name = $('input-name').value.trim();
|
||||
const code = $('input-code').value.trim().toUpperCase();
|
||||
@@ -344,9 +446,20 @@ function spectateGame() {
|
||||
// ─── Socket ───────────────────────────────────────────────────
|
||||
function connectSocket(onReady) {
|
||||
if (socket?.connected) { onReady(); return; }
|
||||
if (!socket) {
|
||||
socket = io({ auth: { token: authToken } });
|
||||
initSocketHandlers();
|
||||
}
|
||||
socket.once('connect', onReady);
|
||||
}
|
||||
|
||||
socket.on('connect', onReady);
|
||||
function initSocketHandlers() {
|
||||
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
|
||||
socket.on('connect', () => {
|
||||
if (myRoomId && mySeat >= 0 && myToken) {
|
||||
socket.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('created', ({ roomId, seat, token }) => {
|
||||
myRoomId = roomId; mySeat = seat; myToken = token;
|
||||
@@ -364,11 +477,32 @@ function connectSocket(onReady) {
|
||||
});
|
||||
socket.on('rejoined', ({ roomId, seat, token }) => {
|
||||
myRoomId = roomId; mySeat = seat; myToken = token;
|
||||
saveSession();
|
||||
});
|
||||
|
||||
socket.on('seatChanged', ({ seat, token }) => {
|
||||
mySeat = seat; myToken = token;
|
||||
saveSession();
|
||||
});
|
||||
|
||||
// Server pushes this when an authenticated user connects with an in-progress game
|
||||
// on a different device (token on that device is unknown / stale)
|
||||
socket.on('hasActiveGame', ({ roomId, seat, token }) => {
|
||||
if (myRoomId) return; // already have a session, ignore
|
||||
myRoomId = roomId; mySeat = seat; myToken = token;
|
||||
saveSession();
|
||||
socket.emit('rejoin', { roomId, seat, token });
|
||||
});
|
||||
|
||||
socket.on('joinError', ({ message } = {}) => { $('lobby-error').textContent = message || 'Could not join room.'; });
|
||||
socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; });
|
||||
socket.on('rejoinError', () => { clearSession(); });
|
||||
socket.on('rejoinError', () => {
|
||||
// Always clear stale session and return to lobby — hasActiveGame will
|
||||
// re-establish it for authenticated users who have a game on another device
|
||||
clearSession();
|
||||
myRoomId = null; mySeat = -1; myToken = null;
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; });
|
||||
socket.on('playError', (msg) => { console.warn('playError:', msg); });
|
||||
|
||||
@@ -381,24 +515,26 @@ function connectSocket(onReady) {
|
||||
socket.on('disconnect', () => {});
|
||||
}
|
||||
|
||||
// ─── Session persistence ──────────────────────────────────────
|
||||
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
|
||||
function saveSession() {
|
||||
sessionStorage.setItem('shelem_room', myRoomId);
|
||||
sessionStorage.setItem('shelem_seat', mySeat);
|
||||
sessionStorage.setItem('shelem_token', myToken);
|
||||
localStorage.setItem('shelem_room', myRoomId);
|
||||
localStorage.setItem('shelem_seat', mySeat);
|
||||
localStorage.setItem('shelem_token', myToken);
|
||||
}
|
||||
function clearSession() {
|
||||
sessionStorage.removeItem('shelem_room');
|
||||
sessionStorage.removeItem('shelem_seat');
|
||||
sessionStorage.removeItem('shelem_token');
|
||||
localStorage.removeItem('shelem_room');
|
||||
localStorage.removeItem('shelem_seat');
|
||||
localStorage.removeItem('shelem_token');
|
||||
}
|
||||
function tryRejoin() {
|
||||
const room = sessionStorage.getItem('shelem_room');
|
||||
const seat = sessionStorage.getItem('shelem_seat');
|
||||
const token = sessionStorage.getItem('shelem_token');
|
||||
const room = localStorage.getItem('shelem_room');
|
||||
const seat = localStorage.getItem('shelem_seat');
|
||||
const token = localStorage.getItem('shelem_token');
|
||||
if (!room || seat === null || !token) return;
|
||||
myRoomId = room; mySeat = +seat; myToken = token;
|
||||
// Set inside the callback so the persistent 'connect' handler doesn't
|
||||
// also emit rejoin on the very first connection (would be a duplicate)
|
||||
connectSocket(() => {
|
||||
myRoomId = room; mySeat = +seat; myToken = token;
|
||||
socket.emit('rejoin', { roomId: room, seat: +seat, token });
|
||||
});
|
||||
}
|
||||
@@ -414,21 +550,57 @@ function renderWaitingRoom(st) {
|
||||
$('waiting-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers';
|
||||
$('waiting-score-label').textContent = `Win: ${st.winScore}`;
|
||||
|
||||
// Public badge
|
||||
let pubBadge = $('waiting-public-badge');
|
||||
if (!pubBadge) {
|
||||
pubBadge = document.createElement('span');
|
||||
pubBadge.id = 'waiting-public-badge';
|
||||
pubBadge.className = 'waiting-opt-label';
|
||||
$('waiting-options').appendChild(pubBadge);
|
||||
}
|
||||
pubBadge.textContent = st.isPublic ? '🌐 Public' : '🔒 Private';
|
||||
|
||||
const isCreator = mySeat === 0;
|
||||
|
||||
// Seat slots — with swap UI for creator
|
||||
const slots = document.querySelectorAll('.seat-slot');
|
||||
slots.forEach(slot => {
|
||||
const s = +slot.dataset.seat;
|
||||
const name = slot.querySelector('.seat-name');
|
||||
name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—');
|
||||
|
||||
if (isCreator) {
|
||||
slot.classList.toggle('seat-swappable', true);
|
||||
slot.classList.toggle('seat-swap-selected', swapSelectedSeat === s);
|
||||
slot.onclick = () => {
|
||||
if (swapSelectedSeat === -1) {
|
||||
swapSelectedSeat = s;
|
||||
} else if (swapSelectedSeat === s) {
|
||||
swapSelectedSeat = -1;
|
||||
} else {
|
||||
socket?.emit('swapSeats', { roomId: myRoomId, seatA: swapSelectedSeat, seatB: s });
|
||||
swapSelectedSeat = -1;
|
||||
}
|
||||
renderWaitingRoom(lastState);
|
||||
};
|
||||
} else {
|
||||
slot.classList.remove('seat-swappable', 'seat-swap-selected');
|
||||
slot.onclick = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Status text
|
||||
const humanCount = st.seats ? st.seats.filter(Boolean).length : 0;
|
||||
const botCount = st.bots.filter(Boolean).length;
|
||||
const filled = humanCount + botCount;
|
||||
$('waiting-status').textContent = filled < 4
|
||||
let statusText = filled < 4
|
||||
? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…`
|
||||
: 'Starting game…';
|
||||
: 'Ready to start!';
|
||||
if (isCreator && swapSelectedSeat >= 0) statusText = 'Tap another seat to swap ↕';
|
||||
$('waiting-status').textContent = statusText;
|
||||
|
||||
if (mySeat === 0) show('btn-fill-bots'); else hide('btn-fill-bots');
|
||||
// Creator controls
|
||||
if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls');
|
||||
}
|
||||
|
||||
// ─── Main render ──────────────────────────────────────────────
|
||||
@@ -526,46 +698,63 @@ function renderTable(st) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase message
|
||||
// Phase message — whose turn it is
|
||||
let msg = '';
|
||||
if (st.state === 'BIDDING') msg = 'Bidding…';
|
||||
else if (st.state === 'WIDOW') msg = 'Picking widow…';
|
||||
else if (st.state === 'PLAYING' && !st.trump) msg = 'First card sets trump…';
|
||||
else if (st.state === 'PLAYING' && st.trump) msg = `Trump: ${suitSymbol(st.trump)}`;
|
||||
$('phase-msg').textContent = msg;
|
||||
let myTurnNow = false;
|
||||
if (st.state === 'BIDDING') {
|
||||
const bidder = st.names[st.currentBidder] || '?';
|
||||
msg = st.currentBidder === mySeat ? 'Your bid' : `${bidder} bidding`;
|
||||
} else if (st.state === 'WIDOW') {
|
||||
const dec = st.names[st.declarer] || '?';
|
||||
msg = st.declarer === mySeat ? 'Pick widow' : `${dec} picking widow`;
|
||||
} else if (st.state === 'PLAYING') {
|
||||
const justWon = st.trick.length === 0 && st.lastTrickWinner >= 0 && st.tricksPlayed > 0;
|
||||
if (justWon) {
|
||||
const w = st.names[st.lastTrickWinner] || '?';
|
||||
msg = st.lastTrickWinner === mySeat ? 'You won!' : `${w.slice(0,9)} won`;
|
||||
// Still need to lead — turn reminder must fire even during the "won" flash
|
||||
if (st.currentTurn === mySeat) myTurnNow = true;
|
||||
} else if (st.currentTurn === mySeat) {
|
||||
msg = 'Your turn!';
|
||||
myTurnNow = true;
|
||||
} else if (st.currentTurn >= 0) {
|
||||
msg = `${(st.names[st.currentTurn] || '?').slice(0,9)}'s turn`;
|
||||
}
|
||||
}
|
||||
const pm = $('phase-msg');
|
||||
pm.textContent = msg;
|
||||
pm.classList.toggle('your-turn', myTurnNow);
|
||||
|
||||
handleTurnReminder(myTurnNow && !spectating);
|
||||
|
||||
// Spectator banner
|
||||
spectating ? show('spectator-banner') : hide('spectator-banner');
|
||||
}
|
||||
|
||||
function renderOpponent(slot, seat, st) {
|
||||
const name = $(`${slot}-name`);
|
||||
const turn = $(`${slot}-turn`);
|
||||
const score = $(`${slot}-score`);
|
||||
const cards = $(`${slot}-cards`);
|
||||
const nameEl = $(`${slot}-name`);
|
||||
const turnEl = $(`${slot}-turn`);
|
||||
const scoreEl = $(`${slot}-score`);
|
||||
const countEl = $(`${slot}-count`);
|
||||
const partnerEl = $(`${slot}-partner`);
|
||||
|
||||
name.textContent = st.names[seat] || '—';
|
||||
score.textContent = st.scores[teamOf(seat)];
|
||||
nameEl.textContent = st.names[seat] || '—';
|
||||
scoreEl.textContent = st.scores[teamOf(seat)];
|
||||
|
||||
if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`);
|
||||
else hide(`${slot}-turn`);
|
||||
|
||||
// Card count badge
|
||||
if (countEl) {
|
||||
const n = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length;
|
||||
countEl.textContent = n > 0 ? `${n}` : '';
|
||||
}
|
||||
|
||||
// Partner badge for top player
|
||||
if (slot === 'top' && mySeat >= 0 && partnerEl) {
|
||||
const isPartner = teamOf(seat) === teamOf(mySeat);
|
||||
isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`);
|
||||
}
|
||||
|
||||
// Show card backs
|
||||
cards.innerHTML = '';
|
||||
const count = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length;
|
||||
const vertical = slot === 'left' || slot === 'right';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cb = makeCardBack();
|
||||
if (vertical) cb.style.width = '14px';
|
||||
cards.appendChild(cb);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrick(st, slots) {
|
||||
@@ -586,9 +775,7 @@ function renderTrick(st, slots) {
|
||||
}
|
||||
}
|
||||
|
||||
if (st.lastTrickWinner >= 0 && st.trick.length === 0) {
|
||||
$('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`;
|
||||
}
|
||||
// phase-msg for trick winner is handled in renderTable
|
||||
}
|
||||
|
||||
function renderMyHand(st) {
|
||||
@@ -1076,6 +1263,21 @@ function wireGameEvents() {
|
||||
clearSession();
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
$('btn-waiting-menu').addEventListener('click', () => {
|
||||
$('waiting-menu-dropdown').classList.toggle('hidden');
|
||||
});
|
||||
$('btn-waiting-reload').addEventListener('click', () => {
|
||||
if (myRoomId && mySeat >= 0 && myToken) {
|
||||
socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||
}
|
||||
hide('waiting-menu-dropdown');
|
||||
});
|
||||
$('btn-waiting-exit').addEventListener('click', () => {
|
||||
hide('waiting-menu-dropdown');
|
||||
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||
clearSession();
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
$('btn-copy').addEventListener('click', async () => {
|
||||
const code = $('display-room-code').textContent.trim();
|
||||
const btn = $('btn-copy');
|
||||
@@ -1101,13 +1303,19 @@ function wireGameEvents() {
|
||||
$('btn-fill-bots').addEventListener('click', () => {
|
||||
socket?.emit('fillBots', { roomId: myRoomId });
|
||||
});
|
||||
$('btn-start-game').addEventListener('click', () => {
|
||||
socket?.emit('forceStart', { roomId: myRoomId });
|
||||
});
|
||||
|
||||
// Game menu
|
||||
$('btn-game-menu').addEventListener('click', () => {
|
||||
$('game-menu-dropdown').classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('.game-menu-wrap')) hide('game-menu-dropdown');
|
||||
if (!e.target.closest('.game-menu-wrap')) {
|
||||
hide('game-menu-dropdown');
|
||||
hide('waiting-menu-dropdown');
|
||||
}
|
||||
});
|
||||
$('btn-refresh-game').addEventListener('click', () => {
|
||||
if (myRoomId && mySeat >= 0 && myToken) {
|
||||
@@ -1251,4 +1459,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Try to rejoin previous session
|
||||
tryRejoin();
|
||||
// For authenticated users with no local session, connect so the server
|
||||
// can push hasActiveGame (enables cross-device rejoin)
|
||||
if (authToken && !myRoomId) connectSocket(() => {});
|
||||
});
|
||||
|
||||
+31
-4
@@ -57,6 +57,13 @@
|
||||
<button class="score-btn" data-score="1005">1005</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<div class="visibility-row">
|
||||
<button class="visibility-btn active" data-public="false">🔒 Private</button>
|
||||
<button class="visibility-btn" data-public="true">🌐 Public</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-create" class="btn-primary">Create Game</button>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +74,14 @@
|
||||
</div>
|
||||
<button id="btn-join" class="btn-primary">Join Game</button>
|
||||
<hr class="divider">
|
||||
<div class="public-rooms-section">
|
||||
<div class="rooms-header">
|
||||
<span class="rooms-label">Public Rooms</span>
|
||||
<button id="btn-refresh-rooms" class="btn-text">↺</button>
|
||||
</div>
|
||||
<div id="public-rooms-list"><p class="hint">Loading…</p></div>
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<div class="field">
|
||||
<label>Watch as spectator</label>
|
||||
<input id="input-spectate-code" type="text" maxlength="8" placeholder="Room code" style="text-transform:uppercase" autocomplete="off">
|
||||
@@ -81,7 +96,16 @@
|
||||
<!-- ══════════════ WAITING ROOM ══════════════ -->
|
||||
<div id="screen-waiting" class="screen">
|
||||
<div class="waiting-box">
|
||||
<div class="waiting-top-row">
|
||||
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
|
||||
<div class="game-menu-wrap" style="margin-left:auto">
|
||||
<button id="btn-waiting-menu" class="btn-leave-screen" title="Options">☰</button>
|
||||
<div id="waiting-menu-dropdown" class="game-menu-dropdown hidden">
|
||||
<button id="btn-waiting-reload" class="game-menu-item">↺ Reload</button>
|
||||
<button id="btn-waiting-exit" class="game-menu-item game-menu-exit">🚪 Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Waiting for Players</h2>
|
||||
<div class="room-code-box">
|
||||
<span class="label">Room Code</span>
|
||||
@@ -103,7 +127,10 @@
|
||||
<span class="waiting-opt-label" id="waiting-mode-label"></span>
|
||||
<span class="waiting-opt-label" id="waiting-score-label"></span>
|
||||
</div>
|
||||
<button id="btn-fill-bots" class="btn-fill-bots hidden">🤖 Fill empty seats with bots</button>
|
||||
<div id="waiting-creator-controls" class="waiting-creator-controls hidden">
|
||||
<button id="btn-fill-bots" class="btn-fill-bots">🤖 Fill with bots</button>
|
||||
<button id="btn-start-game" class="btn-start-game">▶ Start Game</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,8 +176,8 @@
|
||||
<span id="top-turn" class="turn-dot hidden">●</span>
|
||||
<span id="top-partner" class="partner-badge">partner</span>
|
||||
<span id="top-score" class="player-score-badge"></span>
|
||||
<span id="top-count" class="card-count-badge"></span>
|
||||
</div>
|
||||
<div id="top-cards" class="opp-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- Left player (seat 1) -->
|
||||
@@ -159,8 +186,8 @@
|
||||
<span id="left-name">—</span>
|
||||
<span id="left-turn" class="turn-dot hidden">●</span>
|
||||
<span id="left-score" class="player-score-badge"></span>
|
||||
<span id="left-count" class="card-count-badge"></span>
|
||||
</div>
|
||||
<div id="left-cards" class="opp-cards vertical"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center trick area -->
|
||||
@@ -182,8 +209,8 @@
|
||||
<span id="right-name">—</span>
|
||||
<span id="right-turn" class="turn-dot hidden">●</span>
|
||||
<span id="right-score" class="player-score-badge"></span>
|
||||
<span id="right-count" class="card-count-badge"></span>
|
||||
</div>
|
||||
<div id="right-cards" class="opp-cards vertical"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /table-grid -->
|
||||
|
||||
+216
-12
@@ -28,7 +28,11 @@ html, body {
|
||||
|
||||
/* ── Screens ──────────────────────────────────── */
|
||||
.screen { display: none; }
|
||||
.screen.active { display: flex; flex-direction: column; height: 100vh; }
|
||||
.screen.active {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100vh; /* fallback */
|
||||
height: 100dvh; /* modern: excludes browser chrome / address bar */
|
||||
}
|
||||
|
||||
/* ── Lobby ────────────────────────────────────── */
|
||||
#screen-lobby {
|
||||
@@ -119,6 +123,26 @@ input[type=text], input[type=password], input[type=email] {
|
||||
}
|
||||
input:focus { border-color: var(--gold); }
|
||||
|
||||
/* ── Visibility toggle (Private / Public) ─────── */
|
||||
.visibility-row { display: flex; gap: 6px; }
|
||||
.visibility-btn {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
background: rgba(255,255,255,.07);
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: .82rem;
|
||||
text-align: center;
|
||||
transition: background .15s;
|
||||
}
|
||||
.visibility-btn.active, .visibility-btn:hover {
|
||||
background: rgba(245,197,24,.2);
|
||||
border-color: var(--gold);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* ── Mode / score buttons ─────────────────────── */
|
||||
.mode-row, .score-row { display: flex; gap: 6px; }
|
||||
.mode-btn, .score-btn {
|
||||
@@ -239,17 +263,60 @@ input:focus { border-color: var(--gold); }
|
||||
border: 1px solid rgba(245,197,24,.25);
|
||||
border-radius: 12px;
|
||||
}
|
||||
/* ── Public rooms list ────────────────────────── */
|
||||
.public-rooms-section { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.rooms-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: .78rem; color: var(--muted);
|
||||
}
|
||||
.rooms-label { font-weight: 600; letter-spacing: .5px; }
|
||||
.public-room-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.public-room-info { display: flex; flex-direction: column; gap: 2px; text-align: left; }
|
||||
.public-room-host { font-size: .85rem; font-weight: 600; color: #fff; }
|
||||
.public-room-meta { font-size: .72rem; color: var(--muted); }
|
||||
.btn-join-pub { padding: 5px 14px; font-size: .82rem; flex-shrink: 0; }
|
||||
|
||||
/* ── Creator controls in waiting room ────────── */
|
||||
.waiting-creator-controls {
|
||||
display: flex; gap: 8px; width: 100%;
|
||||
}
|
||||
.btn-fill-bots {
|
||||
padding: 8px 16px;
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255,255,255,.08);
|
||||
border: 1px solid rgba(255,255,255,.16);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: .85rem;
|
||||
width: 100%;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.btn-fill-bots:hover { background: rgba(255,255,255,.16); color: #fff; }
|
||||
.btn-start-game {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
background: rgba(100,200,100,.15);
|
||||
border: 1px solid rgba(100,200,100,.35);
|
||||
border-radius: 10px;
|
||||
color: #a5d6a7;
|
||||
cursor: pointer;
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-start-game:hover { background: rgba(100,200,100,.28); color: #c8e6c9; }
|
||||
|
||||
/* ── Seat swap (creator taps to reorder) ─────── */
|
||||
.seat-slot.seat-swappable { cursor: pointer; transition: background .12s, border-color .12s; }
|
||||
.seat-slot.seat-swappable:hover { background: rgba(255,255,255,.1); }
|
||||
.seat-slot.seat-swap-selected {
|
||||
border-color: var(--gold);
|
||||
background: rgba(245,197,24,.12);
|
||||
}
|
||||
|
||||
/* ── Game screen ──────────────────────────────── */
|
||||
#screen-game { background: var(--felt); overflow: hidden; position: relative; }
|
||||
@@ -523,11 +590,25 @@ input:focus { border-color: var(--gold); }
|
||||
display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||
}
|
||||
.trick-center-info {
|
||||
width: 60px; text-align: center; flex-shrink: 0;
|
||||
flex: 0 0 80px;
|
||||
text-align: center;
|
||||
}
|
||||
.phase-msg {
|
||||
font-size: .72rem; color: rgba(255,255,255,.6);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.phase-msg.your-turn {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-size: .78rem;
|
||||
animation: your-turn-flash .8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes your-turn-flash {
|
||||
from { opacity: .7; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Drop-zone pulse when dragging */
|
||||
@@ -914,6 +995,37 @@ input:focus { border-color: var(--gold); }
|
||||
.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.lb-table tr:hover td { background: rgba(255,255,255,.04); }
|
||||
|
||||
/* ── Waiting room top row (Leave + ☰ menu) ───── */
|
||||
.waiting-top-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Turn urgent reminder ──────────────────────── */
|
||||
@keyframes turn-urgent-glow {
|
||||
0%, 100% { border-top-color: rgba(0,0,0,.3); box-shadow: none; }
|
||||
50% {
|
||||
border-top-color: rgba(245,197,24,.7);
|
||||
box-shadow: 0 -2px 16px rgba(245,197,24,.3);
|
||||
}
|
||||
}
|
||||
#my-area.turn-urgent {
|
||||
animation: turn-urgent-glow .65s ease-in-out 6;
|
||||
}
|
||||
|
||||
/* ── Card count badge (replaces card backs) ──── */
|
||||
.card-count-badge {
|
||||
font-size: .65rem;
|
||||
color: rgba(255,255,255,.55);
|
||||
padding: 1px 5px;
|
||||
background: rgba(255,255,255,.08);
|
||||
border-radius: 8px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Util ─────────────────────────────────────── */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
@@ -928,8 +1040,89 @@ input:focus { border-color: var(--gold); }
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
/* ── Mobile bidding: transparent panel at top so hand stays visible ── */
|
||||
/* ═══════════════════════════════════════════════
|
||||
MOBILE OVERHAUL (touch devices / small screens)
|
||||
═══════════════════════════════════════════════ */
|
||||
@media (pointer: coarse) {
|
||||
|
||||
/* Prevent text selection and system long-press menus on game elements */
|
||||
#screen-game {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Allow horizontal scroll in scroll mode, block vertical pull-to-refresh */
|
||||
#my-hand {
|
||||
touch-action: pan-x;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
/* In fan mode cards need full touch control for drag */
|
||||
#my-hand.fan-mode {
|
||||
touch-action: none;
|
||||
}
|
||||
#my-hand.fan-mode .card {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* ── Info bar: compact ─────────────────────── */
|
||||
#info-bar { padding: 3px 8px; gap: 5px; }
|
||||
.team-score { font-size: .7rem; padding: 2px 5px; }
|
||||
.trump-display, .bid-display { font-size: .68rem; padding: 2px 6px; }
|
||||
.btn-leave-screen { font-size: .75rem; padding: 3px 7px; }
|
||||
|
||||
/* ── Table grid: narrow side columns ──────── */
|
||||
#table-grid {
|
||||
grid-template-columns: 36px 1fr 36px;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* ── Top player label: minimal height ──────── */
|
||||
.area-top .player-label {
|
||||
font-size: .7rem;
|
||||
padding: 2px 6px;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.area-top .player-score-badge { display: none; }
|
||||
|
||||
/* ── Side player labels: vertical, very narrow */
|
||||
.player-label.vertical {
|
||||
font-size: .6rem;
|
||||
padding: 3px 2px;
|
||||
gap: 3px;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
overflow: hidden;
|
||||
max-height: 140px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Rotate back the children that shouldn't be rotated */
|
||||
.player-label.vertical .turn-dot {
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
font-size: .55rem;
|
||||
}
|
||||
.player-label.vertical .player-score-badge { display: none; }
|
||||
.player-label.vertical .card-count-badge {
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
font-size: .55rem;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
/* ── My area: ensure it's always fully visible */
|
||||
#my-area {
|
||||
padding: 3px 6px calc(6px + env(safe-area-inset-bottom));
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#my-label { font-size: .72rem; gap: 4px; }
|
||||
.btn-hand-mode, .btn-play-mode { font-size: .65rem; padding: 2px 6px; }
|
||||
|
||||
/* ── Bidding overlay: compact panel at top so hand stays visible ── */
|
||||
#overlay-bid {
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
@@ -942,17 +1135,28 @@ input:focus { border-color: var(--gold); }
|
||||
max-width: 100%;
|
||||
border-radius: 0 0 20px 20px;
|
||||
border-top: none;
|
||||
background: rgba(8, 22, 8, 0.97);
|
||||
box-shadow: 0 6px 28px rgba(0,0,0,.75);
|
||||
max-height: 62vh;
|
||||
background: rgba(6, 20, 6, 0.97);
|
||||
box-shadow: 0 6px 28px rgba(0,0,0,.8);
|
||||
max-height: 55vh;
|
||||
}
|
||||
/* Shrink bid history on mobile so it doesn't swallow the screen */
|
||||
.bid-history { max-height: 110px; }
|
||||
.bid-box h3 { font-size: .95rem; margin-bottom: 0; }
|
||||
}
|
||||
|
||||
/* ── Mobile tweaks ────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
/* ── Small phones (≤ 390px wide) ─────────────── */
|
||||
@media (pointer: coarse) and (max-width: 390px) {
|
||||
:root { --card-w: 48px; --card-h: 70px; }
|
||||
#table-grid { grid-template-columns: 32px 1fr 32px; }
|
||||
.overlay-box { padding: 14px 12px; }
|
||||
.bid-amount-display { font-size: 1.3rem; }
|
||||
.trump-suit-btn { font-size: .82rem; padding: 10px 4px; }
|
||||
}
|
||||
|
||||
/* ── Desktop / mouse-pointer fallback tweaks ─── */
|
||||
@media (max-width: 480px) and (pointer: fine) {
|
||||
:root { --card-w: 54px; --card-h: 78px; }
|
||||
#table-grid { grid-template-columns: 70px 1fr 70px; }
|
||||
.overlay-box { padding: 18px 16px; }
|
||||
.bid-amount-display { font-size: 1.4rem; }
|
||||
.trump-suit-btn { font-size: .88rem; padding: 11px 6px; }
|
||||
}
|
||||
|
||||
+27
-1
@@ -1,5 +1,28 @@
|
||||
'use strict';
|
||||
const CACHE_NAME = 'shelem-v1';
|
||||
const CACHE_NAME = 'shelem-v2';
|
||||
|
||||
// ── Generate all 55 card paths ──────────────────────────────────
|
||||
const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE'];
|
||||
const CARD_PATHS = [
|
||||
'/cards/JOKER-1.svg',
|
||||
'/cards/JOKER-2.svg',
|
||||
'/cards/JOKER-3.svg',
|
||||
...SUITS.flatMap(s => [
|
||||
`/cards/${s}-1.svg`,
|
||||
`/cards/${s}-2.svg`,
|
||||
`/cards/${s}-3.svg`,
|
||||
`/cards/${s}-4.svg`,
|
||||
`/cards/${s}-5.svg`,
|
||||
`/cards/${s}-6.svg`,
|
||||
`/cards/${s}-7.svg`,
|
||||
`/cards/${s}-8.svg`,
|
||||
`/cards/${s}-9.svg`,
|
||||
`/cards/${s}-10.svg`,
|
||||
`/cards/${s}-11-JACK.svg`,
|
||||
`/cards/${s}-12-QUEEN.svg`,
|
||||
`/cards/${s}-13-KING.svg`,
|
||||
]),
|
||||
];
|
||||
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
@@ -10,6 +33,7 @@ const PRECACHE = [
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon.svg',
|
||||
...CARD_PATHS,
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
@@ -33,6 +57,7 @@ self.addEventListener('fetch', event => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
if (url.includes('/socket.io/')) return;
|
||||
|
||||
// Cards: always serve from cache (never change)
|
||||
if (url.includes('/cards/')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
@@ -46,6 +71,7 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: network-first, cache as offline fallback
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(res => {
|
||||
|
||||
@@ -216,6 +216,22 @@ app.get('/api/leaderboard', (_req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/rooms', (_req, res) => {
|
||||
const list = [];
|
||||
for (const [, room] of rooms) {
|
||||
if (!room.isPublic || room.state !== 'WAITING') continue;
|
||||
const playerCount = room.seats.filter(Boolean).length + room.bots.filter(Boolean).length;
|
||||
list.push({
|
||||
id: room.id,
|
||||
playerCount,
|
||||
jokerMode: room.jokerMode,
|
||||
winScore: room.winScore,
|
||||
hostName: room.names[0] || '?',
|
||||
});
|
||||
}
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
app.post('/api/admin/toggle-signups', requireAuth, (req, res) => {
|
||||
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
|
||||
config.signupsOpen = !config.signupsOpen;
|
||||
@@ -337,6 +353,7 @@ function makeToken() {
|
||||
function newRoom(id) {
|
||||
return {
|
||||
id,
|
||||
isPublic: false,
|
||||
state: 'WAITING',
|
||||
names: ['', '', '', ''],
|
||||
seats: [null, null, null, null],
|
||||
@@ -414,6 +431,7 @@ function publicInfo(room, seat) {
|
||||
gameWinner: room.gameWinner,
|
||||
jokerMode: room.jokerMode,
|
||||
winScore: room.winScore,
|
||||
isPublic: room.isPublic,
|
||||
spectatorCount: room.spectators.size,
|
||||
};
|
||||
}
|
||||
@@ -844,14 +862,25 @@ io.use((socket, next) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
if (user) userSockets.set(user.id, socket.id);
|
||||
if (user) {
|
||||
userSockets.set(user.id, socket.id);
|
||||
// Notify authenticated user if they have an in-progress game (enables cross-device rejoin)
|
||||
for (const [, room] of rooms) {
|
||||
const seat = room.userIds.indexOf(user.id);
|
||||
if (seat >= 0 && room.tokens[seat] !== null && room.state !== 'GAME_OVER') {
|
||||
socket.emit('hasActiveGame', { roomId: room.id, seat, token: room.tokens[seat] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create room ────────────────────────────────────────────
|
||||
socket.on('create', ({ name, jokerMode, winScore } = {}) => {
|
||||
socket.on('create', ({ name, jokerMode, winScore, isPublic } = {}) => {
|
||||
if (!name?.trim()) return socket.emit('error', 'Name is required');
|
||||
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
const room = newRoom(id);
|
||||
room.jokerMode = jokerMode !== false; // default true
|
||||
room.jokerMode = jokerMode !== false;
|
||||
room.isPublic = isPublic === true;
|
||||
const ws = [205, 505, 1005];
|
||||
room.winScore = ws.includes(+winScore) ? +winScore : 505;
|
||||
room.names[0] = name.trim().slice(0, 16);
|
||||
@@ -871,11 +900,24 @@ io.on('connection', (socket) => {
|
||||
if (!room) return socket.emit('joinError', 'Room not found');
|
||||
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
|
||||
|
||||
let openSeat = -1;
|
||||
// Prevent the same socket, user, or name from occupying more than one seat
|
||||
const trimmedName = name.trim().toLowerCase();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; }
|
||||
if (room.seats[i] === socket.id) return socket.emit('joinError', 'You are already in this room');
|
||||
if (user?.id && room.userIds[i] === user.id) return socket.emit('joinError', 'You are already in this room');
|
||||
if (room.seats[i] && room.names[i].toLowerCase() === trimmedName)
|
||||
return socket.emit('joinError', 'That name is already taken in this room');
|
||||
}
|
||||
if (openSeat === -1) return socket.emit('joinError', 'Room is full');
|
||||
|
||||
// For public rooms assign a random open seat; private rooms take first open seat
|
||||
const openSeats = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (!room.seats[i] && !room.bots[i]) openSeats.push(i);
|
||||
}
|
||||
if (openSeats.length === 0) return socket.emit('joinError', 'Room is full');
|
||||
const openSeat = room.isPublic
|
||||
? openSeats[Math.floor(Math.random() * openSeats.length)]
|
||||
: openSeats[0];
|
||||
|
||||
room.names[openSeat] = name.trim().slice(0, 16);
|
||||
room.userIds[openSeat] = user?.id || null;
|
||||
@@ -901,11 +943,21 @@ io.on('connection', (socket) => {
|
||||
// ── Rejoin ─────────────────────────────────────────────────
|
||||
socket.on('rejoin', ({ roomId, seat, token } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room) return socket.emit('rejoinError', 'Room no longer exists');
|
||||
if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token');
|
||||
if (!room) return socket.emit('rejoinError', 'not_found');
|
||||
|
||||
const tokenOk = room.tokens[seat] === token;
|
||||
const userOk = user && room.userIds[seat] != null
|
||||
&& room.userIds[seat] === user.id
|
||||
&& room.tokens[seat] !== null; // null means player explicitly left
|
||||
|
||||
if (!tokenOk && !userOk) return socket.emit('rejoinError', 'bad_token');
|
||||
|
||||
// Re-issue token when only userId matched (e.g. different device)
|
||||
if (!tokenOk) room.tokens[seat] = makeToken();
|
||||
|
||||
room.seats[seat] = socket.id;
|
||||
socket.join(room.id);
|
||||
socket.emit('rejoined', { roomId: room.id, seat, token });
|
||||
socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] });
|
||||
socket.emit('roomInfo', publicInfo(room, seat));
|
||||
broadcastState(room, 'roomInfo');
|
||||
});
|
||||
@@ -926,6 +978,42 @@ io.on('connection', (socket) => {
|
||||
tryStartGame(room);
|
||||
});
|
||||
|
||||
// ── Swap seats (creator only) ──────────────────────────────
|
||||
socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room || room.state !== 'WAITING') return;
|
||||
const isCreator = room.seats[0] === socket.id ||
|
||||
(user?.id && room.userIds[0] === user.id);
|
||||
if (!isCreator) return;
|
||||
if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return;
|
||||
|
||||
for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) {
|
||||
[room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]];
|
||||
}
|
||||
// Tell each affected human player their new seat and token
|
||||
if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] });
|
||||
if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] });
|
||||
broadcastState(room, 'roomInfo');
|
||||
});
|
||||
|
||||
// ── Force start (creator only, fills remaining seats with bots) ──
|
||||
socket.on('forceStart', ({ roomId } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room || room.state !== 'WAITING') return;
|
||||
const isCreator = room.seats[0] === socket.id ||
|
||||
(user?.id && room.userIds[0] === user.id);
|
||||
if (!isCreator) return;
|
||||
const botNames = ['Ali', 'Mina', 'Reza', 'Sara'];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (!room.seats[i] && !room.bots[i]) {
|
||||
room.bots[i] = true;
|
||||
room.names[i] = botNames[i];
|
||||
}
|
||||
}
|
||||
dealHand(room);
|
||||
startBidding(room);
|
||||
});
|
||||
|
||||
// ── Bid ────────────────────────────────────────────────────
|
||||
socket.on('bid', ({ roomId, seat, token, amount } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
@@ -960,6 +1048,7 @@ io.on('connection', (socket) => {
|
||||
if (room.tokens[seat] === token) {
|
||||
room.seats[seat] = null;
|
||||
room.tokens[seat] = null;
|
||||
room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
|
||||
}
|
||||
socket.leave(room.id);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user