Merge pull request #48 from Oliver-Akins/dev

Merge the dev branch into main
This commit is contained in:
Oliver 2025-07-14 19:49:56 -06:00 committed by GitHub
commit 2a4ba73934
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 7705 additions and 69 deletions

View file

@ -29,10 +29,6 @@ jobs:
if: ${{ steps.check-tag.outputs.exists == 'true' }}
run: exit 1
- name: Ensure there are specific files to release
if: ${{ vars.files_to_release == '' }}
run: exit 1
- name: Move system.json to a temp file
id: manifest-move
run: mv system.json module.temp.json
@ -42,7 +38,7 @@ jobs:
run: cat module.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/release.zip"' > system.json
- name: Create the zip
run: zip -r release.zip ${{ vars.files_to_release }}
run: zip -r release.zip system.json module langs assets templates README.md
- name: Create the draft release
uses: ncipollo/release-action@v1
@ -50,5 +46,6 @@ jobs:
tag: "v${{ steps.version.outputs.version }}"
commit: ${{ github.ref }}
draft: true
body: <img aria-hidden="true" src="https://img.shields.io/github/downloads/${{ github.repository }}/v${{ steps.version.outputs.version }}/release.zip?style=flat-square&color=%2300aa00">
generateReleaseNotes: true
artifacts: "release.zip,system.json"

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
dist/
*.link
*.txt
# Dependency directories
node_modules/

18
.vscode/foundry.html-data.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"version": 1.1,
"globalAttributes": [
{ "name": "data-tooltip", "description": "The content for the tooltip to display" },
{ "name": "data-tooltip-direction", "description": "The direction that the tooltip renders in, in relation to the element that has the tooltip", "valueSet": "tooltip-direction" }
],
"valueSets": [
{
"name": "tooltip-direction",
"values": [
{ "name": "UP", "description": "Put the tooltip above the element" },
{ "name": "LEFT", "description": "Put the tooltip to the left of the element" },
{ "name": "RIGHT", "description": "Put the tooltip to the right of element" },
{ "name": "DOWN", "description": "Put the tooltip below the element" }
]
}
]
}

45
.vscode/ripcrypt.html-data.json vendored Normal file
View file

@ -0,0 +1,45 @@
{
"version": 1.1,
"tags": [
{
"name": "rc-icon",
"description": "Loads an icon asynchronously, caching the result for future uses",
"attributes": [
{ "name": "name", "description": "The name of the icon, this is relative to the assets folder of the system" },
{ "name": "path", "description": "The full path of the icon, this will only be used if `name` isn't provided or fails to fetch." },
{ "name": "var:size", "description": "The size of the icon, must be a valid CSS unit" },
{ "name": "var:fill", "description": "The fill of the icon, must be a valid CSS colour" },
{ "name": "var:stroke", "description": "The stroke colour of the icon, must be a valid CSS colour" },
{ "name": "var:stroke-width", "description": "The stroke width of the icon, must be a valid CSS unit" },
{ "name": "var:stroke-linejoin", "description": "The stroke linejoin of the icon, must be a valid CSS value" }
]
},
{
"name": "rc-svg",
"description": "Loads an icon asynchronously, caching the result for future uses",
"attributes": [
{ "name": "name", "description": "The name of the icon, this is relative to the assets folder of the system" },
{ "name": "path", "description": "The full path of the icon, this will only be used if `name` isn't provided or fails to fetch." },
{ "name": "var:size", "description": "The size of the icon, must be a valid CSS unit" },
{ "name": "var:fill", "description": "The fill of the icon, must be a valid CSS colour" },
{ "name": "var:stroke", "description": "The stroke colour of the icon, must be a valid CSS colour" },
{ "name": "var:stroke-width", "description": "The stroke width of the icon, must be a valid CSS unit" },
{ "name": "var:stroke-linejoin", "description": "The stroke linejoin of the icon, must be a valid CSS value" }
]
},
{
"name": "rc-border",
"description": "Creates a stylized border in the same sort of design that the published RipCrypt book uses",
"attributes": [
{ "name": "var:vertical-displacement", "description": "How much vertical displacement the title receives, defaults to 12.5px" },
{ "name": "var:padding", "description": "How much padding the border container has" },
{ "name": "var:border-color", "description": "The CSS value that is used as the colour of the border" },
{ "name": "var:padding-top", "description": "How much padding the top of the border element has, if not provided, defaults to the value of vertical displacement plus 4px" },
{ "name": "var:margin-top", "description": "How much margin the top of the border element has, if not provided, defaults to the value of vertical displacement" },
{ "name": "var:border-mask", "description": "The CSS colour used to mask out the border element, if not provided defaults to the --base-background CSS variable"},
{ "name": "var:title-height", "description": "The CSS height for the title, defaults to 20px" },
{ "name": "var:title-background", "description": "The CSS colour to make the title element, defaults to var:border-color" }
]
}
]
}

15
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
"files.exclude": {
"**/node_modules": true
},
"search.exclude": {
"foundry.*.link": true
},
"html.customData": [
"./.vscode/foundry.html-data.json",
"./.vscode/ripcrypt.html-data.json"
],
"workbench.editorAssociations": {
"*.svg": "default",
}
}

View file

@ -1,2 +1,21 @@
# Foundry-RipCrypt
An implementation of the RipCrypt TTRPG System for FoundryVTT
# RipCrypt - A Dungeon Sprint RPG
RipCrypt is a fast, pick-up and play, tabletop RPG for 1-8 players.
# Features:
## Themes
The RipCrypt system comes with a dark and light theme for **everything** within the system!
All you need to do is select whichever theme you like more!
## Hero Sheets
This system comes with multiple different Actor sheets for players
to make your experience, _yours_.
Some of the sheets included by default are:
- Summary Card
- Skill Card
- Craft Card
- Full Sheet (Includes Summary Card & Skill Card)
- Tabbed Full Sheet (Includes Summary Card, Skill Card, and Craft Card as tabs within a single sheet)
## Geist Sheets

5
assets/README.md Normal file
View file

@ -0,0 +1,5 @@
Some of the assets provided in this repository may be Creative Commons by Attribution 3, or they may have some other license.
Oliver Akins does not grant any permission to use these assets outside of what the licenses allow. Make sure that anything you do with these assets is within the permitted usage of their respective licenses if they are outside of this repository.
For a detailed overview of what icons have what licenses, please see [the credit file](_credit.txt).

28
assets/_credit.txt Normal file
View file

