โš”๏ธ FROM SUNNYVILLE
Ultimate Virtual Tabletop v4.0 FINAL

v4.0 โ€ข Multi-DM โ€ข Character Sheets โ€ข Ambient Music

โš”๏ธ FROM SUNNYVILLE
Connection โ–ผ
โ€”
d20

๐Ÿ“ก Status

Disconnected

๐Ÿ‘ฅ Connected Users

No users

Session
Zoom: 100%

๐ŸŽญ DM Controls

๐Ÿ“ฌ Player Requests

No pending requests

๐Ÿ’พ Campaign Manager

Export/Import Campaign:
Cloud Campaign Link (Google Drive, Dropbox, etc.):
๐Ÿ’ก Upload exported .json to Google Drive โ†’ Get shareable link โ†’ Paste here โ†’ Save
Other DMs can use this link to access the campaign!
Campaign exported!
Campaign imported!
Cloud link saved!
Import failed. Check file format.

๐Ÿ—พ Maps

Click a cell to navigate everyone there

๐Ÿ—บ๏ธ Map Builder

Cell: Center
๐Ÿ–Œ๏ธ
Brush
๐Ÿชฃ
Fill
๐Ÿงน
Erase
๐Ÿฐ
Stamp
๐Ÿ–ผ๏ธ
Image
๐Ÿ‘†
Select
Active: Brush
8px
๐Ÿ’ก Place Image: Upload images to main canvas - they can be moved, resized & rotated! Use Select tool (๐Ÿ‘†) to manipulate them.

๐ŸŒซ๏ธ Fog of War

Everyone
Fog Paint Mode:
60px
๐Ÿ’ก Hold Ctrl + Click on the map to paint fog

๐ŸŽฒ Tokens

๐Ÿ—บ๏ธ Navigator
โˆ’
๐Ÿ”“
โŠ™ Center
๐Ÿ” Reset
๐ŸŽต Jukebox
โœ•
Nothing playing
Vol

๐Ÿ“œ Characters

Your Characters

No characters yet. Click "+ New" to create one.

๐ŸŽฒ Dice Advantage

