// --- CONSTANTS & CONFIG --- const PEER_PREFIX = "discord-spin-wheel-"; const COLORS = ["#FF5733", "#33FF57", "#3357FF", "#F3FF33", "#FF33F3", "#33FFF0", "#FFAF33", "#AF33FF"]; // --- APP STATE --- let state = { hostId: null, items: ["Truth", "Dare", "Spin Again", "Pass", "Wildcard"], isSpinning: false, spinAngle: 0, winner: "" }; let myId = null; let myUsername = "Player_" + Math.floor(Math.random() * 1000); let participants = {}; let peer = null; let connections = []; // Canvas Setup const canvas = document.getElementById("wheel-canvas"); const ctx = canvas.getContext("2d"); // --- INITIAL RENDERING (Failsafe) --- // Draw the wheel immediately so the screen isn't blank while loading connections try { drawWheel(); updateUI(); updateActiveParticipantsUI(); } catch (e) { console.error("Initial render failed:", e); } // --- DISCORD ACTIVITY SETUP --- async function initDiscord() { // Failsafe check to see if Discord SDK loaded successfully const hasDiscordSDK = (typeof window.discordSdk !== 'undefined' && window.discordSdk.DiscordSDK); if (!hasDiscordSDK) { console.warn("Discord SDK not detected. Operating in standalone/local mode."); setupP2P("local-sandbox-room"); return; } try { const discordSdk = new window.discordSdk.DiscordSDK("YOUR_CLIENT_ID_IF_REGISTERED"); await discordSdk.ready(); const auth = await discordSdk.commands.authorize({ client_id: discordSdk.clientId, scope: ["identify", "guilds"], response_type: "code" }); const user = await fetch(`https://discord.com/api/v10/users/@me`, { headers: { Authorization: `Bearer ${auth.access_token}` } }).then(r => r.json()); myUsername = user.username || myUsername; setupP2P(discordSdk.instanceId || "fallback-room"); } catch (err) { console.error("Discord SDK authorization failed. Falling back to sandbox mode:", err); setupP2P("sandbox-room-test"); } } // --- PEER-TO-PEER SYNC ENGINE --- function setupP2P(roomKey) { if (typeof Peer === 'undefined') { console.error("PeerJS library failed to load. Are you offline or is a CDN blocked?"); document.getElementById("status-text").innerText = "Offline Mode (No P2P)"; document.getElementById("status-dot").className = "w-3 h-3 rounded-full bg-red-500"; // Make self the host locally so they can still play offline state.hostId = "local"; updateRoleBadge(true); updateUI(); return; } myId = PEER_PREFIX + roomKey + "-" + Math.floor(Math.random() * 1000000); peer = new Peer(myId); peer.on('open', (id) => { document.getElementById("status-dot").className = "w-3 h-3 rounded-full bg-green-500"; document.getElementById("status-text").innerText = `Connected as: ${myUsername}`; findOrCreateRoom(roomKey); }); peer.on('error', (err) => { console.error("P2P connection error:", err); document.getElementById("status-dot").className = "w-3 h-3 rounded-full bg-red-500"; document.getElementById("status-text").innerText = "Network Error - Offline Mode"; // Failsafe: assume local hosting if signaling server acts up if (!state.hostId) { state.hostId = "local"; updateRoleBadge(true); updateUI(); } }); } function findOrCreateRoom(roomKey) { const hostSlotId = PEER_PREFIX + roomKey + "-host"; let conn = peer.connect(hostSlotId); let handshakeTimeout = setTimeout(() => { conn.close(); becomeHost(hostSlotId); }, 2500); conn.on('open', () => { clearTimeout(handshakeTimeout); state.hostId = hostSlotId; updateRoleBadge(false); conn.send({ type: "JOIN", username: myUsername }); conn.on('data', (data) => { handleIncomingPayload(data); }); }); } function becomeHost(hostSlotId) { if (peer && !peer.destroyed) { peer.destroy(); } peer = new Peer(hostSlotId); peer.on('open', () => { state.hostId = hostSlotId; updateRoleBadge(true); document.getElementById("status-text").innerText = `Room Host: ${myUsername}`; drawWheel(); updateUI(); peer.on('connection', (conn) => { connections.push(conn); conn.on('data', (data) => { if (data.type === "JOIN") { participants[conn.peer] = data.username; broadcastState(); updateActiveParticipantsUI(); } else if (data.type === "ACTION") { handleHostAction(data.payload); } }); conn.on('close', () => { connections = connections.filter(c => c !== conn); delete participants[conn.peer]; broadcastState(); updateActiveParticipantsUI(); }); }); }); } // --- STATE SYNCHRONIZATION --- function broadcastState() { connections.forEach(conn => { if (conn.open) { conn.send({ type: "STATE_UPDATE", state, participants }); } }); } function handleIncomingPayload(msg) { if (msg.type === "STATE_UPDATE") { state = msg.state; participants = msg.participants; updateUI(); updateActiveParticipantsUI(); if (state.isSpinning) { animateClientSpin(state.spinAngle); } else { drawWheel(); } } } function handleHostAction(action) { const isHost = (myId === state.hostId || state.hostId === "local"); if (!isHost) return; if (action.type === "ADD_ITEM") { state.items.push(action.value); state.winner = ""; } else if (action.type === "REMOVE_ITEM") { state.items.splice(action.index, 1); state.winner = ""; } else if (action.type === "SPIN") { if (state.isSpinning) return; state.isSpinning = true; state.winner = ""; const extraSpins = 5 + Math.floor(Math.random() * 5); const targetAngle = state.spinAngle + (extraSpins * 360) + Math.floor(Math.random() * 360); state.spinAngle = targetAngle; broadcastState(); animateClientSpin(targetAngle); } else if (action.type === "TRANSFER") { const targetPeerId = action.targetPeerId; if (targetPeerId) { state.hostId = targetPeerId; broadcastState(); setTimeout(() => { location.reload(); }, 500); } } broadcastState(); updateUI(); } // --- WHEEL DRAWING & PHYSICS --- function drawWheel(currentAngle = 0) { const numSegments = state.items.length; if (numSegments === 0) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#fff"; ctx.font = "16px sans-serif"; ctx.textAlign = "center"; ctx.fillText("Add items to start!", canvas.width / 2, canvas.height / 2); return; } const arcSize = (2 * Math.PI) / numSegments; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate((currentAngle * Math.PI) / 180); for (let i = 0; i < numSegments; i++) { const angle = i * arcSize; ctx.fillStyle = COLORS[i % COLORS.length]; ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, canvas.width / 2 - 10, angle, angle + arcSize); ctx.lineTo(0, 0); ctx.fill(); ctx.stroke(); ctx.save(); ctx.fillStyle = "#000"; ctx.font = "bold 14px sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; ctx.rotate(angle + arcSize / 2); const text = state.items[i]; const truncatedText = text.length > 12 ? text.substring(0, 10) + ".." : text; ctx.fillText(truncatedText, canvas.width / 2 - 30, 0); ctx.restore(); } ctx.restore(); } function animateClientSpin(targetAngle) { let start = null; const duration = 4000; const startingAngle = state.spinAngle - (state.spinAngle % 360); function step(timestamp) { if (!start) start = timestamp; const progress = timestamp - start; const percent = Math.min(progress / duration, 1); const easeOutQuad = 1 - Math.pow(1 - percent, 3); const currentAngle = startingAngle + (targetAngle - startingAngle) * easeOutQuad; drawWheel(currentAngle); if (progress < duration) { requestAnimationFrame(step); } else { state.isSpinning = false; const normalizedAngle = (360 - (targetAngle % 360)) % 360; const segmentDegrees = 360 / state.items.length; const winIndex = Math.floor(normalizedAngle / segmentDegrees); state.winner = state.items[winIndex]; document.getElementById("winner-display").innerText = `🎉 Winning slice: ${state.winner}! 🎉`; if (myId === state.hostId) { broadcastState(); } } } requestAnimationFrame(step); } // --- DOM UI UPDATES --- function updateUI() { const isHost = (myId === state.hostId || state.hostId === "local"); const spinBtn = document.getElementById("spin-btn"); spinBtn.disabled = !isHost || state.isSpinning || state.items.length === 0; const itemsList = document.getElementById("items-list"); itemsList.innerHTML = ""; document.getElementById("items-count").innerText = state.items.length; state.items.forEach((item, index) => { const li = document.createElement("li"); li.className = "flex justify-between items-center bg-[#202225] px-3 py-2 rounded text-sm"; li.innerHTML = ` ${item} ${isHost ? `` : ''} `; itemsList.appendChild(li); }); const hostControls = document.getElementById("host-controls"); hostControls.style.display = isHost ? "flex" : "none"; const select = document.getElementById("transfer-select"); select.innerHTML = ''; Object.keys(participants).forEach(pId => { const opt = document.createElement("option"); opt.value = pId; opt.innerText = participants[pId]; select.appendChild(opt); }); } function updateRoleBadge(isHost) { const badge = document.getElementById("role-badge"); if (isHost) { badge.innerHTML = 'Role: 👑 Room Host'; } else { badge.innerHTML = 'Role: Spectator'; } } function updateActiveParticipantsUI() { const list = document.getElementById("players-list"); list.innerHTML = ""; const selfTag = document.createElement("span"); selfTag.className = "px-2.5 py-1 rounded bg-[#5865F2] text-white font-semibold"; selfTag.innerText = `${myUsername} (You)`; list.appendChild(selfTag); Object.keys(participants).forEach(pId => { const guestTag = document.createElement("span"); guestTag.className = "px-2.5 py-1 rounded bg-[#2f3136] border border-[#3f4147] text-gray-200"; guestTag.innerText = participants[pId]; list.appendChild(guestTag); }); } // --- BUTTON INTERACTION TRIGGERS --- document.getElementById("spin-btn").addEventListener('click', () => { handleHostAction({ type: "SPIN" }); }); document.getElementById("add-btn").addEventListener('click', () => { const input = document.getElementById("item-input"); const value = input.value.trim(); if (value) { sendActionToHost({ type: "ADD_ITEM", value }); input.value = ""; } }); function triggerRemoveItem(index) { sendActionToHost({ type: "REMOVE_ITEM", index }); } document.getElementById("transfer-btn").addEventListener('click', () => { const select = document.getElementById("transfer-select"); const targetPeerId = select.value; if (targetPeerId) { handleHostAction({ type: "TRANSFER", targetPeerId }); } }); function sendActionToHost(action) { if (myId === state.hostId || state.hostId === "local") { handleHostAction(action); } else { const connToHost = peer.connect(state.hostId); connToHost.on('open', () => { connToHost.send({ type: "ACTION", payload: action }); }); } } // --- START APPLICATION INITIALIZATION --- initDiscord();