@ -0,0 +1,28 @@
Oliver Akins:
- geist-silhouette.v2.svg : All rights reserved.
- caster-silhouette.v1.svg : All rights reserved.
- icons/star-empty.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
- icons/star.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
Kýnan Antos (Gritsilk Games):
- hero-silhouette.svg : Licensed to Distribute and Modify within the bounds of the "Foundry-RipCrypt" system.
ARISO:
- icons/hourglass.svg (https://thenounproject.com/icon/hourglass-7546736/) : Rights Purchased
Abdulloh Fauzan:
- icons/info-circle.svg (https://thenounproject.com/icon/information-4176576/) : Rights Purchased
QOLBIN SALIIM:
- icons/arrow-left.svg (https://thenounproject.com/icon/arrow-1933583/) : Rights Purchased
- icons/arrow-right.svg (https://thenounproject.com/icon/arrow-1933581/) : Rights Purchased
- icons/arrow-compass.svg (https://thenounproject.com/icon/arrow-2052607/) : Rights Purchased
Soetarman Atmodjo:
- icons/roll.svg (https://thenounproject.com/icon/dice-5195278/) : Rights Purchased
SuperNdre:
- icons/edit.svg (https://thenounproject.com/icon/edit-5208207/) : Rights Purchased
YANDI RS:
- icons/d8-outline.svg (https://thenounproject.com/icon/d8-7272826/) : Rights Purchased

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 124 45"><path d="M57.684.377c-.627-.016-1.63.187-2.633 1.111-1.003.925-.169 1.578-.485 1.569-.549-.016-1.913 1.268-3.511 2.992s-1.222 4.388-1.05 5.14c.173.753.987 1.317.987 1.317s-.218-.972-.297-1.897c-.078-.924.392-1.833 1.27-3.322.877-1.489 1.746-1.92 1.746-1.92s-.367 1.026-.531 1.41-.448 2.813.242 3.409.407.572.258 1.16c-.15.587 0 .79.525 1.465.525.673.47 1.582.611 2.632s.323.948 1.67.885c1.348-.063 1.159.714.446 1.043-.713.33-.508.49-.065 1.377s.654 1.287-1.418 1.242c-6.249-.133-21.563 9.817-25.441 9.684s-9.995-2.105-10.86-2.504c-.864-.399-2.66-1.062-3.435-1.129-.776-.066-1.662-.532-2.77-1.086s-4.084-.338-4.084-.338-.232-.399-.398-.715-.322-.476-.787-.338c-.466.139.021 1.053.021 1.053s-.537.082-.78-.056c-.245-.139-.333-.45-.644-.893-.31-.443-.287-.73-.93-.441-.642.288.2 1.373.2 1.373s-.2.022-.775.244c-.577.221-.178.974-.178.974s-.865-.665-.998-1.086c-.133-.42-.421-.953-1.086-.554s.665 1.884.576 1.884-.53-.154-1.062-.597-.866-.644-1.309-.422.31 1.041 1.219 1.861c.908.82 1.64 1.197 3.258 1.397 1.617.2 2.24 1.196 3.28 1.152 1.042-.044 2.06-.62 4.698-.265 2.637.354 8.51 3.9 8.953 4.232s.466.643.488 1.02c.023.376-.377 1.862-.421 2.216-.045.355-.975 1.153-1.75 1.907-.776.753-1.33 1.86-1.33 2.28 0 .422-.887 1.663-.887 1.663s23.27.489 24.156.467c.887-.023 3.325-2.194 4.522-3.258 1.196-1.064 2.989-3.073 3.283-3.7.294-.626.33-.364.58-.05.25.313.345.203.69.234.344.032.218.032.25.502.03.47.753 1.52 1.505 2.256.752.737 1.88 2.852 2.334 4.106.455 1.253 21.03.753 20.592.22s-1.207-2.978-1.285-3.605c-.079-.627.25-2.036.25-2.506s-.313-1.553-.094-1.568c.22-.016 4.922 6.66 6.176 7.914 1.253 1.253 18.396.392 19.117.377.72-.016.627.313.58-1.223s-1.034-3.574-1.394-4.373-.064-.861.265-1.002c.33-.141 2.273-.847 3.244-1.254s3.87-1.347 5.438-1.582c.863-.13 1.983-.43 3.12-.78.559-.15 1.682-.535 4.089-1.574 3.071-1.325 5.214-3.823 6.363-4.906.265-.22.818-.84 1.746-2.033s.442-.949-.154-.97c-.597-.023-2.498 1.81-2.498 1.81s.044-1.326-.22-2.873c-.266-1.547-.73-.617-.84-.264-.111.354-.067 1.768-.067 1.768s-.774-1.68-.973-1.68-.508.75-.154 1.436c.353.685.441 1.79.441 1.79s-.44-.332-1.015-1.591c-.575-1.26-.795-.132-.729.398s.883 1.613.883 2.188c0 .574-.486.95-1.502.927s-.796-.619-.553-1.26c.243-.64.73-1.944.752-2.519.022-.574-.509-1.28-.885-1.258-.375.022-.309.574-.574 2.01s-2.365 2.475-3.426 3.094c-.827.483-.957.818-1.257 1.152-.578.456-1.311.876-1.684.793-.564-.125-.565.095-.91.408-.345.314-1.221.532-3.133.94 0 0-2.79.345-3.824.408s-2.07-.156-2.916-.752c-.846-.595-1.88-1.004-4.543-.283-2.664.72-4.17.564-4.17.564s-.39-1.512-1.785-3.306-2.586-2.186-2.586-2.186.54-.07 1.55-.125 1.898.548 2.446.877c.548.33.728 1.417.814 1.723.087.305-.148.855-.015.87s.478-1.12.478-1.41-.345-1.286-.455-1.513-.729-.744-.588-.877.698.063 1.207.51c.51.446.791.775.862 2.1.07 1.324-.893 2.09-.698 2.287.196.195 1.584-1.535 1.569-3s-1.05-2.703-1.912-3.495c-.862-.791-2.328-1.066-2.313-1.207.016-.14.385-.556.377-1.425-.008-.87-.745-1.968-1.027-2.336-.282-.369-1.41-1.018-1.15-1.057.258-.04.837.188 1.746.705.908.517 1.396 1.762 1.56 1.691s-.165-1.05-.635-1.92-1.81-1.308-1.81-1.308.312-.163.94-1.096c.626-.932.046-1.7-.071-1.912-.118-.211-.822-.572-.328.04.493.61-.337 1.268-1.434 2.052-1.097.783-3.918.156-3.918.156s-.125.024 0-.916c.126-.94-.596-2.539-2.445-3.949-1.85-1.41-3.448-.282-6.645-.094s-3.95-2.757-5.861-6.174c-1.912-3.416-5.093-3.996-6.049-3.996S58.31.393 57.684.377m21.06 12.541a3.3 3.3 0 0 1 1.844.582c1.208.79 1.01 2.57 1.01 2.57s-1.325-.392-2.547-1.841c-.833-.988-2.155-1.223-2.155-1.223s.568-.069 1.202-.086c.227-.006.44-.01.646-.002m2.465 5.38c.124-.012.623.257 1.277.507.698.266 1.774.277 2.627.343.854.067 1.386.111 2.416 1.086s.287 2.371.287 2.371l-1.33-.056s.312-.388.534-.71c.221-.32.375-1.428-.467-1.76s-.366-.056-.3.52c.067.577-.73 1.075-1.405 1.075-.676 0-1.496-.598-2.205-1.24-1.197-.998-1.531-2.072-1.454-2.127a.04.04 0 0 1 .02-.008" style="paint-order:markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -0,0 +1,3 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 60 144">
<path d="M60.09,49.6c-.29-.54-.81-.99-1.07-1.53-.97-2.08-1.89-4.18-2.78-6.28-.37-.88-1.1-1.83.44-2.51.24-.11.37-.72.29-1.05-.23-.96-.61-1.88-.88-2.83-.52-1.84-1.24-3.66-1.46-5.53-.52-4.34-1.83-8.17-6.3-10.68-1.25-.7-2.31-1.83-3.11-2.97-.97-1.38-1.6-1.83-3.48-1.49-.29-.4-.45-.81-.78-1.02-1.36-.89-1.63-2.09-1.65-3.51-.02-1.32-.36-2.63-.6-3.94-.04-.24-.31-.46-.52-.64-.3-.26-.83-.41-.94-.71-.61-1.69-1.12-3.4-3.49-3.79-.11-.02-.22-.19-.28-.3-.51-1.05-1.37-.9-2.34-.59-3.21,1-5.22,2.99-6.34,5.82-1.37,3.46-2.41,6.97-2.63,10.64-.08,1.31-.71,1.97-2.14,2.43-1.98.64-3.59,1.64-3.97,3.88-.42,2.42-1.38,4.77-2.08,7.15-.12.42-.39,1.06-.18,1.25,1.07.99.2,1.81-.11,2.69-.83,2.34-1.82,4.65-2.45,7.03-.45,1.71-.4,3.53-.62,5.29-.53,4.33-1.81,8.55-1.96,12.97-.14,4.25.08,8.59-1.55,12.74-.19.48.21,1.43.69,1.76,1.49,1.03,3.14,1.87,5.12,3.01-.4,2.52-.71,5.51-1.38,8.44-.94,4.14-2.42,8.2-3.12,12.37-.53,3.14-.17,6.4-.27,9.61-.03,1.05-.28,2.1-.44,3.28.92-.11,1.6-.18,2.58-.3-.48,6.28,2.18,12.34.72,18.67-.52-.28-.84-.46-1.59-.86,0,.98.08,1.65-.01,2.29-.3,2.08-.07,4.41-2.28,5.82-1.65,1.06-3.67,1.66-5.24,2.79-.96.69-1.98,2.04-1.88,3,.14,1.33,1.95.79,3.08.92.05,0,.11-.01.17,0,5.11.57,9.73-1.66,14.66-2.09.87-.08,1.71-.39,2.56-.6,1.03-.26,1.42-.75,1.08-1.76-.56-1.65-.99-3.33-1.42-5.01-.12-.46-.26-1.08-.03-1.41.66-.92.43-1.74.1-2.67-.26-.74-.48-1.56-.4-2.32.32-2.89.65-5.79,1.2-8.64.73-3.72,2.31-7.32,1.8-11.22-.04-.32.81-.95,1.34-1.06,1.61-.32,3.27-.42,4.91-.66,2.23-.32,2.22-.32,2.34,1.77.08,1.43.24,2.86.39,4.55,2.42-2.14,2.42-2.08,4.57-2.2,1.19-.06,1.48.36,1.65,1.32.31,1.69.81,3.35,1.26,5.02.36,1.33.96,2.63,1.08,3.97.28,2.96.34,5.94.47,8.91.02.43.06.93-.15,1.29-.75,1.29-.5,3.23.78,4.32-.65,1.24-1.07,2.71-2.05,3.77-1.38,1.5-1.01,2.91-.47,4.43.4,1.12,1.29,1.47,2.58,1.34,2.89-.28,5.26-1.46,7.18-3.34.48-.47.81-1.17.96-1.8.49-2.05.88-4.11,1.29-6.18.13-.66.52-1.75.23-1.94-1.69-1.09-1.5-2.23-.52-3.56.13-.18.07-.48.07-.73.06-2.74.24-5.49.14-8.22-.1-2.66,1.19-5.27.19-7.98-.42-1.13.1-2.22,1.26-3,.33-.22.53-.61,1.07-1.26-1.18.25-1.84.39-2.49.53,1.97-.86,2.88-2.03,2.75-4.03-.49-8.08-.9-16.17-1.28-24.26-.06-1.35.14-2.71.22-4.06,1.23-.08,2.28-.12,3.33-.22,1.51-.14,2.32-.78,2.44-2.26.25-3.12.71-6.23,1.03-9.35.4-3.78.77-7.57,1.1-11.36.04-.45-.16-.94-.38-1.36ZM20.83,53.69s-.02.07-.04.1c-.09.25-.19.51-.3.76,0,.02-.02.05-.03.07-.11.26-.23.52-.35.78,0,.02-.01.03-.02.05-.13.26-.26.53-.41.79,0,0,0,.02-.01.03-.15.27-.3.54-.46.8,0,0,0,0,0,0-.16.27-.33.54-.5.81,0,0,0,0,0,0-.78,2.16-1.73,4.74-2.02,5.77,0,0,0,0,0,0h0c-.1-.02-.2-.04-.3-.06-.05-.01-.1-.02-.15-.03.41-1.63.81-3.25,1.22-4.88.02,0,.04,0,.06.01.39-1.47.76-2.86.94-3.57.03,0,.09,0,.15,0,.34-1.59.67-3.18,1-4.75.43-2.06.86-4.1,1.29-6.09h0s0,.02,0,.02c.11.29.2.58.29.87.03.12.06.24.09.35.05.18.1.36.14.54.03.15.05.29.08.44.03.15.06.3.08.44.02.17.04.33.06.49.01.13.03.26.04.38.01.18.02.36.03.54,0,.11.01.22.01.33,0,.19,0,.39-.01.58,0,.09,0,.19,0,.28-.01.21-.03.41-.05.62,0,.08-.01.16-.02.24-.03.22-.06.43-.1.65-.01.07-.02.14-.03.2-.04.23-.09.45-.14.68-.01.06-.02.11-.04.17-.06.24-.13.47-.2.71-.01.04-.02.09-.04.13-.08.25-.16.49-.25.73ZM50.47,56.5c-.74-1.25-1.47-2.51-2.22-3.76-.63-1.04-1.17-2.14-1.96-3.08-1.85-2.2-1.27-3.65.9-6.38,2.41,4.15,2.71,8.69,3.65,13.09-.13.04-.25.09-.38.13Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m38.418 8.5469 59.746 36.863c2.2617 1.2695 2.4023 7.9102 0 9.3203l-59.746 36.863c-51.27 31.781-51.129-115.39 0-83.051z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m41.461 8.6953-41.461 39.906c14.129 13.355 28.105 26.707 42.234 40.062 2.6406 2.6406 8.2305-1.707 8.2305-5.4336v-15.684c44.875 7.918 49.379 27.176 49.535 24.379 2.1758-50.621-49.535-56.523-49.535-61.801v-15.219c0-4.1914-5.9023-9.1602-9.0078-6.2109z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m58.539 8.6953 41.461 39.906c-14.129 13.355-28.105 26.707-42.234 40.062-2.6406 2.6406-8.2305-1.707-8.2305-5.4336v-15.684c-44.875 7.918-49.379 27.176-49.535 24.379-2.1758-50.621 49.535-56.523 49.535-61.801v-15.219c0-4.1914 6.0547-9.1602 9.0078-6.2109z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m51.176 1.332 20.191 11.656 0.11719 0.074219 20.094 11.602c0.73438 0.42188 1.1445 1.1914 1.1484 1.9844h0.007813v46.699c0 0.92578-0.55078 1.7266-1.3438 2.0898l-20.027 11.562 0.003907 0.007812-20.223 11.672c-0.74219 0.43359-1.6328 0.39453-2.3203-0.015626l-20.191-11.656-0.11719-0.074218-20.094-11.602c-0.73438-0.42188-1.1445-1.1914-1.1484-1.9844h-0.007813v-46.699c0-0.92578 0.55078-1.7266 1.3438-2.0898l20.027-11.562-0.003907-0.007813 20.223-11.672c0.74219-0.43359 1.6328-0.39453 2.3203 0.015625zm4.4375 7.8516 16.27 28.18 0.003907-0.003906 16.262 28.168v-37.562l-18.945-10.938-0.12891-0.070313zm26.922 66.09h-65.066l13.332 7.6953 0.12891 0.070312 19.074 11.012 19.074-11.012 0.003906 0.007813 13.457-7.7695zm-70.676-9.7461 16.262-28.168 0.070312-0.12109 16.199-28.055-13.461 7.7734-0.003907-0.007812-19.07 11.012v37.562zm56.066-25.879-17.918-31.035-17.852 30.918-0.070312 0.12891-17.914 31.027h71.66l-17.914-31.027 0.003906-0.003906-0.003906-0.007812z"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

4
assets/icons/edit.svg Normal file
View file

@ -0,0 +1,4 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m8.332 12.5c0-2.3008 1.8672-4.168 4.168-4.168h37.5v8.3359h-33.332v66.664h66.664v-33.332h8.3359v37.5c0 2.3008-1.8672 4.168-4.168 4.168h-75c-2.3008 0-4.168-1.8672-4.168-4.168z" fill-rule="evenodd"/>
<path d="m75.305 8.332c1.1055 0 2.1641 0.44141 2.9453 1.2227l12.195 12.195c1.6289 1.625 1.6289 4.2656 0 5.8906l-36.586 36.586c-0.6875 0.6875-1.5977 1.1133-2.5664 1.2031l-13.414 1.2188c-1.2344 0.11328-2.4492-0.32813-3.3242-1.2031s-1.3164-2.0898-1.2031-3.3242l1.2188-13.414c0.089844-0.96875 0.51563-1.8789 1.2031-2.5664l36.586-36.586c0.78125-0.78125 1.8398-1.2227 2.9453-1.2227zm-5.082 15.145 6.3008 6.3008 5.0859-5.082-6.3047-6.3047zm0.41016 12.195-6.3047-6.3047-21.594 21.598-0.63281 6.9336 6.9336-0.63281z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 822 B

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m73.391 98.984h-46.781c-2.2578 0-4.3125-0.92188-5.8008-2.4102-1.4844-1.4844-2.4102-3.5391-2.4102-5.8008 0-2.2578 0.92578-4.3125 2.4102-5.8008 1.2383-1.2383 2.8672-2.0859 4.6836-2.3359v-10.438c0-6.7422 2.7578-12.867 7.1953-17.309 2.0117-2.0117 4.3711-3.6797 6.9766-4.8984-2.6055-1.2227-4.9648-2.8906-6.9766-4.8984-4.4414-4.4414-7.1953-10.566-7.1953-17.309v-10.438c-1.8164-0.25-3.4453-1.0938-4.6836-2.3359-1.4844-1.4844-2.4102-3.5391-2.4102-5.8008 0-2.2578 0.92578-4.3125 2.4102-5.8008 1.4844-1.4844 3.5391-2.4102 5.8008-2.4102h46.781c2.2578 0 4.3125 0.92188 5.8008 2.4102 1.4844 1.4844 2.4102 3.5391 2.4102 5.8008 0 2.2578-0.92187 4.3125-2.4102 5.8008-1.2383 1.2383-2.8672 2.0859-4.6836 2.3359v10.438c0 6.7422-2.7578 12.867-7.1953 17.309-2.0117 2.0117-4.3711 3.6797-6.9766 4.8984 2.6055 1.2227 4.9648 2.8906 6.9766 4.8984 4.4414 4.4414 7.1953 10.566 7.1953 17.309v10.438c1.8164 0.25 3.4453 1.0938 4.6836 2.3359 1.4844 1.4844 2.4102 3.5391 2.4102 5.8008 0 2.2578-0.92187 4.3125-2.4102 5.8008-1.4844 1.4844-3.5391 2.4102-5.8008 2.4102zm-3.4766-22.914c-6.7383 1.1562-11.543-0.99219-16.391-3.1602-6.0977-2.7227-12.277-5.4883-23.434 0.40625v9.2539h39.824zm-39.824-52.137c6.7383-1.1562 11.543 0.99219 16.391 3.1602 6.0977 2.7227 12.277 5.4883 23.434-0.40625v-9.2539h-39.824z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m85.938 26.562c0-3.3164-1.3164-6.4961-3.6602-8.8398s-5.5234-3.6602-8.8398-3.6602h-46.875c-3.3164 0-6.4961 1.3164-8.8398 3.6602s-3.6602 5.5234-3.6602 8.8398v46.875c0 3.3164 1.3164 6.4961 3.6602 8.8398s5.5234 3.6602 8.8398 3.6602h46.875c3.3164 0 6.4961-1.3164 8.8398-3.6602s3.6602-5.5234 3.6602-8.8398zm-45.312 23.438v-9.375h14.062v28.125h4.6875v9.375h-14.062v-28.125zm9.375-28.125c3.4492 0 6.25 2.8008 6.25 6.25s-2.8008 6.25-6.25 6.25-6.25-2.8008-6.25-6.25 2.8008-6.25 6.25-6.25z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

5
assets/icons/roll.svg Normal file
View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 83 95" xmlns="http://www.w3.org/2000/svg" version="1.1">
<g>
<path id="svg_1" d="m77.95688,69.37862c0,-1.375 1.1133,-2.4883 2.4883,-2.4883s2.4883,1.1133 2.4883,2.4883l0,0.53516c0,1.375 -1.1133,2.4883 -2.4883,2.4883s-2.4883,-1.1133 -2.4883,-2.4883l0,-0.53516zm-49.355,-34.766c1.4609,-1.4648 3.832,-1.4648 5.2969,0c1.4609,1.4609 1.4609,3.832 0,5.2969c-1.4648,1.4609 -3.832,1.4609 -5.2969,0c-1.4648,-1.4648 -1.4648,-3.832 0,-5.2969zm20.453,20.453c1.4648,-1.4609 3.832,-1.4609 5.2969,0c1.4648,1.4648 1.4648,3.832 0,5.2969c-1.4609,1.4648 -3.832,1.4648 -5.2969,0c-1.4609,-1.4609 -1.4609,-3.832 0,-5.2969zm-10.227,-10.227c1.4609,-1.4648 3.832,-1.4648 5.293,0c1.4648,1.4609 1.4648,3.832 0,5.293c-1.4609,1.4648 -3.832,1.4648 -5.293,0c-1.4648,-1.4609 -1.4648,-3.832 0,-5.293zm-9.8594,-20.359l25.016,0c2.8828,0 5.5039,1.1797 7.4062,3.082l0.00781,0.00781c1.9023,1.9023 3.082,4.5273 3.082,7.4062l0,25.016c0,2.8828 -1.1797,5.5039 -3.082,7.4062l-0.00781,0.00782c-1.9023,1.90229 -4.5273,3.08199 -7.4062,3.08199l-25.016,0c-2.8828,0 -5.5039,-1.1797 -7.4062,-3.08199l-0.00781,-0.00782c-1.9023,-1.9023 -3.082,-4.5273 -3.082,-7.4062l0,-25.016c0,-2.8828 1.1797,-5.5039 3.082,-7.4062l0.00781,-0.00781c1.9023,-1.9023 4.5273,-3.082 7.4062,-3.082zm25.016,5l-25.016,0c-1.5156,0 -2.8906,0.61719 -3.8867,1.6094c-0.99219,0.99609 -1.6094,2.375 -1.6094,3.8867l0,25.016c0,1.5156 0.61719,2.8906 1.6094,3.8867c0.99609,0.99219 2.375,1.6094 3.8867,1.6094l25.016,0c1.5156,0 2.8906,-0.61719 3.8867,-1.6094c0.99219,-0.99609 1.6094,-2.375 1.6094,-3.8867l0,-25.016c0,-1.5156 -0.61719,-2.8906 -1.6094,-3.8867c-0.99609,-0.99219 -2.375,-1.6094 -3.8867,-1.6094zm18.355,42.309c1.1914,-0.69141 2.7188,-0.28516 3.4062,0.91016c0.69141,1.1914 0.28516,2.7188 -0.91016,3.4062l-32.113,18.539c-0.84766,0.49219 -1.8672,0.42578 -2.6328,-0.08203l-38.828,-22.418c-0.80078,-0.46094 -1.25,-1.2969 -1.25,-2.1562l-0.01172,-45c0,-1.0117 0.60156,-1.8867 1.4688,-2.2773l38.766,-22.379c0.80859,-0.46484 1.7695,-0.42578 2.5195,0.02344l38.934,22.477c0.80078,0.46094 1.25,1.2969 1.25,2.1562l0,36.395c0,1.375 -1.1133,2.4883 -2.4883,2.4883s-2.4883,-1.1133 -2.4883,-2.4883l0,-34.961l-36.48,-21.062l-36.473,21.059l0,42.141l36.469,21.055l30.867,-17.82l-0.00522,-0.00647z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m50 89.988 0.011719 0.011719v-0.011719l10.82-5.4102c12.969-6.4883 21.129-19.711 21.129-34.199v-30.25c-18.762 0-31.699-9.9219-31.949-10.121v-0.007812s-13.199 10.129-31.969 10.129v30.25c0 14.488 8.1602 27.711 21.129 34.199l10.82 5.4102-0.003907 0.011719z"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M93.824 44.383 80.058 61.379a6.3 6.3 0 0 0-1.398 4.3l1.144 21.84c.2 3.802-3.578 6.548-7.133 5.18l-20.418-7.84a6.3 6.3 0 0 0-4.52 0L27.317 92.7c-3.551 1.364-7.332-1.382-7.133-5.18l1.145-21.84a6.3 6.3 0 0 0-1.399-4.3L6.163 44.383C3.77 41.426 5.21 36.985 8.886 36l21.125-5.66a6.3 6.3 0 0 0 3.656-2.656L45.577 9.34c2.07-3.192 6.742-3.192 8.816 0l11.91 18.344a6.3 6.3 0 0 0 3.657 2.656L91.085 36c3.675.985 5.128 5.43 2.734 8.387z" style="stroke-width: 8px; stroke: black; fill: transparent;"/></svg>

After

Width:  |  Height:  |  Size: 565 B

1
assets/icons/star.svg Normal file
View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M93.824 44.383 80.058 61.379a6.3 6.3 0 0 0-1.398 4.3l1.144 21.84c.2 3.802-3.578 6.548-7.133 5.18l-20.418-7.84a6.3 6.3 0 0 0-4.52 0L27.317 92.7c-3.551 1.364-7.332-1.382-7.133-5.18l1.145-21.84a6.3 6.3 0 0 0-1.399-4.3L6.163 44.383C3.77 41.426 5.21 36.985 8.886 36l21.125-5.66a6.3 6.3 0 0 0 3.656-2.656L45.577 9.34c2.07-3.192 6.742-3.192 8.816 0l11.91 18.344a6.3 6.3 0 0 0 3.657 2.656L91.085 36c3.675.985 5.128 5.43 2.734 8.387z"/></svg>

After

Width:  |  Height:  |  Size: 504 B

BIN
assets/turn-marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View file

@ -16,20 +16,25 @@ export default [
languageOptions: {
globals: {
CONFIG: `writable`,
CONST: `readonly`,
game: `readonly`,
Handlebars: `readonly`,
Hooks: `readonly`,
ui: `readonly`,
Actor: `readonly`,
Actors: `readonly`,
Item: `readonly`,
Items: `readonly`,
foundry: `readonly`,
ChatMessage: `readonly`,
ActiveEffect: `readonly`,
Dialog: `readonly`,
renderTemplate: `readonly`,
TextEditor: `readonly`,
fromUuid: `readonly`,
Combat: `readonly`,
Combatant: `readonly`,
canvas: `readonly`,
Token: `readonly`,
Tour: `readonly`,
},
},
},

11
jsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "ES2020",
"target": "ES2020"
},
"exclude": ["node_modules", "**/node_modules/*"],
"include": ["module/**/*", "foundry.v13.link/client/**/*.js", "foundry.v13.link/**/*.mjs"],
"typeAcquisition": {
"include": ["jquery"]
}
}

View file

@ -1,7 +1,217 @@
{
"TYPES": {
"Actor": {
"hero": "Hero"
"hero": "Hero",
"geist": "Geist"
},
"Item": {
"ammo": "Ammo",
"armour": "Armour",
"craft": "Craft",
"good": "Good",
"shield": "Shield",
"skill": "Skill",
"weapon": "Weapon"
}
},
"RipCrypt": {
"sheet-names": {
"AllItemsSheetV1": "RipCrypt Item Sheet",
"CombinedHeroSheet": "Hero Sheet",
"StatsCardV1": "Hero Stat Card",
"CraftCardV1": "Hero Craft Card",
"SkillsCardV1": "Hero Skill Card"
},
"app-titles": {
"AmmoTracker": "Ammo Tracker"
},
"common": {
"abilities": {
"grit": "Grit",
"gait": "Gait",
"grip": "Grip",
"glim": "Glim",
"thin-glim": "Thin Glim"
},
"ability": "Ability",
"access": "Access",
"accessLevels": {
"Common": "Common",
"Uncommon": "Uncommon",
"Rare": "Rare",
"Scarce": "Scarce"
},
"advances": "Advances",
"ammo": "Ammo",
"anatomy": {
"head": "Head",
"body": "Body",
"arms": "Arms",
"legs": "Legs"
},
"armour": "Armour",
"aspect": "Aspect",
"aspectNames": {
"flect": "Flect",
"fract": "Fract",
"focus": "Focus"
},
"aura": "Aura",
"cost": "Cost",
"currency": {
"gold": "Gold",
"silver": "Silver",
"copper": "Copper"
},
"damage": "Damage",
"delete": "Delete",
"description": "Description",
"details": "Details",
"difficulties": {
"easy": "Easy",
"normal": "Normal",
"tough": "Tough",
"hard": "Hard",
"random": "Random Condition"
},
"difficulty": "Difficulty",
"drag": "Drag",
"edit": "Edit",
"edge": "Edge",
"empty": "---",
"equipped": "Equipped",
"fate": "Fate",
"gear": "Gear",
"glimcraft": "Glimcraft",
"glory": "Glory",
"guts": "Guts",
"location": "Location",
"move": "Move",
"ordinals": {
"North": {
"full": "North",
"abbv": "N"
},
"East": {
"full": "East",
"abbv": "E"
},
"South": {
"full": "South",
"abbv": "S"
},
"West": {
"full": "West",
"abbv": "W"
}
},
"protection": "Protection",
"quantity": "Quantity",
"rank": "Rank",
"rankNames": {
"novice": "Novice",
"adept": "Adept",
"expert": "Expert",
"master": "Master"
},
"range": "Range",
"run": "Run",
"shield": "Shield",
"slot": "Slot",
"step": "Step",
"traits": "Traits",
"weapon": {
"singular": "Weapon",
"plural": "Weapons"
},
"wear": "Wear",
"weightRating": "Weight",
"weightRatings": {
"light": "Light",
"modest": "Modest",
"heavy": "Heavy"
}
},
"setting": {
"abbrAccess": {
"name": "Abbreviate Access Names",
"hint": "Shortens the Access level names the way the book does. (e.g. \"Common\" becomes \"C\")"
},
"condensedRange": {
"name": "Condense Weapon Range Input",
"hint": "With this enabled, the weapon range will be displayed as \"X / Y\" when editing a weapon. While disabled it will be as displayed as two different rows, one for Short Range and one for Long Range"
},
"sandsOfFateInitial": {
"name": "Sands of Fate Initial",
"hint": "What value should The Hourglass reset to when a Cryptic Event occurs"
},
"onCrypticEvent": {
"name": "Cryptic Event Alert",
"hint": "What happens when a cryptic event occurs by clicking the \"Next Delve Tour\" button in the HUD",
"options": {
"notif": "Notification",
"pause": "Pause Game",
"both": "Notification and Pause Game",
"nothing": "Do Nothing"
}
}
},
"Apps": {
"traits-range": "@RipCrypt.common.traits & @RipCrypt.common.range",
"grit-skills": "@RipCrypt.common.abilities.grit Skills",
"gait-skills": "@RipCrypt.common.abilities.gait Skills",
"grip-skills": "@RipCrypt.common.abilities.grip Skills",
"glim-skills": "@RipCrypt.common.abilities.glim Skills",
"a11y": {
"guts-value-edit": "The current amount of guts the character has",
"guts-value-readonly": "The current amount of guts the character has",
"guts-max-readonly": "The maximum amount of guts the character can have"
},
"traits-placeholder": "New Trait...",
"short-range": "Short @RipCrypt.common.range",
"long-range": "Long @RipCrypt.common.range",
"current-wear": "Current @RipCrypt.common.wear",
"max-wear": "Maximum @RipCrypt.common.wear",
"location-placeholder": "New Location...",
"numberOfDice": "# of Dice",
"rollTarget": "Target",
"difficulty": "(DC: {dc})",
"RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost.",
"starred-ammo-placeholder": "Starred Ammo Slot",
"AmmoTracker": {
"no-ammo": "You don't have any ammo!",
"star-button": "Add {name} as a starred ammo",
"star-button-tooltip": "Add Star",
"unstar-button": "Remove {name} as a starred ammo",
"unstar-button-tooltip": "Remove Star"
}
},
"notifs": {
"error": {
"cannot-equip": "Cannot equip the {itemType}, see console for more details.",
"invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action.",
"at-favourite-limit": "Cannot favourite more than three items, unfavourite one to make space."
},
"warn": {
"cannot-go-negative": "\"{name}\" is unable to be a negative number."
},
"info": {
"cryptic-event-alert": "A Cryptic Event Has Occured!"
}
},
"tooltips": {
"shield-bonus": "Shield Bonus: {value}",
"set-fate-to": "Set Fate to {ordinal}",
"current-tour": "Current Delve Tour",
"next-tour": "Next Delve Tour",
"prev-tour": "Previous Delve Tour",
"auras": {
"normal": "The distance of your aura normally",
"heavy": "The distance of your aura when using Heavycraft"
}
}
},
"USER": {
"GM": "Keeper"
}
}

View file

@ -0,0 +1,116 @@
import { CraftCardV1 } from "./CraftCardV1.mjs";
import { filePath } from "../../consts.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { SkillsCardV1 } from "./SkillsCardV1.mjs";
import { StatsCardV1 } from "./StatsCardV1.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--CombinedHeroSheet`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
actions: {},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
summary: {
template: filePath(`templates/Apps/StatsCardV1/content.hbs`),
},
skills: {
template: filePath(`templates/Apps/SkillsCardV1/content.hbs`),
},
craft: {
template: filePath(`templates/Apps/CombinedHeroSheet/crafts.hbs`),
},
};
// #endregion
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
const summaryElement = this.element.querySelector(`.StatsCardV1`);
StatsCardV1._onRender(
context,
{
...options,
element: summaryElement,
isEditable: this.isEditable,
},
);
const skillsElement = this.element.querySelector(`.SkillsCardV1`);
SkillsCardV1._createPopoverListeners.bind(this)();
SkillsCardV1._onRender.bind(this)(
context,
{
...options,
element: skillsElement,
isEditable: this.isEditable,
},
);
const craftsElement = this.element.querySelector(`.crafts-summary`);
CraftCardV1._onRender.bind(this)(
context,
{
...options,
element: craftsElement,
isEditable: this.isEditable,
},
);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
switch (partId) {
case `summary`: {
ctx = await StatsCardV1.prepareGuts(ctx);
ctx = await StatsCardV1.prepareWeapons(ctx);
ctx = await StatsCardV1.prepareArmor(ctx);
ctx = await StatsCardV1.prepareFatePath(ctx);
ctx = await StatsCardV1.prepareAbilityRow(ctx);
ctx = await StatsCardV1.prepareSpeed(ctx);
ctx = await StatsCardV1.prepareLevelData(ctx);
break;
};
case `skills`: {
ctx = await SkillsCardV1.prepareGear(ctx);
ctx = await SkillsCardV1.prepareAmmo(ctx);
ctx = await SkillsCardV1.prepareSkills(ctx);
break;
};
case `craft`: {
ctx = await CraftCardV1.prepareCraft(ctx);
break;
};
};
Logger.debug(`Context keys:`, Object.keys(ctx));
return ctx;
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,132 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { documentSorter, filePath } from "../../consts.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { ContextMenu } = foundry.applications.ux;
const { deepClone } = foundry.utils;
export class CraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--CraftCardV1`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
actions: {
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
content: {
template: filePath(`templates/Apps/CraftCardV1/content.hbs`),
},
};
// #endregion
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
CraftCardV1._onRender.bind(this)(context, options);
};
static async _onRender(_context, options) {
const {
element = this.element,
isEditable = this.isEditable,
} = options;
new ContextMenu(
element,
`[data-ctx-menu="craft"]`,
[
{
name: localizer(`RipCrypt.common.edit`),
condition: (el) => {
const itemId = el.dataset.itemId;
return isEditable && itemId !== ``;
},
callback: editItemFromElement,
},
{
name: localizer(`RipCrypt.common.delete`),
condition: (el) => {
const itemId = el.dataset.itemId;
return isEditable && itemId !== ``;
},
callback: deleteItemFromElement,
},
],
{ jQuery: false, fixed: true },
);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx = await CraftCardV1.prepareAura(ctx);
ctx = await CraftCardV1.prepareCraft(ctx);
Logger.debug(`Context:`, ctx);
return ctx;
};
static async prepareAura(ctx) {
ctx.aura = deepClone(ctx.actor.system.aura);
return ctx;
};
static async prepareCraft(ctx) {
ctx.craft = {};
const aspects = Object.values(gameTerms.Aspects);
const heroRank = ctx.actor.system.level.rank;
const embeddedCrafts = ctx.actor.itemTypes.craft;
const limit = 4;
for (const aspect of aspects) {
let crafts = [];
for (const craft of embeddedCrafts) {
if (craft.system.aspect !== aspect) { continue };
crafts.push({
uuid: craft.uuid,
name: craft.name,
sort: craft.sort,
use: craft.system.advances[heroRank],
});
};
// Ensure limit isn't surpassed
const length = crafts.length;
if (length >= limit) {
crafts = crafts.slice(0, limit);
} else {
crafts = crafts
.concat(Array(limit - length).fill(null))
.slice(0, limit);
};
ctx.craft[aspect] = crafts.sort(documentSorter);
}
return ctx;
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,212 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { documentSorter, filePath } from "../../consts.mjs";
import { AmmoTracker } from "../popovers/AmmoTracker.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { PopoverEventManager } from "../../utils/PopoverEventManager.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { ContextMenu } = foundry.applications.ux;
const { deepClone } = foundry.utils;
export class SkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--SkillsCardV1`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
actions: {
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
content: {
template: filePath(`templates/Apps/SkillsCardV1/content.hbs`),
},
};
// #endregion
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
SkillsCardV1._onRender.bind(this)(context, options);
SkillsCardV1._createPopoverListeners.bind(this)();
};
static async _onRender(_context, options) {
const {
element = this.element,
isEditable = this.isEditable,
} = options;
new ContextMenu(
element,
`[data-ctx-menu="gear"],[data-ctx-menu="skill"]`,
[
{
name: localizer(`RipCrypt.common.edit`),
condition: (el) => {
const itemId = el.dataset.itemId;
return isEditable && itemId !== ``;
},
callback: editItemFromElement,
},
{
name: localizer(`RipCrypt.common.delete`),
condition: (el) => {
const itemId = el.dataset.itemId;
return isEditable && itemId !== ``;
},
callback: deleteItemFromElement,
},
],
{ jQuery: false, fixed: true },
);
};
/** @this {SkillsCardV1} */
static async _createPopoverListeners() {
const ammoInfoIcon = this.element.querySelector(`.ammo-info-icon`);
const idPrefix = this.actor.uuid;
const manager = new PopoverEventManager(`${idPrefix}.ammo-info-icon`, ammoInfoIcon, AmmoTracker);
this._popoverManagers.set(`.ammo-info-icon`, manager);
this._hookIDs.set(Hooks.on(`prepare${manager.id}Context`, (ctx) => {
ctx.ammos = this.actor.itemTypes.ammo;
}), `prepare${manager.id}Context`);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx = await SkillsCardV1.prepareGear(ctx);
ctx = await SkillsCardV1.prepareAmmo(ctx);
ctx = await SkillsCardV1.prepareSkills(ctx);
ctx.aura = deepClone(ctx.actor.system.aura);
Logger.debug(`Context:`, ctx);
return ctx;
};
static async prepareGear(ctx) {
const limit = ctx.actor.system.limit.equipment;
ctx.gear = [];
const items = [...ctx.actor.items];
for (const item of items) {
if (!gameTerms.gearItemTypes.has(item.type)) { continue };
if (`equipped` in item.system && item.system.equipped) { continue };
ctx.gear.push({
index: ctx.gear.length,
uuid: item.uuid,
name: item.quantifiedName,
empty: false,
});
if (ctx.gear.length >= limit) { break };
};
if (ctx.gear.length < limit) {
for (let i = ctx.gear.length; i < limit; i++) {
ctx.gear.push({
index: ctx.gear.length,
uuid: ``,
name: ``,
empty: true,
});
};
};
return ctx;
};
static async prepareAmmo(ctx) {
let total = 0;
let favouriteCount = 0;
ctx.favouriteAmmo = new Array(3).fill(null);
for (const ammo of ctx.actor.itemTypes.ammo) {
total += ammo.system.quantity;
if (favouriteCount < 3 && ammo.getFlag(game.system.id, ItemFlags.FAVOURITE)) {
ctx.favouriteAmmo[favouriteCount] = {
uuid: ammo.uuid,
name: ammo.name,
quantity: ammo.system.quantity,
};
favouriteCount++;
};
};
ctx.ammo = total;
return ctx;
};
static async prepareSkills(ctx) {
ctx.skills = {};
const abilities = Object.values(gameTerms.Abilities);
const heroRank = ctx.actor.system.level.rank;
const embeddedSkills = ctx.actor.itemTypes.skill;
for (let ability of abilities) {
const skills = [];
for (const skill of embeddedSkills) {
if (skill.system.ability !== ability) { continue };
skills.push({
uuid: skill.uuid,
name: skill.name,
sort: skill.sort,
use: skill.system.advances[heroRank],
});
};
// Thin Glim is grouped with full glim.
if (ability === gameTerms.Abilities.THINGLIM) {
ability = gameTerms.Abilities.GLIM;
};
ctx.skills[ability] ??= [];
ctx.skills[ability].push(...skills);
};
const limit = ctx.actor.system.limit.skills;
for (const ability of abilities) {
if (ctx.skills[ability] == null) { continue };
const length = ctx.skills[ability].length;
if (length >= limit) {
ctx.skills[ability] = ctx.skills[ability].slice(0, limit);
} else {
ctx.skills[ability] = ctx.skills[ability]
.concat(Array(limit - length).fill(null))
.slice(0, limit);
};
// Sort the skills
ctx.skills[ability] = ctx.skills[ability].sort(documentSorter);
}
return ctx;
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,223 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { filePath } from "../../consts.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { ContextMenu } = foundry.applications.ux;
export class StatsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--StatsCardV1`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
actions: {
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
content: {
template: filePath(`templates/Apps/StatsCardV1/content.hbs`),
},
};
// #endregion
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
StatsCardV1._onRender.bind(this)(context, options);
};
static async _onRender(context, options) {
const {
element = this.element,
isEditable = this.isEditable,
} = options;
new ContextMenu(
element,
`[data-ctx-menu="weapon"],[data-ctx-menu="armour"]`,
[
{
name: localizer(`RipCrypt.common.edit`),
condition: (el) => {
const itemId = el.dataset.itemId;
return isEditable && itemId !== ``;
},
callback: editItemFromElement,
},
{
name: localizer(`RipCrypt.common.delete`),
condition: (el) => {
const itemId = el.dataset.itemId;
return isEditable && itemId !== ``;
},
callback: deleteItemFromElement,
},
],
{ jQuery: false, fixed: true },
);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx = await StatsCardV1.prepareGuts(ctx);
ctx = await StatsCardV1.prepareWeapons(ctx);
ctx = await StatsCardV1.prepareArmor(ctx);
ctx = await StatsCardV1.prepareFatePath(ctx);
ctx = await StatsCardV1.prepareAbilityRow(ctx);
ctx = await StatsCardV1.prepareSpeed(ctx);
ctx = await StatsCardV1.prepareLevelData(ctx);
Logger.debug(`Context:`, ctx);
return ctx;
};
static async prepareLevelData(ctx) {
ctx.level = {
glory: ctx.actor.system.level.glory,
step: ctx.actor.system.level.step,
rank: {
selected: ctx.actor.system.level.rank,
options: Object.values(gameTerms.Rank).map(rank => ({
label: `RipCrypt.common.rankNames.${rank}`,
value: rank,
})),
},
};
if (ctx.meta.limited) {
ctx.level.glory = `?`;
ctx.level.step = `?`;
ctx.level.rank.selected = `?`;
};
return ctx;
};
static async prepareFatePath(ctx) {
ctx.fate = {};
ctx.fate.selected = ctx.actor.system.fate;
ctx.fate.options = [
{ label: `RipCrypt.common.empty`, v: `` },
...Object.values(gameTerms.FatePath)
.map(v => ({ label: `RipCrypt.common.ordinals.${v}.full`, value: v })),
];
return ctx;
};
static async prepareAbilityRow(ctx) {
ctx.abilities = [];
for (const key in ctx.actor.system.ability) {
ctx.abilities.push({
id: key,
name: localizer(
`RipCrypt.common.abilities.${key}`,
{ value: ctx.actor.system.ability[key] },
),
value: ctx.meta.limited ? `?` : ctx.actor.system.ability[key],
readonly: !ctx.meta.editable,
});
};
return ctx;
};
static async prepareSpeed(ctx) {
ctx.speed = foundry.utils.deepClone(ctx.actor.system.speed);
if (ctx.meta.limited) {
ctx.speed = {
move: `?`,
run: `?`,
};
};
return ctx;
};
static async prepareArmor(ctx) {
ctx.armours = {};
const equipped = ctx.actor.system.equippedArmour;
const shield = ctx.actor.system.equippedShield;
const defenses = ctx.actor.system.defense;
for (const slot of Object.values(gameTerms.Anatomy)) {
const item = equipped[slot];
ctx.armours[slot] = {
name: item?.name ?? ``,
uuid: item?.uuid ?? ``,
defense: defenses[slot],
shielded: shield?.system.location.has(slot) ?? false,
};
};
ctx.shield = {
name: shield?.name ?? ``,
uuid: shield?.uuid ?? ``,
bonus: shield?.system.protection ?? 0,
};
return ctx;
};
static async prepareWeapons(ctx) {
const limit = ctx.actor.system.limit.weapons;
const embedded = ctx.actor.itemTypes.weapon;
ctx.weapons = [];
for (const item of embedded) {
if (!item.system.equipped) { continue };
const index = ctx.weapons.length;
ctx.weapons.push({
data: item,
empty: false,
index,
class: index % 2 === 1 ? `row-alt` : ``,
});
if (ctx.weapons.length >= limit) { break };
};
if (ctx.weapons.length < limit) {
for (let i = ctx.weapons.length; i < limit; i++) {
const itemIndex = ctx.weapons.length;
ctx.weapons.push({
data: null,
empty: true,
index: itemIndex,
class: itemIndex % 2 === 1 ? `row-alt` : ``,
});
};
};
return ctx;
};
static async prepareGuts(ctx) {
ctx.guts = foundry.utils.deepClone(ctx.actor.system.guts);
if (ctx.meta.limited) {
ctx.guts = {
value: `?`,
max: `?`,
};
};
return ctx;
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,251 @@
import { distanceBetweenFates, nextFate, previousFate } from "../utils/fates.mjs";
import { filePath } from "../consts.mjs";
import { gameTerms } from "../gameTerms.mjs";
import { localizer } from "../utils/Localizer.mjs";
import { Logger } from "../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { ContextMenu } = foundry.applications.ux;
const { FatePath } = gameTerms;
const CompassRotations = {
[FatePath.NORTH]: -90,
[FatePath.EAST]: 0,
[FatePath.SOUTH]: 90,
[FatePath.WEST]: 180,
};
const conditions = [
{ label: `RipCrypt.common.difficulties.easy`, value: 4 },
{ label: `RipCrypt.common.difficulties.normal`, value: 5 },
{ label: `RipCrypt.common.difficulties.tough`, value: 6 },
{ label: `RipCrypt.common.difficulties.hard`, value: 7 },
];
export class DelveDiceHUD extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
id: `ripcrypt-delve-dice`,
tag: `aside`,
classes: [
`ripcrypt`,
`ripcrypt--DelveDiceHUD`,
`hud`,
],
window: {
frame: false,
positioned: false,
},
actions: {
tourDelta: this.#tourDelta,
setFate: this.#setFate,
},
};
static PARTS = {
previousTour: {
template: filePath(`templates/Apps/DelveDiceHUD/tour/previous.hbs`),
},
difficulty: {
template: filePath(`templates/Apps/DelveDiceHUD/difficulty.hbs`),
},
fateCompass: {
template: filePath(`templates/Apps/DelveDiceHUD/fateCompass.hbs`),
},
sandsOfFate: {
template: filePath(`templates/Apps/DelveDiceHUD/tour/current.hbs`),
},
nextTour: {
template: filePath(`templates/Apps/DelveDiceHUD/tour/next.hbs`),
},
};
// #endregion
// #region Instance Data
/**
* The current number of degrees the compass pointer should be rotated, this
* is not stored in the DB since we only care about the initial rotation on
* reload, which is derived from the current fate.
* @type {Number}
*/
_rotation;
constructor(...args) {
super(...args);
this._sandsOfFate = game.settings.get(`ripcrypt`, `sandsOfFate`);
this._currentFate = game.settings.get(`ripcrypt`, `currentFate`);
this._rotation = CompassRotations[this._currentFate];
this._difficulty = game.settings.get(`ripcrypt`, `dc`);
};
// #endregion
// #region Lifecycle
/**
* Injects the element into the Foundry UI in the top middle
*/
_insertElement(element) {
const existing = document.getElementById(element.id);
if (existing) {
existing.replaceWith(element);
} else {
const parent = document.getElementById(`ui-top`);
parent.prepend(element);
};
};
async _onRender(context, options) {
await super._onRender(context, options);
// Shortcut because users can't edit
if (!game.user.isGM) { return };
new ContextMenu(
this.element,
`#delve-difficulty`,
[
...conditions.map(condition => ({
name: localizer(condition.label),
callback: DelveDiceHUD.#setDifficulty.bind(this, condition.value),
})),
{
name: localizer(`RipCrypt.common.difficulties.random`),
callback: () => {
const condition = conditions[Math.floor(Math.random() * conditions.length)];
DelveDiceHUD.#setDifficulty.bind(this)(condition.value);
},
},
],
{ jQuery: false, fixed: true },
);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.meta ??= {};
ctx.meta.editable = game.user.isGM;
switch (partId) {
case `sandsOfFate`: {
ctx.sandsOfFate = this._sandsOfFate;
break;
};
case `difficulty`: {
ctx.dc = this._difficulty;
break;
};
case `fateCompass`: {
ctx.fate = this._currentFate;
ctx.rotation = `${this._rotation}deg`;
break;
};
};
Logger.log(`${partId} Context`, ctx);
return ctx;
};
async animate({ parts = [] } = {}) {
if (parts.includes(`fateCompass`)) {
this.#animateCompassTo();
};
if (parts.includes(`sandsOfFate`)) {
this.#animateSandsTo();
};
};
#animateCompassTo(newFate) {
if (newFate === this._currentFate) { return };
/** @type {HTMLElement|undefined} */
const pointer = this.element.querySelector(`.compass-pointer`);
if (!pointer) { return };
newFate ??= game.settings.get(`ripcrypt`, `currentFate`);
let distance = distanceBetweenFates(this._currentFate, newFate);
if (distance === 3) { distance = -1 };
this._rotation += distance * 90;
pointer.style.setProperty(`transform`, `rotate(${this._rotation}deg)`);
this._currentFate = newFate;
};
#animateSandsTo(newSands) {
/** @type {HTMLElement|undefined} */
const sands = this.element.querySelector(`.sands-value`);
if (!sands) { return };
newSands ??= game.settings.get(`ripcrypt`, `sandsOfFate`);
sands.innerHTML = newSands;
this._sandsOfFate = newSands;
};
// #endregion
// #region Actions
/** @this {DelveDiceHUD} */
static async #tourDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
const initial = game.settings.get(`ripcrypt`, `sandsOfFateInitial`);
let newSands = this._sandsOfFate + delta;
if (newSands > initial) {
Logger.info(`Cannot go to a previous Delve Tour above the initial value`);
return;
};
if (newSands === 0) {
newSands = initial;
await this.alertCrypticEvent();
};
switch (Math.sign(delta)) {
case -1: {
game.settings.set(`ripcrypt`, `currentFate`, nextFate(this._currentFate));
break;
}
case 1: {
game.settings.set(`ripcrypt`, `currentFate`, previousFate(this._currentFate));
break;
}
};
this.#animateSandsTo(newSands);
game.settings.set(`ripcrypt`, `sandsOfFate`, newSands);
};
/** @this {DelveDiceHUD} */
static async #setFate(_event, element) {
const fate = element.dataset.toFate;
this.#animateCompassTo(fate);
game.settings.set(`ripcrypt`, `currentFate`, fate);
};
/** @this {DelveDiceHUD} */
static async #setDifficulty(value) {
this._difficulty = value;
game.settings.set(`ripcrypt`, `dc`, value);
};
// #endregion
// #region Public API
async alertCrypticEvent() {
const alertType = game.settings.get(`ripcrypt`, `onCrypticEvent`);
if (alertType === `nothing`) { return };
if ([`both`, `notif`].includes(alertType)) {
ui.notifications.info(
localizer(`RipCrypt.notifs.info.cryptic-event-alert`),
{ console: false },
);
};
if ([`both`, `pause`].includes(alertType) && game.user.isGM) {
game.togglePause(true, { broadcast: true });
};
};
// #endregion
};

