Skip to content

Instantly share code, notes, and snippets.

@scooper4711
Last active January 16, 2026 22:47
Show Gist options
  • Select an option

  • Save scooper4711/f1c466c03ac4761397842d73e57f425f to your computer and use it in GitHub Desktop.

Select an option

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/
// 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