// This script allows you to import Dungeon Alchemist maps into Roll20. // By: Karel Crombecq, Briganti // Contact: info@briganti.be const DungeonAlchemistImporter = (() => { // eslint-disable-line no-unused-vars const scriptName = "DungeonAlchemistImporter"; const version = "0.0.8"; const defaultGridSize = 70; const clearURL = 'https://s3.amazonaws.com/files.d20.io/images/4277467/iQYjFOsYC5JsuOPUCI9RGA/thumb.png?1401938659'; const regex = { colors: /^(?:#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?|transparent)$/, }; const _h = { outer: (...o) => `
${o.join( " " )}
`, title: (t, v) => `
${t} v${v}
`, subhead: (...o) => `${o.join(" ")}`, minorhead: (...o) => `${o.join(" ")}`, optional: (...o) => `${ch("[")}${o.join(` ${ch("|")} `)}${ch("]")}`, required: (...o) => `${ch("<")}${o.join(` ${ch("|")} `)}${ch(">")}`, header: (...o) => `
${o.join(" ")}
`, section: (s, ...o) => `${_h.subhead(s)}${_h.inset(...o)}`, paragraph: (...o) => `

${o.join(" ")}

`, group: (...o) => `${o.join(" ")}`, items: (o) => `
  • ${o.join("
  • ")}
  • `, ol: (...o) => `
      ${_h.items(o)}
    `, ul: (...o) => ``, clearBoth: () => `
    `, grid: (...o) => `
    ${o.join("")}${_h.clearBoth()}
    `, cell: (o) => `
    ${o}
    `, inset: (...o) => `
    ${o.join(" ")}
    `, join: (...o) => o.join(" "), configRow: (...o) => `
    ${o.join(" ")}
    `, makeButton: (c, l, bc, color) => `${l}`, floatRight: (...o) => `
    ${o.join(" ")}
    `, pre: (...o) => `
    ${o.join( " " )}
    `, preformatted: (...o) => _h.pre(o.join("
    ").replace(/\s/g, ch(" "))), code: (...o) => `${o.join(" ")}`, attr: { bare: (o) => `${ch("@")}${ch("{")}${o}${ch("}")}`, selected: (o) => `${ch("@")}${ch("{")}selected${ch("|")}${o}${ch("}")}`, target: (o) => `${ch("@")}${ch("{")}target${ch("|")}${o}${ch("}")}`, char: (o, c) => `${ch("@")}${ch("{")}${c || "CHARACTER UniversalVTTImporter"}${ch( "|" )}${o}${ch("}")}`, }, bold: (...o) => `${o.join(" ")}`, italic: (...o) => `${o.join(" ")}`, font: { command: (...o) => `${o.join(" ")}`, }, }; const helpParts = { helpBody: (context) => _h.join( _h.header( _h.paragraph( `${scriptName} allows you to import Dungeon Alchemist map data (walls and lights) into Roll20.` ) ), _h.section( "How to import?", _h.paragraph( `In order to import a Dungeon Alchemist map into Roll20, you need to export it with the Roll20 setting. This will give you two files: your map image with extension .jpg, and .txt file with the same file name.` ), _h.paragraph( `Just copy all the text in the .txt file, and paste it in the chat window. It should import all your walls and lights. If you go to the "Dynamic Lighting" layer, they should be there now.` ), _h.paragraph(`That's it! Enjoy your adventure.`) ) ), helpConfig: (context) => _h.outer( _h.title(scriptName, version), playerIsGM(context.playerid) ? _h.group(_h.subhead("Configuration"), getAllConfigOptions()) : "" ), helpDoc: (context) => _h.join(_h.title(scriptName, version), helpParts.helpBody(context)), helpChat: (context) => _h.outer(_h.title(scriptName, version), helpParts.helpBody(context)), }; const assureHelpHandout = (create = true) => { const helpIcon = "https://s3.amazonaws.com/files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385"; // find handout let props = { type: "handout", name: `Help: ${scriptName}` }; let hh = findObjs(props)[0]; if (!hh) { log(`Create handout for ${scriptName}`); hh = createObj("handout", Object.assign(props, { avatar: helpIcon })); create = true; } if (create || version !== state[scriptName].lastHelpVersion) { hh.set({ notes: helpParts.helpDoc({ who: "handout", playerid: "handout" }), }); state[scriptName].lastHelpVersion = version; log(" > Updated Help Handout to v" + version + " <"); } }; const checkInstall = () => { log("-=> Dungeon Alchemist importer, version " + version); if (!state[scriptName]) { state[scriptName] = {}; } assureHelpHandout(); }; const createWall = (page, map, wall, originalGridSize, gridSize, version) => { let x1 = wall.wall3D.p1.top.x * gridSize / originalGridSize; let y1 = wall.wall3D.p1.top.y * gridSize / originalGridSize; let x2 = wall.wall3D.p2.top.x * gridSize / originalGridSize; let y2 = wall.wall3D.p2.top.y * gridSize / originalGridSize; const xCenter = (x1 + x2) * 0.5; const yCenter = (y1 + y2) * 0.5; const xMin = Math.min(x1, x2); const yMin = Math.min(y1, y2); const xMax = Math.max(x1, x2); const yMax = Math.max(y1, y2); const width = xMax - xMin; const height = yMax - yMin; // because partial walls used to be exported as windows, we can't support them for older exports const generateWindows = version >= 2; // new door/window API if (wall.type == 1 || (wall.type == 2 && generateWindows)) { const type = (wall.type == 1) ? "door" : "window"; const color = (wall.type == 1) ? "#00ff00" : "#00ffff"; x1 -= xCenter; x2 -= xCenter; y1 -= yCenter; y2 -= yCenter; let open = wall.open; if (typeof(open) === 'undefined') open = false; var doorObj = { pageid: page.get("_id"), color: color, x: xCenter, y: -yCenter, isOpen: open, isLocked: false, isSecret: false, path: { handle0: {x: x1, y: y1}, handle1: {x: x2, y: y2}, }, controller_by: map.get("_id"), }; createObj(type, doorObj); } // default else if (wall.type == 0 || wall.type == 4 || (wall.type == 2 && !generateWindows)) { x1 -= xMin; x2 -= xMin; y1 -= yMin; y2 -= yMin; const path = [ ["M", x1, y1], ["L", x2, y2], ]; // different wall types have different colors - we use a color scheme compatible with WOTC modules and DoorKnocker let color = "#0000ff"; let barrierType = "wall"; if (wall.type == 4) { color = "#5555ff"; barrierType = "transparent"; } // backwards compatibility else if (wall.type == 2) { color = "#00ffff"; // window (light blue) barrierType = "transparent"; } createObj("path", { pageid: page.get("_id"), stroke: color, fill: "transparent", left: xCenter, top: yCenter, width: width, height: height, rotation: 0, scaleX: 1, scaleY: 1, stroke_width: 5, layer: "walls", path: JSON.stringify(path), barrierType: barrierType, controller_by: map.get("_id"), }); } }; const createLight = (page, light, originalGridSize, gridSize) => { const x = light.position.x * gridSize / originalGridSize; const y = light.position.y * gridSize / originalGridSize; const range = light.range * 1.0; let dim_radius = range; let bright_radius = range / 2; // convert to the local scale value const scale_number = page.get("scale_number"); //log("Go from dim_radius " + dim_radius + " which has range " + range + " to per tile " + (dim_radius/originalGridSize) + " from original grid size " + originalGridSize + " and scale_number is " + scale_number); dim_radius *= scale_number / originalGridSize; bright_radius *= scale_number / originalGridSize; var newObj = createObj('graphic',{ imgsrc: clearURL, subtype: 'token', name: '', aura1_radius: 0.5, aura1_color: "#" + light.color.substring(0, 6), // UDL emits_bright_light: true, emits_low_light: true, bright_light_distance: bright_radius, low_light_distance: dim_radius, width:70, height:70, top: y, left: x, layer: "walls", pageid: page.get("_id") }); //log("New obj light distance: " + newObj.get("bright_light_distance") + " / " + newObj.get("low_light_distance")); }; const getMap = (page, msg) => { // simplest case - get the ONLY map graphic and use that one var mapGraphics = findObjs({ _pageid: page.get("_id"), _type: "graphic", layer: "map", }); // filter them all so we only consider the layer=map graphics if (mapGraphics.length === 1) { return mapGraphics[0]; } // no map if (mapGraphics.length == 0) { sendChat( "Dungeon Alchemist", "You need to upload your map image and put it in the Map Layer before importing the line-of-sight data. Make sure that your map is in the background layer by right clicking on it, selecting \"Layer\" and choosing \"Map Layer\"." ); return null; } // otherwise, see if we selected one var selected = msg.selected; if (selected === undefined || selected.length == 0) { sendChat( "Dungeon Alchemist", "If you have more than one image in the map layer, you need to select the one that contains the Dungeon Alchemist map image before running the command." ); return null; } else { return getObj("graphic", selected[0]._id); } }; const resizeMap = (gridSize, grid, page, map) => { const mapWidth = grid.x * gridSize; const mapHeight = grid.y * gridSize; page.set({ width: (grid.x * gridSize) / defaultGridSize, height: (grid.y * gridSize) / defaultGridSize, }); map.set({ width: mapWidth, height: mapHeight, top: mapHeight / 2, left: mapWidth / 2, layer: "map", }); }; const handleInput = (msg) => { if ( "api" === msg.type && /^!dungeonalchemist\b/i.test(msg.content) && playerIsGM(msg.playerid) ) { const s = msg.content; const endOfHeader = s.indexOf(" "); try { // parse the data const json = s.substring(endOfHeader); const data = JSON.parse(json); // determine the version let version = data.version; if (typeof(version) === 'undefined') version = 1; // load the player const player = getObj("player", msg.playerid); //log("PLAYER:"); //log(player); const lastPageId = player.get("_lastpage"); //log(lastPageId); // load the page const pageId = Campaign().get("playerpageid"); //log("Page id: " + pageId + " vs last page " + lastPageId); const page = getObj("page", lastPageId); //log("PAGE:"); //log(page); // calculate the REAL grid size const gridSize = defaultGridSize * page.get("snapping_increment"); // load the map const map = getMap(page, msg); //log("MAP:"); //log(map); // we are done - no map found if (map === null) return; // resize the map properly resizeMap(gridSize, data.grid, page, map); // spawn the walls & lights for (const wall of data.walls) { createWall(page, map, wall, data.pixelsPerTile, gridSize, version); } for (const light of data.lights) { createLight(page, light, data.pixelsPerTile, gridSize); } sendChat( "Dungeon Alchemist", "Succesfully imported map data!" ); } catch (err) { sendChat( "Dungeon Alchemist", "Failed to import Dungeon Alchemist map data: " + err ); } } }; const registerEventHandlers = () => { on("chat:message", handleInput); }; on("ready", () => { checkInstall(); registerEventHandlers(); }); return { /* public interface */ }; })();