222
module/Apps/DicePool.mjs Normal file
View file

@ -0,0 +1,222 @@
import { filePath } from "../consts.mjs";
import { GenericAppMixin } from "./GenericApp.mjs";
import { localizer } from "../utils/Localizer.mjs";
import { Logger } from "../utils/Logger.mjs";
const { Roll } = foundry.dice;
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(ApplicationV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--DicePool`,
],
window: {
title: `Dice Pool`,
frame: true,
positioned: true,
resizable: false,
minimizable: true,
},
position: {
width: `auto`,
height: `auto`,
},
actions: {
diceCountDelta: this.#diceCountDelta,
targetDelta: this.#targetDelta,
edgeDelta: this.#edgeDelta,
dragDelta: this.#dragDelta,
roll: this.#roll,
},
};
static PARTS = {
numberOfDice: {
template: filePath(`templates/Apps/DicePool/numberOfDice.hbs`),
},
target: {
template: filePath(`templates/Apps/DicePool/target.hbs`),
},
drag: {
template: filePath(`templates/Apps/DicePool/drag.hbs`),
},
edge: {
template: filePath(`templates/Apps/DicePool/edge.hbs`),
},
buttons: {
template: filePath(`templates/Apps/DicePool/buttons.hbs`),
},
};
// #endregion
// #region Instance Data
_diceCount;
_target;
_drag;
_edge;
constructor({
diceCount = 1,
target,
drag = 0, edge = 0,
flavor = ``,
...opts
} = {}) {
super(opts);
this._drag = drag;
this._edge = edge;
this._flavor = flavor;
this._diceCount = diceCount;
this._target = target ?? game.settings.get(`ripcrypt`, `dc`) ?? 1;
};
get title() {
if (!this._flavor) {
return super.title;
}
return `${super.title}: ${this._flavor}`;
};
// #endregion
// #region Lifecycle
async _preparePartContext(partId, ctx, _opts) {
ctx = {};
switch (partId) {
case `numberOfDice`: {
await this._prepareNumberOfDice(ctx);
break;
};
case `target`: {
await this._prepareTarget(ctx);
break;
};
case `edge`: {
await this._prepareEdge(ctx);
break;
};
case `drag`: {
await this._prepareDrag(ctx);
break;
};
case `buttons`: {
break;
};
}
Logger.debug(`${partId} Context:`, ctx);
return ctx;
};
async _prepareNumberOfDice(ctx) {
ctx.numberOfDice = this._diceCount;
ctx.decrementDisabled = this._diceCount <= 1;
};
async _prepareTarget(ctx) {
ctx.target = this._target;
ctx.incrementDisabled = this._target >= 8;
ctx.decrementDisabled = this._target <= 1;
};
async _prepareEdge(ctx) {
ctx.edge = this._edge;
ctx.incrementDisabled = false;
ctx.decrementDisabled = this._edge <= 0;
};
async _prepareDrag(ctx) {
ctx.drag = this._drag;
ctx.incrementDisabled = false;
ctx.decrementDisabled = this._drag <= 0;
};
// #endregion
// #region Actions
static async #diceCountDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
if (Number.isNaN(delta)) {
ui.notifications.error(
localizer(`RipCrypt.notifs.error.invalid-delta`, { name: `@RipCrypt.Apps.numberOfDice` }),
);
return;
};
let newCount = this._diceCount + delta;
if (newCount < 0) {
ui.notifications.warn(
localizer(`RipCrypt.notifs.warn.cannot-go-negative`, { name: `@RipCrypt.Apps.numberOfDice` }),
);
};
this._diceCount = Math.max(newCount, 0);
this.render({ parts: [`numberOfDice`] });
};
static async #targetDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
if (Number.isNaN(delta)) {
ui.notifications.error(
localizer(`RipCrypt.notifs.error.invalid-delta`, { name: `@RipCrypt.Apps.rollTarget` }),
);
return;
};
this._target += delta;
this.render({ parts: [`target`] });
};
static async #edgeDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
if (Number.isNaN(delta)) {
ui.notifications.error(
localizer(`RipCrypt.notifs.error.invalid-delta`, { name: `@RipCrypt.common.edge` }),
);
return;
};
this._edge += delta;
this.render({ parts: [`edge`] });
};
static async #dragDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
if (Number.isNaN(delta)) {
ui.notifications.error(
localizer(`RipCrypt.notifs.error.invalid-delta`, { name: `@RipCrypt.common.drag` }),
);
return;
};
this._drag += delta;
this.render({ parts: [`drag`] });
};
static async #roll() {
let target = this._target;
target -= this._edge;
target += this._drag;
target = Math.max(target, 1);
const formula = `${this._diceCount}d8rc${target}`;
Logger.debug(`Attempting to roll formula: ${formula}`);
let flavor = this._flavor;
if (this._flavor) {
flavor += ` ` + localizer(`RipCrypt.Apps.difficulty`, { dc: this._target });
}
const roll = new Roll(formula);
await roll.evaluate();
await roll.toMessage({
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
flavor,
});
this.close();
};
// #endregion
};

156
module/Apps/GenericApp.mjs Normal file
View file

@ -0,0 +1,156 @@
import { createItemFromElement, deleteItemFromElement, editItemFromElement, updateForeignDocumentFromEvent } from "./utils.mjs";
import { DicePool } from "./DicePool.mjs";
import { RichEditor } from "./RichEditor.mjs";
import { toBoolean } from "../consts.mjs";
/**
* A mixin that takes the class from HandlebarsApplicationMixin and
*/
export function GenericAppMixin(HandlebarsApp) {
class GenericRipCryptApp extends HandlebarsApp {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt`,
],
actions: {
roll: this.#rollDice,
createItem(_event, target) { // uses arrow-less function for "this"
const parent = this.document;
createItemFromElement(target, { parent });
},
editItem: (_event, target) => editItemFromElement(target),
deleteItem: (_event, target) => deleteItemFromElement(target),
openRichEditor: this.#openRichEditor,
},
};
static themes = {
dark: `SETTINGS.UI.FIELDS.colorScheme.dark`,
};
// #endregion
// #region Instance Data
/** @type {Map<string, PopoverEventManager>} */
_popoverManagers = new Map();
/** @type {Map<number, string>} */
_hookIDs = new Map();
// #endregion
// #region Lifecycle
/**
* @override
* Making it so that if the app is already open, it's brought to
* top after being re-rendered as normal
*/
async render(options = {}, _options = {}) {
await super.render(options, _options);
const instance = foundry.applications.instances.get(this.id);
if (instance !== undefined && options.orBringToFront) {
instance.bringToFront();
};
};
/** @override */
async _onRender(...args) {
await super._onRender(...args);
/*
Rendering each of the popover managers associated with this app allows us
to have them be dynamic and update when their parent application is rerendered,
this could eventually be something we can move into the Document's apps
collection so Foundry auto-rerenders it, but because it isn't actually
associated with the Document (as it's dependendant on the Application), I
decided that it would be best to do my own handling for it.
*/
for (const manager of this._popoverManagers.values()) {
manager.render();
};
/*
Foreign update listeners so that we can easily update items that may not
be this document itself, but are useful to be able to be edited from this
sheet. Primarily useful for editing the Actors' Item collection, or an Items'
ActiveEffect collection.
*/
this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
const events = el.dataset.foreignUpdateOn.split(`,`);
for (const event of events) {
el.addEventListener(event, updateForeignDocumentFromEvent);
};
});
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
delete ctx.document;
delete ctx.fields;
ctx.meta ??= {};
ctx.meta.idp = this.document?.uuid ?? this.id;
if (this.document) {
ctx.meta.limited = this.document.limited;
ctx.meta.editable = ctx.editable;
}
delete ctx.editable;
return ctx;
};
_tearDown(options) {
// Clear all popovers associated with the app
for (const manager of this._popoverManagers.values()) {
manager.destroy();
};
this._popoverManagers.clear();
// Remove any hooks added for this app
for (const [id, hook] of this._hookIDs.entries()) {
Hooks.off(hook, id);
};
this._hookIDs.clear();
super._tearDown(options);
};
// #endregion
// #region Actions
/** @this {GenericRipCryptApp} */
static async #rollDice(_event, target) {
const data = target.dataset;
const diceCount = parseInt(data.diceCount);
const flavor = data.flavor;
const dp = new DicePool({ diceCount, flavor });
dp.render({ force: true });
};
/** @this {GenericRipCryptApp} */
static async #openRichEditor(_event, target) {
const data = target.dataset;
const {
uuid,
path,
collaborative,
compact,
} = data;
if (!uuid || !path) {
console.error(`Rich Editor requires a document uuid and path to edit`);
return;
};
const document = await fromUuid(uuid);
const app = new RichEditor({
document,
path,
collaborative: toBoolean(collaborative),
compact: toBoolean(compact ),
});
app.render({ force: true });
};
// #endregion
};
return GenericRipCryptApp;
};

View file

@ -0,0 +1,71 @@
import { filePath } from "../../consts.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ItemSheetV2 } = foundry.applications.sheets;
export class AllItemSheetV1 extends GenericAppMixin(HandlebarsApplicationMixin(ItemSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--item`,
`ripcrypt--AllItemSheetV1`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
content: {
template: filePath(`templates/Apps/AllItemSheetV1/content.hbs`),
},
};
// #endregion
// #region Lifecycle
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.item = this.document;
ctx.formFields = await this.document.system.getFormFields(ctx);
Logger.debug(`Context:`, ctx);
return ctx;
};
async _onRender() {
// remove the flag if it exists when we render the sheet
delete this.document?.system?.forceRerender;
};
/**
* Used to make it so that items that don't get updated because of the
* _preUpdate hook removing/changing the data submitted, can still get
* re-rendered when the diff is empty. If the document does get updated,
* this rerendering does not happen.
*
* @override
*/
async _processSubmitData(...args) {
await super._processSubmitData(...args);
if (this.document.system.forceRerender) {
await this.render(false);
};
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,88 @@
/*
This Application is used by parts of the system to enable a better experience
while editing enriched text, because a lot of the spaces for text are really
small and are better served by a bigger text editor so that the controls are
more visible and doesn't cause as much overflow chaos.
*/
import { filePath } from "../consts.mjs";
const { HandlebarsApplicationMixin, DocumentSheetV2 } = foundry.applications.api;
const { hasProperty, getProperty } = foundry.utils;
export class RichEditor extends HandlebarsApplicationMixin(DocumentSheetV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt`,
`ripcrypt--RichEditor`,
],
window: {
title: `Text Editor`,
frame: true,
positioned: true,
resizable: false,
minimizable: true,
},
position: {
width: `auto`,
height: `auto`,
},
actions: {},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
editor: {
template: filePath(`templates/Apps/RichEditor/content.hbs`),
root: true,
},
};
// #endregion
// #region Instance Data
document;
path;
constructor(opts) {
const {
document,
path,
compact = false,
collaborative = true,
} = opts;
if (!hasProperty(document, path)) {
throw new Error(`Document provided to text editor must have the property specified by the path.`);
};
opts.sheetConfig = false;
super(opts);
this.compact = compact;
this.collaborative = collaborative;
this.document = document;
this.path = path;
};
// #endregion
// #region Lifecycle
async _preparePartContext(partId, ctx, _opts) {
ctx = {
uuid: this.document.uuid,
editable: true, // this.isEditable
collaborative: this.collaborative,
compact: this.compact,
path: this.path,
};
const value = getProperty(this.document, this.path);
ctx.enriched = await TextEditor.enrichHTML(value);
ctx.raw = value;
return ctx;
};
// #endregion
};

View file

@ -0,0 +1,125 @@
import { Logger } from "../../utils/Logger.mjs";
import { StyledShadowElement } from "./mixins/StyledShadowElement.mjs";
/**
Attributes:
@property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file
*/
export class RipCryptIcon extends StyledShadowElement(HTMLElement) {
static elementName = `rc-icon`;
static formAssociated = false;
/* Stuff for the mixin to use */
static _stylePath = `css/components/icon.css`;
static _cache = new Map();
#container;
/** @type {null | string} */
_name;
/** @type {null | string} */
_path;
/* Stored IDs for all of the hooks that are in this component */
#svgHmr;
constructor() {
super();
this.#container = document.createElement(`div`);
this._shadow.appendChild(this.#container);
};
_mounted = false;
async connectedCallback() {
super.connectedCallback();
if (this._mounted) { return };
this._name = this.getAttribute(`name`);
this._path = this.getAttribute(`path`);
/*
This converts all of the double-dash prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
/*
Try to retrieve the icon if it isn't present, try the path then default to
the slot content, as then we can have a default per-icon usage
*/
let content;
if (this._name) {
content = await this.#getIcon(`./systems/${game.system.id}/assets/${this._name}.svg`);
};
if (this._path && !content) {
content = await this.#getIcon(this._path);
};
if (content) {
this.#container.appendChild(content.cloneNode(true));
};
/*
This is so that when we get an HMR event from Foundry we can appropriately
handle it using our logic to update the component and the icon cache.
*/
if (game.settings.get(`ripcrypt`, `devMode`)) {
this.#svgHmr = Hooks.on(`${game.system.id}-hmr:svg`, (iconName, data) => {
if (this._name === iconName || this._path?.endsWith(data.path)) {
const svg = this.#parseSVG(data.content);
this.constructor._cache.set(iconName, svg);
this.#container.replaceChildren(svg.cloneNode(true));
};
});
};
this._mounted = true;
};
disconnectedCallback() {
super.disconnectedCallback();
if (!this._mounted) { return };
Hooks.off(`${game.system.id}-hmr:svg`, this.#svgHmr);
this._mounted = false;
};
async #getIcon(path) {
// Cache hit!
if (this.constructor._cache.has(path)) {
Logger.debug(`Image ${path} cache hit`);
return this.constructor._cache.get(path);
};
const r = await fetch(path);
switch (r.status) {
case 200:
case 201:
break;
default:
Logger.error(`Failed to fetch icon: ${path}`);
return;
};
Logger.debug(`Adding image ${path} to the cache`);
const svg = this.#parseSVG(await r.text());
this.constructor._cache.set(path, svg);
return svg;
};
/** Takes an SVG string and returns it as a DOM node */
#parseSVG(content) {
const temp = document.createElement(`div`);
temp.innerHTML = content;
return temp.querySelector(`svg`);
};
};

View file

@ -0,0 +1,55 @@
import { StyledShadowElement } from "./mixins/StyledShadowElement.mjs";
/**
Attributes:
*/
export class RipCryptBorder extends StyledShadowElement(HTMLElement) {
static elementName = `rc-border`;
static formAssociated = false;
/* Stuff for the mixin to use */
static _stylePath = `css/components/rc-border.css`;
#container;
_mounted = false;
async connectedCallback() {
super.connectedCallback();
if (this._mounted) { return };
/*
This converts all of the double-dash prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
this.#container = document.createElement(`div`);
this.#container.classList = `rc-border`;
const titleContainer = document.createElement(`div`);
titleContainer.classList = `title`;
const titleSlot = document.createElement(`slot`);
titleSlot.innerHTML = `No Title`;
titleSlot.name = `title`;
titleContainer.appendChild(titleSlot.cloneNode(true));
this.#container.appendChild(titleContainer.cloneNode(true));
const contentSlot = document.createElement(`slot`);
contentSlot.name = `content`;
this.#container.appendChild(contentSlot.cloneNode(true));
this._shadow.appendChild(this.#container);
this._mounted = true;
};
disconnectedCallback() {
super.disconnectedCallback();
if (!this._mounted) { return };
this._mounted = false;
};
};

View file

@ -0,0 +1,26 @@
import { Logger } from "../../utils/Logger.mjs";
import { RipCryptBorder } from "./RipCryptBorder.mjs";
import { RipCryptIcon } from "./Icon.mjs";
import { RipCryptSVGLoader } from "./svgLoader.mjs";
const components = [
RipCryptIcon,
RipCryptSVGLoader,
RipCryptBorder,
];
export function registerCustomComponents() {
(CONFIG.CACHE ??= {}).componentListeners ??= [];
for (const component of components) {
if (!window.customElements.get(component.elementName)) {
Logger.debug(`Registering component "${component.elementName}"`);
window.customElements.define(
component.elementName,
component,
);
if (component.formAssociated) {
CONFIG.CACHE.componentListeners.push(component.elementName);
}
};
}
};

View file

@ -0,0 +1,64 @@
/**
* @param {HTMLElement} Base
*/
export function StyledShadowElement(Base) {
return class extends Base {
/**
* The path to the CSS that is loaded
* @type {string}
*/
static _stylePath;
/**
* The stringified CSS to use
* @type {Map<string, string>}
*/
static _styles = new Map();
/**
* The HTML element of the stylesheet
* @type {HTMLStyleElement}
*/
_style;
/** @type {ShadowRoot} */
_shadow;
constructor() {
super();
this._shadow = this.attachShadow({ mode: `open` });
this._style = document.createElement(`style`);
this._shadow.appendChild(this._style);
};
#mounted = false;
connectedCallback() {
if (this.#mounted) { return };
this._getStyles();
this.#mounted = true;
};
disconnectedCallback() {
if (!this.#mounted) { return };
this.#mounted = false;
};
_getStyles() {
// TODO: Cache the CSS content in a more sane way that doesn't break
const stylePath = this.constructor._stylePath;
if (this.constructor._styles.has(stylePath)) {
this._style.innerHTML = this.constructor._styles.get(stylePath);
} else {
fetch(`./systems/${game.system.id}/templates/${stylePath}`)
.then(r => r.text())
.then(t => {
this.constructor._styles.set(stylePath, t);
this._style.innerHTML = t;
});
}
};
};
};

View file

@ -0,0 +1,11 @@
import { RipCryptIcon } from "./Icon.mjs";
/**
Attributes:
@property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file
*/
export class RipCryptSVGLoader extends RipCryptIcon {
static elementName = `rc-svg`;
static _stylePath = `css/components/svg-loader.css`;
};

View file

@ -0,0 +1,96 @@
import { filePath } from "../../consts.mjs";
import { GenericPopoverMixin } from "./GenericPopoverMixin.mjs";
import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class AmmoTracker extends GenericPopoverMixin(HandlebarsApplicationMixin(ApplicationV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt`,
],
window: {
title: `RipCrypt.app-titles.AmmoTracker`,
contentClasses: [
`ripcrypt--AmmoTracker`,
],
},
actions: {
favourite: this.#favourite,
unfavourite: this.#unfavourite,
},
};
static PARTS = {
ammoList: {
template: filePath(`templates/Apps/popovers/AmmoTracker/ammoList.hbs`),
},
};
// #endregion
// #region Instance Data
_favouriteCount = 0;
// #endregion
// #region Lifecycle
async _preparePartContext(partId, data) {
const ctx = {
meta: { idp: this.id },
partId,
};
let favouriteCount = 0;
ctx.ammos = data.ammos.map(ammo => {
const favourite = ammo.getFlag(game.system.id, ItemFlags.FAVOURITE) ?? false;
if (favourite) { favouriteCount++ };
return {
ammo,
favourite,
};
});
this._favouriteCount = favouriteCount;
ctx.atFavouriteLimit = favouriteCount >= 3;
return ctx;
};
// #endregion
// #region Actions
static async #favourite(_, el) {
const targetEl = el.closest(`[data-item-id]`);
if (!targetEl) {
Logger.warn(`Cannot find a parent element with data-item-id`);
return;
};
if (this._favouriteCount > 3) {
ui.notifications.error(localizer(`RipCrypt.notifs.error.at-favourite-limit`));
return;
};
const data = targetEl.dataset;
const item = await fromUuid(data.itemId);
if (!item) { return };
item.setFlag(game.system.id, ItemFlags.FAVOURITE, true);
};
static async #unfavourite(_, el) {
const targetEl = el.closest(`[data-item-id]`);
if (!targetEl) {
Logger.warn(`Cannot find a parent element with data-item-id`);
return;
};
const data = targetEl.dataset;
const item = await fromUuid(data.itemId);
if (!item) { return };
item.unsetFlag(game.system.id, ItemFlags.FAVOURITE);
};
// #endregion
};

View file

@ -0,0 +1,188 @@
import { updateForeignDocumentFromEvent } from "../utils.mjs";
const { ApplicationV2 } = foundry.applications.api;
/**
* This mixin provides the ability to designate an Application as a "popover",
* which means that it will spawn near the x/y coordinates provided it won't
* overflow the bounds of the screen. This also implements a _preparePartContext
* in order to allow the parent application passing new data into the popover
* whenever it rerenders; how the popover handles this data is up to the
* specific implementation.
*/
export function GenericPopoverMixin(HandlebarsApp) {
class GenericRipCryptPopover extends HandlebarsApp {
static DEFAULT_OPTIONS = {
id: `popover-{id}`,
classes: [
`popover`,
],
window: {
frame: false,
positioned: true,
resizable: false,
minimizable: false,
},
actions: {},
};
popover = {};
constructor({ popover, ...options}) {
// For when the caller doesn't provide anything, we want this to behave
// like a normal Application instance.
popover.framed ??= true;
popover.locked ??= false;
if (popover.framed) {
options.window ??= {};
options.window.frame = true;
options.window.minimizable = true;
}
options.classes ??= [];
options.classes.push(popover.framed ? `framed` : `frameless`);
super(options);
this.popover = popover;
};
toggleLock() {
this.popover.locked = !this.popover.locked;
this.classList.toggle(`locked`, this.popover.locked);
};
/**
* This render utility is intended in order to make the popovers able to be
* used in both framed and frameless mode, making sure that the content classes
* from the framed mode get shunted onto the frameless Application's root
* element.
*/
async _onFirstRender(...args) {
await super._onFirstRender(...args);
const hasContentClasses = this.options?.window?.contentClasses?.length > 0;
if (!this.popover.framed && hasContentClasses) {
this.classList.add(...this.options.window.contentClasses);
};
};
async _onRender(...args) {
await super._onRender(...args);
/*
Foreign update listeners so that we can easily update items that may not
be this document itself, but are useful to be able to be edited from this
sheet. Primarily useful for editing the Actors' Item collection, or an Items'
ActiveEffect collection.
*/
this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
const events = el.dataset.foreignUpdateOn.split(`,`);
for (const event of events) {
el.addEventListener(event, updateForeignDocumentFromEvent);
};
});
};
async close(options = {}) {
// prevent locked popovers from being closed
if (this.popover.locked && !options.force) { return };
if (!this.popover.framed) {
options.animate = false;
};
return super.close(options);
};
/**
* @override
* Custom implementation in order to make it show up approximately where I
* want it to when being created.
*
* Most of this implementation is identical to the ApplicationV2
* implementation, the biggest difference is how targetLeft and targetTop
* are calculated.
*/
_updatePosition(position) {
if (!this.element) { return position };
if (this.popover.framed) { return super._updatePosition(position) };
const el = this.element;
let {width, height, left, top, scale} = position;
scale ??= 1.0;
const computedStyle = getComputedStyle(el);
let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0;
let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity;
let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0;
let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity;
let bounds = el.getBoundingClientRect();
const {clientWidth, clientHeight} = document.documentElement;
// Explicit width
const autoWidth = width === `auto`;
if ( !autoWidth ) {
const targetWidth = Number(width || bounds.width);
minWidth = parseInt(minWidth) || 0;
maxWidth = parseInt(maxWidth) || (clientWidth / scale);
width = Math.clamp(targetWidth, minWidth, maxWidth);
}
// Explicit height
const autoHeight = height === `auto`;
if ( !autoHeight ) {
const targetHeight = Number(height || bounds.height);
minHeight = parseInt(minHeight) || 0;
maxHeight = parseInt(maxHeight) || (clientHeight / scale);
height = Math.clamp(targetHeight, minHeight, maxHeight);
}
// Implicit height
if ( autoHeight ) {
Object.assign(el.style, {width: `${width}px`, height: ``});
bounds = el.getBoundingClientRect();
height = bounds.height;
}
// Implicit width
if ( autoWidth ) {
Object.assign(el.style, {height: `${height}px`, width: ``});
bounds = el.getBoundingClientRect();
width = bounds.width;
}
// Left Offset
const scaledWidth = width * scale;
const targetLeft = left ?? (this.popover.x - Math.floor( scaledWidth / 2 ));
const maxLeft = Math.max(clientWidth - scaledWidth, 0);
left = Math.clamp(targetLeft, 0, maxLeft);
// Top Offset
const scaledHeight = height * scale;
const targetTop = top ?? (this.popover.y - scaledHeight);
const maxTop = Math.max(clientHeight - scaledHeight, 0);
top = Math.clamp(targetTop, 0, maxTop);
// Scale
scale ??= 1.0;
return {
width: autoWidth ? `auto` : width,
height: autoHeight ? `auto` : height,
left,
top,
scale,
};
};
/**
* This is here in order allow things that are not this Application
* to provide / augment the context data for the lifecycle of the app.
*/
async _prepareContext(_partId, _context, options) {
const context = {};
Hooks.callAll(`prepare${this.constructor.name}Context`, context, options);
Hooks.callAll(`prepare${this.popover.managerId}Context`, context, options);
return context;
};
};
return GenericRipCryptPopover;
};

View file

@ -0,0 +1,37 @@
const { CombatTracker } = foundry.applications.sidebar.tabs;
export class RipCryptCombatTracker extends CombatTracker {
/**
* Changes the way the combat tracker renders combatant rows to account for
* multiple combatants being in the same combat "group", thus all going at the
* same time.
*
* @override
*/
async _prepareTurnContext(combat, combatant, index) {
const turn = await super._prepareTurnContext(combat, combatant, index);
turn.hasDecimals = true;
turn.initiative = combatant.dynamicInitiative;
const groupKey = combatant?.groupKey;
if (groupKey && combat.started) {
turn.active ||= combat.combatant?.groupKey === groupKey;
if (turn.active && !turn.css.includes(`active`)) {
turn.css += ` active`;
};
};
return turn;
};
async _onRender(...args) {
await super._onRender(...args);
// Purge the combat controls that I don't want to exist because they don't
// make sense in the system.
this.element?.querySelector(`[data-action="resetAll"]`)?.remove();
this.element?.querySelector(`[data-action="rollNPC"]`)?.remove();
this.element?.querySelector(`[data-action="rollAll"]`)?.remove();
};
};

65
module/Apps/utils.mjs Normal file
View file

@ -0,0 +1,65 @@
/*
This file contains utilities used by Applications in order to be DRYer
*/
/**
* @param {HTMLElement} target The element to operate on
*/
export async function createItemFromElement(target, { parent } = {}) {
const data = target.dataset;
const types = data.itemTypes?.split(`,`);
const type = data.defaultItemType;
await Item.createDialog(
{ type },
{ parent },
{
types,
folders: [],
},
);
};
/**
* @param {HTMLElement} target The element to operate on
*/
export async function editItemFromElement(target) {
const itemEl = target.closest(`[data-item-id]`);
if (!itemEl) { return };
const itemId = itemEl.dataset.itemId;
if (!itemId) { return };
const item = await fromUuid(itemId);
item.sheet.render({ force: true, orBringToFront: true });
};
/**
* @param {HTMLElement} target The element to operate on
*/
export async function deleteItemFromElement(target) {
const itemEl = target.closest(`[data-item-id]`);
if (!itemEl) { return };
const itemId = itemEl.dataset.itemId;
if (!itemId) { return };
const item = await fromUuid(itemId);
item.delete();
};
/**
* Updates a document using the UUID, expects there to be the following
* dataset attributes:
* - "data-foreign-uuid" : The UUID of the document to update
* - "data-foreign-name" : The dot-separated path of the value to update
*
* @param {Event} event
*/
export async function updateForeignDocumentFromEvent(event) {
const target = event.currentTarget;
const data = target.dataset;
const document = await fromUuid(data.foreignUuid);
let value = target.value;
switch (target.type) {
case `checkbox`: value = target.checked; break;
};
await document?.update({ [data.foreignName]: value });
};

43
module/api.mjs Normal file
View file

@ -0,0 +1,43 @@
// App imports
import { AmmoTracker } from "./Apps/popovers/AmmoTracker.mjs";
import { CombinedHeroSheet } from "./Apps/ActorSheets/CombinedHeroSheet.mjs";
import { DicePool } from "./Apps/DicePool.mjs";
import { RichEditor } from "./Apps/RichEditor.mjs";
import { SkillsCardV1 } from "./Apps/ActorSheets/SkillsCardV1.mjs";
import { StatsCardV1 } from "./Apps/ActorSheets/StatsCardV1.mjs";
// Util imports
import { distanceBetweenFates, nextFate, previousFate } from "./utils/fates.mjs";
import { documentSorter } from "./consts.mjs";
import { rankToInteger } from "./utils/rank.mjs";
// Misc Imports
import { ItemFlags } from "./flags/item.mjs";
const { deepFreeze } = foundry.utils;
Object.defineProperty(
globalThis,
`ripcrypt`,
{
value: deepFreeze({
Apps: {
AmmoTracker,
DicePool,
CombinedHeroSheet,
StatsCardV1,
SkillsCardV1,
RichEditor,
},
utils: {
documentSorter,
distanceBetweenFates,
nextFate,
previousFate,
rankToInteger,
},
ItemFlags,
}),
writable: false,
},
);

67
module/consts.mjs Normal file
View file

@ -0,0 +1,67 @@
const { getType } = foundry.utils;
// MARK: filePath
export function filePath(path) {
if (path.startsWith(`/`)) {
path = path.slice(1);
};
return `systems/ripcrypt/${path}`;
};
// MARK: toBoolean
/**
* Converts a value into a boolean based on the type of the value provided
*
* @param {any} val The value to convert
*/
export function toBoolean(val) {
switch (getType(val)) {
case `string`: {
return val === `true`;
};
case `number`: {
return val === 1;
};
};
return Boolean(val);
};
// MARK: documentSorter
/**
* @typedef {Object} Sortable
* @property {integer} sort
* @property {string} name
*/
/**
* Compares two Sortable documents in order to determine ordering
* @param {Sortable} a
* @param {Sortable} b
* @returns An integer dictating which order the two documents should be sorted in
*/
export function documentSorter(a, b) {
if (!a && !b) {
return 0;
} else if (!a) {
return 1;
} else if (!b) {
return -1;
};
const sortDelta = b.sort - a.sort;
if (sortDelta !== 0) {
return sortDelta;
};
return Math.sign(a.name.localeCompare(b.name));
};
// MARK: getTooltipDelay
/**
* Retrieves the configured minimum delay between the user hovering an element
* and a tooltip showing up. Used for the pseudo-tooltip Applications that I use.
*
* @returns The number of milliseconds for the timeout
*/
export function getTooltipDelay() {
return game.tooltip.constructor.TOOLTIP_ACTIVATION_MS;
};

View file

@ -0,0 +1,196 @@
import { derivedMaximumBar } from "../helpers.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { rankToInteger } from "../../utils/rank.mjs";
import { sumReduce } from "../../utils/sumReduce.mjs";
const { fields } = foundry.data;
export class EntityData extends foundry.abstract.TypeDataModel {
// MARK: Token Attrs
static get trackableAttributes() {
return {
bar: [
`guts`,
],
value: [
`ability.grit`,
`ability.gait`,
`ability.grip`,
`ability.glim`,
`level.glory`,
`level.step`,
`level.rank`,
`coin.gold`,
`coin.silver`,
`coin.copper`,
],
};
};
// MARK: Schema
static defineSchema() {
return {
ability: new fields.SchemaField({
grit: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
gait: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
grip: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
glim: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
}),
guts: derivedMaximumBar(0, 5),
coin: new fields.SchemaField({
gold: new fields.NumberField({
initial: 5,
integer: true,
required: true,
nullable: false,
}),
silver: new fields.NumberField({
initial: 0,
integer: true,
required: true,
nullable: false,
}),
copper: new fields.NumberField({
initial: 0,
integer: true,
required: true,
nullable: false,
}),
}),
fate: new fields.StringField({
initial: ``,
blank: true,
trim: true,
nullable: false,
choices: () => {
return Object.values(gameTerms.FatePath).concat(``);
},
}),
level: new fields.SchemaField({
glory: new fields.NumberField({
min: 0,
initial: 0,
integer: true,
required: true,
nullable: false,
}),
step: new fields.NumberField({
min: 1,
initial: 1,
max: 3,
integer: true,
required: true,
nullable: false,
}),
rank: new fields.StringField({
initial: gameTerms.Rank.NOVICE,
required: true,
nullable: false,
blank: false,
trim: true,
choices: Object.values(gameTerms.Rank),
}),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
// Calculate the person's base Crafting aura
const rank = rankToInteger(this.level.rank);
this.aura = {
normal: ( rank + 1 ) * 2,
heavy: ( rank + 2 ) * 2,
};
this.guts.max = 0;
// The limitations imposed on things like inventory spaces and equipped
// weapon count
this.limit = {
weapons: 4,
equipment: 12,
skills: 4,
};
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
this.guts.max += Object.values(this.ability).reduce(sumReduce);
// Movement speeds
this.speed = {
move: this.ability.gait + 3,
run: (this.ability.gait + 3) * 2,
};
};
// #region Getters
get equippedArmour() {
const armours = this.parent.itemTypes.armour;
const slots = Object.fromEntries(
Object.values(gameTerms.Anatomy).map(v => [v, null]),
);
for (const armour of armours) {
if (!armour.system.equipped) { continue };
for (const locationTag of [...armour.system.location.values()]) {
const location = locationTag.toLowerCase();
slots[location] = armour;
};
};
return slots;
};
get equippedShield() {
const shields = this.parent.itemTypes.shield;
return shields.find(item => item.system.equipped);
};
get defense() {
const defenses = {};
const armour = this.equippedArmour;
for (const slot in armour) {
defenses[slot] = armour[slot]?.system.protection ?? 0;
};
const shield = this.equippedShield;
if (shield) {
for (const location of [...shield.system.location.values()]) {
const slot = location.toLowerCase();
defenses[slot] += shield.system.protection;
};
};
return defenses;
};
// #endregion
};

View file

@ -0,0 +1,3 @@
import { EntityData } from "./Entity.mjs";
export class GeistData extends EntityData {};

View file

@ -1,43 +1,3 @@
const { fields } = foundry.data;
import { EntityData } from "./Entity.mjs";
export class HeroData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const schema = new fields.SchemaField({
ability: new fields.SchemaField({
grit: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
gait: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
grip: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
glim: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
}),
});
return schema;
};
prepareBaseData() {};
prepareDerivedData() {};
};
export class HeroData extends EntityData {};

59
module/data/Item/Ammo.mjs Normal file
View file

@ -0,0 +1,59 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
export class AmmoData extends CommonItemData {
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Getters
// #endregion
// #region Sheet Data
getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
type: `integer`,
label: `RipCrypt.common.quantity`,
path: `system.quantity`,
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
];
return fields;
};
// #endregion
};

199
module/data/Item/Armour.mjs Normal file
View file

@ -0,0 +1,199 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { requiredInteger } from "../helpers.mjs";
const { fields } = foundry.data;
const { hasProperty, diffObject, mergeObject } = foundry.utils;
/** Used for Armour and Shields */
export class ArmourData extends CommonItemData {
// MARK: Schema
static defineSchema() {
return {
...super.defineSchema(),
protection: requiredInteger({ min: 0, initial: 1 }),
location: new fields.SetField(
new fields.StringField({
blank: false,
trim: true,
nullable: false,
options: Object.values(gameTerms.Anatomy),
}),
{
nullable: false,
required: true,
},
),
equipped: new fields.BooleanField({
initial: false,
required: true,
nullable: false,
}),
weight: new fields.StringField({
blank: false,
nullable: true,
initial: null,
options: Object.values(gameTerms.WeightRatings),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Lifecycle
async _preUpdate(changes, options, user) {
// return false
if (options.force && game.settings.get(`ripcrypt`, `devMode`)) { return };
// Ensure changes is a diffed object
const diff = diffObject(this.parent._source, changes);
let valid = await super._preUpdate(changes, options, user);
if (hasProperty(diff, `system.equipped`) && !this._canEquip()) {
ui.notifications.error(
localizer(
`RipCrypt.notifs.error.cannot-equip`,
{ itemType: `@TYPES.Item.${this.parent.type}` },
),
{ console: false },
);
// Don't stop the update, but don't allow changing the equipped status
mergeObject(changes, {
"system.equipped": false,
});
// Set a flag so that we can tell the sheet that it needs to rerender
this.forceRerender = true;
};
return valid;
};
/** Used to tell the preUpdate logic whether or not to prevent the */
_canEquip() {
const parent = this.parent;
if (!parent.isEmbedded || !(parent.parent instanceof Actor)) {
Logger.error(`Unable to equip item when it's not embedded`);
return false;
};
if (this.location.size === 0) {
Logger.error(`Unable to equip an item without any locations`);
return false;
};
const slots = parent.parent.system.equippedArmour ?? {};
Logger.debug(`slots`, slots);
for (const locationTag of this.location) {
if (slots[locationTag.toLowerCase()] != null) {
Logger.error(`Unable to equip multiple items in the same slot`);
return false;
};
};
return true;
};
// #endregion
// #region Getters
get locationString() {
return [...this.location].join(`, `);
};
// #endregion
// #region Sheet Data
getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
type: `integer`,
label: `RipCrypt.common.quantity`,
path: `system.quantity`,
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
{
id: `weight`,
type: `dropdown`,
label: `RipCrypt.common.weightRating`,
path: `system.weight`,
value: this.weight,
options: [
{
label: `RipCrypt.common.empty`,
value: null,
},
...Object.values(gameTerms.WeightRatings).map(opt => ({
label: `RipCrypt.common.weightRatings.${opt}`,
value: opt,
})),
],
},
{
id: `location`,
type: `string-set`,
label: `RipCrypt.common.location`,
placeholder: `RipCrypt.Apps.location-placeholder`,
path: `system.location`,
value: this.locationString,
},
{
id: `protection`,
type: `integer`,
label: `RipCrypt.common.protection`,
value: this.protection,
path: `system.protection`,
min: 0,
},
];
if (this.parent.isEmbedded) {
fields.push({
id: `equipped`,
type: `boolean`,
label: `RipCrypt.common.equipped`,
value: this.equipped,
path: `system.equipped`,
});
};
return fields;
};
// #endregion
};

View file

@ -0,0 +1,34 @@
import { optionalInteger, requiredInteger } from "../helpers.mjs";
import { gameTerms } from "../../gameTerms.mjs";
const { fields } = foundry.data;
export class CommonItemData extends foundry.abstract.TypeDataModel {
// MARK: Schema
static defineSchema() {
return {
quantity: requiredInteger({ min: 0, initial: 1 }),
access: new fields.StringField({
blank: true,
nullable: false,
trim: true,
choices: gameTerms.Access,
}),
cost: new fields.SchemaField({
gold: optionalInteger(),
silver: optionalInteger(),
copper: optionalInteger(),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
};

View file

@ -0,0 +1,78 @@
import { gameTerms } from "../../gameTerms.mjs";
import { SkillData } from "./Skill.mjs";
const { fields } = foundry.data;
export class CraftData extends SkillData {
// MARK: Schema
static defineSchema() {
const schema = super.defineSchema();
delete schema.ability;
schema.aspect = new fields.StringField({
initial: gameTerms.Aspects.FLECT,
blank: true,
trim: true,
nullable: false,
required: true,
choices: () => Object.values(gameTerms.Aspects),
});
return schema;
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Getters
// #endregion
// #region Sheet Data
async getFormFields(_ctx) {
const fields = [
{
id: `fate-path`,
type: `dropdown`,
label: `RipCrypt.common.aspect`,
path: `system.aspect`,
value: this.aspect,
options: Object.values(gameTerms.Aspects).map(aspect => ({
label: `RipCrypt.common.aspectNames.${aspect}`,
value: aspect,
})),
},
{
id: `description`,
type: `prosemirror`,
label: `RipCrypt.common.description`,
path: `system.description`,
uuid: this.parent.uuid,
value: await TextEditor.enrichHTML(this.description),
collaborative: false,
},
{
type: `group`,
title: `RipCrypt.common.advances`,
paddingTop: `20px`,
fields: Object.values(gameTerms.Rank).map(rank => {
return {
id: `advance-${rank}`,
type: `text`,
label: `RipCrypt.common.rankNames.${rank}`,
path: `system.advances.${rank}`,
value: this.advances[rank] ?? ``,
};
}),
},
];
return fields;
};
// #endregion
};

83
module/data/Item/Good.mjs Normal file
View file

@ -0,0 +1,83 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
const { fields } = foundry.data;
export class GoodData extends CommonItemData {
// MARK: Schema
static defineSchema() {
const schema = super.defineSchema();
schema.description = new fields.HTMLField({
blank: true,
nullable: false,
trim: true,
});
return schema;
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Getters
// #endregion
// #region Sheet Data
async getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
type: `integer`,
label: `RipCrypt.common.quantity`,
path: `system.quantity`,
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
{
id: `description`,
type: `prosemirror`,
label: `RipCrypt.common.description`,
path: `system.description`,
uuid: this.parent.uuid,
value: await TextEditor.enrichHTML(this.description),
collaborative: false,
},
];
return fields;
};
// #endregion
};

View file

@ -0,0 +1,19 @@
import { ArmourData } from "./Armour.mjs";
import { Logger } from "../../utils/Logger.mjs";
export class ShieldData extends ArmourData {
_canEquip() {
const parent = this.parent;
if (!parent.isEmbedded || !(parent.parent instanceof Actor)) {
Logger.error(`Unable to equip item when it's not embedded`);
return false;
};
const shield = parent.parent.system.equippedShield;
if (shield) {
Logger.error(`Unable to equip multiple shields`);
return false;
};
return true;
};
};

View file

@ -0,0 +1,91 @@
import { gameTerms } from "../../gameTerms.mjs";
const { fields } = foundry.data;
export class SkillData extends foundry.abstract.TypeDataModel {
// MARK: Schema
static defineSchema() {
const schema = {
ability: new fields.StringField({
initial: gameTerms.Abilities.GRIT,
blank: true,
trim: true,
nullable: false,
required: true,
choices: () => Object.values(gameTerms.Abilities),
}),
description: new fields.HTMLField({
blank: true,
nullable: false,
trim: true,
}),
};
const advances = {};
for (const rank of Object.values(gameTerms.Rank)) {
advances[rank] = new fields.StringField({
blank: false,
nullable: true,
initial: null,
});
};
schema.advances = new fields.SchemaField(advances);
return schema;
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Getters
// #endregion
// #region Sheet Data
async getFormFields(_ctx) {
const fields = [
{
id: `fate-path`,
type: `dropdown`,
label: `RipCrypt.common.ability`,
path: `system.ability`,
value: this.ability,
options: Object.values(gameTerms.Abilities).map(ability => ({
label: `RipCrypt.common.abilities.${ability}`,
value: ability,
})),
},
{
id: `description`,
type: `prosemirror`,
label: `RipCrypt.common.description`,
path: `system.description`,
uuid: this.parent.uuid,
value: await TextEditor.enrichHTML(this.description),
collaborative: false,
},
{
type: `group`,
title: `RipCrypt.common.advances`,
paddingTop: `20px`,
fields: Object.values(gameTerms.Rank).map(rank => {
return {
id: `advance-${rank}`,
type: `text`,
label: `RipCrypt.common.rankNames.${rank}`,
path: `system.advances.${rank}`,
value: this.advances[rank] ?? ``,
};
}),
},
];
return fields;
};
// #endregion
};

229
module/data/Item/Weapon.mjs Normal file
View file

@ -0,0 +1,229 @@
import { barAttribute, optionalInteger, requiredInteger } from "../helpers.mjs";
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { localizer } from "../../utils/Localizer.mjs";
const { fields } = foundry.data;
const { hasProperty, mergeObject } = foundry.utils;
export class WeaponData extends CommonItemData {
// MARK: Schema
static defineSchema() {
return {
...super.defineSchema(),
traits: new fields.SetField(
new fields.StringField({
blank: false,
trim: true,
nullable: false,
}),
{
nullable: false,
required: true,
},
),
range: new fields.SchemaField({
short: optionalInteger(),
long: optionalInteger(),
}),
damage: requiredInteger({ min: 0, initial: 0 }),
wear: barAttribute(0, 0, 4),
equipped: new fields.BooleanField({
initial: false,
required: true,
nullable: false,
}),
weight: new fields.StringField({
blank: false,
nullable: true,
initial: null,
options: Object.values(gameTerms.WeightRatings),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Lifecycle
async _preUpdate(changes, options, user) {
if (options.force && game.settings.get(`ripcrypt`, `devMode`)) { return };
let valid = super._preUpdate(changes, options, user);
if (hasProperty(changes, `system.equipped`) && !this.parent.isEmbedded) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.cannot-equip-not-embedded`,
{ itemType: `@TYPES.Item.${this.parent.type}` },
));
mergeObject(
changes,
{ "-=system.equipped": null },
{ inplace: true, performDeletions: true },
);
return false;
};
return valid;
};
// #endregion
// #region Getters
get traitString() {
return [...this.traits].join(`, `);
};
get rangeString() {
if (this.range.short && this.range.long) {
return `${this.range.short} / ${this.range.long}`;
};
return String(this.range.short ?? this.range.long ?? ``);
};
// #endregion
// #region Sheet Data
async getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
type: `integer`,
label: `RipCrypt.common.quantity`,
path: `system.quantity`,
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
{
id: `weight`,
type: `dropdown`,
label: `RipCrypt.common.weightRating`,
path: `system.weight`,
value: this.weight,
options: [
{
label: `RipCrypt.common.empty`,
value: null,
},
...Object.values(gameTerms.WeightRatings).map(opt => ({
label: `RipCrypt.common.weightRatings.${opt}`,
value: opt,
})),
],
},
{
id: `traits`,
type: `string-set`,
label: `RipCrypt.common.traits`,
placeholder: `RipCrypt.Apps.traits-placeholder`,
path: `system.traits`,
value: this.traitString,
},
];
// Add the range inputs depending on whether the user wants condensed range
// or not.
if (game.settings.get(`ripcrypt`, `condensedRange`)) {
fields.push({
type: `bar`,
label: `RipCrypt.common.range`,
value: {
label: `RipCrypt.Apps.short-range`,
path: `system.range.short`,
value: this.range.short,
},
max: {
label: `RipCrypt.Apps.long-range`,
path: `system.range.long`,
value: this.range.long,
},
});
} else {
fields.push({
id: `short-range`,
type: `integer`,
label: `RipCrypt.Apps.short-range`,
path: `system.range.short`,
value: this.range.short ?? ``,
min: 0,
},
{
id: `long-range`,
type: `integer`,
label: `RipCrypt.Apps.long-range`,
path: `system.range.long`,
value: this.range.long ?? ``,
min: 0,
});
};
fields.push(
{
id: `damage`,
type: `integer`,
label: `RipCrypt.common.damage`,
path: `system.damage`,
value: this.damage,
min: 0,
},
{
type: `bar`,
label: `RipCrypt.common.wear`,
value: {
label: `RipCrypt.Apps.current-wear`,
path: `system.wear.value`,
value: this.wear.value,
min: 0, max: this.wear.max,
},
max: {
label: `RipCrypt.Apps.max-wear`,
path: `system.wear.max`,
value: this.wear.max,
min: 0,
},
},
);
if (this.parent.isEmbedded) {
fields.push({
id: `equipped`,
type: `boolean`,
label: `RipCrypt.common.equipped`,
value: this.equipped,
path: `system.equipped`,
});
};
return fields;
};
// #endregion
};

56
module/data/helpers.mjs Normal file
View file

@ -0,0 +1,56 @@
const { fields } = foundry.data;
export function barAttribute(min, initial, max = undefined) {
return new fields.SchemaField({
value: new fields.NumberField({
min,
initial,
max,
integer: true,
nullable: false,
}),
max: new fields.NumberField({
min,
initial,
max,
integer: true,
nullable: false,
}),
});
};
export function derivedMaximumBar(min, initial) {
return new fields.SchemaField({
value: new fields.NumberField({
min,
initial,
integer: true,
nullable: false,
}),
});
};
export function optionalInteger({min, initial = null, max} = {}) {
return new fields.NumberField({
min,
initial,
max,
required: true,
nullable: true,
integer: true,
});
};
export function requiredInteger({ min, initial, max } = {}) {
if (initial == null || typeof initial !== `number`) {
throw new Error(`"initial" must be a number`);
};
return new fields.NumberField({
min,
initial,
max,
required: true,
nullable: false,
integer: true,
});
};

54
module/dice/CryptDie.mjs Normal file
View file

@ -0,0 +1,54 @@
const { Die } = foundry.dice.terms;
export class CryptDie extends Die {
static get MODIFIERS() {
return {
...super.MODIFIERS,
"rc": `ripOrCrypt`,
};
};
ripCryptState = undefined;
async ripOrCrypt(modifier) {
const rgx = /rc([0-9]+)/i;
const match = modifier.match(rgx);
if (!match) { return false };
let [ target ] = match.slice(1);
/*
Handle "Ripping" rolls, which is equivalent to re-rolling 8's and counting
it as a success.
*/
await this.explode(`x=8`, { recursive: true });
if(this.results.some(result => result.exploded)) {
this.ripCryptState = `ripping`;
};
/*
Handles "Crypting" rolls, which is a single explosion on 1's which if it
results in a second 1, causes the roll to "crypt"
*/
if (!this.ripCryptState) {
await this.explode(`xo=1`, { recursive: false });
let almostCrypted = false;
for (const result of this.results) {
if (result.result !== 1) { continue };
if (almostCrypted) {
this.ripCryptState = `crypted`;
break;
} else {
almostCrypted = true;
}
}
};
// Count successes
await this.countSuccess(`cs>=${target}`);
};
get total() {
return Math.max(super.total, 0);
};
};

139
module/documents/combat.mjs Normal file
View file

@ -0,0 +1,139 @@
/*
Resources:
- Combat : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/documents/combat.mjs
- Combatant : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/documents/combatant.mjs
- CombatTracker : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/applications/combat/combat-tracker.mjs
*/
export class RipCryptCombat extends Combat {
get customGroups() {
let groups = new Map();
for (const combatant of this.combatants) {
const groupKey = combatant.groupKey;
if (!groupKey) { continue };
if (groups.has(groupKey)) {
groups.get(groupKey).push(combatant);
} else {
groups.set(groupKey, [combatant]);
};
};
return groups;
};
/**
* @override
* Sorts combatants for the combat tracker in the following way:
* - Distance from the current fate ordinal. (0 -> 3)
* - Coin Flip result (if disposition matches flip result, then 0, otherwise, 0.5)
*/
_sortCombatants(a, b) {
const ia = Number.isNumeric(a.dynamicInitiative) ? a.dynamicInitiative : -Infinity;
const ib = Number.isNumeric(b.dynamicInitiative) ? b.dynamicInitiative : -Infinity;
const delta = ia - ib;
if (Math.sign(delta) !== 0) {
return delta;
};
// fallback to alphabetical sort
return a.name < b.name ? -1 : 1;
};
async nextTurn() {
if (this.round === 0) {return this.nextRound()}
const turn = this.turn ?? -1;
const groupKey = this.turns[turn]?.groupKey;
// Determine the next turn number
let nextTurn = null;
for (let i = turn + 1; i < this.turns.length; i++) {
const combatant = this.turns[i];
if (combatant.groupKey !== groupKey) {
nextTurn = i;
break;
};
};
// Maybe advance to the next round
if ((nextTurn === null) || (nextTurn >= this.turns.length)) {return this.nextRound()}
const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, nextTurn);
// Update the document, passing data through a hook first
const updateData = {round: this.round, turn: nextTurn};
const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
Hooks.callAll(`combatTurn`, this, updateData, updateOptions);
await this.update(updateData, updateOptions);
return this;
};
async previousTurn() {
if (this.round === 0) { return this }
if ((this.turn === 0) || (this.turns.length === 0)) {return this.previousRound()}
const currentTurn = (this.turn ?? this.turns.length) - 1;
let previousTurn = null;
const groupKey = this.combatant.groupKey;
for (let i = currentTurn; i >= 0; i--) {
const combatant = this.turns[i];
if (combatant.groupKey !== groupKey) {
previousTurn = i;
break;
}
}
if (previousTurn < 0) {
if (this.round === 1) {
this.round = 0;
return this;
};
return this.previousRound();
}
const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, previousTurn);
// Update the document, passing data through a hook first
const updateData = {round: this.round, turn: previousTurn};
const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
Hooks.callAll(`combatTurn`, this, updateData, updateOptions);
await this.update(updateData, updateOptions);
return this;
};
/**
* Overridden to make it so that there can be multiple tokens with turn markers
* visible at the same time.
*
* @protected
* @internal
* @override
*/
_updateTurnMarkers() {
if (!canvas.ready) { return };
const tokenGroup = this.combatant?.groupKey;
for (const token of canvas.tokens.turnMarkers) {
const actor = token.actor ?? token.baseActor;
if (actor?.groupKey !== tokenGroup) {
token.renderFlags.set({refreshTurnMarker: true});
}
}
if (!this.active) { return };
const currentToken = this.combatant?.token?._object;
if (!tokenGroup && currentToken) {
currentToken.renderFlags.set({refreshTurnMarker: true});
} else {
const group = this.customGroups.get(tokenGroup) ?? [];
for (const combatant of group) {
combatant.token?._object?.renderFlags.set({ refreshTurnMarker: true });
}
}
}
};

View file

@ -0,0 +1,70 @@
import { distanceBetweenFates } from "../utils/fates.mjs";
export class RipCryptCombatant extends Combatant {
get disposition() {
switch (this.token?.disposition) {
case CONST.TOKEN_DISPOSITIONS.HOSTILE:
return `hostile`;
case CONST.TOKEN_DISPOSITIONS.FRIENDLY:
return `friendly`;
};
return `unknown`;
};
/**
* Used by the Combat tracker to order combatants according to their
* fate path and the coin flip.
*/
get dynamicInitiative() {
let total = 0;
const start = game.settings.get(`ripcrypt`, `currentFate`);
const end = this.actor?.system?.fate || this.baseActor?.system?.fate;
total += distanceBetweenFates(start, end);
const whoFirst = game.settings.get(`ripcrypt`, `whoFirst`);
if (whoFirst) {
const disposition = this.disposition;
if (disposition === `unknown`) {
total += 0.25;
} else if (whoFirst !== disposition) {
total += 0.5;
};
};
return total;
};
get groupKey() {
const path = this.token?.actor?.system?.fate;
// Disallow grouping things that don't have a fate path
if (!path) { return null };
// Token Disposition (group into: friendlies, unknown, hostiles)
let disposition = this.disposition;
return `${path}:${disposition}`;
};
/**
* Used to create the turn marker when the combatant is added if they're in
* the group whose turn it is.
*
* @override
*/
_onCreate() {
this.token?._object?._refreshTurnMarker();
};
/**
* Used to remove the turn marker when the combatant is removed from combat
* if they had it visible so that it doesn't stick around infinitely.
*
* @override
*/
_onDelete() {
this.token?._object?._refreshTurnMarker();
};
};

View file

@ -0,0 +1,8 @@
export class RipCryptItem extends Item {
get quantifiedName() {
if (this.system.quantity != null && this.system.quantity !== 1) {
return `${this.name} (${this.system.quantity})`;
};
return this.name;
};
};

View file

@ -0,0 +1,35 @@
const { Token } = foundry.canvas.placeables;
const { TokenTurnMarker } = foundry.canvas.placeables.tokens;
export class RipCryptToken extends Token {
/**
* Overridden using a slightly modified implementation in order to make it so
* that the turn marker shows up on tokens if they're in the same group as the
* currently active combatant
*
* @override
*/
_refreshTurnMarker() {
// Should a Turn Marker be active?
const {turnMarker} = this.document;
const markersEnabled = CONFIG.Combat.settings.turnMarker.enabled
&& (turnMarker.mode !== CONST.TOKEN_TURN_MARKER_MODES.DISABLED);
const combatant = game.combat?.active ? game.combat.combatant : null;
const isTurn = combatant && (combatant.groupKey === this.combatant?.groupKey);
const isDefeated = combatant && combatant.isDefeated;
const markerActive = markersEnabled && isTurn && !isDefeated;
// Activate a Turn Marker
if (markerActive) {
if (!this.turnMarker) {
this.turnMarker = this.addChildAt(new TokenTurnMarker(this), 0);
};
canvas.tokens.turnMarkers.add(this);
this.turnMarker.draw();
} else if (this.turnMarker) {
canvas.tokens.turnMarkers.delete(this);
this.turnMarker.destroy();
this.turnMarker = null;
}
}
};

4
module/flags/item.mjs Normal file
View file

@ -0,0 +1,4 @@
export const ItemFlags = Object.freeze({
/** The boolean value to indicate if an item is considered favourited/starred or not */
FAVOURITE: `favourited`,
});

51
module/gameTerms.mjs Normal file
View file

@ -0,0 +1,51 @@
export const gameTerms = Object.preventExtensions({
Abilities: Object.freeze({
GRIT: `grit`,
GRIP: `grip`,
GAIT: `gait`,
GLIM: `glim`,
THINGLIM: `thin-glim`,
}),
Aspects: Object.freeze({
FOCUS: `focus`,
FLECT: `flect`,
FRACT: `fract`,
}),
FatePath: Object.freeze({
NORTH: `North`,
EAST: `East`,
SOUTH: `South`,
WEST: `West`,
}),
Access: [
`Common`,
`Uncommon`,
`Rare`,
`Scarce`,
],
Rank: Object.freeze({
NOVICE: `novice`,
ADEPT: `adept`,
EXPERT: `expert`,
MASTER: `master`,
}),
Anatomy: Object.freeze({
HEAD: `head`,
BODY: `body`,
ARMS: `arms`,
LEGS: `legs`,
}),
/** The types of items that contribute to the gear limit */
gearItemTypes: new Set([
`ammo`,
`armour`,
`weapon`,
`shield`,
`good`,
]),
WeightRatings: Object.freeze({
LIGHT: `light`,
MODEST: `modest`,
HEAVY: `heavy`,
}),
});

View file

@ -0,0 +1,13 @@
import { handlebarsLocalizer, localizer } from "../utils/Localizer.mjs";
import { formFields } from "./inputs/formFields.mjs";
import { options } from "./options.mjs";
export default {
// #region Complex
"rc-formFields": formFields,
"rc-i18n": handlebarsLocalizer,
"rc-options": options,
// #region Simple
"rc-empty-state": (v) => v ?? localizer(`RipCrypt.common.empty`),
};

View file

@ -0,0 +1,36 @@
import { localizer } from "../../utils/Localizer.mjs";
export function barInput(input, data) {
const label = localizer(input.label);
// Trying to do limited bar info is... annoying to do.
if (data.meta.limited && input.limited) {
return ``;
};
return `<div data-input-type="bar">
<span class="label" aria-hidden="true">
${label}
</span>
<div class="pill-bar">
<input
type="number"
aria-label="${localizer(input.value.label)}"
${data.meta.editable ? `` : `disabled`}
name="${input.value.path}"
value="${input.value.value}"
min="${input.value.min ?? ``}"
max="${input.value.max ?? ``}"
>
<input
type="number"
aria-label="${localizer(input.max.label)}"
${data.meta.editable ? `` : `disabled`}
name="${input.max.path}"
value="${input.max.value}"
min="${input.max.min ?? ``}"
max="${input.max.max ?? ``}"
>
</div>
</div>`;
};

View file

@ -0,0 +1,30 @@
import { localizer } from "../../utils/Localizer.mjs";
export function booleanInput(input, data) {
const label = localizer(input.label);
const id = `${data.meta.idp}-${input.id}`;
if (data.meta.limited) {
return `<div data-input-type="boolean">
<span class="label">${label}</span>
<div>
<span class="value">???</span>
</div>
</div>`;
};
return `<div data-input-type="boolean">
<label for="${id}">
${label}
</label>
<div class="checkbox-container">
<input
type="checkbox"
id="${id}"
${input.value ? `checked` : ``}
name="${input.path}"
${data.meta.editable ? `` : `disabled`}
>
</div>
</div>`;
};

View file

@ -0,0 +1,33 @@
import { groupInput } from "./groupInput.mjs";
export function costInput(input, data) {
return groupInput({
title: input.label,
fields: [
{
id: input.id + `-gold`,
type: `integer`,
label: `RipCrypt.common.currency.gold`,
value: input.gold,
path: `system.cost.gold`,
limited: input.limited,
},
{
id: input.id + `-silver`,
type: `integer`,
label: `RipCrypt.common.currency.silver`,
value: input.silver,
path: `system.cost.silver`,
limited: input.limited,
},
{
id: input.id + `-copper`,
type: `integer`,
label: `RipCrypt.common.currency.copper`,
value: input.copper,
path: `system.cost.copper`,
limited: input.limited,
},
],
}, data);
};

View file

@ -0,0 +1,36 @@
import { localizer } from "../../utils/Localizer.mjs";
import { options } from "../options.mjs";
export function dropdownInput(input, data) {
const label = localizer(input.label);
const id = `${data.meta.idp}-${input.id}`;
if (!data.meta.editable) {
return `<div data-input-type="dropdown">
<span class="label">${label}</span>
<span class="value">${data.meta.limited && input.limited ? `???` : input.value}</span>
</div>`;
};
if (!input.options.length) {
throw new Error(`dropdown type inputs must have some options`);
};
return `<div data-input-type="dropdown">
<label
for="${id}"
>
${label}
</label>
<select
id="${id}"
name="${input.path}"
>
${options(
input.value,
input.options,
{ hash: { localize: true }},
)}
</select>
</div>`;
};

View file

@ -0,0 +1,47 @@
import { barInput } from "./barInput.mjs";
import { booleanInput } from "./booleanInput.mjs";
import { costInput } from "./currency.mjs";
import { dropdownInput } from "./dropdownInput.mjs";
import { groupInput } from "./groupInput.mjs";
import { numberInput } from "./numberInput.mjs";
import { prosemirrorInput } from "./prosemirrorInput.mjs";
import { stringSet } from "./stringSet.mjs";
import { textInput } from "./textInput.mjs";
const { getType } = foundry.utils;
const inputTypes = {
"string-set": stringSet,
prosemirror: prosemirrorInput,
integer: numberInput,
bar: barInput,
dropdown: dropdownInput,
boolean: booleanInput,
group: groupInput,
text: textInput,
cost: costInput,
};
const typesToSanitize = new Set([ `string`, `number` ]);
export function formFields(inputs, opts) {
const fields = [];
for (const input of inputs) {
if (inputTypes[input.type] == null) { continue };
if (input.type !== `group`) {
input.limited ??= true;
};
if (
input.type !== `prosemirror`
&& typesToSanitize.has(getType(input.value))
) {
input.value = Handlebars.escapeExpression(input.value);
};
fields.push(inputTypes[input.type](input, opts.data.root));
};
return fields
.filter(i => i.length > 0)
.join(opts.hash?.joiner ?? `<hr />`);
};

View file

@ -0,0 +1,26 @@
import { formFields } from "./formFields.mjs";
import { localizer } from "../../utils/Localizer.mjs";
export function groupInput(input, data) {
const title = localizer(input.title);
const content = formFields(
input.fields,
{
data: { root: data },
hash: { joiner: input.joiner ?? `` },
},
);
return `<rc-border
data-input-type="group"
var:border-color="${input.borderColor ?? `var(--accent-1)`}"
var:vertical-displacement="${input.verticalDisplacement ?? `12px`}"
var:padding-top="${input.paddingTop ?? `20px`}"
>
<div slot="title">${title}</div>
<div slot="content" class="content">
${content}
</div>
</rc-border>`;
};

View file

@ -0,0 +1,33 @@
import { localizer } from "../../utils/Localizer.mjs";
export function numberInput(input, data) {
const label = localizer(input.label);
const id = `${data.meta.idp}-${input.id}`;
if (!data.meta.editable) {
return `<div data-input-type="integer">
<span class="label">${label}</span>
<span class="value">${data.meta.limited && input.limited ? `???` : input.value}</span>
</div>`;
};
let attrs = ``;
if (input.min != undefined) { attrs += ` min="${input.min}"` };
if (input.max != undefined) { attrs += ` max="${input.max}"` };
if (input.step != undefined) { attrs += `step="${input.step}"` };
return `<div data-input-type="integer">
<label
for="${id}"
>
${label}
</label>
<input
type="number"
id="${id}"
value="${input.value}"
name="${input.path}"
${attrs}
/>
</div>`;
};

View file

@ -0,0 +1,43 @@
import { localizer } from "../../utils/Localizer.mjs";
export function prosemirrorInput(input, data) {
const label = localizer(input.label);
if (!data.meta.editable) {
return `<div data-input-type="prose-mirror">
<div class="label-row">
<div class="label">
${label}
</div>
</div>
<div class="value">
${input.value}
</div>
</div>`;
};
return `<div data-input-type="prose-mirror">
<div class="label-row">
<div class="label">
${label}
</div>
<button
type="button"
data-action="openRichEditor"
data-uuid="${input.uuid}"
data-path="${input.path}"
data-compact="${input.compact}"
data-collaborative="${input.collaborative}"
>
${localizer(`RipCrypt.common.edit`)}
</button>
</div>
<!--
This cannot be spread across multiple lines because of the :empty selector
considering whitespace as "not being empty". Though browsers will eventually
treat :empty as "empty, or only whitespace".
-->
<div class="value">${input.value}</div>
</div>`;
};

View file

@ -0,0 +1,51 @@
import { localizer } from "../../utils/Localizer.mjs";
export function stringSet(input, data) {
const label = localizer(input.label);
const placeholder = localizer(input.placeholder ?? ``);
const id = `${data.meta.idp}-${input.id}`;
if (!data.meta.editable) {
const tagList = input.value
.split(/,\s*/)
.filter(t => t.length > 0)
.map(t => {
return `<div class="tag">${t.trim()}</div>`;
});
let count = tagList.length;
let tags = tagList.join(``);
if (tagList.length === 0) {
tags = `---`;
};
if (data.meta.limited && input.limited) {
count = 0;
tags = `???`;
};
return `<div data-input-type="string-set">
<span class="label">${label}</span>
<div
class="input-element-tags tags ${count == 0 ? `empty` : ``}"
data-tag-count="${count}"
>
${tags}
</div>
</div>`;
};
return `<div data-input-type="string-set">
<label
for="${id}"
>
${label}
</label>
<string-tags
id="${id}"
placeholder="${placeholder}"
value="${input.value}"
name="${input.path}"
/>
</div>`;
};

View file

@ -0,0 +1,27 @@
import { localizer } from "../../utils/Localizer.mjs";
export function textInput(input, data) {
const label = localizer(input.label);
const id = `${data.meta.idp}-${input.id}`;
if (!data.meta.editable) {
return `<div data-input-type="text">
<span class="label">${label}</span>
<span class="value">${data.meta.limited && input.limited ? `???` : input.value}</span>
</div>`;
};
return `<div data-input-type="text">
<label
for="${id}"
>
${label}
</label>
<input
type="text"
id="${id}"
value="${input.value}"
name="${input.path}"
/>
</div>`;
};

View file

@ -0,0 +1,36 @@
import { localizer } from "../utils/Localizer.mjs";
/**
* @typedef {object} Option
* @property {string} [label]
* @property {string|number} value
* @property {boolean} [disabled]
*/
/**
* @param {string | number} selected
* @param {Array<Option | string>} opts
* @param {any} meta
*/
export function options(selected, opts, meta) {
const { localize = false } = meta.hash;
selected = Handlebars.escapeExpression(selected);
const htmlOptions = [];
for (let opt of opts) {
if (typeof opt === `string`) {
opt = { label: opt, value: opt };
};
opt.value = Handlebars.escapeExpression(opt.value);
htmlOptions.push(
`<option
value="${opt.value}"
${selected === opt.value ? `selected` : ``}
${opt.disabled ? `disabled` : ``}
>
${localize ? localizer(opt.label) : opt.label}
</option>`,
);
};
return new Handlebars.SafeString(htmlOptions.join(`\n`));
};

View file

@ -1,3 +1,5 @@
import { Logger } from "../utils/Logger.mjs";
const loaders = {
svg(data) {
const iconName = data.path.split(`/`).slice(-1)[0].slice(0, -4);
@ -5,10 +7,7 @@ const loaders = {
Hooks.call(`${game.system.id}-hmr:svg`, iconName, data);
},
mjs() {window.location.reload()},
css(data) {
Logger.debug(`Hot-reloading CSS: ${data.path}`);
Hooks.call(`${game.system.id}-hmr:css`, data);
},
css() {window.location.reload()},
};
Hooks.on(`hotReload`, async (data) => {

View file

@ -1,9 +1,126 @@
// Applications
import { AllItemSheetV1 } from "../Apps/ItemSheets/AllItemSheetV1.mjs";
import { CombinedHeroSheet } from "../Apps/ActorSheets/CombinedHeroSheet.mjs";
import { CraftCardV1 } from "../Apps/ActorSheets/CraftCardV1.mjs";
import { DelveDiceHUD } from "../Apps/DelveDiceHUD.mjs";
import { RipCryptCombatTracker } from "../Apps/sidebar/CombatTracker.mjs";
import { SkillsCardV1 } from "../Apps/ActorSheets/SkillsCardV1.mjs";
import { StatsCardV1 } from "../Apps/ActorSheets/StatsCardV1.mjs";
// Data Models
import { AmmoData } from "../data/Item/Ammo.mjs";
import { ArmourData } from "../data/Item/Armour.mjs";
import { CraftData } from "../data/Item/Craft.mjs";
import { GeistData } from "../data/Actor/Geist.mjs";
import { GoodData } from "../data/Item/Good.mjs";
import { HeroData } from "../data/Actor/Hero.mjs";
import { ShieldData } from "../data/Item/Shield.mjs";
import { SkillData } from "../data/Item/Skill.mjs";
import { WeaponData } from "../data/Item/Weapon.mjs";
// Class Overrides
import { CryptDie } from "../dice/CryptDie.mjs";
// Documents
import { RipCryptCombat } from "../documents/combat.mjs";
import { RipCryptCombatant } from "../documents/combatant.mjs";
import { RipCryptItem } from "../documents/item.mjs";
import { RipCryptToken } from "../documents/token.mjs";
// Misc
import helpers from "../handlebarHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../Apps/elements/_index.mjs";
import { registerDevSettings } from "../settings/devSettings.mjs";
import { registerMetaSettings } from "../settings/metaSettings.mjs";
import { registerUserSettings } from "../settings/userSettings.mjs";
import { registerWorldSettings } from "../settings/worldSettings.mjs";
const { Items, Actors } = foundry.documents.collections;
const { ItemSheet, ActorSheet } = foundry.appv1.sheets;
Hooks.once(`init`, () => {
Logger.log(`Initializing`);
// Datamodel registrations
CONFIG.Combat.initiative.decimals = 2;
CONFIG.ui.delveDice = DelveDiceHUD;
// #region Settings
registerMetaSettings();
registerDevSettings();
registerUserSettings();
registerWorldSettings();
// #endregion
// #region Datamodels
CONFIG.Actor.dataModels.hero = HeroData;
CONFIG.Actor.dataModels.geist = GeistData;
CONFIG.Item.dataModels.ammo = AmmoData;
CONFIG.Item.dataModels.armour = ArmourData;
CONFIG.Item.dataModels.craft = CraftData;
CONFIG.Item.dataModels.good = GoodData;
CONFIG.Item.dataModels.shield = ShieldData;
CONFIG.Item.dataModels.skill = SkillData;
CONFIG.Item.dataModels.weapon = WeaponData;
// #endregion
// #region Class Changes
CONFIG.ui.combat = RipCryptCombatTracker;
CONFIG.Combat.documentClass = RipCryptCombat;
CONFIG.Combatant.documentClass = RipCryptCombatant;
CONFIG.Token.objectClass = RipCryptToken;
CONFIG.Item.documentClass = RipCryptItem;
CONFIG.Dice.terms.d = CryptDie;
// #endregion
// #region Sheets
// Unregister core sheets
Items.unregisterSheet(`core`, ItemSheet);
Actors.unregisterSheet(`core`, ActorSheet);
// #region Actors
Actors.registerSheet(game.system.id, CombinedHeroSheet, {
makeDefault: true,
types: [`hero`],
label: `RipCrypt.sheet-names.CombinedHeroSheet`,
themes: CombinedHeroSheet.themes,
});
Actors.registerSheet(game.system.id, StatsCardV1, {
types: [`hero`],
label: `RipCrypt.sheet-names.StatsCardV1`,
themes: StatsCardV1.themes,
});
Actors.registerSheet(game.system.id, StatsCardV1, {
makeDefault: true,
types: [`geist`],
label: `RipCrypt.sheet-names.StatsCardV1`,
themes: StatsCardV1.themes,
});
Actors.registerSheet(game.system.id, SkillsCardV1, {
types: [`hero`, `geist`],
label: `RipCrypt.sheet-names.SkillsCardV1`,
themes: SkillsCardV1.themes,
});
Actors.registerSheet(game.system.id, CraftCardV1, {
types: [`hero`, `geist`],
label: `RipCrypt.sheet-names.CraftCardV1`,
themes: CraftCardV1.themes,
});
// #endregion
// #region Items
Items.registerSheet(game.system.id, AllItemSheetV1, {
makeDefault: true,
label: `RipCrypt.sheet-names.AllItemsSheetV1`,
themes: AllItemSheetV1.themes,
});
// #endregion
// #endregion
// #region Token Attrs
CONFIG.Actor.trackableAttributes.hero = HeroData.trackableAttributes;
// #endregion
registerCustomComponents();
Handlebars.registerHelper(helpers);
});

View file

@ -1,5 +1,34 @@
import { filePath } from "../consts.mjs";
import { Logger } from "../utils/Logger.mjs";
Hooks.once(`ready`, () => {
Logger.log(`Ready`);
let defaultTab = game.settings.get(`ripcrypt`, `defaultTab`);
if (defaultTab) {
if (!ui.sidebar?.TABS?.[defaultTab]) {
Logger.error(`Couldn't find a sidebar tab with ID:`, defaultTab);
} else {
Logger.debug(`Switching sidebar tab to:`, defaultTab);
ui.sidebar.activateTab(defaultTab);
};
};
if (game.settings.get(`ripcrypt`, `devMode`)) {
ui.sidebar.expand();
if (game.paused) { game.togglePause() };
};
ui.delveDice.render({ force: true });
// MARK: 1-time updates
if (!game.settings.get(`ripcrypt`, `firstLoadFinished`)) {
// Update the turnMarker to be the RipCrypt defaults
const combatConfig = game.settings.get(`core`, `combatTrackerConfig`);
combatConfig.turnMarker.src = filePath(`assets/turn-marker.png`);
combatConfig.turnMarker.animation = `spinPulse`;
game.settings.set(`core`, `combatTrackerConfig`, combatConfig);
}
game.settings.set(`ripcrypt`, `firstLoadFinished`, true);
});

View file

@ -0,0 +1,7 @@
// Hooks
import "./hooks/init.mjs";
import "./hooks/ready.mjs";
import "./hooks/hotReload.mjs";
// Global API
import "./api.mjs";

View file

@ -0,0 +1,17 @@
export function registerDevSettings() {
game.settings.register(`ripcrypt`, `devMode`, {
scope: `client`,
type: Boolean,
config: false,
default: false,
requiresReload: false,
});
game.settings.register(`ripcrypt`, `defaultTab`, {
name: `Default Tab`,
scope: `client`,
type: String,
config: game.settings.get(`ripcrypt`, `devMode`),
requiresReload: false,
});
};

View file

@ -0,0 +1,61 @@
import { gameTerms } from "../gameTerms.mjs";
const { StringField } = foundry.data.fields;
const { FatePath } = gameTerms;
export function registerMetaSettings() {
game.settings.register(`ripcrypt`, `dc`, {
scope: `world`,
type: Number,
default: 5,
config: false,
requiresReload: false,
onChange: () => {
ui.delveDice.render({ parts: [`difficulty`] });
},
});
game.settings.register(`ripcrypt`, `sandsOfFate`, {
scope: `world`,
type: Number,
default: 8,
config: false,
requiresReload: false,
onChange: async () => {
ui.delveDice.animate({ parts: [`sandsOfFate`] });
},
});
game.settings.register(`ripcrypt`, `currentFate`, {
scope: `world`,
type: new StringField({
blank: false,
nullable: false,
initial: FatePath.NORTH,
}),
config: false,
requiresReload: false,
onChange: async () => {
ui.delveDice.animate({ parts: [`fateCompass`] });
},
});
game.settings.register(`ripcrypt`, `whoFirst`, {
scope: `world`,
type: String,
config: false,
requiresReload: false,
default: `friendly`,
onChange: async () => {
await game.combat.setupTurns();
await ui.combat.render({ parts: [ `tracker` ] });
},
});
game.settings.register(`ripcrypt`, `firstLoadFinished`, {
scope: `world`,
type: Boolean,
default: false,
requiresReload: false,
});
};

View file

@ -0,0 +1,23 @@
export function registerUserSettings() {
/* ! Non-Functional
game.settings.register(`ripcrypt`, `abbrAccess`, {
name: `RipCrypt.setting.abbrAccess.name`,
hint: `RipCrypt.setting.abbrAccess.hint`,
scope: `user`,
type: Boolean,
config: true,
default: false,
requiresReload: false,
});
*/
game.settings.register(`ripcrypt`, `condensedRange`, {
name: `RipCrypt.setting.condensedRange.name`,
hint: `RipCrypt.setting.condensedRange.hint`,
scope: `user`,
type: Boolean,
config: true,
default: true,
requiresReload: false,
});
};

View file

@ -0,0 +1,42 @@
const { NumberField, StringField } = foundry.data.fields;
export function registerWorldSettings() {
game.settings.register(`ripcrypt`, `sandsOfFateInitial`, {
name: `RipCrypt.setting.sandsOfFateInitial.name`,
hint: `RipCrypt.setting.sandsOfFateInitial.hint`,
scope: `world`,
config: true,
requiresReload: false,
type: new NumberField({
required: true,
min: 1,
step: 1,
max: 10,
initial: 8,
}),
onChange: async (newInitialSands) => {
const currentSands = game.settings.get(`ripcrypt`, `sandsOfFate`);
if (newInitialSands <= currentSands) {
game.settings.set(`ripcrypt`, `sandsOfFate`, newInitialSands);
};
},
});
game.settings.register(`ripcrypt`, `onCrypticEvent`, {
name: `RipCrypt.setting.onCrypticEvent.name`,
hint: `RipCrypt.setting.onCrypticEvent.hint`,
scope: `world`,
config: true,
requiresReload: false,
type: new StringField({
required: true,
initial: `notif`,
choices: {
"notif": `RipCrypt.setting.onCrypticEvent.options.notif`,
"pause": `RipCrypt.setting.onCrypticEvent.options.pause`,
"both": `RipCrypt.setting.onCrypticEvent.options.both`,
"nothing": `RipCrypt.setting.onCrypticEvent.options.nothing`,
},
}),
});
};

View file

@ -0,0 +1,40 @@
const config = Object.preventExtensions({
subKeyPattern: /@(?<key>[a-zA-Z.]+)/gm,
maxDepth: 10,
});
export function handlebarsLocalizer(key, ...args) {
let data = args[0];
if (args.length === 1) { data = args[0].hash };
if (key instanceof Handlebars.SafeString) { key = key.toString() };
const localized = localizer(key, data);
return localized;
};
export function localizer(key, args = {}, depth = 0) {
/** @type {string} */
let localized = game.i18n.format(key, args);
const subkeys = localized.matchAll(config.subKeyPattern);
// Short-cut to help prevent infinite recursion
if (depth > config.maxDepth) {
return localized;
};
/*
Helps prevent recursion on the same key so that we aren't doing excess work.
*/
const localizedSubkeys = new Map();
for (const match of subkeys) {
const subkey = match.groups.key;
if (localizedSubkeys.has(subkey)) { continue };
localizedSubkeys.set(subkey, localizer(subkey, args, depth + 1));
};
return localized.replace(
config.subKeyPattern,
(_fullMatch, subkey) => {
return localizedSubkeys.get(subkey);
},
);
};

View file

@ -15,7 +15,7 @@ const augmentedProps = new Set([
export const Logger = new Proxy(console, {
get(target, prop, _receiver) {
if (augmentedProps.has(prop)) {
return (...args) => target[prop](game.system.id, `|`, ...args);
return target[prop].bind(target, game.system.id, `|`);
};
return target[prop];
},

View file

@ -0,0 +1,184 @@
import { getTooltipDelay } from "../consts.mjs";
import { Logger } from "./Logger.mjs";
export class PopoverEventManager {
#options;
#id;
get id() {
return this.#id;
};
/** @type {Map<string, PopoverEventManager>} */
static #existing = new Map();
/**
* @param {HTMLElement} element The element to attach the listeners to.
* @param {GenericPopoverMixin} popoverClass The class reference that represents the popover app
*/
constructor(id, element, popoverClass, options = {}) {
id = `${id}-${popoverClass.name}`;
this.#id = id;
if (PopoverEventManager.#existing.has(id)) {
const manager = PopoverEventManager.#existing.get(id);
manager.#addListeners(element);
return manager;
};
options.managerId = id;
options.locked ??= false;
options.lockable ??= true;
this.#options = options;
this.#element = element;
this.#class = popoverClass;
this.#addListeners(element);
PopoverEventManager.#existing.set(id, this);
};
/**
* @param {HTMLElement} element
*/
#addListeners(element) {
element.addEventListener(`pointerenter`, this.#pointerEnterHandler.bind(this));
element.addEventListener(`pointerout`, this.#pointerOutHandler.bind(this));
element.addEventListener(`click`, this.#clickHandler.bind(this));
if (this.#options.lockable) {
element.addEventListener(`pointerup`, this.#pointerUpHandler.bind(this));
};
};
destroy() {
this.close();
this.#element.removeEventListener(`pointerenter`, this.#pointerEnterHandler);
this.#element.removeEventListener(`pointerout`, this.#pointerOutHandler);
this.#element.removeEventListener(`click`, this.#clickHandler);
if (this.#options.lockable) {
this.#element.removeEventListener(`pointerup`, this.#pointerUpHandler);
};
this.#stopOpen();
this.#stopClose();
};
close() {
this.#frameless?.close({ force: true });
this.#framed?.close({ force: true });
};
#stopOpen() {
if (this.#openTimeout != null) {
clearTimeout(this.#openTimeout);
this.#openTimeout = null;
};
};
#stopClose() {
if (this.#closeTimeout != null) {
clearTimeout(this.#closeTimeout);
this.#closeTimeout = null;
}
};
get rendered() {
return Boolean(this.#frameless?.rendered || this.#framed?.rendered);
};
render(options) {
if (this.#framed?.rendered) {
this.#framed.render(options);
};
if (this.#frameless?.rendered) {
this.#frameless.render(options);
};
};
#element;
#class;
#openTimeout = null;
#closeTimeout = null;
#frameless;
#framed;
#construct(options) {
options.popover ??= {};
options.popover.managerId = this.#id;
return new this.#class(options);
};
#clickHandler() {
Logger.debug(`click event handler`);
// Cleanup for the frameless lifecycle
this.#stopOpen();
this.#stopClose();
this.#frameless?.close({ force: true });
if (!this.#framed) {
this.#framed = this.#construct({ popover: { ...this.#options, framed: true } });
}
this.#framed?.render({ force: true });
};
#pointerEnterHandler(event) {
this.#stopClose();
const pos = event.target.getBoundingClientRect();
const x = pos.x + Math.floor(pos.width / 2);
const y = pos.y;
this.#openTimeout = setTimeout(
() => {
this.#openTimeout = null;
// When we have the framed version rendered, we might as well just focus
// it instead of rendering a new application
if (this.#framed?.rendered) {
this.#framed.bringToFront();
return;
};
// When the frameless is already rendered, we should just move it to the
// new location instead of spawning a new one
if (this.#frameless?.rendered) {
const { width, height } = this.#frameless.element.getBoundingClientRect();
const top = y - height;
const left = x - Math.floor(width / 2);
this.#frameless.setPosition({ left, top });
return;
}
this.#frameless = this.#construct({
popover: {
...this.#options,
framed: false,
x, y,
},
});
this.#frameless?.render({ force: true });
},
getTooltipDelay(),
);
};
#pointerOutHandler() {
this.#stopOpen();
this.#closeTimeout = setTimeout(
() => {
this.#closeTimeout = null;
this.#frameless?.close();
},
getTooltipDelay(),
);
};
#pointerUpHandler(event) {
if (event.button !== 1 || !this.#frameless?.rendered || Tour.tourInProgress) { return };
event.preventDefault();
this.#frameless.toggleLock();
};
};

51
module/utils/fates.mjs Normal file
View file

@ -0,0 +1,51 @@
import { gameTerms } from "../gameTerms.mjs";
import { Logger } from "./Logger.mjs";
const { FatePath } = gameTerms;
export function isOppositeFates(a, b) {
return (a === FatePath.NORTH && b === FatePath.SOUTH)
|| (a === FatePath.EAST && b === FatePath.WEST);
};
export function distanceBetweenFates(start, end) {
if (!start || !end) {
Logger.error(`Start and End must both have a defined value, given`, {start, end});
return undefined;
};
if (start === end) {
return 0;
};
if (isOppositeFates(start, end) || isOppositeFates(end, start)) {
return 2;
};
let isForward = start === FatePath.SOUTH && end === FatePath.WEST;
isForward ||= start === FatePath.NORTH && end === FatePath.EAST;
isForward ||= start === FatePath.WEST && end === FatePath.NORTH;
isForward ||= start === FatePath.EAST && end === FatePath.SOUTH;
if (isForward) {
return 1;
};
return 3;
};
const fateOrder = [
FatePath.WEST, // to make the .find not integer overflow
FatePath.NORTH,
FatePath.EAST,
FatePath.SOUTH,
FatePath.WEST,
];
export function nextFate(fate) {
const fateIndex = fateOrder.findIndex(f => f === fate);
return fateOrder[fateIndex + 1];
};
export function previousFate(fate) {
const fateIndex = fateOrder.lastIndexOf(fate);
return fateOrder[fateIndex - 1];
};

13
module/utils/rank.mjs Normal file
View file

@ -0,0 +1,13 @@
import { gameTerms } from "../gameTerms.mjs";
/**
* Converts a rank's name into an integer form for use in mathematical calculations
* that rely on rank.
*
* @param {Novice|Adept|Expert|Master} rankName The rank to convert into an integer
* @returns An integer between 1 and 4
*/
export function rankToInteger(rankName) {
return Object.values(gameTerms.Rank)
.findIndex(r => r === rankName) + 1;
};

View file

@ -0,0 +1,3 @@
export function sumReduce(sum, val) {
return sum + val;
};

View file

@ -1,12 +1,12 @@
{
"id": "ripcrypt",
"title": "RipCrypt",
"description": "",
"description": "A dungeon sprint RPG. Faster than an arrow to the eye. Smoother than a clean blade. Compact with consequences.",
"version": "0.0.1",
"compatibility": {
"minimum": 12,
"verified": 12,
"maximum": 12
"minimum": 13,
"verified": 13.339,
"maximum": 13
},
"authors": [
{
@ -17,7 +17,12 @@
"esmodules": [
"module/main.mjs"
],
"styles": [],
"styles": [
{
"src": "templates/css/main.css",
"layer": "system"
}
],
"languages": [
{
"lang": "en",
@ -33,13 +38,22 @@
"flags": {
"hotReload": {
"extensions": ["css", "hbs", "json", "mjs", "svg"],
"paths": ["Apps", "langs", "styles", "module"]
"paths": ["assets", "templates", "langs", "module"]
}
},
"documentTypes": {
"Actor": {
"hero": {}
"hero": {},
"geist": {}
},
"Item": {}
"Item": {
"ammo": {},
"armour": {},
"craft": {},
"good": {},
"shield": {},
"skill": {},
"weapon": {}
}
}
}

View file

@ -0,0 +1,17 @@
<div class="AllItemSheetV1">
{{#if meta.editable}}
<label for="{{meta.idp}}-name">Name</label>
<input
type="text"
id="{{meta.idp}}-name"
class="name"
name="name"
value="{{item.name}}"
>
{{else}}
<span class="label">Name</span>
<span class="value">{{item.name}}</span>
{{/if}}
<hr>
{{{ rc-formFields formFields }}}
</div>

View file

@ -0,0 +1,119 @@
.ripcrypt .AllItemSheetV1 {
--input-height: 1rem;
--input-underline: none;
--col-gap: 8px;
--row-gap: 8px;
--string-tags-tag-text: var(--header-text);
--string-tags-tag-background: var(--header-background);
--string-tags-add-text: white;
--string-tags-add-background: var(--accent-1);
--string-tags-input-text: white;
--string-tags-input-background: var(--accent-2);
--input-text: white;
--input-background: var(--accent-2);
--button-text: white;
--button-background: var(--accent-2);
--pill-width: 100%;
--pill-border-radius: 4px;
display: grid;
grid-template-columns: auto 200px;
column-gap: var(--col-gap);
row-gap: var(--row-gap);
max-width: 350px;
padding: 8px;
background: var(--base-background);
color: var(--base-text);
[data-input-type] {
display: contents;
}
> [data-input-type="group"] {
display: unset;
grid-column: 1 / -1;
> .content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
column-gap: var(--col-gap);
row-gap: var(--row-gap);
}
}
> [data-input-type="prose-mirror"] {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: var(--row-gap);
> .label-row {
display: flex;
flex-direction: row;
width: 100%;
justify-content: space-between;
}
.value {
background: var(--input-background);
color: var(--input-text);
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
&:empty {
display: none;
}
}
}
hr {
background: var(--accent-1);
grid-column: 1 / -1;
height: 1px;
width: 90%;
margin: 0 auto;
}
hr:has(+ [data-input-type="group"]),
[data-input-type="group"] + hr {
display: none;
};
label, .label {
display: flex;
align-items: center;
box-sizing: border-box;
padding: 2px 4px;
text-transform: uppercase;
font-size: var(--font-size-14);
overflow: hidden;
text-overflow: ellipsis;
font-weight: bold;
}
button, input, select, .value, [data-tag-count] {
border-radius: 4px;
padding: 2px 4px;
}
.value, [data-tag-count="0"] {
border: 2px solid var(--accent-2);
}
[data-tag-count="0"] {
justify-content: start;
}
[data-input-type="boolean"] > .checkbox-container {
display: flex;
justify-content: right;
}
}

View file

@ -0,0 +1,38 @@
.ripcrypt.ripcrypt--CombinedHeroSheet .crafts-summary {
display: grid;
column-gap: var(--col-gap);
grid-template-columns: repeat(3, minmax(0, 3fr));
grid-template-rows: repeat(5, minmax(0, 1fr));
grid-auto-flow: column;
padding: 8px;
.col-header {
background: var(--section-header-background);
color: var(--section-header-text);
border-radius: 999px;
}
label, .label {
box-sizing: border-box;
padding: 2px 4px;
text-transform: uppercase;
font-size: var(--font-size-14);
overflow: hidden;
text-overflow: ellipsis;
font-weight: bold;
}
.craft-list {
grid-row: span 4;
display: contents;
> :nth-child(odd) {
background: var(--alt-row-background);
color: var(--alt-row-text);
}
}
span.name {
flex-grow: 1;
}
}

View file

@ -0,0 +1,67 @@
<div class="crafts-summary">
<div class="label col-header">Focus</div>
<ol class="craft-list num-before">
{{#each craft.focus as | craft |}}
{{#if craft}}
<li data-item-id="{{craft.uuid}}" data-ctx-menu="craft">
<span class="name">{{ craft.name }}</span>
{{#if craft.use}}
<rc-icon
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
data-tooltip="{{ craft.use }}"
data-tooltip-direction="UP"
></rc-icon>
{{/if}}
</li>
{{else}}
<li></li>
{{/if}}
{{/each}}
</ol>
<div class="label col-header">Fract</div>
<ol class="craft-list num-before">
{{#each craft.fract as | craft |}}
{{#if craft}}
<li data-item-id="{{craft.uuid}}" data-ctx-menu="craft">
<span class="name">{{ craft.name }}</span>
{{#if craft.use}}
<rc-icon
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
data-tooltip="{{ craft.use }}"
data-tooltip-direction="UP"
></rc-icon>
{{/if}}
</li>
{{else}}
<li></li>
{{/if}}
{{/each}}
</ol>
<div class="label col-header">Flect</div>
<ol class="craft-list num-before">
{{#each craft.flect as | craft |}}
{{#if craft}}
<li data-item-id="{{craft.uuid}}" data-ctx-menu="craft">
<span class="name">{{ craft.name }}</span>
{{#if craft.use}}
<rc-icon
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
data-tooltip="{{ craft.use }}"
data-tooltip-direction="UP"
></rc-icon>
{{/if}}
</li>
{{else}}
<li></li>
{{/if}}
{{/each}}
</ol>
</div>

View file

@ -0,0 +1,13 @@
@import url("./crafts.css");
.ripcrypt.ripcrypt--CombinedHeroSheet {
> .window-content {
gap: 4px;
background: var(--base-background);
overflow-y: auto;
}
.HeroSkillsCardV1 {
--col-gap: 2px;
}
}

View file

@ -0,0 +1,101 @@
<div class="CraftCardV1">
<div class="col-header">
{{rc-i18n "RipCrypt.common.glimcraft"}}
</div>
<div class="aura-container">
<div class="circle-fragment">10</div>
<div class="circle-fragment">8</div>
<div class="circle-fragment">6</div>
<div class="full-circle">4</div>
<rc-svg
class="caster-silhouette"
name="caster-silhouette.v1"
var:fill="var(--accent-1)"
></rc-svg>
<div class="aura-values">
<div class="dual-pill">
<span class="label" aria-hidden="true">{{rc-i18n "RipCrypt.common.aura"}}</span>
<div class="values">
<span class="value" data-tooltip="RipCrypt.tooltips.auras.normal">{{aura.normal}}</span>
<span class="slash" aria-hidden="true"></span>
<span class="value" data-tooltip="RipCrypt.tooltips.auras.heavy">{{aura.heavy}}</span>
</div>
</div>
</div>
</div>
<div data-aspect="focus" class="col-header aspect-header">
<span class="name">{{rc-i18n "RipCrypt.common.aspectNames.focus"}}</span>
<span class="small">{{rc-i18n "RipCrypt.common.details"}}</span>
</div>
<ol data-aspect="focus" class="num-before craft-list">
{{#each craft.focus as | craft |}}
{{#if craft}}
<li data-item-id="{{craft.uuid}}" data-ctx-menu="craft">
<span class="name">{{ craft.name }}</span>
{{#if craft.use}}
<rc-icon
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
data-tooltip="{{ craft.use }}"
data-tooltip-direction="UP"
></rc-icon>
{{/if}}
</li>
{{else}}
<li></li>
{{/if}}
{{/each}}
</ol>
<div data-aspect="flect" class="col-header aspect-header">
<span class="name">{{rc-i18n "RipCrypt.common.aspectNames.flect"}}</span>
<span class="small">{{rc-i18n "RipCrypt.common.details"}}</span>
</div>
<ol data-aspect="flect" class="num-before craft-list">
{{#each craft.flect as | craft |}}
{{#if craft}}
<li data-item-id="{{craft.uuid}}" data-ctx-menu="craft">
<span class="name">{{ craft.name }}</span>
{{#if craft.use}}
<rc-icon
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
data-tooltip="{{ craft.use }}"
data-tooltip-direction="UP"
></rc-icon>
{{/if}}
</li>
{{else}}
<li></li>
{{/if}}
{{/each}}
</ol>
<div data-aspect="fract" class="col-header aspect-header">
<span class="name">{{rc-i18n "RipCrypt.common.aspectNames.fract"}}</span>
<span class="small">{{rc-i18n "RipCrypt.common.details"}}</span>
</div>
<ol data-aspect="fract" class="num-before craft-list">
{{#each craft.fract as | craft |}}
{{#if craft}}
<li data-item-id="{{craft.uuid}}" data-ctx-menu="craft">
<span class="name">{{ craft.name }}</span>
{{#if craft.use}}
<rc-icon
name="icons/info-circle"
var:size="16px"
var:fill="currentColor"
data-tooltip="{{ craft.use }}"
data-tooltip-direction="UP"
></rc-icon>
{{/if}}
</li>
{{else}}
<li></li>
{{/if}}
{{/each}}
</ol>
</div>

View file

@ -0,0 +1,137 @@
.ripcrypt .CraftCardV1 {
--col-gap: 8px;
display: grid;
column-gap: var(--col-gap);
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(15, minmax(0, 1fr));
background: var(--base-background);
color: var(--base-text);
.col-header {
display: flex;
flex-direction: row;
align-items: center;
background: var(--section-header-background);
color: var(--section-header-text);
padding: 2px 4px;
border-radius: 999px;
}
label, .label {
box-sizing: border-box;
padding: 2px 4px;
text-transform: uppercase;
font-size: var(--font-size-14);
overflow: hidden;
text-overflow: ellipsis;
font-weight: bold;
}
.aura-container {
grid-column: 1 / -1;
grid-row: 2 / span 4;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr);
position: relative;
}
.circle-fragment, .full-circle {
display: flex;
justify-content: center;
align-items: center;
}
.circle-fragment {
border-top-left-radius: 24% 100%;
border-bottom-left-radius: 25% 100%;
border-left: 2px dashed var(--accent-3);
margin-right: -5%;
}
.full-circle {
border: 2px dashed var(--accent-3);
flex-grow: 0;
border-radius: 999px;
width: 80%;
aspect-ratio: 1;
align-self: center;
justify-self: center;
grid-row: 1;
grid-column: 4;
}
.caster-silhouette {
grid-column: 4 / span 4;
grid-row: 1;
position: absolute;
left: 2rem;
width: 70%;
bottom: -10px;
}
.aura-values {
grid-row: 1;
grid-column: -3 / -1;
display: flex;
justify-content: center;
align-items: center;
z-index: 3;
.dual-pill {
border-radius: 999px;
background: var(--accent-1);
display: flex;
flex-direction: row;
gap: 0.25rem;
align-items: center;
padding-left: 8px;
margin-left: 1rem;
margin-bottom: 1.2rem;
}
.values {
border-radius: 999px;
margin: 2px;
background: var(--base-background);
color: var(--base-text);
padding: 0.125rem 0.5rem;
display: flex;
flex-direction: row;
gap: 0.5rem;
--slash-color: var(--accent-1);
}
}
.craft-list {
display: grid;
grid-template-rows: subgrid;
> :nth-child(even) {
background: var(--alt-row-background);
color: var(--alt-row-text);
}
}
span.name {
flex-grow: 1;
}
[data-aspect="focus"] { --row: 6; --col: 1; }
[data-aspect="flect"] { --row: 6; --col: 2; }
[data-aspect="fract"] { --row: 11; --col: 1; }
[data-aspect] {
&.aspect-header {
z-index: 1;
grid-row: var(--row);
grid-column: var(--col);
}
&.craft-list {
grid-row: calc(var(--row) + 1) / span 4;
grid-column: var(--col);
}
}
}

View file

@ -0,0 +1,14 @@
<div id="delve-difficulty">
<div
class="icon-container"
data-tooltip="RipCrypt.common.difficulty"
>
<span class="large">{{dc}}</span>
<rc-icon
class="hourglass"
name="icons/d8-outline"
var:size="34px"
var:fill="var(--accent-2)"
></rc-icon>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show more