├── LICENSE.md ├── README.md ├── TODO.md ├── docs └── demo_video.gif ├── module.json ├── scripts ├── hello.js └── one_page_parser.js └── templates └── one-page-parser-form.html /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tarkan Al-Kazily 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # One Page Parser for Foundry VTT 2 | 3 | Create instant scenes for Foundry VTT from Watabou's [One Page Dungeon](https://watabou.itch.io/one-page-dungeon). This project is unafilliated with Watabou's work. 4 | 5 | Manifest URL: https://github.com/TarkanAl-Kazily/foundryvtt-one-page-parser/releases/latest/download/module.json 6 | 7 | # Usage 8 | 9 | ![Generate a new dungeon as soon as your players finish the last one](docs/demo_video.gif) 10 | 11 | First, generate a map you like from [One Page Dungeon](https://watabou.itch.io/one-page-dungeon). From the context menu (right click the map) select "Export PNG", choosing a reasonable pixel size (70 is default). Do the same for "Export JSON". 12 | 13 | In the Scenes tab, click the new "OnePageParser Import Scene" button. In the form that pops up, provide a Scene Name (optional), select the PNG you exported for the Background Image, and the JSON you exported for Import JSON. Finally, make sure the grid size matches the exported grid size. Click Save. 14 | 15 | The scene will be created in the sidebar. Activate it and go! 16 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Stuff to do 2 | 3 | [X] Modify "Create Scene" initial UI: 4 | [X] Add button next to "Create Scene" that says "Import" 5 | [X] Button creates a dialog 6 | [X] Import will perform dummy scene creation actions 7 | [X] Import must be one-time action, after which normal scene edit UI should work 8 | 9 | [X] Button Dialog UI - extends FormApplication (or SceneConfig or BaseEntitySheet) 10 | [X] Field to give name to scene 11 | [X] Button to select import PNG 12 | [/] Button to select import JSON - need to account for multiple possible import dialogs at once 13 | [X] Field to set grid pixel size 14 | [X] Default grid pixel size 70 15 | [X] Button to "Save Changes" ("Create One Page Dungeon") 16 | 17 | [ ] Dummy scene creation actions 18 | [X] Create new scene object 19 | [X] Define the scene background with a dummy image artifact 20 | [X] Define the scene dimensions according to the image dimensions 21 | [X] Define the grid size based on a constant 22 | [X] Add a square wall system in the middle of the map 23 | [X] Set the lighting to dynamic (default) 24 | [ ] Set the default camera view to center the map 25 | 26 | [X] Smart scene creation actions 27 | [X] Set scene background to imported PNG 28 | [X] Set grid size dimension 29 | [X] Create walls from JSON 30 | 31 | --- 32 | 33 | Feature improvements: 34 | 35 | [X] Add (some) instructions to the Import form (/u/revgizmo) 36 | [X] Offset the walls from the art for some visual padding (/u/revgizmo) 37 | [X] Add door support using JSON data 38 | [ ] Add note support using JSON data 39 | [X] Default scene name from JSON data (/u/revgizmo) 40 | [ ] Call out u/baileywiki and the Token Attacher module for extra awesome 41 | - https://www.reddit.com/r/FoundryVTT/comments/lw872j/foundry_module_highlight_onepage_dungeons_watabou/ 42 | 43 | -------------------------------------------------------------------------------- /docs/demo_video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TarkanAl-Kazily/one-page-parser/1fb551f50f64ebd9df074473f401a8365a4bd7a1/docs/demo_video.gif -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "one-page-parser", 3 | "name": "one-page-parser", 4 | "title": "One Page Parser", 5 | "description": "Import instant scenes from One Page Dungeon", 6 | "author": "Tarkan Al-Kazily", 7 | "authors": [ { "name": "Tarkan Al-Kazily"} ], 8 | "version": "0.7.0", 9 | "minimumCoreVersion": "0.8.6", 10 | "compatibleCoreVersion" : "12", 11 | "esmodules": [ 12 | "scripts/one_page_parser.js", 13 | "scripts/hello.js" 14 | ], 15 | "compatibility": { 16 | "minimum": "0.8.6", 17 | "verified": "12" 18 | }, 19 | "url": "https://github.com/TarkanAl-Kazily/one-page-parser", 20 | "manifest": "https://github.com/TarkanAl-Kazily/one-page-parser/releases/download/v0.7.0/module.json", 21 | "download": "https://github.com/TarkanAl-Kazily/one-page-parser/archive/refs/tags/v0.7.0.zip" 22 | } 23 | -------------------------------------------------------------------------------- /scripts/hello.js: -------------------------------------------------------------------------------- 1 | import { OnePageParser } from "./one_page_parser.js" ; 2 | 3 | Hooks.once("init", function() { 4 | console.log("OnePageParser | Init"); 5 | 6 | // Setup global One Page Parser 7 | window.onePageParser = window.onePageParser || new OnePageParser(); 8 | 9 | Hooks.on( 10 | "renderSceneDirectory", 11 | (app, html, data) => { 12 | console.log("OnePageParser | Hook to add button to SceneDirectory"); 13 | 14 | // one-page-parser-actions: My class 15 | // action-buttons and flexrow: FoundryVTT class to format like a button 16 | window.onePageParser.importButton = $( 17 | `
18 | 19 |
` 20 | ); 21 | 22 | window.onePageParser.importButton.click(() => { 23 | console.log("OnePageParser | importButton click"); 24 | window.onePageParser.importButtonClicked(); 25 | }); 26 | 27 | html.find(".header-actions").after(window.onePageParser.importButton); 28 | } 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /scripts/one_page_parser.js: -------------------------------------------------------------------------------- 1 | const WALL_OFFSET = 0.25; 2 | 3 | // The OnePageParser class 4 | export class OnePageParser { 5 | // Button to open UI to import a dungeon 6 | importButton; 7 | 8 | importButtonClicked() { 9 | console.log("OnePageParser | importButtonClicked"); 10 | let form = new OnePageParserForm({}); 11 | form.render(true); 12 | } 13 | } 14 | 15 | class MatrixMap { 16 | // for fast checking 17 | matrix; 18 | // for fast iterating 19 | list; 20 | 21 | constructor() { 22 | this.matrix = {}; 23 | this.list = []; 24 | } 25 | 26 | get(x, y) { 27 | return this.matrix[x] && this.matrix[x][y]; 28 | } 29 | 30 | put(x, y) { 31 | if (!this.matrix[x]) { 32 | this.matrix[x] = {}; 33 | } 34 | this.matrix[x][y] = true; 35 | this.list.push([x, y]); 36 | } 37 | 38 | addRect(rect) { 39 | for (let x = rect.x; x < rect.x + rect.w; x++) { 40 | for (let y = rect.y; y < rect.y + rect.h; y++) { 41 | this.put(x, y); 42 | } 43 | } 44 | } 45 | 46 | getWalls() { 47 | let walls = []; 48 | this.list.forEach(p => { 49 | let x = p[0]; 50 | let y = p[1]; 51 | 52 | if (!this.get(x, y-1)) { 53 | walls.push([x, y, x+1, y]); 54 | } 55 | 56 | if (!this.get(x, y+1)) { 57 | walls.push([x, y+1, x+1, y+1]); 58 | } 59 | 60 | if (!this.get(x-1, y)) { 61 | walls.push([x, y, x, y+1]); 62 | } 63 | 64 | if (!this.get(x+1, y)) { 65 | walls.push([x+1, y, x+1, y+1]); 66 | } 67 | }); 68 | return walls; 69 | } 70 | 71 | getProcessedWalls() { 72 | let walls = this.getWalls(); 73 | let keys = [[], []]; 74 | let sorting = [{}, {}]; 75 | walls.forEach(w => { 76 | if (w[1] == w[3]) { 77 | if (!sorting[0][w[1]]) { 78 | sorting[0][w[1]] = []; 79 | keys[0].push(w[1]); 80 | } 81 | sorting[0][w[1]].push(w); 82 | } else { 83 | if (!sorting[1][w[0]]) { 84 | sorting[1][w[0]] = []; 85 | keys[1].push(w[0]); 86 | } 87 | sorting[1][w[0]].push(w); 88 | } 89 | }); 90 | 91 | let result = []; 92 | 93 | // Do for both x and y. For y, shift indexing points by 1 94 | for (let i = 0; i < 2; i++) { 95 | keys[i].forEach(k => { 96 | // Sort heap by starting time 97 | let heap = sorting[i][k]; 98 | heap.sort((a, b) => a[i] > b[i] ? 1 : -1); 99 | // Add first element to the stack 100 | let stack = []; 101 | stack.push(heap[0]); 102 | heap.forEach(wall => { 103 | if (wall[i] > stack[stack.length - 1][i+2]) { 104 | // new wall starts after current segment ends, so push to stack 105 | stack.push(wall); 106 | } else if (stack[stack.length - 1][i+2] < wall[i+2]) { 107 | // new wall is longer than current segment, so lengthen wall 108 | stack[stack.length - 1][i+2] = wall[i+2]; 109 | } else { 110 | // else wall is contained inside current segment 111 | } 112 | }); 113 | stack.forEach(wall => result.push(wall)); 114 | }); 115 | } 116 | 117 | // For every wall coordinate, offset it into the open space (away from the filled tiles) 118 | result.forEach((wall, index, list) => { 119 | for (let p = 0; p < 2; p++) { 120 | let x = wall[2 * p]; 121 | let y = wall[2 * p + 1]; 122 | 123 | // get grid: 124 | let subgrid = [[false, false], [false, false]]; 125 | let parity = 0; 126 | for (let i = 0; i < 2; i++) { 127 | for (let j = 0; j < 2; j++) { 128 | subgrid[i][j] = this.get(x-1 + i, y-1 + j); 129 | if (subgrid[i][j]) { 130 | parity += 1; 131 | } 132 | } 133 | } 134 | // if outside corner case, switch to equivalent inside corner case 135 | if (parity == 1) { 136 | subgrid = [ 137 | [!subgrid[1][1], !subgrid[1][0]], 138 | [!subgrid[0][1], !subgrid[0][0]], 139 | ] 140 | } 141 | 142 | // find the inside corner to shift the wall toward 143 | let inside_corner = []; 144 | for (let i = 0; i < 2; i++) { 145 | for (let j = 0; j < 2; j++) { 146 | if (!subgrid[i][j]) { 147 | inside_corner = [i, j]; 148 | } 149 | } 150 | } 151 | 152 | result[index][2 * p] = x + (inside_corner[0] == 0 ? -WALL_OFFSET : WALL_OFFSET); 153 | result[index][2 * p + 1] = y + (inside_corner[1] == 0 ? -WALL_OFFSET : WALL_OFFSET); 154 | } 155 | }); 156 | 157 | return result; 158 | } 159 | 160 | } 161 | 162 | // DOOR TYPES 163 | const DOOR_TYPE_EMPTY = 0; 164 | const DOOR_TYPE_SINGLE_DOOR = 1; 165 | const DOOR_TYPE_OPENING = 2; 166 | const DOOR_TYPE_STAIR_ENTRANCE = 3; 167 | const DOOR_TYPE_BARS = 4; 168 | const DOOR_TYPE_DOUBLE_DOOR = 5; 169 | const DOOR_TYPE_SECRET_WALL = 6; 170 | const DOOR_TYPE_FLUSH_DOOR = 7; 171 | const DOOR_TYPE_STAIR_EXIT = 8; 172 | 173 | // Helper function that converts a JSON door input to a wall (in map grid coordinates) 174 | function doorToWall(door) { 175 | let result = {}; 176 | result["c"] = [door["x"] - 0.75 * door["dir"]["y"], door["y"] - 0.75 * door["dir"]["x"], door["x"] + 0.75 * door["dir"]["y"], door["y"] + 0.75 * door["dir"]["x"]]; 177 | result["c"] = result["c"].map(p => p + 0.5); 178 | if (door["type"] == DOOR_TYPE_SECRET_WALL || 179 | door["type"] == DOOR_TYPE_FLUSH_DOOR) { 180 | result["c"] = [result["c"][0] - WALL_OFFSET * door["dir"]["x"], 181 | result["c"][1] - WALL_OFFSET * door["dir"]["y"], 182 | result["c"][2] - WALL_OFFSET * door["dir"]["x"], 183 | result["c"][3] - WALL_OFFSET * door["dir"]["y"]]; 184 | } 185 | result["door"] = CONST.WALL_DOOR_TYPES.DOOR; 186 | if (door["type"] == DOOR_TYPE_SECRET_WALL) { 187 | result["door"] = CONST.WALL_DOOR_TYPES.SECRET; 188 | } 189 | if (door["type"] == DOOR_TYPE_BARS) { 190 | result["sense"] = CONST.WALL_SENSE_TYPES.NONE; 191 | result["ds"] = CONST.WALL_DOOR_STATES.LOCKED; 192 | } 193 | if (door["type"] == DOOR_TYPE_DOUBLE_DOOR) { 194 | result["ds"] = CONST.WALL_DOOR_STATES.LOCKED; 195 | } 196 | if (door["type"] == DOOR_TYPE_EMPTY || 197 | door["type"] == DOOR_TYPE_OPENING || 198 | door["type"] == DOOR_TYPE_STAIR_ENTRANCE || 199 | door["type"] == DOOR_TYPE_STAIR_EXIT) { 200 | result["remove"] = true; 201 | } 202 | return result; 203 | } 204 | 205 | class OnePageParserForm extends FormApplication { 206 | constructor(options) { 207 | super(options); 208 | console.log("OnePageParser | OnePageParserForm constructor"); 209 | } 210 | 211 | // overrides superclass 212 | static get defaultOptions() { 213 | const options = super.defaultOptions; 214 | options.title = "OnePageParser Import Scene"; 215 | options.template = "modules/one-page-parser/templates/one-page-parser-form.html"; 216 | options.editable = true; 217 | options.width = 450; 218 | options.height = "auto"; 219 | options.classes = ["one-page-parser"]; 220 | return options; 221 | } 222 | 223 | // must override - abstract function 224 | _updateObject(event, formData) { 225 | const promiseResult = new Promise(async (resolve, reject) => { 226 | // The parser logic 227 | // Tries to create a new scene from the info in the form. 228 | // On success, can call 'resolve' with a helpful message. 229 | // On failure, calls 'reject' with an error message. 230 | 231 | // TODO Find right filelist that contains the formData.json-file 232 | const fileList = $("#one-page-parser-json")[0].files; 233 | 234 | let validData = true; 235 | 236 | if (isNaN(formData.grid)) { 237 | ui.notifications.error("Grid Size must be a number"); 238 | validData = false; 239 | } 240 | 241 | if (formData.grid < 50) { 242 | ui.notifications.error("Grid Size must be at least 50"); 243 | validData = false; 244 | } 245 | 246 | if (fileList.length != 1) { 247 | ui.notifications.error("Must import a JSON file"); 248 | validData = false; 249 | } else { 250 | await readTextFromFile(fileList[0]).then(json => formData.json = json); 251 | } 252 | 253 | await $.get(formData.img).fail( () => { 254 | ui.notifications.error("Background Image file not found"); 255 | validData = false; 256 | }); 257 | 258 | if (validData) { 259 | try { 260 | this.updateScene(formData); 261 | ui.notifications.info("Imported Scene"); 262 | resolve("Imported Scene"); 263 | } catch (error) { 264 | reject(error); 265 | } 266 | } else { 267 | reject("Form data is not valid. See error notifications."); 268 | } 269 | }); 270 | return promiseResult; 271 | } 272 | 273 | async updateScene(formData) { 274 | const loader = new TextureLoader(); 275 | const texture = await loader.loadTexture(formData.img); 276 | 277 | let info = await JSON.parse(formData.json); 278 | let map = new MatrixMap(); 279 | 280 | info["rects"].forEach(r => map.addRect(r)); 281 | 282 | try { 283 | const newScene = await Scene.create({ 284 | name: formData.name == "" ? info["title"]: formData.name, 285 | grid: { 286 | size: formData.grid 287 | }, 288 | background: { 289 | src: formData.img 290 | }, 291 | height: texture.height, 292 | width: texture.width, 293 | padding: 0, 294 | fogExploration: true, 295 | tokenVision: true, 296 | }); 297 | let g = formData.grid; 298 | let walls = map.getProcessedWalls().map(m => m.map(v => v*g)).map(m => { return {c : m} }); 299 | 300 | // Gets rid of doors that aren't associated with walls 301 | let doors = info["doors"].map(d => doorToWall(d)).filter(d => !d.remove); 302 | // doors can spawn on the border of the map, so we need extra logic to find final offsets 303 | doors = doors.map(d => { 304 | d["c"] = d["c"].map(v => v*g); 305 | return d; 306 | }); 307 | 308 | // Creates all the walls 309 | walls = walls.concat(doors); 310 | 311 | let minvals = [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; 312 | walls.forEach(w => { 313 | minvals[0] = Math.min(minvals[0], w.c[0], w.c[2]); 314 | minvals[1] = Math.min(minvals[1], w.c[1], w.c[3]); 315 | }); 316 | 317 | // Find the effective top left corner coordinate of the map 318 | let min_tile = [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; 319 | // Find the list of tiles assuming the left and top edges 320 | let min_tile_pos = [ [], [] ]; 321 | 322 | info["rects"].forEach(r => { 323 | if (r.x < min_tile[0]) { 324 | min_tile[0] = Math.min(min_tile[0], r.x); 325 | min_tile_pos[0] = [r.y]; 326 | } else if (r.x == min_tile[0]) { 327 | min_tile_pos[0].push(r.y); 328 | } 329 | 330 | if (r.y < min_tile[1]) { 331 | min_tile[1] = r.y; 332 | min_tile_pos[1] = [r.x]; 333 | } else if (r.y == min_tile[1]) { 334 | min_tile_pos[1].push(r.x); 335 | } 336 | }); 337 | 338 | let x_edge_has_tile = true; 339 | // Check all the rectangles on the left side 340 | min_tile_pos[0].forEach(r => { 341 | // if is not door, then x_edge_has_tile = false 342 | let matches = info["doors"].filter(d => (d.x == min_tile[0] && d.y == r)); 343 | if (matches.length == 0) { 344 | x_edge_has_tile = false; 345 | } 346 | }); 347 | 348 | let y_edge_has_tile = true; 349 | // Check all the rectangles on the top side 350 | min_tile_pos[1].forEach(r => { 351 | // if is not door, then y_edge_has_tile = false 352 | let matches = info["doors"].filter(d => (d.x == r && d.y == min_tile[1])); 353 | if (matches.length == 0) { 354 | y_edge_has_tile = false; 355 | } 356 | }); 357 | 358 | // If a door (usually stairs) spawns on the left or top side of the map, move all the walls 359 | let x_offset = (x_edge_has_tile) ? -0.25 * g : 0.75 * g ; 360 | let y_offset = (y_edge_has_tile) ? -0.25 * g : 0.75 * g ; 361 | 362 | walls = walls.map(w => { 363 | w.c = [w.c[0] - minvals[0] + x_offset, 364 | w.c[1] - minvals[1] + y_offset, 365 | w.c[2] - minvals[0] + x_offset, 366 | w.c[3] - minvals[1] + y_offset]; 367 | return w; 368 | }); 369 | 370 | await newScene.createEmbeddedDocuments("Wall", walls, {noHook: false}); 371 | 372 | // Dynamic Width (Build Regex) - https://stackoverflow.com/a/51506718 373 | const wrap = (s, w) => s.replace( 374 | new RegExp(`(?![^\\n]{1,${w}}$)([^\\n]{1,${w}})\\s`, 'g'), '$1\n' 375 | ); 376 | 377 | await newScene.createEmbeddedDocuments("Note", info["notes"].map(d => { 378 | const txt = wrap(d["text"], 24); 379 | return { 380 | x : d["pos"]["x"] * g - minvals[0] + x_offset, 381 | y : d["pos"]["y"] * g - minvals[1] + y_offset, 382 | text: txt, 383 | iconTint: "#FF0010", 384 | textColor: "#FF0010", 385 | }; 386 | }), {noHook: false}); 387 | 388 | if (formData.debug) { 389 | console.log("Debug enabled"); 390 | await newScene.createEmbeddedDocuments("Drawing", info["doors"].map(d => { 391 | return { 392 | type: CONST.DRAWING_TYPES.RECTANGLE, 393 | author: game.user._id, 394 | x : (d["x"] + 0.5) * g, 395 | y : (d["y"] + 0.5) * g, 396 | width : g * 1.5, 397 | height : g * 1.5, 398 | text: d["type"], 399 | strokeColor: "#FF0000", 400 | textColor: "#FF0000", 401 | }; 402 | }), {noHook: false}); 403 | } 404 | } catch (error) { 405 | ui.notifications.error(error); 406 | console.log("OnePageParser | Error creating scene"); 407 | } 408 | 409 | } 410 | 411 | } 412 | -------------------------------------------------------------------------------- /templates/one-page-parser-form.html: -------------------------------------------------------------------------------- 1 |
2 |

Import from One Page Dungeon by right-clicking your One 3 | Page Dungeon to select "Export PNG" and "Export JSON", and upload the files here.

4 |
5 |
6 |
7 |
8 |
9 |
10 | 11 |
12 | 13 |
14 | 16 |
17 |
18 | 19 |
20 | 21 |
22 | 23 |
24 |
25 | 26 |
27 | 28 |
29 | 30 |
31 |
32 | 33 | 41 | 42 | 43 | 46 |
--------------------------------------------------------------------------------