diff --git a/README.md b/README.md index d9ebfc2..14840ef 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,30 @@ -# foundry_pushable -Pushable tokens for FoundryVTT. +# Pushable Tokens For FoundryVTT + +## Notice:πŸ’“ Updated to Foundry V13! + +Sum117: My changes aim to update this package to v13 foundry, using the new methods implemented in the new version. Not much has changed, just the way collision checks work. + +If you want a foundry module revived, contact me through discord: masoria and I'll see what I can do. + +If you like my work, please consider supporting me: https://ko-fi.com/sum117 + +Thank you [Leah Azure](https://github.com/LeahAzure) for the Foundry V12 update. + +--- System agnostic. + +## Installation + To set up, install the manifest below (or use the default Foundry installer), add some tokens, edit them (1) and mark them as pushable(2) and/or pullable(3). ![image](https://user-images.githubusercontent.com/8543541/160937714-1cc164bb-ee06-4bb7-a6c5-78081b15a387.png) Install manually by adding the module manifest: -https://github.com/oOve/pushable/releases/latest/download/module.json - -Version 1.1.1, -* tokens can now also be pulled. Default key for this is P. Hold down P, and move away from a token. -* Mark tokens individually as pushable, and/or pullable -* Configure Settings: - * "Enable pull as well", enables pulling of tokens, tokens must individually be marked as pullable. - * "Maximum pushed tokens" set the number of tokens that can be maximum pushed and pulled. Set to -1 if there is no limit. -* Configure Controls: - * The key that enables pulling +``` +https://github.com/sum117/pushable/releases/download/10.0.4/module.json +``` ## Localization Current support for: @@ -35,9 +42,8 @@ If you want to translate this module, download [this file](lang/en.json) and tra } ` -## Demo: +## Demo + [![Sokoban puzzle using pushable tokens](http://img.youtube.com/vi/FOMEqN03SUU/0.jpg)](http://www.youtube.com/watch?v=FOMEqN03SUU "Sokoban video puzzle") -Do you like this module?; then please support me at [Patreon](https://www.patreon.com/drO_o). -Would you like to show off your creations or ask questions about the module feel free to drop in at my [discord channel](https://discord.gg/5CCAhsKFDp).Β  diff --git a/module.json b/module.json index 846e2b3..fd7406f 100644 --- a/module.json +++ b/module.json @@ -1,61 +1,77 @@ { - "name": "pushable", - "id": "pushable", - "title": "Pushable tokens", - "description": "Adds tokens that can be pushed around, respecting walls, and that will stop movement if you cannot push anything further.", - "version": "10.0.2", - "compatibility": { - "minimum": "10", - "verified": "10"}, - "author": "o_Ove", - "authors": [{ + "name": "pushable", + "id": "pushable", + "title": "Pushable Tokens", + "description": "Adds tokens that can be pushed around, respecting walls, and that will stop movement if you cannot push anything further.", + "version": "10.0.4", + "compatibility": { + "minimum": "13", + "verified": "13" + }, + "author": "o_Ove", + "authors": [ + { "name": "o_Ove", "discord": "Ove#4315", "patreon": "https://www.patreon.com/drO_o" - }], - "languages": [ - { - "lang": "en", - "name": "English", - "path": "lang/en.json" - },{ - "lang": "pt-BR", - "name": "PortuguΓͺs Brasileiro", - "path": "lang/pt-BR.json" - },{ - "lang": "de", - "name": "deutsch", - "path": "lang/de.json" - },{ - "lang": "ja", - "name": "ζ—₯本θͺž", - "path": "lang/ja.json" - } - ], - "relationships": { - "requires": [ - {"id":"socketlib", "type": "module"} - ] }, - "socket": true, - "esmodules": ["./scripts/pushable.mjs"], - "url": "https://github.com/oOve/pushable", - "download": "https://github.com/oOve/pushable/archive/10.0.2.zip", - "manifest": "https://github.com/oOve/pushable/releases/latest/download/module.json", - - "license": "LICENSE", - "readme": "README.md", - "styles":[], - "packs": [ + { + "name": "Sum117", + "discord": "masoria", + "patreon": "https://ko-fi.com/sum117" + }, + { + "name": "Azure", + "discord": "AzureProject", + "patreon": "-" + } + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + }, + { + "lang": "pt-BR", + "name": "PortuguΓͺs Brasileiro", + "path": "lang/pt-BR.json" + }, + { + "lang": "de", + "name": "Deutsch", + "path": "lang/de.json" + }, + { + "lang": "ja", + "name": "ζ—₯本θͺž", + "path": "lang/ja.json" + } + ], + "relationships": { + "requires": [ { - "entity": "Actor", - "name": "pushable", - "label": "Pushable Tokens", - "path": "./packs/pushable.db", - "type": "Actor", - "system": "dnd5e" + "id": "socketlib", + "type": "module" } - ], - "library": false, - "minimumCoreVersion": 10 - } \ No newline at end of file + ] + }, + "socket": true, + "esmodules": ["./scripts/pushable.mjs"], + "url": "https://github.com/sum117/pushable", + "download": "https://github.com/sum117/pushable/archive/refs/tags/10.0.4.zip", + "manifest": "https://github.com/sum117/pushable/releases/download/10.0.4/module.json", + "license": "LICENSE", + "readme": "README.md", + "styles": [], + "packs": [ + { + "type": "Actor", + "name": "pushable", + "label": "Pushable Tokens", + "path": "./packs/pushable.db", + "system": "dnd5e" + } + ], + "library": false +} diff --git a/scripts/pushable.mjs b/scripts/pushable.mjs index 0edc63b..23ce9e0 100644 --- a/scripts/pushable.mjs +++ b/scripts/pushable.mjs @@ -1,16 +1,3 @@ -/* -β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„ β–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆ β–’β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ -β–’β–ˆβ–ˆβ–€ β–ˆβ–ˆβ–Œβ–“β–ˆβ–ˆ β–’ β–ˆβ–ˆβ–’ β–’β–ˆβ–ˆβ–’ β–ˆβ–ˆβ–’ -β–‘β–ˆβ–ˆ β–ˆβ–Œβ–“β–ˆβ–ˆ β–‘β–„β–ˆ β–’ β–’β–ˆβ–ˆβ–‘ β–ˆβ–ˆβ–’ -β–‘β–“β–ˆβ–„ β–Œβ–’β–ˆβ–ˆβ–€β–€β–ˆβ–„ β–’β–ˆβ–ˆ β–ˆβ–ˆβ–‘ -β–‘β–’β–ˆβ–ˆβ–ˆβ–ˆβ–“ β–‘β–ˆβ–ˆβ–“ β–’β–ˆβ–ˆβ–’ β–ˆβ–ˆβ–“ β–‘ β–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘ - β–’β–’β–“ β–’ β–‘ β–’β–“ β–‘β–’β–“β–‘ β–’β–“β–’ β–‘ β–’β–‘β–’β–‘β–’β–‘ - β–‘ β–’ β–’ β–‘β–’ β–‘ β–’β–‘ β–‘β–’ β–‘ β–’ β–’β–‘ - β–‘ β–‘ β–‘ β–‘β–‘ β–‘ β–‘ β–‘ β–‘ β–‘ β–’ - β–‘ β–‘ β–‘ β–‘ β–‘ - β–‘ β–‘ - */ - const MOD_NAME = "pushable"; const LAMBDA = 5; @@ -20,28 +7,20 @@ function Lang(k){ let pushable_socket; Hooks.once("socketlib.ready", () => { - // socketlib is activated, lets register our function moveAsGM - pushable_socket = socketlib.registerModule("pushable"); - pushable_socket.register("moveAsGM", doMoveAsGM); + pushable_socket = socketlib.registerModule("pushable"); + pushable_socket.register("moveAsGM", doMoveAsGM); }); - function doMoveAsGM(updates){ canvas.scene.updateEmbeddedDocuments('Token', updates, {pushable_triggered:true}); } - -/** - * Display a text above a token - * @param {*} token A token object - * @param {String} text The text to display above the token - */ - function scrollText(token, text){ +function scrollText(token, text){ let config = { x: token.x, y: token.y, text: text, - anchor: CONST.TEXT_ANCHOR_POINTS.TOP, + anchor: foundry.CONST.TEXT_ANCHOR_POINTS.TOP, fill: "#FFFFFF", stroke: "#FFFFFF" } @@ -49,7 +28,6 @@ function doMoveAsGM(updates){ } function rectIntersect(x1, y1, w1, h1, x2, y2, w2, h2) { - // Check x and y for overlap return !(x2 > w1 + x1 || x1 > w2 + x2 || y2 > h1 + y1 || y1 > h2 + y2); } @@ -58,24 +36,23 @@ function isPushable(token){ token.flags?.pushable?.isPushable; } -// Return list of tokens overlapping 'token' function find_collisions(token){ - let x1=token.x+LAMBDA; - let y1=token.y+LAMBDA; - let w1=token.hitArea.x + token.hitArea.width -(LAMBDA*2); - let h1=token.hitArea.y + token.hitArea.height-(LAMBDA*2); + let x1 = token.x + LAMBDA; + let y1 = token.y + LAMBDA; + let w1 = token.w - (LAMBDA * 2); + let h1 = token.h - (LAMBDA * 2); let collisions = []; for (let tok of canvas.tokens.placeables){ if (tok.id != token.id){ - let x2=tok.x; - let y2=tok.y; - let w2=tok.hitArea.x + tok.hitArea.width; - let h2=tok.hitArea.y + tok.hitArea.height; - - if (rectIntersect(x1, y1, w1, h1, x2, y2, w2, h2)){ - collisions.push(tok); - } + let x2 = tok.x; + let y2 = tok.y; + let w2 = tok.w; + let h2 = tok.h; + + if (rectIntersect(x1, y1, w1, h1, x2, y2, w2, h2)){ + collisions.push(tok); + } } } return collisions; @@ -83,201 +60,157 @@ function find_collisions(token){ function duplicate_tk(token){ return { - id:token.id, - x: token.x, - y: token.y, - flags: { pushable:{isPushable: isPushable(token)}}, - hitArea: token.hitArea, - _id:token.id - }; -} - -// Does the centerpoint of 'token' collide with wall if moved along 'direction' -function collides_with_wall(token, direction){ - let cx = token.x + (token.hitArea.width/2); - let cy = token.y + (token.hitArea.height/2); - let ray = new Ray({x:cx, y:cy}, {x:cx+direction.x, y:cy+direction.y}); - return canvas.walls.checkCollision(ray, {type:'move',mode:'any'}); + id: token.id, + x: token.x, + y: token.y, + flags: { pushable: { isPushable: isPushable(token) } }, + w: token.w, + h: token.h, + _id: token.id + }; } +function collides_with_wall(token, direction) { + const origin = token.center; + const target = new PIXI.Point(origin.x + direction.x, origin.y + direction.y); -// Tests the candidate moved token (in its new position) coming via "direction" -// Recursively tests new candidates after this move + // ClockwiseSweepPolygon implements _testCollision in v13 (PointSourcePolygon is now abstract) + return foundry.canvas.geometry.ClockwiseSweepPolygon.testCollision(origin, target, { + type: "move", + mode: "any" + }); +} function candidate_move(token, direction, updates, depth){ let pushlimit = game.settings.get('pushable', 'max_depth'); - if((depth > pushlimit+1)&&(pushlimit>0)){return false;} - - let result = {valid: true}; + if ((depth > pushlimit + 1) && (pushlimit > 0)) { return false; } + + let result = { valid: true }; let colls = find_collisions(token); - // Exit early to avoid doing sqrt - if (colls.length==0){return result;} - - - let len = Math.sqrt(direction.x**2+direction.y**2); - let dir = {x:direction.x/len, y:direction.y/len}; + if (colls.length == 0){ return result; } + let len = Math.sqrt(direction.x**2 + direction.y**2); + let dir = { x: direction.x / len, y: direction.y / len }; let wePushable = isPushable(token); for (let coll_obj of colls){ - let nx=coll_obj.x; - let ny=coll_obj.y; - + let nx = coll_obj.x; + let ny = coll_obj.y; let collPushable = isPushable(coll_obj); - - // Are we the pushable, in that case we can't be pushed onto a non-pushble, if that setting is enabled - if (wePushable && !collPushable ){ + + if (wePushable && !collPushable){ if (game.settings.get(MOD_NAME, 'collideWithNonPushables')){ - return { - valid: false, - reason: "CantPushEntity" - } + return { valid: false, reason: "CantPushEntity" }; } else { - // Don't push the non-pushable in that case continue; } } - // Are we not a pushable, then we can move through (into) a non-pushable - if (!wePushable && !collPushable ){ - continue; - } - - if (direction.x){ // dir.x != 0 - // positive : negative - nx = (direction.x>0)?( token.x + token.hitArea.width ):( nx = token.x - coll_obj.hitArea.width); + if (!wePushable && !collPushable){ continue; } + + if (direction.x){ + nx = (direction.x > 0) ? (token.x + token.w) : (token.x - coll_obj.w); } - if (direction.y){ // Positive : Negative - ny = (direction.y>0)?( token.y + token.hitArea.height ):( token.y - coll_obj.hitArea.height); + if (direction.y){ + ny = (direction.y > 0) ? (token.y + token.h) : (token.y - coll_obj.h); } - let new_dir = {x: nx-coll_obj.x, y: ny-coll_obj.y}; - - // Does this "new_dir" take coll_obj through a wall? + + let new_dir = { x: nx - coll_obj.x, y: ny - coll_obj.y }; + if (collides_with_wall(coll_obj, new_dir)){ - return { - valid: false, - reason: "CantPushWall" - }; + return { valid: false, reason: "CantPushWall" }; } - - updates.push({_id:coll_obj.id, id:coll_obj.id, x:nx, y:ny}); + + updates.push({ _id: coll_obj.id, id: coll_obj.id, x: nx, y: ny }); if (overLimit(updates)){ - return {valid:false, reason: 'CantPushMax'}; + return { valid: false, reason: 'CantPushMax' }; } - + let candidate_token = duplicate_tk(coll_obj); candidate_token.x += new_dir.x; candidate_token.y += new_dir.y; - let res = candidate_move(candidate_token, new_dir, updates, depth+1); + let res = candidate_move(candidate_token, new_dir, updates, depth + 1); result.valid &= res.valid; - if (!res.valid){ - result.reason = res.reason; - } + if (!res.valid){ result.reason = res.reason; } } return result; } -// Returns a token overlapping point 'p', return null if none exists. function tokenAtPoint(p){ for (let tok of canvas.tokens.placeables){ - if (p.x > tok.x && - p.x < tok.x+tok.hitArea.width && - p.y > tok.y && - p.y < tok.y+tok.hitArea.height){ - return tok; - } + if (p.x > tok.x && p.x < tok.x + tok.w && p.y > tok.y && p.y < tok.y + tok.h){ + return tok; + } } return null; } -// Find candidate token to be pulled in direction 'direction' function checkPull(token, direction, updates){ - // l is the length of the direction vector - let l = Math.sqrt( direction.x**2 + direction.y**2 ); - // nv is the normalized direction vector - let nv = {x:direction.x/l, y:direction.y/l}; - let center = {x: token.x + token.hitArea.width/2, y: token.y + token.hitArea.height/2}; - let pull_from = {x: center.x - token.hitArea.width *nv.x, - y: center.y - token.hitArea.height*nv.y }; - let ray = new Ray(pull_from, center); - if (canvas.walls.checkCollision(ray, {type:'move',mode:'any'})){ - return {valid:false, reason: "CantPull"}; + let l = Math.sqrt(direction.x**2 + direction.y**2); + let nv = { x: direction.x / l, y: direction.y / l }; + let center = { x: token.x + token.w / 2, y: token.y + token.h / 2 }; + let pull_from = { x: center.x - token.w * nv.x, y: center.y - token.h * nv.y }; + let ray = new Ray(new PIXI.Point(pull_from.x, pull_from.y), new PIXI.Point(center.x, center.y)); + if (foundry.canvas.geometry.ClockwiseSweepPolygon.testCollision(new PIXI.Point(pull_from.x, pull_from.y), new PIXI.Point(center.x, center.y), { type: "move", mode: "any" })) { + return { valid: false, reason: "CantPull" }; } - - // We also need to check if there are other tokens, than the "puller" at the destination + let colls = find_collisions(token); if (colls.length && game.settings.get(MOD_NAME, 'collideWithNonPushables')){ - return { valid:false, reason: "CantPullEntity" } + return { valid: false, reason: "CantPullEntity" }; } - + let pulle = tokenAtPoint(pull_from); if (pulle){ if (pulle.document.getFlag(MOD_NAME, 'isPullable')){ - updates.push({id:pulle.id, x: pulle.x+direction.x, y: pulle.y+direction.y, _id:pulle.id}); - } - else{ - return {valid: false, reason: "CantPull"}; + updates.push({ id: pulle.id, x: pulle.x + direction.x, y: pulle.y + direction.y, _id: pulle.id }); + } else { + return { valid: false, reason: "CantPull" }; } } - return {valid: true}; + return { valid: true }; } - -function showHint(token, hint, isError=true){ +function showHint(token, hint, isError = true){ if (game.settings.get(MOD_NAME, "showHints")){ scrollText(token, hint); } } -function overLimit( updates ){ + +function overLimit(updates){ let limit = game.settings.get(MOD_NAME, 'max_depth'); - let valid = (limit<0) || (updates.length <= limit); - return !valid; + return !(limit < 0 || updates.length <= limit); } +Hooks.on('preUpdateToken', (token, change, options, user_id) => { + if (foundry.utils.hasProperty(options, 'pushable_triggered')) return true; + if (!foundry.utils.hasProperty(change, 'x') && !foundry.utils.hasProperty(change, 'y')) return true; - -// Hook into token movemen. Push 'pushables' along with this movement, and cancel movement if pushing is not possible -Hooks.on('preUpdateToken', (token, change, options, user_id)=>{ - if (hasProperty(options, 'pushable_triggered')){ return true; } // We don't need to pre-evaluate already approved moves. - if (!hasProperty(change,'x')&&!(hasProperty(change, 'y'))){return true;} - - let nx = (hasProperty(change, 'x'))?(change.x):(token.x); - let ny = (hasProperty(change, 'y'))?(change.y):(token.y); - let direction = {x:nx-token.x, y: ny-token.y}; - let tok=canvas.tokens.get(token.id); - + let nx = foundry.utils.hasProperty(change, 'x') ? change.x : token.x; + let ny = foundry.utils.hasProperty(change, 'y') ? change.y : token.y; + let direction = { x: nx - token.x, y: ny - token.y }; + let tok = canvas.tokens.get(token.id); let token_after_move = duplicate_tk(tok); token_after_move.x = nx; token_after_move.y = ny; - let res = {valid:true}; + let res = { valid: true }; let updates = []; if (game.settings.get("pushable", "pull")){ let pulling = false; - let pk=game.keybindings.get("pushable", 'pull_key'); - for (let k of pk){ - pulling = pulling || keyboard.downKeys.has(k.key); - } + let pk = game.keybindings.get("pushable", 'pull_key'); + for (let k of pk){ pulling ||= game.keyboard.downKeys.has(k.key); } if (pulling){ - let res = checkPull(tok, direction, updates); - if (!res.valid ){ - showHint(tok, Lang(res.reason)); - } + let result = checkPull(tok, direction, updates); + if (!result.valid){ showHint(tok, Lang(result.reason)); } } } res = candidate_move(token_after_move, direction, updates, 1); - if(!res.valid){ - showHint( tok, Lang(res.reason)); - } - if (res.valid && updates.length){ - // This move is valid. Execute our updates as GM - pushable_socket.executeAsGM("moveAsGM",updates); - } + if (!res.valid){ showHint(tok, Lang(res.reason)); } + if (res.valid && updates.length){ pushable_socket.executeAsGM("moveAsGM", updates); } - return (res.valid==true)||game.user.isGM; + return res.valid || game.user.isGM; }); - -// Settings: Hooks.once("init", () => { game.settings.register("pushable", "pull", { name: Lang('PullTitle'), @@ -314,62 +247,40 @@ Hooks.once("init", () => { game.keybindings.register("pushable", "pull_key", { name: Lang('PullKey'), hint: Lang("PullKeyHint"), - editable: [ - { - key: 'KeyP' - } - ], + editable: [{ key: 'KeyP' }], restricted: false, - precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL + precedence: foundry.CONST.KEYBINDING_PRECEDENCE.NORMAL }); - }); - - function createCheckBox(app, fields, data_name, title, hint){ const label = document.createElement('label'); label.textContent = title; const input = document.createElement("input"); - input.name = 'flags.'+MOD_NAME+'.' + data_name; + input.name = 'flags.' + MOD_NAME + '.' + data_name; input.type = "checkbox"; input.title = hint; - - if (app.token.getFlag(MOD_NAME, data_name)){ - input.checked = "true"; - } - + input.checked = !!app.token.getFlag(MOD_NAME, data_name); fields.append(label); fields.append(input); } - -// Hook into the token config render Hooks.on("renderTokenConfig", (app, html) => { if (!game.user.isGM) return; - // Create a new form group const formGroup = document.createElement("div"); - formGroup.classList.add("form-group"); - formGroup.classList.add("slim"); - - // Create a label for this setting + formGroup.classList.add("form-group", "slim"); const label = document.createElement("label"); label.textContent = Lang("Pushable"); formGroup.prepend(label); - // Create a form fields container const formFields = document.createElement("div"); formFields.classList.add("form-fields"); formGroup.append(formFields); createCheckBox(app, formFields, 'isPushable', Lang('Pushable'), ''); createCheckBox(app, formFields, 'isPullable', Lang('Pullable'), ''); - - // Add the form group to the bottom of the Identity tab - html[0].querySelector("div[data-tab='character']").append(formGroup); - // Set the apps height correctly + html.querySelector("footer.form-footer").before(formGroup); app.setPosition(); }); -