Add features: escalating turn reminder, AFK AI takeover, public rooms, persistent rejoin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+186
-12
@@ -11,7 +11,17 @@ let authToken = localStorage.getItem('hearts_token') || null;
|
||||
let authUser = localStorage.getItem('hearts_user') || null;
|
||||
let lastState = null;
|
||||
let deferredInstallPrompt = null;
|
||||
let selectedScore = 100;
|
||||
|
||||
// ─── Turn escalation state ────────────────────────────────────
|
||||
let turnEscalationInterval = null;
|
||||
let turnEscalationLevel = 0;
|
||||
let wasMyTurn = false;
|
||||
|
||||
// ─── AFK / AI-takeover state ──────────────────────────────────
|
||||
let aiControlledSeats = new Set();
|
||||
let afkWarnSeat = -1;
|
||||
let selectedScore = 100;
|
||||
let selectedPublic = false;
|
||||
|
||||
// ─── Play mode & hand display mode ───────────────────────────
|
||||
function isTouchDevice() {
|
||||
@@ -160,6 +170,55 @@ const RANK_FILE = {
|
||||
'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING',
|
||||
};
|
||||
|
||||
// ─── Turn escalation ──────────────────────────────────────────
|
||||
function handleTurnReminder(isMyTurn) {
|
||||
clearInterval(turnEscalationInterval);
|
||||
turnEscalationLevel = 0;
|
||||
applyTurnEscalation(0);
|
||||
if (!isMyTurn) return;
|
||||
turnEscalationInterval = setInterval(() => {
|
||||
turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
|
||||
applyTurnEscalation(turnEscalationLevel);
|
||||
if (turnEscalationLevel >= 3) {
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = 880;
|
||||
g.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||
o.start(); o.stop(ctx.currentTime + 0.4);
|
||||
} catch(e) {}
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function applyTurnEscalation(level) {
|
||||
const el = $('phase-msg');
|
||||
if (!el) return;
|
||||
el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
|
||||
if (level > 0) el.classList.add(`your-turn-lvl${level}`);
|
||||
}
|
||||
|
||||
// ─── AFK / AI-takeover UI ─────────────────────────────────────
|
||||
function updateAiControlBanner() {
|
||||
const banner = $('ai-control-banner');
|
||||
const msg = $('ai-control-msg');
|
||||
if (!banner || !msg) return;
|
||||
if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; }
|
||||
if (aiControlledSeats.has(mySeat)) {
|
||||
msg.textContent = 'AI is playing for you — play a card to resume control';
|
||||
banner.className = 'ai-control-banner ai-self';
|
||||
} else {
|
||||
const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', ');
|
||||
msg.textContent = `AI is playing for ${names}`;
|
||||
banner.className = 'ai-control-banner ai-other';
|
||||
}
|
||||
show('ai-control-banner');
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); }
|
||||
@@ -295,6 +354,14 @@ function initSocket() {
|
||||
socket.on('rejoinError', (msg) => {
|
||||
clearSession();
|
||||
$('lobby-error').textContent = msg || 'Could not rejoin';
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
|
||||
socket.on('hasActiveGame', ({ roomId, seat, token }) => {
|
||||
if (myRoomId) return; // already in a session
|
||||
myRoomId = roomId; mySeat = seat; myToken = token;
|
||||
saveSession(roomId, seat, token);
|
||||
socket.emit('rejoin', { roomId, seat, token });
|
||||
});
|
||||
|
||||
// Core game events — all handled by renderState
|
||||
@@ -308,11 +375,44 @@ function initSocket() {
|
||||
socket.on('joinError', msg => { $('lobby-error').textContent = msg; });
|
||||
socket.on('passError', msg => console.warn('passError:', msg));
|
||||
socket.on('playError', msg => console.warn('playError:', msg));
|
||||
|
||||
socket.on('afkWarning', ({ seat, name }) => {
|
||||
afkWarnSeat = seat;
|
||||
const isMe = seat === mySeat;
|
||||
$('afk-banner-msg').textContent = isMe
|
||||
? "You haven't played yet!"
|
||||
: `${name} hasn't played. Let AI take over?`;
|
||||
if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn');
|
||||
show('afk-banner');
|
||||
});
|
||||
|
||||
socket.on('afkResolved', () => {
|
||||
afkWarnSeat = -1;
|
||||
hide('afk-banner');
|
||||
});
|
||||
|
||||
socket.on('aiControl', ({ seat, active, name }) => {
|
||||
if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat);
|
||||
updateAiControlBanner();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── State rendering ──────────────────────────────────────────
|
||||
function syncAiState(state) {
|
||||
aiControlledSeats = new Set(state.aiControlledSeats || []);
|
||||
updateAiControlBanner();
|
||||
}
|
||||
|
||||
function handleState(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
|
||||
// Restore locally-selected pass cards that the server doesn't know about yet
|
||||
if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) {
|
||||
state.passSelected = passSelectedLocal;
|
||||
} else {
|
||||
passSelectedLocal = [];
|
||||
}
|
||||
|
||||
// Always dismiss result overlays when fresh state arrives
|
||||
hide('overlay-hand', 'overlay-gameover');
|
||||
@@ -335,6 +435,7 @@ function handleState(state) {
|
||||
|
||||
function handleTrickWon(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
// Flash the winning slot briefly
|
||||
@@ -346,6 +447,8 @@ function handleTrickWon(state) {
|
||||
|
||||
function handleHandOver(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
hide('afk-banner');
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
hide('overlay-pass');
|
||||
@@ -354,6 +457,8 @@ function handleHandOver(state) {
|
||||
|
||||
function handleGameOver(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
hide('afk-banner');
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
hide('overlay-pass', 'overlay-hand');
|
||||
@@ -440,6 +545,9 @@ function renderWaitingRoom(state) {
|
||||
} else {
|
||||
hide('btn-fill-bots');
|
||||
}
|
||||
|
||||
const badge = $('waiting-public-badge');
|
||||
if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private';
|
||||
}
|
||||
|
||||
// ── Game table ────────────────────────────────────────────────
|
||||
@@ -616,6 +724,12 @@ function renderPhaseMsg(state) {
|
||||
const el = $('phase-msg');
|
||||
if (!el) return;
|
||||
|
||||
const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating;
|
||||
if (isMyTurn !== wasMyTurn) {
|
||||
wasMyTurn = isMyTurn;
|
||||
handleTurnReminder(isMyTurn);
|
||||
}
|
||||
|
||||
if (state.state === 'WAITING') {
|
||||
el.textContent = 'Waiting…';
|
||||
} else if (state.state === 'PASSING') {
|
||||
@@ -728,8 +842,9 @@ function togglePassSelect(code, state) {
|
||||
return; // already 3 selected
|
||||
}
|
||||
|
||||
// Optimistic update for immediate feedback
|
||||
// Optimistic update for immediate feedback; persist so server pushes don't wipe it
|
||||
state.passSelected = selected;
|
||||
passSelectedLocal = selected;
|
||||
renderPassOverlay(state);
|
||||
// Also update my-hand in game table
|
||||
renderMyHand(myHand, state);
|
||||
@@ -740,6 +855,7 @@ function confirmPass() {
|
||||
if (!state) return;
|
||||
const selected = state.passSelected || [];
|
||||
if (selected.length !== 3) return;
|
||||
passSelectedLocal = [];
|
||||
socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected });
|
||||
}
|
||||
|
||||
@@ -987,6 +1103,48 @@ function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── Lobby helpers ────────────────────────────────────────────
|
||||
function joinGame() {
|
||||
const name = $('input-name').value.trim();
|
||||
const roomId = $('input-code').value.trim().toUpperCase();
|
||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||
if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
myName = name;
|
||||
socket.emit('join', { name, roomId });
|
||||
}
|
||||
|
||||
async function loadPublicRooms() {
|
||||
const listEl = $('public-rooms-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
||||
try {
|
||||
const list = await apiFetch('/api/rooms');
|
||||
if (!Array.isArray(list) || 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 · score limit ${room.winScore}</span>
|
||||
</div>
|
||||
<button class="btn-join-pub" 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>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PWA install ──────────────────────────────────────────────
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault();
|
||||
@@ -1038,28 +1196,33 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// Visibility toggle (public/private)
|
||||
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';
|
||||
});
|
||||
});
|
||||
|
||||
// Public rooms refresh
|
||||
$('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
|
||||
|
||||
// Create game
|
||||
$('btn-create').addEventListener('click', () => {
|
||||
const name = $('input-name').value.trim();
|
||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
myName = name;
|
||||
socket.emit('create', { name, winScore: selectedScore });
|
||||
socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic });
|
||||
});
|
||||
|
||||
// Join game
|
||||
$('btn-join').addEventListener('click', () => {
|
||||
const name = $('input-name').value.trim();
|
||||
const roomId = $('input-code').value.trim().toUpperCase();
|
||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||
if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
myName = name;
|
||||
socket.emit('join', { name, roomId });
|
||||
});
|
||||
$('btn-join').addEventListener('click', joinGame);
|
||||
|
||||
// Spectate
|
||||
$('btn-spectate').addEventListener('click', () => {
|
||||
@@ -1090,6 +1253,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// Pass overlay confirm
|
||||
$('btn-confirm-pass').addEventListener('click', confirmPass);
|
||||
|
||||
// AFK banner
|
||||
$('afk-vote-btn').addEventListener('click', () => {
|
||||
socket.emit('voteAITakeover', { roomId: myRoomId });
|
||||
hide('afk-banner');
|
||||
});
|
||||
$('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner'));
|
||||
|
||||
// Leave game
|
||||
$('btn-leave-game').addEventListener('click', () => {
|
||||
if (spectating) {
|
||||
@@ -1107,6 +1277,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
clearSession();
|
||||
socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
||||
wasMyTurn = false; handleTurnReminder(false);
|
||||
aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner');
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
|
||||
@@ -1116,6 +1288,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
hide('overlay-gameover');
|
||||
clearSession();
|
||||
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
||||
wasMyTurn = false; handleTurnReminder(false);
|
||||
aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner');
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user