// --- 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();