โ–ฒ
Character:
Character
Class โ€ข Race
HP10/10
10
AC
+0
Init
30
Speed
+2
Prof
10
STR
10
DEX
10
CON
10
INT
10
WIS
10
CHA
tag, so the canvas doesn't exist during script parse. window.addEventListener('load', function setupSubMapCanvasEvents() { const smc = document.getElementById('subMapCanvas'); if (!smc) return; const smctx = smc.getContext('2d'); function smCoords(e) { const r = smc.getBoundingClientRect(); return [ (e.clientX - r.left) * (smc.width / r.width), (e.clientY - r.top) * (smc.height / r.height) ]; } smc.addEventListener('mousedown', (e) => { if (!activeSubMapId) return; isSubMapDrawing = true; const [x, y] = smCoords(e); subMapLastX = x; subMapLastY = y; if (subMapTool === 'fill') { smctx.fillStyle = subMapColor; smctx.fillRect(0, 0, smc.width, smc.height); isSubMapDrawing = false; } else if (subMapTool === 'eraser') { smctx.save(); smctx.globalCompositeOperation = 'destination-out'; } // brush: nothing extra needed โ€” each mousemove segment draws itself }); smc.addEventListener('mousemove', (e) => { if (!isSubMapDrawing || !activeSubMapId) return; const [x, y] = smCoords(e); if (subMapTool === 'brush') { // Draw only the new segment โ€” never accumulate a long path smctx.beginPath(); smctx.moveTo(subMapLastX, subMapLastY); smctx.lineTo(x, y); smctx.strokeStyle = subMapColor; smctx.lineWidth = subMapSize; smctx.lineCap = 'round'; smctx.lineJoin = 'round'; smctx.stroke(); } else if (subMapTool === 'eraser') { smctx.beginPath(); smctx.moveTo(subMapLastX, subMapLastY); smctx.lineTo(x, y); smctx.lineWidth = subMapSize * 2; smctx.lineCap = 'round'; smctx.lineJoin = 'round'; smctx.stroke(); } subMapLastX = x; subMapLastY = y; }); function smStopDrawing() { if (!isSubMapDrawing) return; isSubMapDrawing = false; if (subMapTool === 'eraser') smctx.restore(); } smc.addEventListener('mouseup', smStopDrawing); smc.addEventListener('mouseleave', smStopDrawing); // Touch support for sub-map canvas function toMouse(touch) { return { clientX: touch.clientX, clientY: touch.clientY }; } smc.addEventListener('touchstart', e => { e.preventDefault(); smc.dispatchEvent(new MouseEvent('mousedown', toMouse(e.touches[0]))); }, { passive: false }); smc.addEventListener('touchmove', e => { e.preventDefault(); smc.dispatchEvent(new MouseEvent('mousemove', toMouse(e.touches[0]))); }, { passive: false }); smc.addEventListener('touchend', e => { e.preventDefault(); smc.dispatchEvent(new MouseEvent('mouseup', {})); }, { passive: false }); }); // Player token functions let playerTokenType = 'emoji'; function showPlayerTokenModal() { document.getElementById('playerTokenModal').classList.add('active'); setPlayerTokenType('emoji'); } function closePlayerTokenModal() { document.getElementById('playerTokenModal').classList.remove('active'); } function setPlayerTokenType(type) { playerTokenType = type; document.getElementById('playerEmojiTokenBtn').classList.remove('btn-primary'); document.getElementById('playerImageTokenBtn').classList.remove('btn-primary'); if (type === 'emoji') { document.getElementById('playerEmojiTokenBtn').classList.add('btn-primary'); document.getElementById('playerEmojiTokenInputs').style.display = 'block'; document.getElementById('playerImageTokenInputs').style.display = 'none'; } else { document.getElementById('playerImageTokenBtn').classList.add('btn-primary'); document.getElementById('playerEmojiTokenInputs').style.display = 'none'; document.getElementById('playerImageTokenInputs').style.display = 'block'; } } function requestPlayerToken() { const color = '#3b82f6'; if (playerTokenType === 'emoji') { const emoji = document.getElementById('playerTokenEmoji').value || 'โš”๏ธ'; connections.forEach(({ conn, role }) => { if (role === 'dm') { sendToPeer(conn, { type: 'tokenRequest', playerName: myName, tokenData: { id: Date.now(), type: 'emoji', icon: emoji, color: color, size: 12 // Default size } }); } }); closePlayerTokenModal(); alert('โœ… Token request sent to DM for approval!'); } else { // Image token const file = document.getElementById('playerTokenImageFile').files[0]; if (!file) { alert('Please select an image file'); return; } const reader = new FileReader(); reader.onload = function(e) { connections.forEach(({ conn, role }) => { if (role === 'dm') { sendToPeer(conn, { type: 'tokenRequest', playerName: myName, tokenData: { id: Date.now(), type: 'image', imageData: e.target.result, color: color, size: 12 // Default size } }); } }); closePlayerTokenModal(); alert('โœ… Token request sent to DM for approval!'); }; reader.readAsDataURL(file); } } function setStamp(emoji) { currentStamp = emoji; currentAsset = 'Stamp: ' + emoji; (_elCurrentAsset||{}).textContent = currentAsset; } function updateColor(value) { color = value; } function importMapImage(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { const img = new Image(); img.onload = function() { mapCtx.drawImage(img, 0, 0, mapCanvas.width, mapCanvas.height); saveHistory(); }; img.src = e.target.result; }; reader.readAsDataURL(file); event.target.value = ''; } function importImageToCanvas(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { const img = new Image(); img.onload = function() { // Place image at center of current cell const cellX = currentCellX * GRID_SIZE + GRID_SIZE / 2; const cellY = currentCellY * GRID_SIZE + GRID_SIZE / 2; // Scale image to fit within cell const maxSize = GRID_SIZE * 0.8; let width = img.width; let height = img.height; if (width > maxSize || height > maxSize) { const scale = Math.min(maxSize / width, maxSize / height); width *= scale; height *= scale; } const placedImg = { img: img, x: cellX - width / 2, y: cellY - height / 2, width: width, height: height, rotation: 0, gridX: currentCellX, gridY: currentCellY }; placedImages.push(placedImg); selectedImage = placedImg; setTool('select'); draw(); // Broadcast to players broadcastImages(); }; img.src = e.target.result; }; reader.readAsDataURL(file); event.target.value = ''; } function broadcastImages() { const imageData = placedImages.map(pi => ({ src: pi.img.src, x: pi.x, y: pi.y, width: pi.width, height: pi.height, rotation: pi.rotation, gridX: pi.gridX, gridY: pi.gridY })); broadcast({ type: 'imagesUpdate', images: imageData }); } function setEraserMode(mode) { eraserMode = mode; updateEraserModeUI(); // Update asset indicator if (tool === 'eraser') { const assetNames = { content: lastToolBeforeEraser === 'brush' ? 'Brush Strokes' : lastToolBeforeEraser === 'texture' ? 'Textures' : lastToolBeforeEraser === 'image' ? 'Images' : 'Content', all: 'Everything' }; currentAsset = assetNames[mode]; (_elCurrentAsset||{}).textContent = currentAsset; } } function updateEraserModeUI() { // Update button states document.getElementById('eraseContentBtn').classList.remove('btn-primary'); document.getElementById('eraseAllBtn').classList.remove('btn-primary'); if (eraserMode === 'content') { document.getElementById('eraseContentBtn').classList.add('btn-primary'); document.getElementById('eraserModeDesc').textContent = 'Erases drawn content without touching background'; } else { document.getElementById('eraseAllBtn').classList.add('btn-primary'); document.getElementById('eraserModeDesc').textContent = 'Erases everything including background color'; } } function setSize(v) { size = v; document.getElementById('sizeValue').textContent = v + 'px'; } function saveHistory() { const imageData = mapCtx.getImageData(0, 0, mapCanvas.width, mapCanvas.height); if (historyIndex < history.length - 1) { history = history.slice(0, historyIndex + 1); } history.push(imageData); if (history.length > MAX_HISTORY) { history.shift(); } else { historyIndex++; } updateHistoryButtons(); } function undo() { if (historyIndex > 0) { historyIndex--; mapCtx.putImageData(history[historyIndex], 0, 0); updateHistoryButtons(); } } function redo() { if (historyIndex < history.length - 1) { historyIndex++; mapCtx.putImageData(history[historyIndex], 0, 0); updateHistoryButtons(); } } function updateHistoryButtons() { document.getElementById('undoBtn').disabled = historyIndex <= 0; document.getElementById('redoBtn').disabled = historyIndex >= history.length - 1; } function clearMapCanvas() { mapCtx.fillStyle = '#f5f5dc'; mapCtx.fillRect(0, 0, mapCanvas.width, mapCanvas.height); saveHistory(); } function submitCell() { const key = `${currentCellX},${currentCellY}`; const cellCanvas = document.createElement('canvas'); cellCanvas.width = CELL_SIZE; cellCanvas.height = CELL_SIZE; const cellCtx = cellCanvas.getContext('2d'); cellCtx.drawImage(mapCanvas, 0, 0); gridCells[key] = cellCanvas; draw(); broadcast({ type: 'gridUpdate', cellX: currentCellX, cellY: currentCellY, imageData: cellCanvas.toDataURL() }); } function deselectAllTools() { document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); tool = null; currentAsset = 'None'; (_elCurrentAsset||{}).textContent = currentAsset; } // Map canvas drawing mapCanvas.addEventListener('mousedown', (e) => { isDrawing = true; const rect = mapCanvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (mapCanvas.width / rect.width); const y = (e.clientY - rect.top) * (mapCanvas.height / rect.height); lastX = x; lastY = y; if (tool === 'brush') { mapCtx.beginPath(); mapCtx.moveTo(x, y); mapCtx.strokeStyle = color; mapCtx.lineWidth = size; mapCtx.lineCap = 'round'; mapCtx.lineJoin = 'round'; } else if (tool === 'fill') { mapCtx.fillStyle = color; mapCtx.fillRect(0, 0, mapCanvas.width, mapCanvas.height); saveHistory(); isDrawing = false; } else if (tool === 'stamp') { mapCtx.font = '48px Arial'; mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle'; mapCtx.fillText(currentStamp, x, y); saveHistory(); // Track stamp in world space for the sub-map / landmark feature const worldX = currentCellX * GRID_SIZE + x; const worldY = currentCellY * GRID_SIZE + y; const stampId = 'stamp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); placedStamps.push({ id: stampId, emoji: currentStamp, worldX, worldY }); broadcast({ type: 'stampUpdate', placedStamps }); scheduleSave(); isDrawing = false; // Reveal "Edit Landmark Illustration" button so DM can open the editor right away const editBtn = document.getElementById('editStampIllustrationBtn'); if (editBtn) editBtn.style.display = 'block'; } else if (tool === 'eraser') { mapCtx.save(); if (eraserMode === 'content') { // Erase only drawn content, not background mapCtx.globalCompositeOperation = 'destination-out'; mapCtx.strokeStyle = 'rgba(0,0,0,1)'; } else { // Erase everything including background mapCtx.globalCompositeOperation = 'source-over'; mapCtx.strokeStyle = '#f5f5dc'; // Match background color } mapCtx.beginPath(); mapCtx.moveTo(x, y); mapCtx.lineWidth = size; mapCtx.lineCap = 'round'; mapCtx.lineJoin = 'round'; } }); mapCanvas.addEventListener('mousemove', (e) => { if (!isDrawing) return; const rect = mapCanvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (mapCanvas.width / rect.width); const y = (e.clientY - rect.top) * (mapCanvas.height / rect.height); if (tool === 'brush' || tool === 'eraser') { mapCtx.lineTo(x, y); mapCtx.stroke(); } }); mapCanvas.addEventListener('mouseup', () => { if (isDrawing) { isDrawing = false; if (tool === 'eraser') { mapCtx.restore(); // Restore composite operation } saveHistory(); } }); mapCanvas.addEventListener('mouseleave', () => { if (isDrawing) { isDrawing = false; if (tool === 'eraser') { mapCtx.restore(); // Restore composite operation } saveHistory(); } }); document.getElementById('colorPicker').addEventListener('change', (e) => { color = e.target.value; }); // Main canvas interaction canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); const screenX = e.clientX; const screenY = e.clientY; const mx = (screenX - rect.left - panX) / zoom; const my = (screenY - rect.top - panY) / zoom; // === LANDMARK STAMP CLICK (players open sub-map viewer) === if (!isDM && !fogMode) { for (const stamp of placedStamps) { if (!stampSubMaps[stamp.id]) continue; // only illustrated stamps if (Math.hypot(mx - stamp.worldX, my - stamp.worldY) < 64) { openSubMapViewer(stamp.id); return; } } } // Check if clicking on a placed token (for moving it) if (!fogMode && tool !== 'select') { for (let i = placedTokens.length - 1; i >= 0; i--) { const token = placedTokens[i]; const tokenSize = token.size || 12; const dist = Math.hypot(mx - token.x, my - token.y); if (dist < tokenSize) { // Check permissions: DM can move any token, players can only move their own if (isDM || token.owner === myName) { draggedPlacedToken = token; tokenDragOffset = { x: mx - token.x, y: my - token.y }; console.log('๐ŸŽฏ Selected token:', token.owner, token.icon || 'image'); return; } else { console.log('๐Ÿšซ Cannot move token owned by:', token.owner); return; } } } } // Image selection and manipulation (Select tool ONLY) if (isDM && tool === 'select') { // Check if clicking on rotation handle if (selectedImage) { const rotX = selectedImage.x + selectedImage.width / 2; const rotY = selectedImage.y - 20; if (Math.hypot(mx - rotX, my - rotY) < 10) { imageResizeHandle = 'rotate'; imageDragStart = { x: mx, y: my }; return; } // Check corner handles for resize const handleSize = 8; const handles = [ { type: 'nw', x: selectedImage.x, y: selectedImage.y }, { type: 'ne', x: selectedImage.x + selectedImage.width, y: selectedImage.y }, { type: 'sw', x: selectedImage.x, y: selectedImage.y + selectedImage.height }, { type: 'se', x: selectedImage.x + selectedImage.width, y: selectedImage.y + selectedImage.height } ]; for (let h of handles) { if (Math.abs(mx - h.x) < handleSize && Math.abs(my - h.y) < handleSize) { imageResizeHandle = h.type; imageDragStart = { x: mx, y: my, origWidth: selectedImage.width, origHeight: selectedImage.height, origX: selectedImage.x, origY: selectedImage.y }; return; } } } // Check if clicking on an image for (let i = placedImages.length - 1; i >= 0; i--) { const pi = placedImages[i]; if (mx >= pi.x && mx <= pi.x + pi.width && my >= pi.y && my <= pi.y + pi.height) { selectedImage = pi; imageDragStart = { x: mx, y: my, imgX: pi.x, imgY: pi.y }; draw(); return; } } // Clicked empty space - deselect selectedImage = null; draw(); return; } // Fog mode painting if (isDM && fogMode) { isFogDrawing = true; if (fogPaintMode === 'brush') { paintFog(mx, my); } else if (fogPaintMode === 'rect') { fogRectStart = { x: mx, y: my }; } else if (fogPaintMode === 'triangle') { fogTriangleStart = { x: mx, y: my }; } return; } // Normal panning (when not in fog mode and not using select tool) lastX = screenX; lastY = screenY; isDrawing = true; }); canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const screenX = e.clientX; const screenY = e.clientY; const mx = (screenX - rect.left - panX) / zoom; const my = (screenY - rect.top - panY) / zoom; // Token dragging if (draggedPlacedToken) { draggedPlacedToken.x = snapToGrid(mx - tokenDragOffset.x); draggedPlacedToken.y = snapToGrid(my - tokenDragOffset.y); draw(); return; } // Image manipulation (Select tool only) if (isDM && tool === 'select' && selectedImage && imageDragStart) { if (imageResizeHandle === 'rotate') { // Rotate around center const centerX = selectedImage.x + selectedImage.width / 2; const centerY = selectedImage.y + selectedImage.height / 2; const angle = Math.atan2(my - centerY, mx - centerX) * 180 / Math.PI + 90; selectedImage.rotation = angle; draw(); return; } else if (imageResizeHandle) { // Resize const dx = mx - imageDragStart.x; const dy = my - imageDragStart.y; if (imageResizeHandle === 'se') { selectedImage.width = imageDragStart.origWidth + dx; selectedImage.height = imageDragStart.origHeight + dy; } else if (imageResizeHandle === 'nw') { selectedImage.width = imageDragStart.origWidth - dx; selectedImage.height = imageDragStart.origHeight - dy; selectedImage.x = imageDragStart.origX + dx; selectedImage.y = imageDragStart.origY + dy; } else if (imageResizeHandle === 'ne') { selectedImage.width = imageDragStart.origWidth + dx; selectedImage.height = imageDragStart.origHeight - dy; selectedImage.y = imageDragStart.origY + dy; } else if (imageResizeHandle === 'sw') { selectedImage.width = imageDragStart.origWidth - dx; selectedImage.height = imageDragStart.origHeight + dy; selectedImage.x = imageDragStart.origX + dx; } // Minimum size if (selectedImage.width < 50) selectedImage.width = 50; if (selectedImage.height < 50) selectedImage.height = 50; draw(); return; } else { // Move selectedImage.x = imageDragStart.imgX + (mx - imageDragStart.x); selectedImage.y = imageDragStart.imgY + (my - imageDragStart.y); draw(); return; } } // Fog painting if (isDM && isFogDrawing) { if (fogPaintMode === 'brush') { paintFog(mx, my); } else if (fogPaintMode === 'rect' && fogRectStart) { draw(); // Draw preview rectangle ctx.save(); const reveal = document.getElementById('revealMode').checked; ctx.strokeStyle = reveal ? '#10b981' : '#ef4444'; ctx.lineWidth = 2 / zoom; ctx.setLineDash([5 / zoom, 5 / zoom]); ctx.translate(panX, panY); ctx.scale(zoom, zoom); ctx.strokeRect(fogRectStart.x, fogRectStart.y, mx - fogRectStart.x, my - fogRectStart.y); ctx.restore(); } else if (fogPaintMode === 'triangle' && fogTriangleStart) { draw(); // Draw preview equilateral triangle const tx1 = fogTriangleStart.x, ty1 = fogTriangleStart.y; const dx = mx - tx1, dy = my - ty1; const len = Math.sqrt(dx * dx + dy * dy); if (len > 1) { const halfBase = len / Math.sqrt(3); const nx = -dy / len, ny = dx / len; // perpendicular unit vector const lx = mx + halfBase * nx, ly = my + halfBase * ny; const rx = mx - halfBase * nx, ry = my - halfBase * ny; ctx.save(); const reveal2 = document.getElementById('revealMode').checked; ctx.strokeStyle = reveal2 ? '#10b981' : '#ef4444'; ctx.lineWidth = 2 / zoom; ctx.setLineDash([5 / zoom, 5 / zoom]); ctx.translate(panX, panY); ctx.scale(zoom, zoom); ctx.beginPath(); ctx.moveTo(tx1, ty1); ctx.lineTo(lx, ly); ctx.lineTo(rx, ry); ctx.closePath(); ctx.stroke(); ctx.restore(); } } return; } // Normal panning (only when not in fog mode and not dragging anything) if (!isDrawing || fogMode || (isDM && tool === 'select') || draggedPlacedToken) return; panX += screenX - lastX; panY += screenY - lastY; lastX = screenX; lastY = screenY; draw(); }); canvas.addEventListener('mouseup', (e) => { // Token movement complete - broadcast to all if (draggedPlacedToken) { console.log('โœ… Token moved:', draggedPlacedToken.owner); broadcast({ type: 'tokenUpdate', tokens: [...stagingTokens, ...placedTokens] }); draggedPlacedToken = null; tokenDragOffset = { x: 0, y: 0 }; } // Image manipulation complete if (imageDragStart) { imageDragStart = null; imageResizeHandle = null; broadcastImages(); } if (isFogDrawing) { const rect = canvas.getBoundingClientRect(); const mx = (e.clientX - rect.left - panX) / zoom; const my = (e.clientY - rect.top - panY) / zoom; if (fogPaintMode === 'rect' && fogRectStart) { paintFogRect(fogRectStart.x, fogRectStart.y, mx, my); fogRectStart = null; } else if (fogPaintMode === 'triangle' && fogTriangleStart) { paintFogTriangle(fogTriangleStart.x, fogTriangleStart.y, mx, my); fogTriangleStart = null; } } isFogDrawing = false; isDrawing = false; }); // === DM DOUBLE-CLICK โ†’ open sub-map editor for nearest stamp === canvas.addEventListener('dblclick', (e) => { if (!isDM) return; const rect = canvas.getBoundingClientRect(); const mx = (e.clientX - rect.left - panX) / zoom; const my = (e.clientY - rect.top - panY) / zoom; // Find nearest placed stamp within 64px world-space radius (generous โ€” emoji is ~48px) let closest = null, closestDist = 64; for (const stamp of placedStamps) { const d = Math.hypot(mx - stamp.worldX, my - stamp.worldY); if (d < closestDist) { closest = stamp; closestDist = d; } } if (closest) openSubMapEditor(closest.id); }); canvas.addEventListener('dragover', (e) => { e.preventDefault(); }); canvas.addEventListener('drop', (e) => { e.preventDefault(); if (!draggedToken) return; const rect = canvas.getBoundingClientRect(); const x = snapToGrid((e.clientX - rect.left - panX) / zoom); const y = snapToGrid((e.clientY - rect.top - panY) / zoom); const placedToken = { ...draggedToken, x, y }; placedTokens.push(placedToken); stagingTokens = stagingTokens.filter(t => t.id !== draggedToken.id); renderStagingTokens(); draw(); broadcast({ type: 'tokenUpdate', tokens: [...stagingTokens, ...placedTokens] }); draggedToken = null; }); // === SCROLL-WHEEL ZOOM (zooms toward mouse cursor) === canvas.addEventListener('wheel', (e) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // World coordinates of the mouse before zoom const worldX = (mouseX - panX) / zoom; const worldY = (mouseY - panY) / zoom; const factor = e.deltaY < 0 ? 1.1 : 0.909; const newZoom = Math.min(3.0, Math.max(0.25, zoom * factor)); // Adjust pan so the world point stays under the cursor panX = mouseX - worldX * newZoom; panY = mouseY - worldY * newZoom; zoom = newZoom; const sliderVal = Math.round(zoom * 100); document.getElementById('zoomSlider').value = sliderVal; document.getElementById('zoomValue').textContent = sliderVal + '%'; draw(); }, { passive: false }); // === FOG === function showFogGroupModal() { document.getElementById('fogGroupModal').classList.add('active'); } function closeFogGroupModal() { document.getElementById('fogGroupModal').classList.remove('active'); } function createFogGroup() { const name = document.getElementById('fogGroupName').value.trim(); if (!name) return; const id = 'group_' + Date.now(); const fogCanvas = document.createElement('canvas'); fogCanvas.width = GRID_SIZE * 3; fogCanvas.height = GRID_SIZE * 3; const fogCtx = fogCanvas.getContext('2d'); fogCtx.fillStyle = 'rgba(0,0,0,1)'; // Full opacity black fog fogCtx.fillRect(0, 0, fogCanvas.width, fogCanvas.height); fogGroups[id] = { name, canvas: fogCanvas }; renderFogGroups(); closeFogGroupModal(); document.getElementById('fogGroupName').value = ''; } function renderFogGroups() { const container = document.getElementById('fogGroups'); container.innerHTML = Object.keys(fogGroups).map(id => `
${fogGroups[id].name}
`).join(''); } function setActiveFogGroup(groupId) { activeFogGroup = groupId; renderFogGroups(); draw(); } function setFogPaintMode(mode) { fogPaintMode = mode; document.getElementById('fogBrushBtn').classList.remove('btn-primary'); document.getElementById('fogRectBtn').classList.remove('btn-primary'); document.getElementById('fogTriBtn').classList.remove('btn-primary'); const sizeRow = document.getElementById('fogSizeRow'); const sizeSlider = document.getElementById('fogSize'); if (mode === 'brush') { sizeRow.style.opacity = '1'; sizeSlider.disabled = false; document.getElementById('fogBrushBtn').classList.add('btn-primary'); } else if (mode === 'rect') { sizeRow.style.opacity = '0.35'; sizeSlider.disabled = true; document.getElementById('fogRectBtn').classList.add('btn-primary'); } else if (mode === 'triangle') { sizeRow.style.opacity = '0.35'; sizeSlider.disabled = true; document.getElementById('fogTriBtn').classList.add('btn-primary'); } } function toggleFogMode() { if (!isDM) return; fogMode = !fogMode; const btn = document.getElementById('fogModeToggle'); const controls = document.getElementById('fogControls'); if (fogMode) { // FOG MODE ON - Orange button btn.style.background = 'linear-gradient(135deg, #f97316, #ea580c)'; btn.style.borderColor = '#f97316'; btn.textContent = '๐ŸŒซ๏ธ Fog Paint Mode: ON'; controls.style.opacity = '1'; controls.style.pointerEvents = 'all'; } else { // FOG MODE OFF - Purple button btn.style.background = ''; btn.style.borderColor = ''; btn.classList.add('btn-primary'); btn.textContent = '๐ŸŒซ๏ธ Fog Paint Mode: OFF'; controls.style.opacity = '0.5'; controls.style.pointerEvents = 'none'; } } function updateFogSize(value) { fogBrushSize = parseInt(value); document.getElementById('fogSizeValue').textContent = value + 'px'; } function resetFog() { if (!isDM) return; if (!confirm('Reset all fog for the active group?')) return; if (fogGroups[activeFogGroup]) { const ctx = fogGroups[activeFogGroup].canvas.getContext('2d'); ctx.fillStyle = 'rgba(0,0,0,1)'; ctx.fillRect(0, 0, fogGroups[activeFogGroup].canvas.width, fogGroups[activeFogGroup].canvas.height); draw(); broadcast({ type: 'fogUpdate', group: activeFogGroup, fogData: fogGroups[activeFogGroup].canvas.toDataURL() }); } } function paintFog(x, y) { if (!isDM || !fogGroups[activeFogGroup]) return; const fogCtx = fogGroups[activeFogGroup].canvas.getContext('2d'); const reveal = document.getElementById('revealMode').checked; fogCtx.save(); if (reveal) { // Erase fog (reveal map) fogCtx.globalCompositeOperation = 'destination-out'; fogCtx.fillStyle = 'rgba(0,0,0,1)'; } else { // Add fog (hide map) fogCtx.globalCompositeOperation = 'source-over'; fogCtx.fillStyle = 'rgba(0,0,0,1)'; // Changed to full opacity } fogCtx.beginPath(); fogCtx.arc(x, y, fogBrushSize, 0, Math.PI * 2); fogCtx.fill(); fogCtx.restore(); draw(); // Broadcast fog update in real-time (every brush stroke) broadcastFog(); } function paintFogRect(x1, y1, x2, y2) { if (!isDM || !fogGroups[activeFogGroup]) return; const fogCtx = fogGroups[activeFogGroup].canvas.getContext('2d'); const reveal = document.getElementById('revealMode').checked; fogCtx.save(); if (reveal) { fogCtx.globalCompositeOperation = 'destination-out'; fogCtx.fillStyle = 'rgba(0,0,0,1)'; } else { fogCtx.globalCompositeOperation = 'source-over'; fogCtx.fillStyle = 'rgba(0,0,0,1)'; // Changed to full opacity } const width = x2 - x1; const height = y2 - y1; fogCtx.fillRect(x1, y1, width, height); fogCtx.restore(); draw(); // Broadcast fog update broadcastFog(); } function paintFogTriangle(x1, y1, x2, y2) { if (!isDM || !fogGroups[activeFogGroup]) return; const dx = x2 - x1, dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); if (len < 2) return; const halfBase = len / Math.sqrt(3); const nx = -dy / len, ny = dx / len; const lx = x2 + halfBase * nx, ly = y2 + halfBase * ny; const rx = x2 - halfBase * nx, ry = y2 - halfBase * ny; const fogCtx = fogGroups[activeFogGroup].canvas.getContext('2d'); const reveal = document.getElementById('revealMode').checked; fogCtx.save(); if (reveal) { fogCtx.globalCompositeOperation = 'destination-out'; fogCtx.fillStyle = 'rgba(0,0,0,1)'; } else { fogCtx.globalCompositeOperation = 'source-over'; fogCtx.fillStyle = 'rgba(0,0,0,1)'; } fogCtx.beginPath(); fogCtx.moveTo(x1, y1); fogCtx.lineTo(lx, ly); fogCtx.lineTo(rx, ry); fogCtx.closePath(); fogCtx.fill(); fogCtx.restore(); draw(); broadcastFog(); } function broadcastFog() { if (!isDM) return; // Clear any pending broadcast if (fogBroadcastTimeout) { clearTimeout(fogBroadcastTimeout); } // Throttle broadcasts to every 100ms for smooth real-time updates fogBroadcastTimeout = setTimeout(() => { const fogData = fogGroups[activeFogGroup].canvas.toDataURL(); console.log('๐Ÿ“ก Broadcasting fog update, size:', fogData.length, 'bytes'); broadcast({ type: 'fogUpdate', group: activeFogGroup, fogData: fogData }); fogBroadcastTimeout = null; }, 100); } // Initialize fog mode buttons setFogPaintMode('brush'); // === CAMPAIGN EXPORT/IMPORT === function exportCampaign() { if (!isDM) return; // Serialize grid cells const gridData = {}; for (let key in gridCells) { gridData[key] = gridCells[key].toDataURL(); } // Serialize fog groups const fogData = {}; for (let group in fogGroups) { fogData[group] = { name: fogGroups[group].name, canvas: fogGroups[group].canvas.toDataURL() }; } // Serialize placed images const imagesData = placedImages.map(pi => ({ src: pi.img.src, x: pi.x, y: pi.y, width: pi.width, height: pi.height, rotation: pi.rotation, gridX: pi.gridX, gridY: pi.gridY })); // Get player data const playerData = []; connections.forEach(({ name, role, approved }, peerId) => { if (approved) { playerData.push({ name, role, peerId }); } }); const campaignData = { version: '4.0', name: campaignName, sessionId: roomCode, exported: new Date().toISOString(), gridCells: gridData, lockedCells: lockedCells, fogGroups: fogData, activeFogGroup: activeFogGroup, stagingTokens: stagingTokens, placedTokens: placedTokens, placedImages: imagesData, playerData: playerData, zoom: zoom, panX: panX, panY: panY, currentCellX: currentCellX, currentCellY: currentCellY, gridSnapEnabled: gridSnapEnabled }; // Create downloadable file const dataStr = JSON.stringify(campaignData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `${campaignData.name.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); document.getElementById('exportSuccess').classList.add('show'); setTimeout(() => { document.getElementById('exportSuccess').classList.remove('show'); }, 3000); console.log('โœ… Campaign exported:', campaignData.name); console.log('Includes:', playerData.length, 'players,', stagingTokens.length + placedTokens.length, 'tokens'); } function importCampaign(event) { if (!isDM) return; const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { try { const data = JSON.parse(e.target.result); if (!data.version || !data.gridCells) { throw new Error('Invalid campaign file format'); } // Load grid cells gridCells = {}; let cellsLoaded = 0; const totalCells = Object.keys(data.gridCells).length; for (let key in data.gridCells) { const img = new Image(); img.onload = () => { const cellCanvas = document.createElement('canvas'); cellCanvas.width = CELL_SIZE; cellCanvas.height = CELL_SIZE; const cellCtx = cellCanvas.getContext('2d'); cellCtx.drawImage(img, 0, 0); gridCells[key] = cellCanvas; cellsLoaded++; if (cellsLoaded === totalCells) { draw(); updateMinimap(); } }; img.src = data.gridCells[key]; } // Load fog groups fogGroups = {}; for (let group in data.fogGroups) { const fogCanvas = document.createElement('canvas'); fogCanvas.width = GRID_SIZE * 3; fogCanvas.height = GRID_SIZE * 3; const fogCtx = fogCanvas.getContext('2d'); const img = new Image(); img.onload = () => { fogCtx.drawImage(img, 0, 0); draw(); // Broadcast fog to all players broadcastFog(); }; img.src = data.fogGroups[group].canvas; fogGroups[group] = { name: data.fogGroups[group].name, canvas: fogCanvas }; } // Load placed images if (data.placedImages) { placedImages = []; data.placedImages.forEach(imgData => { const img = new Image(); img.onload = () => { placedImages.push({ img: img, x: imgData.x, y: imgData.y, width: imgData.width, height: imgData.height, rotation: imgData.rotation, gridX: imgData.gridX, gridY: imgData.gridY }); draw(); broadcastImages(); }; img.src = imgData.src; }); } // Restore campaign name if (data.name) { campaignName = data.name; localStorage.setItem('fracturedSkyCampaignName', campaignName); updateCampaignNameDisplay(); } // Load other data lockedCells = data.lockedCells || {}; activeFogGroup = data.activeFogGroup || 'everyone'; stagingTokens = data.stagingTokens || []; placedTokens = data.placedTokens || []; zoom = data.zoom || 1; panX = data.panX || 0; panY = data.panY || 0; currentCellX = data.currentCellX || 1; currentCellY = data.currentCellY || 1; gridSnapEnabled = data.gridSnapEnabled !== undefined ? data.gridSnapEnabled : true; // Update UI document.getElementById('zoomSlider').value = zoom * 100; document.getElementById('zoomValue').textContent = Math.round(zoom * 100) + '%'; updateGridDisplay(); renderFogGroups(); renderStagingTokens(); draw(); updateMinimap(); // Broadcast complete state to all connected players connections.forEach(({conn, approved}) => { if (approved) sendGameState(conn); }); document.getElementById('importSuccess').classList.add('show'); setTimeout(() => { document.getElementById('importSuccess').classList.remove('show'); }, 3000); const playerCount = data.playerData ? data.playerData.length : 0; const tokenCount = stagingTokens.length + placedTokens.length; alert('โœ… Campaign imported: ' + (data.name || 'Untitled') + '\n' + playerCount + ' players, ' + tokenCount + ' tokens loaded'); console.log('โœ… Campaign imported:', data.name); console.log('Loaded:', playerCount, 'players,', tokenCount, 'tokens'); } catch (err) { console.error('Import error:', err); document.getElementById('importError').textContent = 'Import failed: ' + err.message; document.getElementById('importError').classList.add('show'); setTimeout(() => { document.getElementById('importError').classList.remove('show'); }, 5000); } }; reader.readAsText(file); // Reset file input event.target.value = ''; } function saveCloudLink() { if (!isDM) return; const link = document.getElementById('cloudLink').value.trim(); if (!link) { alert('Please enter a cloud link first'); return; } // Save to session storage const key = 'fracturedSky_cloudLink_' + roomCode; localStorage.setItem(key, link); document.getElementById('linkSuccess').classList.add('show'); setTimeout(() => { document.getElementById('linkSuccess').classList.remove('show'); }, 3000); console.log('โœ… Cloud link saved for session:', roomCode); } function openCloudLink() { const link = document.getElementById('cloudLink').value.trim(); if (!link) { // Try to load saved link const key = 'fracturedSky_cloudLink_' + roomCode; const saved = localStorage.getItem(key); if (saved) { window.open(saved, '_blank'); } else { alert('No cloud link saved. Paste a link first.'); } } else { window.open(link, '_blank'); } } // Load saved cloud link on DM connect function loadSavedCloudLink() { if (isDM && roomCode) { const key = 'fracturedSky_cloudLink_' + roomCode; const saved = localStorage.getItem(key); if (saved) { document.getElementById('cloudLink').value = saved; } } } function savePlayerState() { if (!myName || !roomCode) return; const playerStateKey = 'fracturedSky_player_' + roomCode + '_' + myName; const state = { name: myName, roomCode: roomCode, lastSeen: Date.now(), isDM: isDM }; localStorage.setItem(playerStateKey, JSON.stringify(state)); } // Save player state periodically setInterval(savePlayerState, 60000); // Every minute // === PLAYER PANEL DOCKING === let playerPanelDocked = false; let playerPanelDragOffset = null; function togglePlayerPanelCollapse() { document.getElementById('playerPanelDock').classList.toggle('collapsed'); const btn = document.getElementById('playerCollapseBtn'); btn.textContent = document.getElementById('playerPanelDock').classList.contains('collapsed') ? '+' : 'โˆ’'; } function togglePlayerPanelDock() { playerPanelDocked = !playerPanelDocked; const container = document.getElementById('playerPanelContainer'); const btn = document.getElementById('playerDockBtn'); if (playerPanelDocked) { container.style.cursor = 'default'; btn.textContent = '๐Ÿ”’'; } else { container.style.cursor = 'move'; btn.textContent = '๐Ÿ”“'; } } // Player panel dragging const playerPanelContainer = document.getElementById('playerPanelContainer'); playerPanelContainer.addEventListener('mousedown', (e) => { if (playerPanelDocked) return; if (e.target.closest('.minimap-btn') || e.target.closest('.btn')) return; playerPanelDragOffset = { x: e.clientX - playerPanelContainer.offsetLeft, y: e.clientY - playerPanelContainer.offsetTop }; }); let _playerPanelRafPending = false; document.addEventListener('mousemove', (e) => { if (!playerPanelDragOffset || playerPanelDocked) return; if (_playerPanelRafPending) return; _playerPanelRafPending = true; requestAnimationFrame(() => { _playerPanelRafPending = false; if (!playerPanelDragOffset) return; playerPanelContainer.style.left = (e.clientX - playerPanelDragOffset.x) + 'px'; playerPanelContainer.style.top = (e.clientY - playerPanelDragOffset.y) + 'px'; playerPanelContainer.style.right = 'auto'; playerPanelContainer.style.bottom = 'auto'; }); }); document.addEventListener('mouseup', () => { if (playerPanelDragOffset) { playerPanelDragOffset = null; } }); // === PLAYER TOKEN SIZE === function updatePlayerTokenSize(value) { const size = parseInt(value); document.getElementById('playerTokenSizeValue').textContent = size; // Update all tokens owned by this player let updated = false; placedTokens.forEach(token => { if (token.owner === myName) { token.size = size; updated = true; } }); if (updated) { draw(); // Broadcast token update broadcast({ type: 'tokenUpdate', tokens: [...stagingTokens, ...placedTokens] }); } } function adjustPlayerTokenSize(delta) { const slider = document.getElementById('playerTokenSize'); const newValue = Math.max(8, Math.min(30, parseInt(slider.value) + delta)); slider.value = newValue; updatePlayerTokenSize(newValue); } // === MINIMAP === function updateMinimap() { // Build a compact state string; only rebuild the DOM when something changed let stateKey = `${currentCellX},${currentCellY}`; for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { const key = `${col},${row}`; stateKey += (gridCells[key] ? '1' : '0') + (lockedCells[key] ? 'L' : '.'); } } if (stateKey === _minimapLastState) return; _minimapLastState = stateKey; const grid = _elMinimapGrid || document.getElementById('minimapGrid'); const cells = []; for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { const key = `${col},${row}`; const classes = ['minimap-cell']; if (gridCells[key]) classes.push('has-content'); if (lockedCells[key]) classes.push('locked'); if (col === currentCellX && row === currentCellY) classes.push('current'); cells.push(`
`); } } grid.innerHTML = cells.join(''); } function minimapNavigate(col, row) { const centerX = (col + 0.5) * GRID_SIZE; const centerY = (row + 0.5) * GRID_SIZE; panX = canvas.width / 2 - centerX * zoom; panY = canvas.height / 2 - centerY * zoom; draw(); } function recenterView() { panX = (canvas.width - GRID_SIZE * 3 * zoom) / 2; panY = (canvas.height - GRID_SIZE * 3 * zoom) / 2; draw(); } function resetZoom() { zoom = 1; document.getElementById('zoomSlider').value = 100; document.getElementById('zoomValue').textContent = '100%'; recenterView(); } function setZoom(v) { zoom = v / 100; document.getElementById('zoomValue').textContent = Math.round(v) + '%'; draw(); } function toggleMinimapCollapse() { document.getElementById('minimap').classList.toggle('collapsed'); } function toggleMinimapDock() { minimapDocked = !minimapDocked; const container = document.getElementById('minimapContainer'); const btn = document.getElementById('dockBtn'); if (minimapDocked) { container.classList.add('docked'); btn.textContent = '๐Ÿ”’'; } else { container.classList.remove('docked'); btn.textContent = '๐Ÿ”“'; } } // Minimap dragging const minimapContainer = document.getElementById('minimapContainer'); minimapContainer.addEventListener('mousedown', (e) => { if (minimapDocked) return; if (e.target.closest('.minimap-btn') || e.target.closest('.minimap-cell')) return; minimapDragOffset = { x: e.clientX - minimapContainer.offsetLeft, y: e.clientY - minimapContainer.offsetTop }; }); let _minimapRafPending = false; document.addEventListener('mousemove', (e) => { if (!minimapDragOffset || minimapDocked) return; if (_minimapRafPending) return; _minimapRafPending = true; requestAnimationFrame(() => { _minimapRafPending = false; if (!minimapDragOffset) return; minimapContainer.style.left = (e.clientX - minimapDragOffset.x) + 'px'; minimapContainer.style.top = (e.clientY - minimapDragOffset.y) + 'px'; minimapContainer.style.bottom = 'auto'; }); }); document.addEventListener('mouseup', () => { minimapDragOffset = null; }); // === UI === function refreshMapsSection() { const grid = document.getElementById('mapThumbGrid'); if (!grid) return; grid.innerHTML = ''; const names = [ ['Top-Left','Top-Center','Top-Right'], ['Middle-Left','Center','Middle-Right'], ['Bottom-Left','Bottom-Center','Bottom-Right'] ]; for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { const wrap = document.createElement('div'); wrap.className = 'map-thumb-wrap'; wrap.title = names[row][col] + ' โ€” click to send everyone here'; wrap.onclick = () => goToCell(col, row); const thumb = document.createElement('canvas'); thumb.width = 80; thumb.height = 80; const tctx = thumb.getContext('2d'); tctx.fillStyle = '#1a1f2e'; tctx.fillRect(0, 0, 80, 80); const cell = gridCells[col + ',' + row]; if (cell) tctx.drawImage(cell, 0, 0, 80, 80); const label = document.createElement('div'); label.className = 'map-thumb-label'; label.textContent = names[row][col]; wrap.appendChild(thumb); wrap.appendChild(label); grid.appendChild(wrap); } } // Sub-map illustrations const listWrapper = document.getElementById('submapListWrapper'); const list = document.getElementById('submapList'); const stampIds = Object.keys(stampSubMaps); if (stampIds.length === 0) { listWrapper.style.display = 'none'; return; } listWrapper.style.display = 'block'; list.innerHTML = ''; stampIds.forEach(sid => { const stamp = placedStamps.find(s => s.id === sid); const item = document.createElement('div'); item.className = 'submap-item'; item.title = 'Edit illustration'; item.onclick = () => openSubMapEditor(sid); const thumb = document.createElement('canvas'); thumb.width = 60; thumb.height = 37; thumb.getContext('2d').drawImage(stampSubMaps[sid], 0, 0, 60, 37); const lbl = document.createElement('div'); lbl.className = 'submap-item-label'; lbl.textContent = (stamp ? stamp.emoji + ' ' : '') + 'Illustration'; item.appendChild(thumb); item.appendChild(lbl); list.appendChild(item); }); } function goToCell(col, row) { minimapNavigate(col, row); broadcast({ type: 'viewSync', panX, panY, zoom }); } function showFullWorldView() { const worldW = GRID_SIZE * 3; const worldH = GRID_SIZE * 3; zoom = Math.min(canvas.width / worldW, canvas.height / worldH) * 0.9; panX = (canvas.width - worldW * zoom) / 2; panY = (canvas.height - worldH * zoom) / 2; document.getElementById('zoomSlider').value = zoom * 100; document.getElementById('zoomValue').textContent = Math.round(zoom * 100) + '%'; draw(); broadcast({ type: 'viewSync', panX, panY, zoom }); } function toggleDMPanel() { document.getElementById('dmPanel').classList.toggle('open'); if (document.getElementById('dmPanel').classList.contains('open')) refreshMapsSection(); } // Global error handler window.onerror = function(msg, url, lineNo, columnNo, error) { console.error('๐Ÿšจ JavaScript Error:', msg); console.error('Line:', lineNo, 'Column:', columnNo); console.error('Error object:', error); return false; }; // ============================================= // === MUSIC PLAYER === // ============================================= const MUSIC_TRACKS = [ { id: 'combat', label: 'Combat', emoji: 'โš”๏ธ', file: 'music/combat.mp3' }, { id: 'boss', label: 'Boss Battle', emoji: '๐Ÿ’€', file: 'music/boss.mp3' }, { id: 'exploration', label: 'Exploration', emoji: '๐Ÿ—บ๏ธ', file: 'music/exploration.mp3' }, { id: 'dungeon', label: 'Dungeon', emoji: '๐Ÿ•ฏ๏ธ', file: 'music/dungeon.mp3' }, { id: 'tavern', label: 'Tavern', emoji: '๐Ÿบ', file: 'music/tavern.mp3' }, { id: 'town', label: 'Town', emoji: '๐Ÿ˜๏ธ', file: 'music/town.mp3' }, { id: 'rest', label: 'Rest', emoji: '๐ŸŒ™', file: 'music/rest.mp3' }, { id: 'mystery', label: 'Mystery', emoji: '๐Ÿ”ฎ', file: 'music/mystery.mp3' }, ]; let currentTrack = null; let musicPanelDragOffset = null; function initMusicPanel() { const grid = document.getElementById('musicGrid'); grid.innerHTML = MUSIC_TRACKS.map(t => ` `).join(''); } function toggleMusicPanel() { const panel = document.getElementById('musicPanel'); panel.classList.toggle('visible'); if (panel.classList.contains('visible') && !document.getElementById('musicGrid').innerHTML) { initMusicPanel(); } } function playMusic(trackId) { const track = MUSIC_TRACKS.find(t => t.id === trackId); if (!track) return; const audio = document.getElementById('musicAudio'); // Toggle off if same track playing if (currentTrack === trackId && !audio.paused) { stopMusic(); return; } // Update UI buttons document.querySelectorAll('.music-btn').forEach(b => b.classList.remove('playing')); const btn = document.getElementById('mBtn_' + trackId); if (btn) btn.classList.add('playing'); // Play audio audio.src = track.file; audio.volume = document.getElementById('musicVolume').value / 100; audio.play().catch(() => { document.getElementById('musicNowPlaying').innerHTML = `โš  No MP3 file yet for "${track.label}" โ€” add music/${track.id}.mp3`; }); currentTrack = trackId; document.getElementById('musicNowPlaying').innerHTML = `Now Playing: ${track.emoji} ${track.label}`; // Optionally broadcast music cue to players if (document.getElementById('musicBroadcast').checked) { broadcast({ type: 'musicCue', trackId, trackLabel: track.label, emoji: track.emoji }); } } function stopMusic() { const audio = document.getElementById('musicAudio'); audio.pause(); audio.src = ''; currentTrack = null; document.querySelectorAll('.music-btn').forEach(b => b.classList.remove('playing')); document.getElementById('musicNowPlaying').textContent = 'Nothing playing'; if (document.getElementById('musicBroadcast').checked) { broadcast({ type: 'musicCue', trackId: null }); } } function setMusicVolume(val) { const audio = document.getElementById('musicAudio'); audio.volume = val / 100; } // Music panel dragging const musicPanel = document.getElementById('musicPanel'); musicPanel.addEventListener('mousedown', (e) => { if (e.target.closest('.music-btn') || e.target.closest('.minimap-btn') || e.target.closest('input') || e.target.closest('button')) return; musicPanelDragOffset = { x: e.clientX - musicPanel.offsetLeft, y: e.clientY - musicPanel.offsetTop }; }); let _musicRafPending = false; document.addEventListener('mousemove', (e) => { if (!musicPanelDragOffset) return; if (_musicRafPending) return; _musicRafPending = true; requestAnimationFrame(() => { _musicRafPending = false; if (!musicPanelDragOffset) return; musicPanel.style.right = 'auto'; musicPanel.style.bottom = 'auto'; musicPanel.style.left = (e.clientX - musicPanelDragOffset.x) + 'px'; musicPanel.style.top = (e.clientY - musicPanelDragOffset.y) + 'px'; }); }); document.addEventListener('mouseup', () => { musicPanelDragOffset = null; }); // ============================================= // === CHARACTER SHEET === // ============================================= const SKILLS_DEF = [ { name: 'Acrobatics', attr: 'DEX' }, { name: 'Animal Handling', attr: 'WIS' }, { name: 'Arcana', attr: 'INT' }, { name: 'Athletics', attr: 'STR' }, { name: 'Deception', attr: 'CHA' }, { name: 'History', attr: 'INT' }, { name: 'Insight', attr: 'WIS' }, { name: 'Intimidation', attr: 'CHA' }, { name: 'Investigation', attr: 'INT' }, { name: 'Medicine', attr: 'WIS' }, { name: 'Nature', attr: 'INT' }, { name: 'Perception', attr: 'WIS' }, { name: 'Performance', attr: 'CHA' }, { name: 'Persuasion', attr: 'CHA' }, { name: 'Religion', attr: 'INT' }, { name: 'Sleight of Hand', attr: 'DEX' }, { name: 'Stealth', attr: 'DEX' }, { name: 'Survival', attr: 'WIS' }, ]; const SAVES_DEF = ['STR','DEX','CON','INT','WIS','CHA']; let characters = []; // array of character objects let activeCharIdx = -1; // index into characters[] function showCharSheet() { loadCharactersFromStorage(); renderCharList(); buildSkillList(); buildSavingThrows(); document.getElementById('charOverlay').classList.add('active'); updateAllAbilityMods(); updateProfBonus(); renderCharTray(); } function closeCharSheet() { document.getElementById('charOverlay').classList.remove('active'); } function loadCharactersFromStorage() { try { const saved = localStorage.getItem('fracturedSky_characters'); characters = saved ? JSON.parse(saved) : []; } catch(e) { characters = []; } } function saveCharactersToStorage() { localStorage.setItem('fracturedSky_characters', JSON.stringify(characters)); } function renderCharList() { const el = document.getElementById('charListEl'); if (characters.length === 0) { el.innerHTML = '

No characters yet. Click "+ New" to create one.

'; document.getElementById('charEditor').style.display = 'none'; document.getElementById('deleteCharBtn').style.display = 'none'; renderCharTray(); return; } el.innerHTML = characters.map((c, i) => { const hpPct = c.hpMax > 0 ? Math.max(0, Math.min(100, Math.round((c.hp / c.hpMax) * 100))) : 0; return `
${c.emoji || 'โš”๏ธ'}
${c.name || 'Unnamed'}
${c.class || '?'} ${c.level || 1} โ€ข ${c.race || '?'}
โค๏ธ ${c.hp || 0}/${c.hpMax || 0}
๐Ÿ›ก๏ธ AC ${c.ac || 10}
`; }).join(''); renderCharTray(); } function newCharacter() { const char = { name:'', player: myName || '', class:'Fighter', level:1, race:'', background:'', alignment:'True Neutral', xp:0, multiclass:'', emoji:'โš”๏ธ', str:10, dex:10, con:10, int:10, wis:10, cha:10, hp:10, hpMax:10, tempHp:0, ac:10, speed:30, hitDice:'1d10', hitDiceUsed:0, inspiration:false, exhaustion:0, conditions:[], concentration:'', skillProfs:{}, saveProfs:{}, personality:'', ideal:'', bond:'', flaw:'', age:'', height:'', weight:'', eyes:'', skin:'', hair:'', marks:'', languages:'Common', otherProfs:'', // Combat attacks:[], resistances:'', immunities:'', vulnerabilities:'', // Spells cantrips:'', spells:'', spellSlots:{1:{max:0,used:0},2:{max:0,used:0},3:{max:0,used:0},4:{max:0,used:0},5:{max:0,used:0},6:{max:0,used:0},7:{max:0,used:0},8:{max:0,used:0},9:{max:0,used:0}}, // Equipment pp:0, gp:0, ep:0, sp:0, cp:0, inventory:'', currentWeight:0, attune1:'', attune2:'', attune3:'', treasure:'', // Notes features:'', notes:'', allies:'', extra:'', backstory:'', deathSuccesses:[false,false,false], deathFailures:[false,false,false], featureChoices:{} }; characters.push(char); activeCharIdx = characters.length - 1; saveCharactersToStorage(); renderCharList(); buildConditionsGrid(char); buildSpellSlotsUI(char); populateCharEditor(char); document.getElementById('charEditor').style.display = 'block'; document.getElementById('deleteCharBtn').style.display = 'inline-block'; } function loadCharacter(idx) { activeCharIdx = idx; const char = characters[idx]; buildConditionsGrid(char); buildSpellSlotsUI(char); populateCharEditor(char); renderAttacksList(char.attacks || []); document.getElementById('charEditor').style.display = 'block'; document.getElementById('deleteCharBtn').style.display = 'inline-block'; renderCharList(); } function deleteCharacter() { if (activeCharIdx < 0) return; if (!confirm('Delete this character? This cannot be undone.')) return; characters.splice(activeCharIdx, 1); activeCharIdx = -1; saveCharactersToStorage(); renderCharList(); renderCharTray(); document.getElementById('charEditor').style.display = 'none'; document.getElementById('deleteCharBtn').style.display = 'none'; } function _setVal(id, val) { const el = document.getElementById(id); if (el) el.value = val; } function _setChk(id, val) { const el = document.getElementById(id); if (el) el.checked = !!val; } function populateCharEditor(c) { _setVal('cName', c.name || ''); _setVal('cPlayer', c.player || ''); _setVal('cClass', c.class || 'Fighter'); _setVal('cLevel', c.level || 1); _setVal('cRace', c.race || ''); _setVal('cBackground', c.background || ''); _setVal('cAlignment', c.alignment || 'True Neutral'); _setVal('cXP', c.xp || 0); _setVal('cMulticlass', c.multiclass || ''); _setVal('cEmoji', c.emoji || 'โš”๏ธ'); _setChk('cInspiration', c.inspiration); _setVal('aSTR', c.str || 10); _setVal('aDEX', c.dex || 10); _setVal('aCON', c.con || 10); _setVal('aINT', c.int || 10); _setVal('aWIS', c.wis || 10); _setVal('aCHA', c.cha || 10); _setVal('cHP', c.hp || 10); _setVal('cHPMax', c.hpMax || 10); _setVal('cTempHP', c.tempHp || 0); _setVal('cAC', c.ac || 10); _setVal('cSpeed', c.speed || 30); _setVal('cHitDice', c.hitDice || '1d10'); _setVal('cHitDiceUsed', c.hitDiceUsed || 0); _setVal('cConcentration', c.concentration || ''); _setVal('cPersonality', c.personality || ''); _setVal('cIdeal', c.ideal || ''); _setVal('cBond', c.bond || ''); _setVal('cFlaw', c.flaw || ''); _setVal('cAge', c.age || ''); _setVal('cHeight', c.height || ''); _setVal('cWeight', c.weight || ''); _setVal('cEyes', c.eyes || ''); _setVal('cSkin', c.skin || ''); _setVal('cHair', c.hair || ''); _setVal('cMarks', c.marks || ''); _setVal('cLanguages', c.languages || ''); _setVal('cOtherProfs', c.otherProfs || ''); _setVal('cResistances', c.resistances || ''); _setVal('cImmunities', c.immunities || ''); _setVal('cVulnerabilities', c.vulnerabilities || ''); _setVal('cCantrips', c.cantrips || ''); _setVal('cSpells', c.spells || ''); _setVal('cPP', c.pp || 0); _setVal('cGP', c.gp || 0); _setVal('cEP', c.ep || 0); _setVal('cSP', c.sp || 0); _setVal('cCP', c.cp || 0); _setVal('cCurrentWeight', c.currentWeight || 0); _setVal('cAttune1', c.attune1 || ''); _setVal('cAttune2', c.attune2 || ''); _setVal('cAttune3', c.attune3 || ''); _setVal('cTreasure', c.treasure || ''); _setVal('cInventory', c.inventory || ''); _setVal('cFeatures', c.features || ''); _setVal('cNotes', c.notes || ''); _setVal('cAllies', c.allies || ''); _setVal('cExtra', c.extra || ''); _setVal('cBackstory', c.backstory || ''); // Skill profs document.querySelectorAll('.skill-prof-check').forEach(cb => { cb.checked = !!(c.skillProfs && c.skillProfs[cb.dataset.skill]); }); // Save profs document.querySelectorAll('.save-prof-check').forEach(cb => { cb.checked = !!(c.saveProfs && c.saveProfs[cb.dataset.save]); }); // Death saves ['deathSuccesses','deathFailures'].forEach(key => { const type = key === 'deathSuccesses' ? 'success' : 'failure'; document.querySelectorAll(`.death-save-pip.${type}`).forEach((pip, i) => { pip.classList.toggle('filled', !!(c[key] && c[key][i])); }); }); // Exhaustion const exLevel = c.exhaustion || 0; document.querySelectorAll('.exhaustion-pip').forEach(pip => { pip.classList.toggle('active', parseInt(pip.dataset.level) <= exLevel); }); updateExhaustionLabel(exLevel); // Conditions document.querySelectorAll('.condition-tag').forEach(tag => { tag.classList.toggle('active', !!(c.conditions && c.conditions.includes(tag.dataset.cond))); }); // Attacks renderAttacksList(c.attacks || []); // Spell slots buildSpellSlotsUI(c); // Carrying capacity updateCarryingCap(); updateAllAbilityMods(); updateProfBonus(); updateSpellcastingAbility(); } function _getVal(id, def='') { const el = document.getElementById(id); return el ? el.value : def; } function _getInt(id, def=0) { return parseInt(_getVal(id, def)) || def; } function _getChk(id) { const el = document.getElementById(id); return el ? el.checked : false; } function saveCharacter() { if (activeCharIdx < 0) { alert('No character selected. Click "+ New" first.'); return; } const c = characters[activeCharIdx]; c.name = _getVal('cName'); c.player = _getVal('cPlayer'); c.class = _getVal('cClass'); c.level = _getInt('cLevel', 1); c.race = _getVal('cRace'); c.background = _getVal('cBackground'); c.alignment = _getVal('cAlignment'); c.xp = _getInt('cXP', 0); c.multiclass = _getVal('cMulticlass'); c.emoji = _getVal('cEmoji') || 'โš”๏ธ'; c.inspiration = _getChk('cInspiration'); c.str = _getInt('aSTR', 10); c.dex = _getInt('aDEX', 10); c.con = _getInt('aCON', 10); c.int = _getInt('aINT', 10); c.wis = _getInt('aWIS', 10); c.cha = _getInt('aCHA', 10); c.hp = _getInt('cHP', 0); c.hpMax = _getInt('cHPMax', 10); c.tempHp = _getInt('cTempHP', 0); c.ac = _getInt('cAC', 10); c.speed = _getInt('cSpeed', 30); c.hitDice = _getVal('cHitDice'); c.hitDiceUsed = _getInt('cHitDiceUsed', 0); c.concentration = _getVal('cConcentration'); c.personality = _getVal('cPersonality'); c.ideal = _getVal('cIdeal'); c.bond = _getVal('cBond'); c.flaw = _getVal('cFlaw'); c.age = _getVal('cAge'); c.height = _getVal('cHeight'); c.weight = _getVal('cWeight'); c.eyes = _getVal('cEyes'); c.skin = _getVal('cSkin'); c.hair = _getVal('cHair'); c.marks = _getVal('cMarks'); c.languages = _getVal('cLanguages'); c.otherProfs = _getVal('cOtherProfs'); c.resistances = _getVal('cResistances'); c.immunities = _getVal('cImmunities'); c.vulnerabilities = _getVal('cVulnerabilities'); c.cantrips = _getVal('cCantrips'); c.spells = _getVal('cSpells'); c.pp = _getInt('cPP', 0); c.gp = _getInt('cGP', 0); c.ep = _getInt('cEP', 0); c.sp = _getInt('cSP', 0); c.cp = _getInt('cCP', 0); c.currentWeight = _getInt('cCurrentWeight', 0); c.attune1 = _getVal('cAttune1'); c.attune2 = _getVal('cAttune2'); c.attune3 = _getVal('cAttune3'); c.treasure = _getVal('cTreasure'); c.inventory = _getVal('cInventory'); c.features = _getVal('cFeatures'); c.notes = _getVal('cNotes'); c.allies = _getVal('cAllies'); c.extra = _getVal('cExtra'); c.backstory = _getVal('cBackstory'); // Exhaustion const activePips = document.querySelectorAll('.exhaustion-pip.active'); c.exhaustion = activePips.length; // Conditions c.conditions = []; document.querySelectorAll('.condition-tag.active').forEach(tag => c.conditions.push(tag.dataset.cond)); // Attacks c.attacks = readAttacksList(); // Spell slots c.spellSlots = readSpellSlots(); // Skill profs c.skillProfs = {}; document.querySelectorAll('.skill-prof-check').forEach(cb => { if (cb.checked) c.skillProfs[cb.dataset.skill] = true; }); // Save profs c.saveProfs = {}; document.querySelectorAll('.save-prof-check').forEach(cb => { if (cb.checked) c.saveProfs[cb.dataset.save] = true; }); // Death saves c.deathSuccesses = [false, false, false]; c.deathFailures = [false, false, false]; document.querySelectorAll('.death-save-pip.success').forEach((pip, i) => { c.deathSuccesses[i] = pip.classList.contains('filled'); }); document.querySelectorAll('.death-save-pip.failure').forEach((pip, i) => { c.deathFailures[i] = pip.classList.contains('filled'); }); saveCharactersToStorage(); renderCharList(); renderCharTray(); // Flash save button const btn = document.querySelector('[onclick="saveCharacter()"]'); if (btn) { const orig = btn.textContent; btn.textContent = 'โœ“ Saved!'; btn.style.background = 'var(--success)'; btn.style.color = '#0e0b08'; setTimeout(() => { btn.textContent = orig; btn.style.background = ''; btn.style.color = ''; }, 1500); } } function getAbilityMod(score) { return Math.floor((parseInt(score) - 10) / 2); } function updateAbilityMod(attr) { const score = document.getElementById('a' + attr).value; const mod = getAbilityMod(score); document.getElementById('m' + attr).textContent = (mod >= 0 ? '+' : '') + mod; // Update initiative from DEX if (attr === 'DEX') document.getElementById('cInit').value = (mod >= 0 ? '+' : '') + mod; // Update passive perception/investigation from WIS/INT if (attr === 'WIS' || attr === 'INT') updatePassivePerception(); if (attr === 'STR') updateCarryingCap(); updateSkillMods(); updateSaveMods(); updateSpellcastingAbility(); } function updateAllAbilityMods() { ['STR','DEX','CON','INT','WIS','CHA'].forEach(a => updateAbilityMod(a)); } function updateProfBonus() { const level = parseInt(document.getElementById('cLevel').value) || 1; const prof = Math.ceil(level / 4) + 1; document.getElementById('cProfBonus').value = '+' + prof; updateSkillMods(); updateSaveMods(); updatePassivePerception(); updateSpellcastingAbility(); } function getProfBonus() { const level = parseInt(document.getElementById('cLevel').value) || 1; return Math.ceil(level / 4) + 1; } function getAttrMod(attr) { const score = document.getElementById('a' + attr); return score ? getAbilityMod(score.value) : 0; } function updateSkillMods() { const prof = getProfBonus(); document.querySelectorAll('.skill-mod-val').forEach(el => { const skill = el.dataset.skill; const attr = el.dataset.attr; const profCheck = document.querySelector(`.skill-prof-check[data-skill="${skill}"]`); const isProficient = profCheck && profCheck.checked; const mod = getAttrMod(attr) + (isProficient ? prof : 0); el.textContent = (mod >= 0 ? '+' : '') + mod; }); } function updateSaveMods() { const prof = getProfBonus(); document.querySelectorAll('.save-mod-val').forEach(el => { const attr = el.dataset.attr; const profCheck = document.querySelector(`.save-prof-check[data-save="${attr}"]`); const isProficient = profCheck && profCheck.checked; const mod = getAttrMod(attr) + (isProficient ? prof : 0); el.textContent = (mod >= 0 ? '+' : '') + mod; }); } function updatePassivePerception() { const prof = getProfBonus(); const wisMod = getAttrMod('WIS'); const percCheck = document.querySelector('.skill-prof-check[data-skill="Perception"]'); const isProficient = percCheck && percCheck.checked; const pp = 10 + wisMod + (isProficient ? prof : 0); const el = document.getElementById('cPassivePerception'); if (el) el.value = pp; // Also update passive investigation const invCheck = document.querySelector('.skill-prof-check[data-skill="Investigation"]'); const isInvProf = invCheck && invCheck.checked; const intMod = getAttrMod('INT'); const pi = 10 + intMod + (isInvProf ? prof : 0); const el2 = document.getElementById('cPassiveInvestigation'); if (el2) el2.value = pi; } function updateCarryingCap() { const strScore = parseInt((document.getElementById('aSTR') || {}).value) || 10; const cap = strScore * 15; const el = document.getElementById('cCarryingCap'); if (el) el.value = cap + ' lbs'; } // Spellcasting ability by class const SPELL_ABILITY = { Artificer:'INT', Bard:'CHA', Cleric:'WIS', Druid:'WIS', Paladin:'CHA', Ranger:'WIS', Sorcerer:'CHA', Warlock:'CHA', Wizard:'INT' }; function updateSpellcastingAbility() { const cls = (document.getElementById('cClass') || {}).value || 'Fighter'; const ability = SPELL_ABILITY[cls] || ''; _setVal('cSpellAbility', ability); _setVal('cSpellClass', cls); const prof = getProfBonus(); if (ability) { const mod = getAttrMod(ability); _setVal('cSpellSaveDC', 8 + prof + mod); _setVal('cSpellAttackBonus', (prof + mod >= 0 ? '+' : '') + (prof + mod)); } else { _setVal('cSpellSaveDC', 'โ€”'); _setVal('cSpellAttackBonus', 'โ€”'); } } // === EXHAUSTION === const EXHAUSTION_LABELS = ['No exhaustion','Disadvantage on ability checks','Speed halved','Disadvantage on attacks & saves','HP maximum halved','Speed = 0','Death']; function toggleExhaustion(level) { const c = (activeCharIdx >= 0) ? characters[activeCharIdx] : null; if (!c) return; const cur = c.exhaustion || 0; c.exhaustion = (cur === level) ? level - 1 : level; document.querySelectorAll('.exhaustion-pip').forEach(pip => { pip.classList.toggle('active', parseInt(pip.dataset.level) <= c.exhaustion); }); updateExhaustionLabel(c.exhaustion); } function updateExhaustionLabel(level) { const el = document.getElementById('exhaustionLabel'); if (el) el.textContent = EXHAUSTION_LABELS[level] || 'No exhaustion'; } // === CONDITIONS === const CONDITIONS_LIST = ['Blinded','Charmed','Deafened','Exhaustion','Frightened','Grappled','Incapacitated','Invisible','Paralyzed','Petrified','Poisoned','Prone','Restrained','Stunned','Unconscious']; function buildConditionsGrid(c) { const grid = document.getElementById('conditionsGrid'); if (!grid) return; grid.innerHTML = CONDITIONS_LIST.map(cond => `
${cond}
`).join(''); } function toggleCondition(tag) { tag.classList.toggle('active'); } // === ATTACKS === let _attacksData = []; function renderAttacksList(attacks) { _attacksData = attacks ? [...attacks] : []; const el = document.getElementById('attacksList'); if (!el) return; if (_attacksData.length === 0) { el.innerHTML = '

No attacks yet. Click "+ Add Attack / Weapon" below.

'; return; } el.innerHTML = `
Name Atk Bonus Damage / Type Range
` + _attacksData.map((a, i) => `
`).join(''); } function addAttack() { _attacksData.push({name:'', bonus:'', damage:'', range:''}); renderAttacksList(_attacksData); } function removeAttack(i) { _attacksData.splice(i, 1); renderAttacksList(_attacksData); } function readAttacksList() { return [..._attacksData]; } // === SPELL SLOTS === function buildSpellSlotsUI(c) { const el = document.getElementById('spellSlotsGrid'); if (!el) return; const slots = c.spellSlots || {}; const ordinals = ['1st','2nd','3rd','4th','5th','6th','7th','8th','9th']; el.innerHTML = [1,2,3,4,5,6,7,8,9].map(lvl => { const s = slots[lvl] || {max:0, used:0}; const max = s.max || 0; const used = s.used || 0; const pips = Array.from({length: Math.max(max, 0)}, (_, i) => `
` ).join(''); return `
${ordinals[lvl-1]}
${pips || 'No slots'}
Max:
`; }).join(''); } function toggleSpellSlot(lvl, pipIdx) { const c = (activeCharIdx >= 0) ? characters[activeCharIdx] : null; if (!c) return; if (!c.spellSlots) c.spellSlots = {}; if (!c.spellSlots[lvl]) c.spellSlots[lvl] = {max:0, used:0}; const s = c.spellSlots[lvl]; const available = s.max - s.used; if (pipIdx < available) { s.used = s.max - pipIdx; // use slots up to this pip } else { s.used = Math.max(0, s.used - 1); // recover one slot } buildSpellSlotsUI(c); } function updateSpellSlotMax(lvl, val) { const c = (activeCharIdx >= 0) ? characters[activeCharIdx] : null; if (!c) return; if (!c.spellSlots) c.spellSlots = {}; if (!c.spellSlots[lvl]) c.spellSlots[lvl] = {max:0, used:0}; c.spellSlots[lvl].max = Math.max(0, parseInt(val) || 0); c.spellSlots[lvl].used = Math.min(c.spellSlots[lvl].used, c.spellSlots[lvl].max); buildSpellSlotsUI(c); } function recoverAllSlots(lvl) { const c = (activeCharIdx >= 0) ? characters[activeCharIdx] : null; if (!c || !c.spellSlots || !c.spellSlots[lvl]) return; c.spellSlots[lvl].used = 0; buildSpellSlotsUI(c); } function readSpellSlots() { const c = (activeCharIdx >= 0) ? characters[activeCharIdx] : null; return c ? (c.spellSlots || {}) : {}; } function buildSkillList() { const el = document.getElementById('skillListEl'); el.innerHTML = SKILLS_DEF.map(s => `
+0 ${s.name} (${s.attr})
`).join(''); } function buildSavingThrows() { const el = document.getElementById('savingThrowList'); el.innerHTML = SAVES_DEF.map(attr => `
+0 ${attr}
`).join(''); } function switchCharTab(tab) { document.querySelectorAll('.char-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.char-tab-content').forEach(t => t.classList.remove('active')); document.querySelector(`.char-tab[onclick="switchCharTab('${tab}')"]`).classList.add('active'); document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active'); if (tab === 'features') renderFeaturesTab(); } function changeHP(delta) { const input = document.getElementById('cHP'); const max = parseInt(document.getElementById('cHPMax').value) || 10; input.value = Math.max(0, Math.min(max, parseInt(input.value || 0) + delta)); } function toggleDeathSave(pip) { pip.classList.toggle('filled'); } // ============================================= // CHARACTER TRAY // ============================================= let _quickViewCharIdx = -1; function renderCharTray() { loadCharactersFromStorage(); const tray = document.getElementById('charTray'); if (!tray) return; if (characters.length === 0) { tray.innerHTML = ''; return; } tray.innerHTML = 'Party' + characters.map((c, i) => { const hpPct = c.hpMax > 0 ? Math.max(0, Math.min(100, Math.round((c.hp / c.hpMax) * 100))) : 0; const emoji = c.emoji || 'โš”๏ธ'; return `
${emoji}
${c.name || 'Unnamed'}
${c.class || '?'} ${c.level || 1}
${c.hp || 0}/${c.hpMax || 0}
`; }).join(''); // Keep advantage panel in sync with current character list const advBody = document.getElementById('diceAdvantageBody'); if (advBody && advBody.classList.contains('open')) renderAdvantagePanel(); } function openQuickView(idx) { const c = characters[idx]; if (!c) return; _quickViewCharIdx = idx; const qv = document.getElementById('charQuickView'); document.getElementById('qvName').textContent = (c.emoji || 'โš”๏ธ') + ' ' + (c.name || 'Unnamed'); document.getElementById('qvSub').textContent = `${c.class || '?'} ${c.level || 1} โ€ข ${c.race || '?'} โ€ข ${c.alignment || ''}`; const hp = c.hp || 0, hpMax = c.hpMax || 1; document.getElementById('qvHPText').textContent = `${hp}/${hpMax}`; document.getElementById('qvHPBar').style.width = Math.max(0, Math.min(100, Math.round(hp/hpMax*100))) + '%'; document.getElementById('qvAC').textContent = c.ac || 10; const dexMod = Math.floor(((c.dex || 10) - 10) / 2); document.getElementById('qvInit').textContent = (dexMod >= 0 ? '+' : '') + dexMod; document.getElementById('qvSpeed').textContent = (c.speed || 30) + 'ft'; const prof = Math.ceil((c.level || 1) / 4) + 1; document.getElementById('qvProf').textContent = '+' + prof; document.getElementById('qvSTR').textContent = c.str || 10; document.getElementById('qvDEX').textContent = c.dex || 10; document.getElementById('qvCON').textContent = c.con || 10; document.getElementById('qvINT').textContent = c.int || 10; document.getElementById('qvWIS').textContent = c.wis || 10; document.getElementById('qvCHA').textContent = c.cha || 10; qv.classList.add('show'); } function closeQuickView() { document.getElementById('charQuickView').classList.remove('show'); _quickViewCharIdx = -1; } function openFullSheetFromTray() { const idx = _quickViewCharIdx; closeQuickView(); showCharSheet(); if (idx >= 0) setTimeout(() => loadCharacter(idx), 50); } // Click outside quick view to close document.addEventListener('click', (e) => { const qv = document.getElementById('charQuickView'); const tray = document.getElementById('charTray'); if (qv && qv.classList.contains('show')) { if (!qv.contains(e.target) && !tray.contains(e.target)) closeQuickView(); } }); // Auto-load tray from storage on boot window.addEventListener('DOMContentLoaded', () => { loadCharactersFromStorage(); renderCharTray(); }); // ============================================= // DICE ROLL FIREBASE PERSISTENCE // ============================================= function saveDiceRollToFirebase(result, rollerName, rollId) { const roomId = firebaseRoomRef || playerFirebaseRoomId; if (!roomId || !fbDb) return; firebaseSignIn().then(() => { fbDb.collection('vtt_rooms').doc(roomId + '__dice_log') .set({ rolls: firebase.firestore.FieldValue.arrayUnion( { result, rollerName, rollId, timestamp: Date.now() } ) }, { merge: true }) .catch(e => console.error('๐ŸŽฒ Dice log save error:', e)); }); } function loadAndListenDiceRolls(roomId) { if (diceRollsUnsubscribe) { diceRollsUnsubscribe(); diceRollsUnsubscribe = null; } if (!roomId || !fbDb) return; firebaseSignIn().then(() => { const docRef = fbDb.collection('vtt_rooms').doc(roomId + '__dice_log'); // Load last 5 historical rolls so the DM/players can see recent history on join docRef.get().then(snap => { if (!snap.exists) return; const rolls = (snap.data().rolls || []) .sort((a, b) => b.timestamp - a.timestamp) .slice(0, 5); rolls.reverse().forEach(r => showDiceResult(r.result, r.rollerName, false, r.rollId, 0, null, true, r.diceSides || 20)); }).catch(() => {}); // Real-time listener โ€” only surfaces rolls that arrive after we joined diceListenSince = Date.now(); diceRollsUnsubscribe = docRef.onSnapshot(snap => { if (!snap.exists) return; (snap.data().rolls || []).forEach(r => { if (r.timestamp >= diceListenSince) { showDiceResult(r.result, r.rollerName, false, r.rollId, 0, null, false, r.diceSides || 20); } }); }, () => {}); }); } // ============================================= // DICE ADVANTAGE PANEL // ============================================= let selectedAdvantageBonus = 0; let selectedAdvantageLabel = null; let advantageCharIdx = 0; function showDiceAdvantagePanel() { const panel = document.getElementById('diceAdvantagePanel'); if (panel) panel.classList.add('panel-visible'); renderAdvantagePanel(); } function toggleDiceAdvantageBody() { const body = document.getElementById('diceAdvantageBody'); const header = document.getElementById('diceAdvantageHeader'); const arrow = document.getElementById('diceAdvArrow'); if (!body) return; const isOpen = body.classList.toggle('open'); header.classList.toggle('open', isOpen); arrow.textContent = isOpen ? 'โ–ผ' : 'โ–ฒ'; if (isOpen) renderAdvantagePanel(); } function renderAdvantagePanel() { loadCharactersFromStorage(); if (characters.length === 0) { document.getElementById('advantageCharsRow').innerHTML = 'No characters โ€” create one in the character sheet.'; document.getElementById('advantageSkillsRow').innerHTML = ''; return; } if (advantageCharIdx >= characters.length) advantageCharIdx = 0; const charRow = document.getElementById('advantageCharsRow'); charRow.innerHTML = 'Character:' + characters.map((c, i) => `` ).join(''); renderAdvantageSkills(); } function selectAdvantageChar(idx) { advantageCharIdx = idx; renderAdvantagePanel(); } function renderAdvantageSkills() { const c = characters[advantageCharIdx]; if (!c) return; const attrs = [ { key: 'STR', val: c.str || 10 }, { key: 'DEX', val: c.dex || 10 }, { key: 'CON', val: c.con || 10 }, { key: 'INT', val: c.int || 10 }, { key: 'WIS', val: c.wis || 10 }, { key: 'CHA', val: c.cha || 10 } ]; const charName = c.name || ('Char ' + (advantageCharIdx + 1)); const skillsRow = document.getElementById('advantageSkillsRow'); skillsRow.innerHTML = attrs.map(({ key, val }) => { const mod = Math.floor((parseInt(val) - 10) / 2); const modStr = (mod >= 0 ? '+' : '') + mod; const label = key + ' ' + modStr; const isSelected = selectedAdvantageLabel === label && selectedAdvantageBonus === mod; return ``; }).join('') + ''; } function selectDiceAdvantage(attr, bonus, label) { selectedAdvantageBonus = bonus; selectedAdvantageLabel = label; const el = document.getElementById('advantageSelectedLabel'); if (el) el.textContent = label; renderAdvantageSkills(); } function clearDiceAdvantage() { selectedAdvantageBonus = 0; selectedAdvantageLabel = null; const el = document.getElementById('advantageSelectedLabel'); if (el) el.textContent = ''; renderAdvantageSkills(); } // ============================================= // DICE ROLLER // ============================================= let diceToastTimer = null; function rollCurrentDice() { const sides = currentDiceSides; const result = Math.floor(Math.random() * sides) + 1; const rollId = Date.now() + '_' + Math.random().toString(36).slice(2, 8); const btn = document.getElementById('diceRollerBtn'); btn.classList.add('rolling'); setTimeout(() => btn.classList.remove('rolling'), 500); // Bonus/advantage only applies to d20 const bonus = sides === 20 ? (selectedAdvantageBonus || 0) : 0; const advLabel = sides === 20 ? (selectedAdvantageLabel || null) : null; showDiceResult(result, myName || 'Anonymous', true, rollId, bonus, advLabel, false, sides); const msg = { type: 'diceRoll', result, rollerName: myName || 'Anonymous', rollId, bonus, advLabel, diceSides: sides }; if (!isDM) { connections.forEach(({ conn, role }) => { if (role === 'dm') sendToPeer(conn, msg); }); } else { broadcast(msg); } saveDiceRollToFirebase(result, myName || 'Anonymous', rollId); // Close dropdown if open document.getElementById('diceTypeDropdown').classList.remove('open'); } function setDiceType(sides) { currentDiceSides = sides; document.getElementById('diceTypeLabel').textContent = 'd' + sides; document.querySelectorAll('.dice-type-option').forEach(btn => { btn.classList.toggle('active', btn.textContent === 'd' + sides); }); document.getElementById('diceTypeDropdown').classList.remove('open'); } function toggleDiceDropdown(e) { if (e) e.stopPropagation(); document.getElementById('diceTypeDropdown').classList.toggle('open'); } // Close dice dropdown when clicking outside document.addEventListener('click', function(e) { const wrapper = document.getElementById('diceRollerWrapper'); if (wrapper && !wrapper.contains(e.target)) { const dd = document.getElementById('diceTypeDropdown'); if (dd) dd.classList.remove('open'); } }); function showDiceResult(result, rollerName, isOwn, rollId, bonus, advLabel, silent, diceSides) { if (rollId !== undefined) { if (shownRollIds.has(rollId)) return; shownRollIds.add(rollId); } const b = bonus || 0; const total = result + b; const sides = diceSides || 20; const dLabel = 'd' + sides; const isNat20 = sides === 20 && result === 20; const isNat1 = sides === 20 && result === 1; if (!silent) { const toast = document.getElementById('diceToast'); const numEl = document.getElementById('toastNum'); const nameEl = document.getElementById('toastRoller'); const labelEl = document.getElementById('toastDiceLabel'); numEl.className = 'toast-num' + (isNat20 ? ' nat20' : isNat1 ? ' nat1' : ''); if (labelEl) labelEl.textContent = dLabel; if (b !== 0 && advLabel) { const sign = b >= 0 ? '+' : ''; numEl.textContent = result + ' ' + sign + b + ' = ' + total; nameEl.textContent = (isOwn ? 'You roll' : rollerName + ' rolls') + ' ' + dLabel + ' (' + advLabel + ')'; } else { numEl.textContent = result; nameEl.textContent = (isOwn ? 'You roll' : rollerName + ' rolls') + ' ' + dLabel; } toast.classList.add('show'); clearTimeout(diceToastTimer); diceToastTimer = setTimeout(() => toast.classList.remove('show'), 2800); } const log = document.getElementById('diceRollLog'); const entry = document.createElement('div'); entry.className = 'dice-log-entry'; const cls = isNat20 ? ' nat20' : isNat1 ? ' nat1' : ''; const dLabelSpan = `${dLabel}`; if (b !== 0) { const sign = b >= 0 ? '+' : ''; entry.innerHTML = `${rollerName} ${dLabelSpan}${result}${sign}${b}=${total}`; } else { entry.innerHTML = `${rollerName} ${dLabelSpan}${result}`; } log.prepend(entry); // Auto-fade and remove entry after 8 seconds setTimeout(() => { entry.classList.add('removing'); setTimeout(() => { if (entry.parentNode) entry.parentNode.removeChild(entry); }, 500); }, 8000); while (log.children.length > 8) log.lastChild.remove(); } // ============================================= // CLASS FEATURES // ============================================= // Each feature: { level, name, blurb, type (optional 'choice'), choices[] (optional) } const CLASS_FEATURES = { Barbarian: { color:'#ef4444', emoji:'๐Ÿช“', desc:'A fierce warrior who taps into primal rage. No heavy armor needed โ€” your raw toughness and fury are your defense. High damage, high survivability.', features:[ {level:1, name:'Rage', blurb:'Bonus action to enter a rage (2 uses/long rest, more at higher levels). While raging: +2 damage on STR attacks, resistance to bludgeoning/piercing/slashing damage. Lasts 1 minute or until you stop attacking.'}, {level:1, name:'Unarmored Defense', blurb:'While wearing NO armor, your AC = 10 + DEX modifier + CON modifier. You\'re naturally tough enough without armor.'}, {level:2, name:'Reckless Attack', blurb:'On your first attack each turn, you can attack with advantage โ€” but enemies also have advantage attacking you until your next turn. High risk, high reward.'}, {level:2, name:'Danger Sense', blurb:'Advantage on Dexterity saving throws against things you can see (traps, spells, explosions). Your instincts are razor sharp.'}, {level:3, name:'Primal Path', type:'choice', blurb:'Choose your Barbarian subclass. Each path changes how your Rage works and what you specialize in.', choices:['Berserker (frenzy โ€” bonus attacks while raging, but exhausted after)','Totem Warrior (spirit animal powers: Bear=tanky, Eagle=mobile, Wolf=pack leader)','Zealot (divine rage, harder to kill, come back from death)','Ancestral Guardian (protect allies, haunt enemies with ancestral spirits)','Storm Herald (elemental aura around you while raging)','Beast (grow claws, armor, or a tail while raging)']}, {level:4, name:'Ability Score Improvement', blurb:'+2 to one ability score, OR +1 to two different scores, OR choose a Feat (special powerful ability). Happens at levels 4, 8, 12, 16, 19.'}, {level:5, name:'Extra Attack', blurb:'Attack TWICE instead of once whenever you take the Attack action on your turn.'}, {level:5, name:'Fast Movement', blurb:'+10 ft to your movement speed while not wearing heavy armor.'}, {level:7, name:'Feral Instinct', blurb:'Advantage on all Initiative rolls (you go first more often). If you\'re surprised but not incapacitated, you can enter Rage and act normally on your first turn.'}, {level:9, name:'Brutal Critical', blurb:'When you score a critical hit, roll ONE extra weapon damage die. (Becomes 2 extra at level 13, 3 extra at level 17.)'}, {level:11, name:'Relentless Rage', blurb:'If you drop to 0 HP while raging, make a DC 10 CON save (DC rises by 5 each time) โ€” on success you stay at 1 HP instead of going down.'}, {level:15, name:'Persistent Rage', blurb:'Your Rage no longer ends early from lack of attacking or damage โ€” it only stops if you fall unconscious or choose to end it.'}, {level:18, name:'Indomitable Might', blurb:'When you make a Strength check, the minimum result equals your Strength score. You can never roll badly on STR.'}, {level:20, name:'Primal Champion', blurb:'Your Strength and Constitution each permanently increase by 4, and their maximums increase by 4 as well.'}, ] }, Bard: { color:'#ec4899', emoji:'๐ŸŽต', desc:'A magical performer who inspires allies and bends reality with music, words, and wit. Incredibly versatile โ€” good at almost everything, and knows a spell for every situation.', features:[ {level:1, name:'Spellcasting', blurb:'You cast spells using Charisma (CHA). You know a fixed number of spells from the Bard list and can change one when you level up. Regain all slots on a long rest.'}, {level:1, name:'Bardic Inspiration', blurb:'Bonus action: give an ally a special die (d6 โ†’ d12 as you level up) that they can add to ONE attack roll, ability check, or saving throw within the next 10 minutes. Uses = CHA modifier, refreshes on long rest (short rest at level 5).'}, {level:2, name:'Jack of All Trades', blurb:'Add HALF your proficiency bonus to any ability check you\'re not already proficient in. You\'re surprisingly competent at everything.'}, {level:2, name:'Song of Rest', blurb:'While allies rest and hear you perform, they regain extra HP when spending Hit Dice during a short rest (d6 โ†’ d12 as you level).'}, {level:3, name:'Bard College', type:'choice', blurb:'Choose your Bard subclass. This defines your specialty and grants extra features at levels 3, 6, and 14.', choices:['Lore (more spells from any list, cutting words to debuff enemies)','Valor (medium armor, shields, extra attack, inspire allies defensively)','Glamour (fey magic, mass charm, inspiring presence)','Swords (extra attacks, blade flourishes with Bardic Inspiration)','Whispers (dark manipulation, steal voices, become a social assassin)','Eloquence (always minimum 10 on Persuasion/Deception, impossible-to-resist Bardic Inspiration)','Creation (create objects from thin air using Song of Creation)']}, {level:3, name:'Expertise', blurb:'Double your proficiency bonus in TWO skills you choose. You\'re exceptional at those things. Pick two more at level 10.'}, {level:5, name:'Font of Inspiration', blurb:'You now regain Bardic Inspiration on a SHORT rest, not just a long rest.'}, {level:6, name:'Countercharm', blurb:'Use your action to start a performance. While you maintain it, allies within 30 ft have advantage on saves against being charmed or frightened.'}, {level:10, name:'Magical Secrets', blurb:'Learn 2 spells from ANY class\'s spell list (Wizard, Cleric, Druid โ€” anything). Repeat this at levels 14 and 18. This is one of the most powerful abilities in the game.'}, {level:20, name:'Superior Inspiration', blurb:'When you roll Initiative with 0 Bardic Inspiration uses remaining, you immediately regain 1 use.'}, ] }, Cleric: { color:'#f59e0b', emoji:'โœ๏ธ', desc:'A divine spellcaster who channels the power of their deity. The best healer in the game, with powerful support and offensive spells. Your domain defines your specialty.', features:[ {level:1, name:'Spellcasting', blurb:'Cast spells using Wisdom (WIS). Each day you choose which spells to prepare from the full Cleric list (INT modifier + Cleric level spells). Regain all slots on a long rest.'}, {level:1, name:'Divine Domain', type:'choice', blurb:'Choose your subclass at level 1 (earlier than most classes!). This determines your bonus spells, Channel Divinity options, and specialty.', choices:['Life (best healer โ€” your heals heal for way more)','Light (radiant blasts, blindness, protective aura)','War (extra attacks, weapon buffs, combat spells)','Trickery (deception, illusions, duplicate self)','Knowledge (learn any skill, read minds)','Nature (animal friend, plant/animal spells, poison immunity)','Tempest (lightning/thunder damage, weather control)','Grave (prevent death, curse undead, healing the near-dead)','Arcana (wizard spells, banish elementals/fey)','Order (command allies to attack, mind control)','Peace (bonding allies together, massive healing bonus)','Twilight (protect sleeping allies, darkvision aura)']}, {level:2, name:'Channel Divinity', blurb:'A powerful ability that recharges on a short rest. ALL Clerics get Turn Undead (force undead to flee). Your domain gives you a second, unique option.'}, {level:5, name:'Destroy Undead', blurb:'Undead of CR 1/2 or lower that fail Turn Undead are destroyed instantly instead of just fleeing. Gets more powerful at higher levels.'}, {level:10, name:'Divine Intervention', blurb:'Call upon your deity directly for miraculous help. Roll percentile dice โ€” if you roll equal to or lower than your Cleric level, the DM decides what happens (can be almost anything). Once per long rest; at level 20 it always works.'}, ] }, Druid: { color:'#16a34a', emoji:'๐ŸŒฟ', desc:'A nature spellcaster who can transform into animals. Flexible, powerful, and tied to the natural world. Wild Shape alone makes you one of the most versatile classes.', features:[ {level:1, name:'Spellcasting', blurb:'Cast spells using Wisdom (WIS). Prepare from the full Druid list each day. Regain slots on long rest. Druids never use metal armor or shields.'}, {level:1, name:'Druidic', blurb:'You know the secret Druidic language โ€” a mix of sounds and signs. You can leave hidden messages only other Druids can read.'}, {level:2, name:'Wild Shape', blurb:'Use your action (2 uses/short rest) to magically transform into a beast you\'ve seen. You keep your mental stats and class features but use the beast\'s physical stats. Duration = half Druid level in hours.'}, {level:2, name:'Druid Circle', type:'choice', blurb:'Choose your Druid subclass, which changes what you specialize in and what forms you can take.', choices:['Moon (transform into powerful beasts like bears and elementals โ€” the combat Druid)','Land (extra spells from your chosen terrain type, regain spell slots from Wild Shape)','Dreams (fey magic, teleportation, healing)','Shepherd (summon and buff animals and spirits)','Spores (fungal magic, animate the dead, spore aura)','Stars (star map powers, buff healing and spells, transform into star form)','Wildfire (fire damage, teleportation, fire elemental companion)']}, {level:18, name:'Timeless Body', blurb:'You age only 1 year for every 10 that pass, and magical aging effects don\'t affect you.'}, {level:18, name:'Beast Spells', blurb:'You can cast Druid spells while in Wild Shape form, as long as the spell doesn\'t require free hands.'}, {level:20, name:'Archdruid', blurb:'Unlimited Wild Shape uses. Also ignore verbal, somatic, and material components for spells (as long as the material has no gold cost).'}, ] }, Fighter: { color:'#6366f1', emoji:'โš”๏ธ', desc:'A master of weapons and armor. The most reliable and consistent martial class โ€” more attacks, more resources, and more Ability Score Improvements than anyone else.', features:[ {level:1, name:'Fighting Style', type:'choice', blurb:'Choose a combat specialty that passively improves your fighting.', choices:['Archery (+2 to ranged attack rolls)','Defense (+1 AC while wearing armor)','Dueling (+2 damage when using one weapon and no other weapons)','Great Weapon Fighting (reroll 1s and 2s on damage dice for two-handed weapons)','Protection (use reaction to impose disadvantage on an attack against a nearby ally)','Two-Weapon Fighting (add ability modifier to off-hand attack damage)']}, {level:1, name:'Second Wind', blurb:'Bonus action: heal yourself for 1d10 + Fighter level HP. Recharges on a short or long rest. Free emergency healing mid-fight.'}, {level:2, name:'Action Surge', blurb:'Once per short or long rest (twice at level 17): take one additional action on your turn. Extra attack action = devastating burst damage.'}, {level:3, name:'Martial Archetype', type:'choice', blurb:'Choose your Fighter subclass. This is where Fighters gain unique specializations.', choices:['Champion (critical hits on 19โ€“20, then 18โ€“20 โ€” simple and deadly)','Battle Master (tactical maneuvers with superiority dice: trip, disarm, feint, etc.)','Eldritch Knight (learn Wizard spells, bound weapon teleports to your hand)','Arcane Archer (magical arrows with special effects: seek, bane, shadow, etc.)','Cavalier (mounted combat master, protect allies)','Samurai (fighting spirit for advantage, surge of vigor, social skills)','Rune Knight (giant-rune powers carved into your gear, grow to Large size)','Psi Warrior (telekinetic blasts, shield, movement โ€” psychic powers)']}, {level:4, name:'Ability Score Improvement', blurb:'+2 to one ability, or +1 to two, or a Feat. Fighters get this at levels 4, 6, 8, 12, 14, 16, 19 โ€” more than any other class.'}, {level:5, name:'Extra Attack', blurb:'Attack TWICE per Attack action. (3ร— at level 11, 4ร— at level 20.)'}, {level:9, name:'Indomitable', blurb:'Reroll a failed saving throw, keeping the new result. Once/long rest at level 9, twice at level 13, three times at level 17.'}, ] }, Monk: { color:'#0ea5e9', emoji:'๐Ÿ‘Š', desc:'An unarmed martial artist who channels mystical Ki energy. Incredibly fast, hard to hit, and devastates enemies with rapid strikes. Needs no weapons or armor.', features:[ {level:1, name:'Unarmored Defense', blurb:'While wearing NO armor and no shield, your AC = 10 + DEX modifier + WIS modifier. Stay unarmored to benefit.'}, {level:1, name:'Martial Arts', blurb:'Use DEX instead of STR for unarmed strikes and monk weapons. Unarmed strikes deal 1d4 (scaling up to 1d10 at level 17). After attacking, make one free unarmed strike as a bonus action.'}, {level:2, name:'Ki', blurb:'Ki points (= Monk level) refresh on a SHORT rest. Spend them to: Flurry of Blows (2 bonus unarmed strikes for 1 Ki), Patient Defense (Dodge as bonus action for 1 Ki), Step of the Wind (Dash or Disengage as bonus action for 1 Ki).'}, {level:2, name:'Unarmored Movement', blurb:'+10 ft speed while not in armor (grows to +30 ft at level 18). At level 9, run up walls. At level 18, run across water and vertical surfaces.'}, {level:3, name:'Monastic Tradition', type:'choice', blurb:'Choose your Monk subclass โ€” each fundamentally changes how you use Ki.', choices:['Open Hand (push/knock down/prevent reactions after hitting, perfect tranquility)','Shadow (teleport between shadows, cast darkness, silence, pass without trace)','Four Elements (elemental Ki spells โ€” fire, water, earth, air)','Long Death (fear aura, temp HP from kills, hard to kill)','Sun Soul (ranged ki blasts, radiant nova)','Drunken Master (unpredictable movement, attacks deflect blows)','Kensei (ranged or finesse weapon becomes a monk weapon)','Mercy (healing and disease removal with Ki, mask of pain)','Astral Self (project an astral form that attacks for you)']}, {level:3, name:'Deflect Missiles', blurb:'Reaction: reduce ranged weapon attack damage by 1d10 + DEX + Monk level. If reduced to 0, spend 1 Ki to throw it back as a ranged attack.'}, {level:4, name:'Slow Fall', blurb:'Reaction: reduce falling damage by 5 ร— Monk level. Very handy with Monk\'s high mobility.'}, {level:5, name:'Extra Attack', blurb:'Attack TWICE per Attack action.'}, {level:5, name:'Stunning Strike', blurb:'After hitting with a melee attack, spend 1 Ki. Target makes a CON save or is stunned until end of your next turn โ€” they lose their action and reaction, and all attacks against them have advantage.'}, {level:6, name:'Ki-Empowered Strikes', blurb:'Your unarmed strikes count as magical, overcoming resistance and immunity to nonmagical damage.'}, {level:7, name:'Evasion', blurb:'On a DEX save vs. a spell or effect (like Fireball): succeed = 0 damage, fail = half damage (instead of full).'}, {level:10, name:'Purity of Body', blurb:'Immune to disease and poison.'}, {level:14, name:'Diamond Soul', blurb:'Proficient in ALL saving throws. Also spend 1 Ki to reroll any failed save.'}, {level:18, name:'Empty Body', blurb:'Spend 4 Ki: become invisible for 1 minute and gain resistance to ALL damage except force. Or spend 8 Ki to cast Astral Projection (no material cost needed).'}, {level:20, name:'Perfect Self', blurb:'When you roll Initiative with 0 Ki remaining, you regain 4 Ki points immediately.'}, ] }, Paladin: { color:'#fbbf24', emoji:'๐Ÿ›ก๏ธ', desc:'A holy warrior who mixes martial power with divine magic. Incredibly durable with strong healing, powerful smites, and a party-wide saving throw aura that makes everyone better.', features:[ {level:1, name:'Divine Sense', blurb:'Action: detect the presence of celestials, fiends, and undead within 60 ft until end of your next turn. Uses = 1 + CHA modifier per long rest.'}, {level:1, name:'Lay on Hands', blurb:'A healing pool of HP = 5 ร— Paladin level (refreshes on long rest). Touch a creature to restore any amount from the pool. Or spend 5 points to cure one disease or poison.'}, {level:2, name:'Fighting Style', type:'choice', blurb:'Choose a combat specialty.', choices:['Defense (+1 AC while wearing armor)','Dueling (+2 damage one-handed)','Great Weapon Fighting (reroll 1s and 2s on two-handed weapon damage)','Protection (impose disadvantage on attacks against nearby allies)','Blessed Warrior (learn two Cleric cantrips, use CHA for them)','Blind Fighting (blindsight 10 ft)']}, {level:2, name:'Spellcasting', blurb:'Cast spells using Charisma (CHA). Prepare from the Paladin list each day. Regain slots on long rest.'}, {level:2, name:'Divine Smite', blurb:'When you hit with a melee attack, spend any spell slot to deal extra RADIANT damage: 2d8 for a 1st-level slot (+1d8 per slot level above 1st, max 5d8). +1d8 extra against undead and fiends. DECISION: save slots for smiting vs. spells.'}, {level:3, name:'Sacred Oath', type:'choice', blurb:'Choose your Paladin subclass. Your oath grants extra spells (always prepared), a unique Channel Divinity, and powerful later features.', choices:['Devotion (classic paladin โ€” Sacred Weapon, Holy Nimbus, immune to charm)','Ancients (protect nature and joy โ€” Aura of Warding resists spell damage)','Vengeance (ruthless hunter โ€” Vow of Enmity advantage, relentless pursuit)','Conquest (dominance and fear โ€” aura paralyzes frightened enemies)','Glory (inspire greatness, protective rush, legendary champion)','Redemption (nonviolent first, Protective of others, rebuking attackers)','Watchers (protect from extraplanar threats, enhanced senses)','Oathbreaker (dark path โ€” undead commands, aura of hate for evil damage)']}, {level:5, name:'Extra Attack', blurb:'Attack TWICE per Attack action.'}, {level:6, name:'Aura of Protection', blurb:'You and ALL friendly creatures within 10 ft add your CHA modifier to every saving throw. This is one of the most powerful passive abilities in the entire game. (Range expands to 30 ft at level 18.)'}, {level:10, name:'Aura of Courage', blurb:'You and friendly creatures within 10 ft can\'t be frightened while you\'re conscious.'}, {level:11, name:'Improved Divine Smite', blurb:'Every melee weapon hit deals an extra 1d8 radiant damage, even without spending a spell slot.'}, {level:14, name:'Cleansing Touch', blurb:'Action: end one spell affecting you or a willing creature you touch. Uses = CHA modifier per long rest.'}, ] }, Ranger: { color:'#22c55e', emoji:'๐Ÿน', desc:'A wilderness hunter skilled in tracking, archery, and survival. Excellent at controlling enemies, finding prey, and navigating dangerous terrain. Grows more powerful out in the wild.', features:[ {level:1, name:'Favored Enemy', type:'choice', blurb:'Choose a type of creature you\'ve studied. Advantage on Survival checks to track them and History/Nature to recall info. Some versions let you learn their language.', choices:['Aberrations','Beasts','Celestials','Constructs','Dragons','Elementals','Fey','Fiends','Giants','Monstrosities','Oozes','Plants','Undead','Two humanoid races (e.g. goblins and orcs)']}, {level:1, name:'Natural Explorer', type:'choice', blurb:'Choose a favored terrain. In that terrain: your group can\'t become lost, you forage twice as much food, you move at full pace while stealthing and tracking, and difficult terrain doesn\'t slow your group.', choices:['Arctic','Coast','Desert','Forest','Grassland','Mountain','Swamp','Underdark']}, {level:2, name:'Fighting Style', type:'choice', blurb:'Choose a combat specialty.', choices:['Archery (+2 to ranged attack rolls)','Defense (+1 AC in armor)','Dueling (+2 damage one-handed)','Two-Weapon Fighting (add modifier to off-hand damage)','Druidic Warrior (learn 2 Druid cantrips, use WIS)','Blind Fighting (blindsight 10 ft)']}, {level:2, name:'Spellcasting', blurb:'Cast spells using Wisdom (WIS). You know a fixed number from the Ranger list (change one per level up). Regain slots on long rest.'}, {level:3, name:'Ranger Archetype', type:'choice', blurb:'Choose your Ranger subclass.', choices:['Hunter (defensive/offensive techniques: colossus slayer, horde breaker, etc.)','Beast Master (bond with an animal companion that fights for you)','Gloom Stalker (ambush predator, first-round bonus attack, fear in darkness)','Horizon Walker (planar magic, teleportation, ethereal damage)','Monster Slayer (detect and counter magical creatures, spellcasters)','Fey Wanderer (charisma-boosted, charming, misty wandering)','Swarmkeeper (control a swarm to move enemies, deal extra damage)']}, {level:5, name:'Extra Attack', blurb:'Attack TWICE per Attack action.'}, {level:8, name:'Land\'s Stride', blurb:'Moving through nonmagical difficult terrain costs no extra movement. Advantage on saves against plants that impede movement.'}, {level:10, name:'Hide in Plain Sight', blurb:'Spend 1 minute to camouflage yourself against natural surroundings. Gain +10 to Stealth while you remain still.'}, {level:14, name:'Vanish', blurb:'Use the Hide action as a bonus action. Also can\'t be tracked by nonmagical means (unless you choose to leave a trail).'}, {level:20, name:'Foe Slayer', blurb:'Once per turn when you hit your Favored Enemy, add your WIS modifier to the attack roll OR the damage roll.'}, ] }, Rogue: { color:'#71717a', emoji:'๐Ÿ—ก๏ธ', desc:'A sneaky expert who deals massive burst damage with Sneak Attack. Extraordinary skill outside combat, with Expertise making you the best in the party at chosen tasks.', features:[ {level:1, name:'Expertise', blurb:'Double your proficiency bonus in TWO chosen skills. You\'re truly exceptional at those. Choose two more at level 6.'}, {level:1, name:'Sneak Attack', blurb:'Once per turn, deal extra damage when you have ADVANTAGE on the attack OR when an ally is within 5 ft of your target. Starts at 1d6, grows to 10d6 at level 19.'}, {level:1, name:'Thieves\' Cant', blurb:'A secret mix of jargon, signs, and codes shared among criminals and rogues. You can hide messages in normal conversation only other Thieves\' Cant speakers can understand.'}, {level:2, name:'Cunning Action', blurb:'Bonus action each turn to Dash, Disengage, or Hide. This lets you attack and move safely every single round.'}, {level:3, name:'Roguish Archetype', type:'choice', blurb:'Choose your Rogue subclass.', choices:['Thief (faster item use, climb speed, fake magic items at level 13)','Assassin (triple damage against surprised enemies, perfect impostor)','Arcane Trickster (mix Wizard spells with Sneak Attack โ€” Mage Hand steals)','Inquisitive (spot lies, insight attacks, unerring eye)','Mastermind (use Help as bonus action from 30 ft, mimic voices, spy network)','Scout (always first in Initiative, disengage from one enemy as reaction)','Soulknife (psionic blades, telepathy, read minds)','Swashbuckler (dual threat โ€” deal Sneak Attack while fighting alone, add CHA to Initiative)','Phantom (whispers of the dead, steal soul fragments for power)']}, {level:5, name:'Uncanny Dodge', blurb:'Reaction: when an attacker you CAN SEE hits you, halve the incoming damage.'}, {level:7, name:'Evasion', blurb:'On DEX saves vs. spells like Fireball: success = 0 damage, failure = half damage.'}, {level:11, name:'Reliable Talent', blurb:'When you make an ability check using a skill you\'re proficient in, treat any d20 roll of 9 or lower as a 10. You can\'t fail at your specialties.'}, {level:14, name:'Blindsense', blurb:'If you can hear, you automatically know the location of any hidden or invisible creature within 10 ft of you.'}, {level:15, name:'Slippery Mind', blurb:'Gain proficiency in Wisdom saving throws.'}, {level:18, name:'Elusive', blurb:'No attack roll has advantage against you while you\'re not incapacitated. You\'re nearly impossible to catch off-guard.'}, {level:20, name:'Stroke of Luck', blurb:'Turn a missed attack into a hit, OR turn a failed ability check into a 20. Once per short or long rest.'}, ] }, Sorcerer: { color:'#f97316', emoji:'๐Ÿ”ฅ', desc:'An innate spellcaster born with raw magical power. Fewer spells than a Wizard, but Metamagic lets you bend and twist those spells in ways nobody else can.', features:[ {level:1, name:'Spellcasting', blurb:'Cast spells using Charisma (CHA). You know a fixed number of spells (can\'t prepare different ones each day, but can swap one per level). Regain slots on long rest.'}, {level:1, name:'Sorcerous Origin', type:'choice', blurb:'Choose the source of your innate magic power. This is your subclass, taken at level 1.', choices:['Draconic Bloodline (dragon ancestor: natural armor AC 13+DEX, damage bonus, wings at 14)','Wild Magic (random surges of wild magic โ€” chaos that\'s surprisingly fun)','Storm Sorcery (wind walk, lightning speed, weather control)','Shadow Magic (darkness, shadow hound, resistance to death)','Aberrant Mind (telepathy, psychic/psionic spells from birth)','Clockwork Soul (order magic, cancel advantage/disadvantage, summon mechanus gear)']}, {level:2, name:'Font of Magic', blurb:'Sorcery Points (= Sorcerer level, refresh on long rest). Spend 2 to make a spell slot. Or convert a slot to Sorcery Points. These also fuel Metamagic.'}, {level:3, name:'Metamagic', type:'choice', blurb:'Choose 2 ways to twist your spells (more at levels 10 and 17). Spend Sorcery Points to activate.', choices:['Careful (protect allies from your own area spells)','Distant (double a spell\'s range)','Empowered (reroll some damage dice, keep higher)','Extended (double a spell\'s duration)','Heightened (give target disadvantage on first save against spell)','Quickened (cast a spell as a bonus action instead of action)','Subtle (cast with no verbal or somatic components โ€” hard to counter or notice)','Twinned (target two creatures with a single-target spell)','Seeking (if you miss or spell fails, spend 2 to reroll once)','Transmuted (change a spell\'s damage type)']}, {level:4, name:'Ability Score Improvement', blurb:'+2 to one ability, +1 to two, or a Feat. At levels 4, 8, 12, 16, 19.'}, {level:20, name:'Sorcerous Restoration', blurb:'Regain 4 expended Sorcery Points when you finish a short rest.'}, ] }, Warlock: { color:'#7c3aed', emoji:'๐Ÿ‘๏ธ', desc:'A spellcaster who made a pact with a powerful supernatural being. Very few spell slots but they ALWAYS recharge on a SHORT rest. Powerful at-will abilities mean you\'re never truly out of tricks.', features:[ {level:1, name:'Otherworldly Patron', type:'choice', blurb:'Choose who (or what) you made your pact with. Your patron defines your theme, bonus spells, and unique features at levels 1, 6, 10, and 14.', choices:['Fiend (fire and necrotic power, temp HP from kills, eventually immunity to fire)','Great Old One (eldritch horror โ€” telepathy, read minds, remote communication)','Archfey (fey charm โ€” charm and fear, beguiling defenses)','Hexblade (make a pact with a weapon โ€” use CHA for attacks, summon ghost warrior)','Fathomless (deep-sea patron โ€” tentacle, underwater breathing, water teleport)','Genie (choose genie type: Dao/Djinni/Efreeti/Marid for earth/air/fire/water)','Undying (life clinging magic โ€” age slowly, resist death, become undead-lite)','Celestial (divine Warlock โ€” healing, sacred flame, divine fire blasts)']}, {level:1, name:'Pact Magic', blurb:'Your spell slots are very limited (1 at level 1, up to 4 slots at level 11). BUT they always recharge after a SHORT rest. All your slots are the same level, which increases to 5th level at level 9.'}, {level:2, name:'Eldritch Invocations', blurb:'Choose 2 special Warlock abilities (more as you level). Examples: Agonizing Blast (+CHA to Eldritch Blast damage), Devil\'s Sight (see in magical darkness 120 ft), Mask of Many Faces (Disguise Self at will), Repelling Blast (push enemies 10 ft).'}, {level:3, name:'Pact Boon', type:'choice', blurb:'A special gift from your patron.', choices:['Pact of the Chain (superior familiar: imp, pseudodragon, quasit, or sprite)','Pact of the Blade (summon a magic weapon to your hand; it\'s always attuned to you)','Pact of the Tome (Book of Shadows with 3 cantrips from any class, plus ritual magic)','Pact of the Talisman (an amulet that helps you or allies with failed rolls)']}, {level:11, name:'Mystic Arcanum', blurb:'Once per long rest, cast one spell of 6th level without using a slot. Gain a new level (7th, 8th, 9th) at levels 13, 15, and 17.'}, {level:20, name:'Eldritch Master', blurb:'Spend 1 minute communing with your patron to regain all Pact Magic slots. Once per long rest.'}, ] }, Wizard: { color:'#3b82f6', emoji:'๐Ÿ“š', desc:'The ultimate spellcaster who learns magic through study. The largest and most flexible spell list in the game. Copy spells into your spellbook from scrolls and other books โ€” you can always learn more.', features:[ {level:1, name:'Spellcasting', blurb:'Cast spells using Intelligence (INT). Your spellbook starts with 6 spells. Each day prepare INT modifier + Wizard level spells from your book โ€” any spells in it are fair game. Regain slots on long rest. Copy new spells from scrolls and enemy spellbooks.'}, {level:1, name:'Arcane Recovery', blurb:'Once per day after a short rest, recover spell slots whose combined level is up to HALF your Wizard level (rounded up). Can\'t recover 6th level or higher slots this way.'}, {level:2, name:'Arcane Tradition', type:'choice', blurb:'Choose your school of magical specialization. You get a feature now, at level 6, and at levels 10 and 14.', choices:['Evocation (devastate with maximized damage spells, protect allies from your blasts)','Abjuration (powerful magical wards, absorb spells, resistance to spell damage)','Illusion (make your illusions real, perfect copies, malleable images)','Conjuration (teleport short distances, summon creatures and objects)','Divination (Portent: roll 2 dice each day and substitute any roll with them)','Enchantment (bend minds, share charm effects, hold enemies while charming others)','Necromancy (raise more and stronger undead, gain HP from killing)','Transmutation (transform substances, minor alchemy, grow very old or very young)','Bladesinging (mix martial and magic โ€” AC bonus, Concentration protection, extra attack)','War Magic (arcane deflection, tactical wit bonus to Initiative, spells as reactions)','Order of Scribes (your spellbook is a magical creature, change spell damage types)']}, {level:18, name:'Spell Mastery', blurb:'Choose one 1st-level and one 2nd-level spell from your spellbook. Cast each at their lowest level WITHOUT using a spell slot, as many times as you want.'}, {level:20, name:'Signature Spells', blurb:'Choose two 3rd-level spells. They\'re always prepared and can be cast at 3rd level once per short rest for FREE.'}, ] }, }; function renderFeaturesTab() { const c = (activeCharIdx >= 0 && characters[activeCharIdx]) ? characters[activeCharIdx] : null; const panel = document.getElementById('featuresPanel'); if (!c) { panel.innerHTML = '

No character selected. Create or select a character first.

'; return; } const cls = c.class || 'Fighter'; const level = parseInt(c.level) || 1; const data = CLASS_FEATURES[cls]; if (!data) { panel.innerHTML = `

No feature data for ${cls} yet.

`; return; } if (!c.featureChoices) c.featureChoices = {}; const visible = data.features.filter(f => f.level <= level); let html = `
${data.emoji} ${cls}
${data.desc}
`; if (visible.length === 0) { html += '

No features yet โ€” increase your level in the Basics tab.

'; } visible.forEach(f => { const choiceKey = `${cls}_${f.name}`; const savedChoice = c.featureChoices[choiceKey] || ''; let choiceHTML = ''; if (f.type === 'choice' && f.choices) { const opts = f.choices.map(ch => ``).join(''); choiceHTML = `
`; } html += `
Lv ${f.level} ${f.name}
${f.blurb}
${choiceHTML}
`; }); const locked = data.features.filter(f => f.level > level); if (locked.length > 0) { html += `

๐Ÿ”’ Coming at higher levels:

${locked.map(f=>`Lv ${f.level} ${f.name}`).join(' ยท ')}

`; } panel.innerHTML = html; } function saveFeatureChoice(key, value) { const c = (activeCharIdx >= 0 && characters[activeCharIdx]) ? characters[activeCharIdx] : null; if (!c) return; if (!c.featureChoices) c.featureChoices = {}; c.featureChoices[key] = value; saveCharactersToStorage(); } // ============================================= // BOOT // ============================================= try { init(); } catch (err) { console.error('โŒ Init failed:', err); alert('Error loading VTT: ' + err.message); }

๐Ÿ”๏ธ Landmark Illustration

๐Ÿ–Œ๏ธ Brush
๐Ÿชฃ Fill
โฌœ Erase
8px

Draw the vertical illustration players see when approaching this landmark.
Once saved, the stamp will โœจ glow โ€” players can click it to view this scene.

๐Ÿ”๏ธ Approaching...

CREATED BY ROBERT FOR WIZZY