Last active
January 16, 2026 22:47
-
-
Save scooper4711/f1c466c03ac4761397842d73e57f425f to your computer and use it in GitHub Desktop.
Exports the current FoundryVTT scene to RPGSage.io map format, for use with https://rpgsage.io/maps/
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
| // FoundryVTT Macro: Export Current Scene to Map File | |
| // This macro exports the current scene data to a map file format | |
| // Configuration: Update this path for your server. | |
| // This directory will contain the various auras, e.g. for red,blue,green rings | |
| // for distinguising enemies, or named auras for the token. | |
| const ASSETS_PATH = "worlds/yourworld/assets"; | |
| (async () => { | |
| // Get the current scene (not necessarily the active one) | |
| const scene = canvas.scene; | |
| if (!scene) { | |
| ui.notifications.warn("No scene is currently loaded"); | |
| return; | |
| } | |
| // Get scene name | |
| const sceneName = scene.name || "Untitled Scene"; | |
| // Get background image | |
| let backgroundImage = scene.background?.src || ""; | |
| if (backgroundImage && !backgroundImage.startsWith("http")) { | |
| backgroundImage = window.location.origin + "/" + backgroundImage.replace(/^\//, ""); | |
| } | |
| // Get grid dimensions from scene configuration (use sceneWidth/sceneHeight to exclude padding) | |
| const gridSize = scene.grid?.size || 100; | |
| const sceneWidth = scene.dimensions?.sceneWidth || 0; | |
| const sceneHeight = scene.dimensions?.sceneHeight || 0; | |
| const sceneX = scene.dimensions?.sceneX || 0; | |
| const sceneY = scene.dimensions?.sceneY || 0; | |
| const gridSquaresX = Math.round(sceneWidth / gridSize); | |
| const gridSquaresY = Math.round(sceneHeight / gridSize); | |
| // Get default spawn point (center of map if not defined) | |
| const spawnX = Math.round(gridSquaresX / 2); | |
| const spawnY = Math.round(gridSquaresY / 2); | |
| // Build map content | |
| let mapContent = `[map] | |
| ${backgroundImage} | |
| name=${sceneName} | |
| grid=${gridSquaresX}x${gridSquaresY} | |
| spawn=${spawnX},${spawnY}`; | |
| // Collect auras for tokens with _red, _blue, or _green suffixes OR actor-based auras | |
| const auras = []; | |
| // Get all tokens in the scene | |
| const tokens = scene.tokens; | |
| for (const tokenDoc of tokens) { | |
| const tokenName = tokenDoc.name || "Unnamed Token"; | |
| // Skip hidden tokens entirely | |
| if (tokenDoc.hidden) continue; | |
| // Dynamic ring aura: when enabled, attach a standard ring graphic | |
| const hasDynamicRing = tokenDoc.ring?.enabled; | |
| if (hasDynamicRing) { | |
| const tokenWidth = tokenDoc.width || 1; | |
| const tokenHeight = tokenDoc.height || 1; | |
| auras.push({ | |
| filename: "dynamic-ring.png", | |
| anchor: tokenName, | |
| size: `${tokenWidth}x${tokenHeight}`, | |
| opacity: 1.0 | |
| }); | |
| } | |
| // Check if token name ends with _red, _blue, or _green | |
| const colorMatch = tokenName.match(/_(red|blue|green|purple|white)$/i); | |
| if (colorMatch) { | |
| const color = colorMatch[1].toLowerCase(); | |
| const tokenWidth = tokenDoc.width || 1; | |
| const tokenHeight = tokenDoc.height || 1; | |
| auras.push({ | |
| filename: `${color}-circle.png`, | |
| anchor: tokenName, | |
| size: `${tokenWidth}x${tokenHeight}` | |
| }); | |
| } | |
| // Check for actor-based auras | |
| const actor = tokenDoc.actor; | |
| if (actor) { | |
| const actorAuras = actor.auras; | |
| if (actorAuras && actorAuras.size > 0) { | |
| for (const aura of actorAuras.values()) { | |
| // Get aura radius in feet and convert to grid squares (assuming 5ft per square) | |
| const radiusFeet = aura.radius || 0; | |
| const radiusSquares = Math.ceil(radiusFeet / 5); | |
| // Aura size is diameter, so multiply by 2 | |
| const auraSize = radiusSquares * 2; | |
| // Position offset to center the aura on the token | |
| // For a token at 0,0 with aura size NxN, position should be -N/2,-N/2 | |
| const offset = -radiusSquares; | |
| // Use aura slug as the image name (e.g., "champions-aura") | |
| const auraSlug = aura.slug || "aura"; | |
| auras.push({ | |
| filename: `${auraSlug}.webp`, | |
| anchor: tokenName, | |
| size: `${auraSize}x${auraSize}`, | |
| position: `${offset},${offset}` | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Add auras to map content | |
| for (const aura of auras) { | |
| const auraImage = `${window.location.origin}/${ASSETS_PATH}/${aura.filename}`; | |
| const auraName = aura.filename.replace(/\.(png|webp)$/, ''); | |
| const position = aura.position || '0,0'; | |
| const opacity = aura.opacity ?? 0.5; | |
| mapContent += `\n\n[aura] | |
| ${auraImage} | |
| name=${auraName} | |
| anchor=${aura.anchor} | |
| opacity=${opacity} | |
| size=${aura.size} | |
| position=${position}`; | |
| } | |
| // Process tokens | |
| for (const tokenDoc of tokens) { | |
| if (tokenDoc.hidden) continue; | |
| // Get token image (prefer ring.subject.texture if set) | |
| let tokenImage = tokenDoc.ring?.subject?.texture || tokenDoc.texture?.src || ""; | |
| if (tokenImage && !tokenImage.startsWith("http")) { | |
| tokenImage = window.location.origin + "/" + tokenImage.replace(/^\//, ""); | |
| } | |
| // Get token name | |
| const tokenName = tokenDoc.name || "Unnamed Token"; | |
| // Get token size in grid squares | |
| let tokenWidth = tokenDoc.width || 1; | |
| let tokenHeight = tokenDoc.height || 1; | |
| let scale = tokenDoc.texture?.scaleX ?? tokenDoc.texture?.scaleY ?? 1; | |
| // Adjust for fractional sizes while keeping integer size fields for Sage | |
| const equalDims = Math.abs(tokenWidth - tokenHeight) < 1e-6; | |
| const widthInt = Number.isInteger(tokenWidth); | |
| const heightInt = Number.isInteger(tokenHeight); | |
| if (equalDims && (!widthInt || !heightInt)) { | |
| const baseSize = Math.max(1, Math.round(tokenWidth)); | |
| scale = scale * (tokenWidth / baseSize); | |
| scale = Math.max(0.01, Math.min(2, scale)); | |
| tokenWidth = baseSize; | |
| tokenHeight = baseSize; | |
| } | |
| // Fallback clamp for scale | |
| scale = Math.max(0.01, Math.min(2, scale)); | |
| // Get token position in grid squares (adjust for scene padding offset) | |
| // Grid coordinates are 1-indexed, so add 1 to convert from 0-indexed | |
| const posX = Math.floor((tokenDoc.x - sceneX) / gridSize) + 1; | |
| const posY = Math.floor((tokenDoc.y - sceneY) / gridSize) + 1; | |
| // Get owner's username (find first owner who isn't GM, or use GM if no player owner) | |
| let ownerUsername = ""; | |
| let gmUsername = ""; | |
| const ownership = tokenDoc.actor?.ownership || tokenDoc.ownership || {}; | |
| for (const [userId, level] of Object.entries(ownership)) { | |
| if (level >= 3 && userId !== "default") { // OWNER level | |
| const user = game.users.get(userId); | |
| if (user) { | |
| const discordUsername = user.getFlag("world", "discordUsername") || user.name || ""; | |
| if (user.isGM) { | |
| gmUsername = discordUsername; | |
| } else { | |
| // Found a player owner | |
| ownerUsername = discordUsername; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| // If no player owner found, use GM | |
| if (!ownerUsername && gmUsername) { | |
| ownerUsername = gmUsername; | |
| } | |
| // Build token section | |
| mapContent += `\n\n[token] | |
| ${tokenImage} | |
| name=${tokenName} | |
| size=${tokenWidth}x${tokenHeight} | |
| scale=${Number(scale.toFixed(2))} | |
| position=${posX},${posY}`; | |
| if (ownerUsername) { | |
| mapContent += `\nuser=@${ownerUsername}`; | |
| } | |
| } | |
| // Create filename | |
| const fileName = `${sceneName}.map.txt`; | |
| // Create a dialog to display and copy/download the content | |
| const dialog = new Dialog({ | |
| title: "Export Scene to Map File", | |
| content: ` | |
| <div style="margin-bottom: 10px;"> | |
| <p>Map data for scene "${sceneName}" generated. Copy or download below:</p> | |
| </div> | |
| <textarea id="map-output" readonly style="width: 100%; height: 200px; font-family: monospace; font-size: 12px;">${mapContent}</textarea> | |
| `, | |
| buttons: { | |
| copy: { | |
| icon: '<i class="fas fa-copy"></i>', | |
| label: "Copy to Clipboard", | |
| callback: async () => { | |
| await navigator.clipboard.writeText(mapContent); | |
| ui.notifications.info("Map data copied to clipboard!"); | |
| } | |
| }, | |
| download: { | |
| icon: '<i class="fas fa-download"></i>', | |
| label: "Download File", | |
| callback: () => { | |
| foundry.utils.saveDataToFile(mapContent, "text/plain", fileName); | |
| ui.notifications.info(`Map file "${fileName}" downloaded!`); | |
| } | |
| }, | |
| close: { | |
| icon: '<i class="fas fa-times"></i>', | |
| label: "Close" | |
| } | |
| }, | |
| default: "download", | |
| render: (html) => { | |
| // Auto-select the text area content for easy copying | |
| html.find("#map-output").on("click", function() { | |
| this.select(); | |
| }); | |
| } | |
| }); | |
| dialog.render(true); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment