Created
March 10, 2025 09:13
-
-
Save sma/5ea43066466fcb9d4220df76dcbc35b3 to your computer and use it in GitHub Desktop.
A local web app written by Claude.ai to partly reveal maps on a second browser tab
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>TTRPG Map Revealer</title> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
margin: 0; | |
padding: 0; | |
display: flex; | |
flex-direction: column; | |
height: 100vh; | |
overflow: hidden; | |
background-color: #1e1e1e; | |
color: #e0e0e0; | |
} | |
.header { | |
background-color: #2d2d2d; | |
color: #e0e0e0; | |
padding: 10px; | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
} | |
.tab-identifier { | |
font-weight: bold; | |
font-size: 1.2em; | |
} | |
.controls { | |
display: flex; | |
gap: 10px; | |
align-items: center; | |
width: 100%; | |
} | |
.gm-controls { | |
display: flex; | |
gap: 10px; | |
align-items: center; | |
width: 100%; | |
} | |
/* Toggle button styles */ | |
.toggle-container { | |
display: inline-flex; | |
margin-right: 10px; | |
border-radius: 4px; | |
overflow: hidden; | |
} | |
.toggle-container button { | |
border-radius: 0; | |
margin: 0; | |
padding: 8px 15px; | |
} | |
.toggle-container button:first-child { | |
border-top-left-radius: 4px; | |
border-bottom-left-radius: 4px; | |
} | |
.toggle-container button:last-child { | |
border-top-right-radius: 4px; | |
border-bottom-right-radius: 4px; | |
} | |
.toggle-container button.active { | |
background-color: #444; | |
font-weight: bold; | |
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.3); | |
} | |
.toggle-container button:not(.active) { | |
background-color: #333; | |
opacity: 0.8; | |
} | |
.player-message { | |
text-align: center; | |
margin: 10px; | |
font-style: italic; | |
color: #aaa; | |
} | |
.main-content { | |
flex: 1; | |
display: flex; | |
overflow: hidden; | |
} | |
.map-tabs { | |
width: 40px; | |
background-color: #252525; | |
display: flex; | |
flex-direction: column; | |
padding: 5px; | |
gap: 5px; | |
} | |
.map-tab { | |
width: 30px; | |
height: 30px; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
background-color: #333; | |
border-radius: 4px; | |
cursor: pointer; | |
font-weight: bold; | |
transition: background-color 0.2s; | |
} | |
.map-tab:hover { | |
background-color: #444; | |
} | |
.map-tab.active { | |
background-color: #555; | |
} | |
.map-tab.has-content { | |
color: #88c0d0; | |
} | |
.main-area { | |
flex: 1; | |
overflow: hidden; | |
position: relative; | |
background-color: #2d2d2d; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
#dropZone { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
background-color: rgba(0, 0, 0, 0.3); | |
border: 3px dashed #555; | |
z-index: 10; | |
color: #aaa; | |
} | |
#dropZone.hidden { | |
display: none; | |
} | |
.canvas-container { | |
position: relative; | |
overflow: auto; | |
max-width: 100%; | |
max-height: 100%; | |
width: 100%; | |
height: 100%; | |
} | |
canvas { | |
display: block; | |
background-color: #333; | |
} | |
button { | |
padding: 8px 12px; | |
background-color: #444; | |
color: #e0e0e0; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
} | |
button:hover { | |
background-color: #555; | |
} | |
button.danger-btn { | |
background-color: #555; | |
} | |
button.danger-btn:hover { | |
background-color: #666; | |
} | |
.status { | |
margin-left: 10px; | |
font-style: italic; | |
color: #aaa; | |
} | |
/* Zoom controls */ | |
.zoom-controls { | |
position: absolute; | |
bottom: 20px; | |
right: 20px; | |
display: flex; | |
flex-direction: column; | |
gap: 5px; | |
z-index: 20; | |
} | |
.zoom-controls button { | |
width: 40px; | |
height: 40px; | |
font-size: 18px; | |
border-radius: 50%; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
background-color: rgba(50, 50, 50, 0.7); | |
color: white; | |
border: none; | |
} | |
.zoom-controls button:hover { | |
background-color: rgba(70, 70, 70, 0.9); | |
} | |
.zoom-level { | |
text-align: center; | |
font-size: 14px; | |
background-color: rgba(0, 0, 0, 0.5); | |
padding: 5px; | |
border-radius: 4px; | |
} | |
.empty-map-message { | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
height: 100%; | |
color: #666; | |
text-align: center; | |
padding: 20px; | |
} | |
.empty-map-message h2 { | |
margin-bottom: 10px; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="header" id="header"> | |
<div class="tab-identifier" id="tabType">Detecting...</div> | |
<div class="controls" id="controlsArea"> | |
<!-- Controls will be inserted here dynamically --> | |
</div> | |
</div> | |
<div class="main-content"> | |
<div class="map-tabs" id="mapTabs"> | |
<!-- Map tabs will be inserted here dynamically --> | |
</div> | |
<div class="main-area"> | |
<div id="dropZone"> | |
<h2>Drag & Drop or Paste an Image</h2> | |
<p>Click anywhere to browse files</p> | |
</div> | |
<div id="emptyMapMessage" class="empty-map-message"> | |
<h2>No Map Loaded</h2> | |
<p>Drag and drop an image or paste from clipboard</p> | |
</div> | |
<div class="canvas-container" id="canvasContainer"> | |
<canvas id="mapCanvas"></canvas> | |
</div> | |
<div class="zoom-controls" id="zoomControls"> | |
<button id="zoomIn">+</button> | |
<div class="zoom-level" id="zoomLevel">100%</div> | |
<button id="zoomOut">-</button> | |
</div> | |
</div> | |
</div> | |
<input type="file" id="fileInput" style="display: none;" accept="image/*"> | |
<script> | |
// Check if this is the first tab or a subsequent one | |
const isGM = !window.location.hash.includes('player'); | |
const tabType = document.getElementById('tabType'); | |
const controlsArea = document.getElementById('controlsArea'); | |
const dropZone = document.getElementById('dropZone'); | |
const canvas = document.getElementById('mapCanvas'); | |
const ctx = canvas.getContext('2d'); | |
const fileInput = document.getElementById('fileInput'); | |
const zoomInBtn = document.getElementById('zoomIn'); | |
const zoomOutBtn = document.getElementById('zoomOut'); | |
const zoomLevelEl = document.getElementById('zoomLevel'); | |
const canvasContainer = document.getElementById('canvasContainer'); | |
const zoomControls = document.getElementById('zoomControls'); | |
const header = document.getElementById('header'); | |
const mapTabs = document.getElementById('mapTabs'); | |
const emptyMapMessage = document.getElementById('emptyMapMessage'); | |
// Setup UI based on tab type | |
if (isGM) { | |
tabType.textContent = 'GM Tab'; | |
// Create GM controls | |
const gmControls = document.createElement('div'); | |
gmControls.className = 'gm-controls'; | |
// Create toggle container for reveal/hide mode | |
const toggleContainer = document.createElement('div'); | |
toggleContainer.className = 'toggle-container'; | |
const revealModeBtn = document.createElement('button'); | |
revealModeBtn.textContent = 'Reveal Mode'; | |
revealModeBtn.className = 'active'; | |
revealModeBtn.onclick = () => setDrawMode('reveal'); | |
const hideModeBtn = document.createElement('button'); | |
hideModeBtn.textContent = 'Hide Mode'; | |
hideModeBtn.onclick = () => setDrawMode('hide'); | |
toggleContainer.appendChild(revealModeBtn); | |
toggleContainer.appendChild(hideModeBtn); | |
// Create toggle container for shape mode | |
const shapeToggleContainer = document.createElement('div'); | |
shapeToggleContainer.className = 'toggle-container'; | |
const rectModeBtn = document.createElement('button'); | |
rectModeBtn.textContent = 'Rectangle'; | |
rectModeBtn.className = 'active'; | |
rectModeBtn.onclick = () => setShapeMode('rectangle'); | |
const ovalModeBtn = document.createElement('button'); | |
ovalModeBtn.textContent = 'Oval'; | |
ovalModeBtn.onclick = () => setShapeMode('oval'); | |
shapeToggleContainer.appendChild(rectModeBtn); | |
shapeToggleContainer.appendChild(ovalModeBtn); | |
const revealBtn = document.createElement('button'); | |
revealBtn.textContent = 'Reveal All'; | |
revealBtn.onclick = revealAll; | |
const hideBtn = document.createElement('button'); | |
hideBtn.textContent = 'Hide All'; | |
hideBtn.className = 'danger-btn'; | |
hideBtn.onclick = hideAll; | |
const clearBtn = document.createElement('button'); | |
clearBtn.textContent = 'Clear Map'; | |
clearBtn.className = 'danger-btn'; | |
clearBtn.onclick = clearMap; | |
const openPlayerBtn = document.createElement('button'); | |
openPlayerBtn.textContent = 'Open Player Tab'; | |
openPlayerBtn.onclick = openPlayerTab; | |
openPlayerBtn.style.backgroundColor = '#c08e3f'; | |
const status = document.createElement('span'); | |
status.className = 'status'; | |
status.id = 'status'; | |
status.textContent = 'Ready'; | |
gmControls.appendChild(toggleContainer); | |
gmControls.appendChild(shapeToggleContainer); | |
gmControls.appendChild(revealBtn); | |
gmControls.appendChild(hideBtn); | |
gmControls.appendChild(clearBtn); | |
gmControls.appendChild(openPlayerBtn); | |
gmControls.appendChild(status); | |
controlsArea.appendChild(gmControls); | |
// Create map tabs | |
for (let i = 1; i <= 9; i++) { | |
const tab = document.createElement('div'); | |
tab.className = 'map-tab'; | |
tab.textContent = i; | |
tab.dataset.index = i; | |
tab.onclick = () => switchMap(i); | |
mapTabs.appendChild(tab); | |
} | |
} else { | |
tabType.textContent = 'Player Tab'; | |
// Hide header, zoom controls, and map tabs in player tab | |
if (header) header.style.display = 'none'; | |
if (zoomControls) zoomControls.style.display = 'none'; | |
if (mapTabs) mapTabs.style.display = 'none'; | |
// Add player message | |
const playerMessage = document.createElement('div'); | |
playerMessage.className = 'player-message'; | |
playerMessage.textContent = 'This tab displays the map for your players. Wait for the GM tab to upload an image.'; | |
document.body.insertBefore(playerMessage, document.querySelector('.main-content')); | |
// Hide drop zone in player tab | |
dropZone.style.display = 'none'; | |
} | |
// State variables | |
let currentImage = null; | |
let maskCanvas = document.createElement('canvas'); | |
let maskCtx = maskCanvas.getContext('2d'); | |
let drawMode = 'reveal'; // 'reveal' or 'hide' | |
let shapeMode = 'rectangle'; // 'rectangle' or 'oval' | |
let isDrawing = false; | |
let startX = 0; | |
let startY = 0; | |
// For long press and circles animation | |
let longPressTimeout = null; | |
let isLongPress = false; | |
let circleAnimationFrame = null; | |
let circleRadius = 0; | |
let circleX = 0; | |
let circleY = 0; | |
let circleGrowth = true; | |
let isAnimating = false; | |
// For scaling/panning | |
let scale = 1; | |
let zoomStep = 0.1; | |
// For multiple maps | |
let currentMapIndex = 1; // Start with map 1 | |
const maps = Array(9).fill().map(() => ({ | |
image: null, | |
maskData: null, | |
scale: 1, | |
scrollX: 0, | |
scrollY: 0 | |
})); | |
// Communication channel between tabs | |
const channel = new BroadcastChannel('map-revealer'); | |
// Listen for messages from other tabs | |
channel.onmessage = (event) => { | |
const { type, data } = event.data; | |
switch (type) { | |
case 'image-upload': | |
handleImageUpdate(data.imageData, data.width, data.height, data.mapIndex); | |
break; | |
case 'mask-update': | |
updateMask(data.maskData, data.mapIndex); | |
break; | |
case 'reveal-all': | |
updateMaskAll(true); | |
break; | |
case 'hide-all': | |
updateMaskAll(false); | |
break; | |
case 'zoom-update': | |
updateZoom(data.scale); | |
break; | |
case 'scroll-update': | |
updateScroll(data.scrollX, data.scrollY); | |
break; | |
case 'switch-map': | |
if (!isGM) { | |
switchMap(data.mapIndex); | |
} | |
break; | |
case 'clear-map': | |
if (!isGM) { | |
clearMapData(data.mapIndex); | |
if (currentMapIndex === data.mapIndex) { | |
showEmptyState(); | |
} | |
} | |
break; | |
case 'circle-animation': | |
// Only start animation if not already animating | |
if (!isAnimating) { | |
isAnimating = true; | |
startCircleAnimation(data.x, data.y); | |
} | |
break; | |
case 'stop-circle-animation': | |
stopCircleAnimation(); | |
isAnimating = false; | |
break; | |
} | |
}; | |
// Initialize the app | |
function init() { | |
// Handle file input and drag & drop | |
fileInput.addEventListener('change', handleFileSelect); | |
// Set up zoom controls | |
if (zoomInBtn && zoomOutBtn) { | |
zoomInBtn.addEventListener('click', () => { | |
changeZoom(scale + zoomStep); | |
}); | |
zoomOutBtn.addEventListener('click', () => { | |
changeZoom(scale - zoomStep); | |
}); | |
} | |
// Drag and drop events | |
dropZone.addEventListener('dragover', (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
dropZone.style.backgroundColor = 'rgba(0, 0, 0, 0.4)'; | |
}); | |
dropZone.addEventListener('dragleave', (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
dropZone.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'; | |
}); | |
dropZone.addEventListener('drop', (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
dropZone.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'; | |
if (isGM && e.dataTransfer.files.length > 0) { | |
processFile(e.dataTransfer.files[0]); | |
} | |
}); | |
// Click on drop zone to open file browser | |
dropZone.addEventListener('click', () => { | |
if (isGM) { | |
fileInput.click(); | |
} | |
}); | |
// Allow pasting images | |
document.addEventListener('paste', (e) => { | |
if (isGM && e.clipboardData) { | |
const items = e.clipboardData.items; | |
for (let i = 0; i < items.length; i++) { | |
if (items[i].type.indexOf('image') !== -1) { | |
const blob = items[i].getAsFile(); | |
processFile(blob); | |
break; | |
} | |
} | |
} | |
}); | |
// Setup canvas drawing events if GM | |
if (isGM) { | |
canvas.addEventListener('mousedown', handleMouseDown); | |
canvas.addEventListener('mousemove', handleMouseMove); | |
canvas.addEventListener('mouseup', handleMouseUp); | |
canvas.addEventListener('mouseleave', handleMouseLeave); | |
} | |
// Set up scroll synchronization | |
if (canvasContainer) { | |
canvasContainer.addEventListener('scroll', function() { | |
if (isGM && currentImage) { | |
// Save scroll position for current map | |
maps[currentMapIndex - 1].scrollX = canvasContainer.scrollLeft; | |
maps[currentMapIndex - 1].scrollY = canvasContainer.scrollTop; | |
broadcastScrollUpdate(canvasContainer.scrollLeft, canvasContainer.scrollTop); | |
} | |
}); | |
} | |
// Initial UI setup | |
updateMapUI(); | |
// Make first tab active by default | |
if (isGM) { | |
const firstTab = document.querySelector('.map-tab[data-index="1"]'); | |
if (firstTab) { | |
firstTab.classList.add('active'); | |
} | |
} | |
} | |
// Mouse Down Handler - Detect drawing or long press | |
function handleMouseDown(e) { | |
if (!currentImage) return; | |
const rect = canvas.getBoundingClientRect(); | |
startX = (e.clientX - rect.left) / scale; | |
startY = (e.clientY - rect.top) / scale; | |
// Start detecting long press (2 seconds) | |
longPressTimeout = setTimeout(() => { | |
isLongPress = true; | |
isDrawing = false; // Disable drawing when in animation mode | |
isAnimating = true; | |
// Start animation locally | |
startCircleAnimation(startX, startY); | |
// Just broadcast the start command with coordinates | |
broadcastCircleAnimation(startX, startY); | |
}, 2000); // 2000ms (2s) for long press | |
isDrawing = true; | |
} | |
// Mouse Move Handler | |
function handleMouseMove(e) { | |
if (!currentImage) return; | |
if (isLongPress || isAnimating) { | |
// Nothing to do for circle animation while moving | |
return; | |
} | |
// Clear long press timeout | |
clearTimeout(longPressTimeout); | |
if (isDrawing) { | |
// Redraw the image with mask | |
applyMask(); | |
// Get current position | |
const rect = canvas.getBoundingClientRect(); | |
const currentX = (e.clientX - rect.left) / scale; | |
const currentY = (e.clientY - rect.top) / scale; | |
// Draw the shape preview | |
ctx.strokeStyle = drawMode === 'reveal' ? 'rgba(0, 255, 0, 0.8)' : 'rgba(255, 0, 0, 0.8)'; | |
ctx.lineWidth = 2; | |
ctx.save(); | |
ctx.scale(scale, scale); | |
ctx.beginPath(); | |
const width = currentX - startX; | |
const height = currentY - startY; | |
if (shapeMode === 'rectangle') { | |
// Rectangle mode | |
ctx.rect(startX, startY, width, height); | |
} else { | |
// Oval mode | |
const centerX = startX + width / 2; | |
const centerY = startY + height / 2; | |
const radiusX = Math.abs(width / 2); | |
const radiusY = Math.abs(height / 2); | |
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2); | |
} | |
ctx.stroke(); | |
ctx.restore(); | |
} | |
} | |
// Mouse Up Handler | |
function handleMouseUp(e) { | |
if (!currentImage) return; | |
// Clear long press timeout | |
clearTimeout(longPressTimeout); | |
if (isLongPress || isAnimating) { | |
// Stop circle animation | |
stopCircleAnimation(); | |
broadcastStopCircleAnimation(); | |
isLongPress = false; | |
isAnimating = false; | |
isDrawing = false; | |
return; | |
} | |
if (!isDrawing) return; | |
isDrawing = false; | |
// Get current position | |
const rect = canvas.getBoundingClientRect(); | |
const endX = (e.clientX - rect.left) / scale; | |
const endY = (e.clientY - rect.top) / scale; | |
// Calculate width and height | |
const width = Math.abs(endX - startX); | |
const height = Math.abs(endY - startY); | |
// Skip if the shape is too small | |
if (width < 5 / scale || height < 5 / scale) { | |
applyMask(); | |
return; | |
} | |
// Update the mask | |
maskCtx.fillStyle = drawMode === 'reveal' ? 'white' : 'black'; | |
if (shapeMode === 'rectangle') { | |
// Rectangle mode | |
const x = Math.min(startX, endX); | |
const y = Math.min(startY, endY); | |
maskCtx.fillRect(x, y, width, height); | |
} else { | |
// Oval mode | |
const centerX = (startX + endX) / 2; | |
const centerY = (startY + endY) / 2; | |
const radiusX = width / 2; | |
const radiusY = height / 2; | |
maskCtx.beginPath(); | |
maskCtx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2); | |
maskCtx.fill(); | |
} | |
// Apply mask and broadcast change | |
applyMask(); | |
broadcastMaskUpdate(); | |
// Update status | |
updateStatus(`Area ${drawMode === 'reveal' ? 'revealed' : 'hidden'}.`); | |
} | |
// Mouse Leave Handler | |
function handleMouseLeave(e) { | |
// Clear long press timeout | |
clearTimeout(longPressTimeout); | |
if (isLongPress || isAnimating) { | |
stopCircleAnimation(); | |
broadcastStopCircleAnimation(); | |
isLongPress = false; | |
isAnimating = false; | |
} | |
if (isDrawing) { | |
isDrawing = false; | |
applyMask(); | |
} | |
} | |
// Start circle animation | |
function startCircleAnimation(x, y) { | |
circleX = x; | |
circleY = y; | |
circleRadius = 20; // Twice as large, start at 20 | |
circleGrowth = true; | |
isAnimating = true; | |
// Stop any existing animation | |
if (circleAnimationFrame) { | |
cancelAnimationFrame(circleAnimationFrame); | |
} | |
// Start the animation | |
animateCircles(); | |
} | |
// Animate concentric circles | |
function animateCircles() { | |
// Redraw the canvas with mask | |
applyMask(); | |
// Draw the circles | |
ctx.save(); | |
ctx.scale(scale, scale); | |
// Draw 3 circles with different opacities | |
drawCircle(circleRadius, 0.8); | |
if (circleRadius > 30) drawCircle(circleRadius - 30, 0.6); | |
if (circleRadius > 60) drawCircle(circleRadius - 60, 0.4); | |
ctx.restore(); | |
// Update the circle radius (oscillate between 20 and 100) - twice as large | |
if (circleGrowth) { | |
circleRadius += 4; // Move faster | |
if (circleRadius >= 100) circleGrowth = false; | |
} else { | |
circleRadius -= 4; // Move faster | |
if (circleRadius <= 20) circleGrowth = true; | |
} | |
// Continue animation | |
circleAnimationFrame = requestAnimationFrame(animateCircles); | |
} | |
// Draw a single circle | |
function drawCircle(radius, opacity) { | |
ctx.beginPath(); | |
ctx.arc(circleX, circleY, radius, 0, Math.PI * 2); | |
ctx.strokeStyle = `rgba(255, 0, 0, ${opacity})`; // Bright red instead of yellow | |
ctx.lineWidth = 4; // Thicker line | |
ctx.stroke(); | |
} | |
// Stop circle animation | |
function stopCircleAnimation() { | |
if (circleAnimationFrame) { | |
cancelAnimationFrame(circleAnimationFrame); | |
circleAnimationFrame = null; | |
} | |
applyMask(); | |
} | |
// Broadcast circle animation to player tab | |
function broadcastCircleAnimation(x, y) { | |
channel.postMessage({ | |
type: 'circle-animation', | |
data: { x, y } | |
}); | |
} | |
// Broadcast stop circle animation | |
function broadcastStopCircleAnimation() { | |
channel.postMessage({ | |
type: 'stop-circle-animation' | |
}); | |
} | |
// Set the shape mode (rectangle or oval) | |
function setShapeMode(mode) { | |
shapeMode = mode; | |
// Update toggle buttons - ensure we're targeting the correct buttons | |
const shapeToggleContainer = document.querySelector('.toggle-container:nth-child(2)'); | |
if (shapeToggleContainer) { | |
const buttons = shapeToggleContainer.querySelectorAll('button'); | |
if (buttons.length === 2) { | |
if (mode === 'rectangle') { | |
buttons[0].classList.add('active'); | |
buttons[1].classList.remove('active'); | |
} else { | |
buttons[0].classList.remove('active'); | |
buttons[1].classList.add('active'); | |
} | |
} | |
} | |
updateStatus(`Shape set to ${mode}.`); | |
} | |
// Switch between maps | |
function switchMap(index) { | |
if (index === currentMapIndex) return; | |
// Save current map state | |
if (currentImage) { | |
saveCurrentMapState(); | |
} | |
// Update the current map index | |
currentMapIndex = index; | |
// Update UI | |
updateMapUI(); | |
// Load the selected map | |
loadMapState(currentMapIndex); | |
// Broadcast map switch to player tab if this is GM | |
if (isGM) { | |
broadcastMapSwitch(currentMapIndex); | |
} | |
} | |
// Save the current map state | |
function saveCurrentMapState() { | |
const currentMap = maps[currentMapIndex - 1]; | |
currentMap.image = currentImage; | |
currentMap.maskData = maskCanvas.toDataURL(); | |
currentMap.scale = scale; | |
currentMap.scrollX = canvasContainer.scrollLeft; | |
currentMap.scrollY = canvasContainer.scrollTop; | |
} | |
// Load a map state | |
function loadMapState(index) { | |
const mapData = maps[index - 1]; | |
if (mapData.image) { | |
// We have data for this map, load it | |
currentImage = mapData.image; | |
scale = mapData.scale || 1; | |
// Update canvas size | |
canvas.width = currentImage.width; | |
canvas.height = currentImage.height; | |
// Draw image | |
ctx.drawImage(currentImage, 0, 0); | |
// Load mask | |
if (mapData.maskData) { | |
const img = new Image(); | |
img.onload = function() { | |
maskCanvas.width = currentImage.width; | |
maskCanvas.height = currentImage.height; | |
maskCtx.drawImage(img, 0, 0); | |
// Apply mask and update UI | |
applyMask(); | |
// Set scroll position | |
setTimeout(() => { | |
canvasContainer.scrollLeft = mapData.scrollX || 0; | |
canvasContainer.scrollTop = mapData.scrollY || 0; | |
}, 10); | |
// Hide message and show canvas | |
if (emptyMapMessage) emptyMapMessage.style.display = 'none'; | |
canvasContainer.style.display = 'block'; | |
dropZone.classList.add('hidden'); | |
}; | |
img.src = mapData.maskData; | |
} else { | |
// No mask, create a new one (all hidden) | |
maskCanvas.width = currentImage.width; | |
maskCanvas.height = currentImage.height; | |
maskCtx.fillStyle = 'black'; | |
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
// Apply mask and update UI | |
applyMask(); | |
// Set scroll position | |
setTimeout(() => { | |
canvasContainer.scrollLeft = mapData.scrollX || 0; | |
canvasContainer.scrollTop = mapData.scrollY || 0; | |
}, 10); | |
// Hide message and show canvas | |
if (emptyMapMessage) emptyMapMessage.style.display = 'none'; | |
canvasContainer.style.display = 'block'; | |
dropZone.classList.add('hidden'); | |
} | |
} else { | |
// No data for this map, show empty state | |
showEmptyState(); | |
} | |
// Update zoom display | |
updateZoomDisplay(); | |
} | |
// Show empty state | |
function showEmptyState() { | |
currentImage = null; | |
canvas.width = 300; | |
canvas.height = 300; | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
// Show message and hide canvas | |
if (emptyMapMessage) emptyMapMessage.style.display = 'flex'; | |
canvasContainer.style.display = 'none'; | |
if (isGM) { | |
dropZone.classList.remove('hidden'); | |
} | |
} | |
// Clear current map | |
function clearMap() { | |
if (!currentImage) return; | |
// Clear map data | |
clearMapData(currentMapIndex); | |
// Show empty state | |
showEmptyState(); | |
// Broadcast clear map command | |
broadcastClearMap(currentMapIndex); | |
// Update UI | |
updateMapUI(); | |
updateStatus('Map cleared.'); | |
} | |
// Clear map data | |
function clearMapData(mapIndex) { | |
// Clear the map data | |
maps[mapIndex - 1] = { | |
image: null, | |
maskData: null, | |
scale: 1, | |
scrollX: 0, | |
scrollY: 0 | |
}; | |
} | |
// Broadcast clear map command | |
function broadcastClearMap(mapIndex) { | |
channel.postMessage({ | |
type: 'clear-map', | |
data: { | |
mapIndex: mapIndex | |
} | |
}); | |
} | |
// Update map UI (tab highlighting) | |
function updateMapUI() { | |
if (!isGM) return; | |
// Update tab highlighting | |
const tabs = document.querySelectorAll('.map-tab'); | |
tabs.forEach(tab => { | |
const index = parseInt(tab.dataset.index); | |
// Update active state | |
tab.classList.toggle('active', index === currentMapIndex); | |
// Update "has content" indicator | |
tab.classList.toggle('has-content', maps[index - 1].image !== null); | |
}); | |
// Update status message | |
updateStatus(`Switched to map ${currentMapIndex}`); | |
} | |
// Handle file selection from input or drag & drop | |
function handleFileSelect(e) { | |
if (e.target.files.length > 0) { | |
processFile(e.target.files[0]); | |
} | |
} | |
// Process the selected image file | |
function processFile(file) { | |
if (!file.type.match('image.*')) { | |
alert('Please select an image file.'); | |
return; | |
} | |
const reader = new FileReader(); | |
reader.onload = function(e) { | |
const img = new Image(); | |
img.onload = function() { | |
setupCanvas(img); | |
currentImage = img; | |
// Store in the current map slot | |
maps[currentMapIndex - 1].image = img; | |
// Update UI to show this slot has content | |
updateMapUI(); | |
// Broadcast the image to other tabs | |
broadcastImage(img.src, canvas.width, canvas.height, currentMapIndex); | |
// Hide drop zone and empty message | |
dropZone.classList.add('hidden'); | |
if (emptyMapMessage) emptyMapMessage.style.display = 'none'; | |
canvasContainer.style.display = 'block'; | |
// Update status | |
updateStatus('Image loaded. Draw rectangles to reveal/hide areas.'); | |
}; | |
img.src = e.target.result; | |
}; | |
reader.readAsDataURL(file); | |
} | |
// Set up canvas with the loaded image | |
function setupCanvas(img) { | |
// Set canvas dimensions to match image | |
canvas.width = img.width; | |
canvas.height = img.height; | |
// Draw image on canvas | |
ctx.drawImage(img, 0, 0); | |
// Create mask canvas with same dimensions | |
maskCanvas.width = img.width; | |
maskCanvas.height = img.height; | |
// Fill mask with black (hidden) | |
maskCtx.fillStyle = 'black'; | |
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
// Reset zoom | |
scale = 1; | |
updateZoomDisplay(); | |
// Apply the mask immediately to ensure it shows properly on GM view | |
applyMask(); | |
// Force an immediate redraw to ensure the mask is visible right away | |
setTimeout(() => { | |
applyMask(); | |
}, 50); | |
} | |
// Handle image update (for player tab) | |
function handleImageUpdate(imageData, width, height, mapIndex) { | |
const img = new Image(); | |
img.onload = function() { | |
if (mapIndex !== currentMapIndex && !isGM) { | |
// If we're receiving an image for a different map, switch to it | |
switchMap(mapIndex); | |
} | |
// Store the image data in the maps array | |
maps[mapIndex - 1].image = img; | |
if (mapIndex === currentMapIndex) { | |
// Set canvas dimensions | |
canvas.width = width; | |
canvas.height = height; | |
// Draw the image | |
ctx.drawImage(img, 0, 0); | |
// Create mask canvas with same dimensions | |
maskCanvas.width = width; | |
maskCanvas.height = height; | |
// Fill mask with black (hidden) | |
maskCtx.fillStyle = 'black'; | |
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
// Store current image | |
currentImage = img; | |
// Apply the mask | |
applyMask(); | |
// Hide empty message | |
if (emptyMapMessage) emptyMapMessage.style.display = 'none'; | |
canvasContainer.style.display = 'block'; | |
} | |
// Update UI to show this slot has content | |
updateMapUI(); | |
}; | |
img.src = imageData; | |
} | |
// Apply the mask to the canvas | |
function applyMask() { | |
if (!currentImage) return; | |
// Resize canvas based on scale | |
const scaledWidth = Math.ceil(currentImage.width * scale); | |
const scaledHeight = Math.ceil(currentImage.height * scale); | |
// Update canvas size | |
if (canvas.width !== scaledWidth || canvas.height !== scaledHeight) { | |
canvas.width = scaledWidth; | |
canvas.height = scaledHeight; | |
} | |
// Clear canvas | |
ctx.clearRect(0, 0, canvas.width, canvas.height); | |
// Draw the image scaled | |
ctx.save(); | |
ctx.scale(scale, scale); | |
ctx.drawImage(currentImage, 0, 0); | |
ctx.restore(); | |
// Get mask data | |
const maskData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height); | |
const data = maskData.data; | |
// Get the current canvas content (scaled image) | |
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
const imgData = imageData.data; | |
// Apply mask to the scaled image | |
for (let y = 0; y < canvas.height; y++) { | |
for (let x = 0; x < canvas.width; x++) { | |
// Convert the scaled coordinates back to the original image coordinates | |
const srcX = Math.floor(x / scale); | |
const srcY = Math.floor(y / scale); | |
// Make sure we're within bounds of the mask | |
if (srcX >= 0 && srcX < maskCanvas.width && srcY >= 0 && srcY < maskCanvas.height) { | |
// Calculate indices | |
const maskIndex = (srcY * maskCanvas.width + srcX) * 4; | |
const imgIndex = (y * canvas.width + x) * 4; | |
// If this pixel is hidden in the mask (black) | |
if (data[maskIndex] === 0) { | |
if (isGM) { | |
// 50% black overlay in GM tab | |
imgData[imgIndex] = imgData[imgIndex] / 2; // Reduce red | |
imgData[imgIndex + 1] = imgData[imgIndex + 1] / 2; // Reduce green | |
imgData[imgIndex + 2] = imgData[imgIndex + 2] / 2; // Reduce blue | |
// Alpha remains the same | |
} else { | |
// Full transparency in player tab - ensure no dimming happens | |
imgData[imgIndex + 3] = 0; // Set alpha to 0 (transparent) | |
} | |
} | |
} | |
} | |
} | |
// Put the modified image data back | |
ctx.putImageData(imageData, 0, 0); | |
// Update zoom level display | |
updateZoomDisplay(); | |
} | |
// Update mask with data from another tab | |
function updateMask(maskData, mapIndex) { | |
// If we received a mask update for a map other than the current one, just store it | |
if (mapIndex !== currentMapIndex) { | |
maps[mapIndex - 1].maskData = maskData; | |
return; | |
} | |
const img = new Image(); | |
img.onload = function() { | |
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); | |
maskCtx.drawImage(img, 0, 0); | |
applyMask(); | |
// Also save in the map data | |
maps[currentMapIndex - 1].maskData = maskData; | |
}; | |
img.src = maskData; | |
} | |
// Update all pixels in the mask | |
function updateMaskAll(isRevealed) { | |
if (!currentImage) return; | |
maskCtx.fillStyle = isRevealed ? 'white' : 'black'; | |
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); | |
applyMask(); | |
// Save the mask in the map data | |
maps[currentMapIndex - 1].maskData = maskCanvas.toDataURL(); | |
updateStatus(isRevealed ? 'All areas revealed.' : 'All areas hidden.'); | |
} | |
// Reveal all areas of the map | |
function revealAll() { | |
updateMaskAll(true); | |
channel.postMessage({ | |
type: 'reveal-all', | |
data: { | |
mapIndex: currentMapIndex | |
} | |
}); | |
} | |
// Hide all areas of the map | |
function hideAll() { | |
updateMaskAll(false); | |
channel.postMessage({ | |
type: 'hide-all', | |
data: { | |
mapIndex: currentMapIndex | |
} | |
}); | |
} | |
// Set the drawing mode | |
function setDrawMode(mode) { | |
drawMode = mode; | |
// Update toggle buttons | |
const toggleButtons = document.querySelectorAll('.toggle-container:first-child button'); | |
if (toggleButtons.length === 2) { | |
if (mode === 'reveal') { | |
toggleButtons[0].classList.add('active'); | |
toggleButtons[1].classList.remove('active'); | |
} else { | |
toggleButtons[0].classList.remove('active'); | |
toggleButtons[1].classList.add('active'); | |
} | |
} | |
updateStatus(`Mode set to ${mode}.`); | |
} | |
// Broadcast image to other tabs | |
function broadcastImage(imageData, width, height, mapIndex) { | |
channel.postMessage({ | |
type: 'image-upload', | |
data: { | |
imageData, | |
width, | |
height, | |
mapIndex | |
} | |
}); | |
} | |
// Broadcast mask update to other tabs | |
function broadcastMaskUpdate() { | |
channel.postMessage({ | |
type: 'mask-update', | |
data: { | |
maskData: maskCanvas.toDataURL(), | |
mapIndex: currentMapIndex | |
} | |
}); | |
} | |
// Broadcast map switch to other tabs | |
function broadcastMapSwitch(mapIndex) { | |
channel.postMessage({ | |
type: 'switch-map', | |
data: { | |
mapIndex | |
} | |
}); | |
} | |
// Open a player tab | |
function openPlayerTab() { | |
window.open(window.location.href + '#player', '_blank'); | |
} | |
// Update status message | |
function updateStatus(message) { | |
const status = document.getElementById('status'); | |
if (status) { | |
status.textContent = message; | |
// Clear after 3 seconds | |
setTimeout(() => { | |
status.textContent = 'Ready'; | |
}, 3000); | |
} | |
} | |
// Change zoom level | |
function changeZoom(newScale) { | |
// Limit zoom range | |
newScale = Math.max(0.1, Math.min(3, newScale)); | |
if (newScale !== scale) { | |
scale = newScale; | |
applyMask(); // This will redraw with the new scale | |
// Store zoom level in map data | |
maps[currentMapIndex - 1].scale = scale; | |
// Broadcast zoom change to player tab | |
broadcastZoomUpdate(); | |
} | |
} | |
// Update zoom level display | |
function updateZoomDisplay() { | |
if (zoomLevelEl) { | |
zoomLevelEl.textContent = `${Math.round(scale * 100)}%`; | |
} | |
} | |
// Update zoom from another tab | |
function updateZoom(newScale) { | |
scale = newScale; | |
// Store zoom level in map data | |
maps[currentMapIndex - 1].scale = scale; | |
applyMask(); | |
} | |
// Broadcast zoom update to other tabs | |
function broadcastZoomUpdate() { | |
channel.postMessage({ | |
type: 'zoom-update', | |
data: { | |
scale: scale, | |
mapIndex: currentMapIndex | |
} | |
}); | |
} | |
// Broadcast scroll update to other tabs | |
function broadcastScrollUpdate(scrollX, scrollY) { | |
channel.postMessage({ | |
type: 'scroll-update', | |
data: { | |
scrollX: scrollX, | |
scrollY: scrollY, | |
mapIndex: currentMapIndex | |
} | |
}); | |
} | |
// Update scroll from another tab | |
function updateScroll(newScrollX, newScrollY) { | |
if (canvasContainer) { | |
canvasContainer.scrollLeft = newScrollX; | |
canvasContainer.scrollTop = newScrollY; | |
// Store scroll position in map data | |
maps[currentMapIndex - 1].scrollX = newScrollX; | |
maps[currentMapIndex - 1].scrollY = newScrollY; | |
} | |
} | |
// Initialize the app | |
init(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment