diff --git a/README.md b/README.md
index 758c3ca..9ca19e6 100644
--- a/README.md
+++ b/README.md
@@ -10,12 +10,51 @@ It is supposed to aid storytellers and allow them to quickly set up and capture
### Features
- Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**)
-- Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script)
+- Supports custom script JSON generated by the [Script Tool](https://bloodontheclocktower.com/script)
- Live Session for Storyteller / Players including live voting!
- Includes all 3 base editions and travelers
- Night sheet and reminder text for each character ability to help storytellers
- Many other customization options!
+### Custom Characters
+
+In order to add custom characters to your local Grimoire, you need to create a JSON definition for them,
+similar to what is provided in the `roles.json` for the 3 base editions. Here's an example of how such a character
+might be described:
+
+```json
+ {
+ "id": "acrobat",
+ "image": "https://github.com/bra1n/townsquare/blob/master/src/assets/icons/acrobat.png?raw=true",
+ "edition": "custom",
+ "firstNight": 0,
+ "firstNightReminder": "",
+ "otherNight": 49,
+ "otherNightReminder": "If either good living neighbor is drunk or poisoned, the Acrobat dies.",
+ "reminders": ["Die"],
+ "setup": false,
+ "name": "Acrobat",
+ "team": "outsider",
+ "ability": "Each night*, if either good living neighbor is drunk or poisoned, you die."
+ }
+```
+
+**Required properties:** `id`, `name`, `team`, `ability`
+
+- **id**: the internal ID for this character, without spaces or special characters
+- **image**: a URL to a PNG of the character token icon (should have a transparent background!)
+- **edition**: the ID of the edition for this character. can be left blank or "custom"
+- **firstNight** / **otherNight**: the position that this character acts on the first / other nights, compared to all
+ other characters
+- **firstNightReminder** / **otherNightReminder**: reminder text for first / other nights
+- **reminders**: reminder tokens, should be an empty array `[]` if none
+- **setup**: whether this token affects setup (orange leaf), like the Drunk or Baron
+- **name**: the displayed name of this character
+- **team**: the team of the character, has to be one of `townsfolk`, `outsider`, `minion`, `demon` or `traveler`
+- **ability**: the displayed ability text of the character
+
+_Note:_ custom characters are currently not supported in live sessions and will not be synchronised to other players.
+
## [Code of Conduct](CODE_OF_CONDUCT.md)
## [Contributing](CONTRIBUTING.md)
diff --git a/src/components/Token.vue b/src/components/Token.vue
index cb6a663..7d19cc0 100644
--- a/src/components/Token.vue
+++ b/src/components/Token.vue
@@ -4,9 +4,8 @@
class="icon"
v-if="role.id"
v-bind:style="{
- backgroundImage: `url(${require('../assets/icons/' +
- role.id +
- '.png')})`
+ backgroundImage: `url(${role.image ||
+ require('../assets/icons/' + role.id + '.png')})`
}"
>
diff --git a/src/components/modals/EditionModal.vue b/src/components/modals/EditionModal.vue
index 7246a4d..d06e43a 100644
--- a/src/components/modals/EditionModal.vue
+++ b/src/components/modals/EditionModal.vue
@@ -17,12 +17,12 @@
{{ edition.name }}
- Custom Script
+ Custom Script / Characters
-
Upload a custom script
+
Load custom script / characters
To play with a custom script, you need to select the characters you want
to play with in the official
and then upload the generated "custom-list.json" either directly here or
provide a URL to such a hosted JSON file.
+
+ To play with custom characters, please read
+ the documentation
+ on how to write a custom character definition file.
Some popular custom scripts:
- {
- this.parseScript(JSON.parse(reader.result));
+ this.parseRoles(JSON.parse(reader.result));
});
reader.readAsText(file);
}
@@ -123,13 +131,18 @@ export default {
const res = await fetch(url);
if (res && res.json) {
const script = await res.json();
- this.parseScript(script);
+ this.parseRoles(script);
}
},
- parseScript(script) {
- if (!script || !script.length) return;
- const roles = script.map(({ id }) => id.replace(/[^a-z]/g, ""));
- this.$store.commit("setRoles", roles);
+ parseRoles(roles) {
+ if (!roles || !roles.length) return;
+ this.$store.commit(
+ "setCustomRoles",
+ roles.map(role => {
+ role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9-]/g, "");
+ return role;
+ })
+ );
this.$store.commit("setEdition", "custom");
this.isCustom = false;
},
diff --git a/src/components/modals/ReferenceModal.vue b/src/components/modals/ReferenceModal.vue
index f5d0e7e..d66a4c2 100644
--- a/src/components/modals/ReferenceModal.vue
+++ b/src/components/modals/ReferenceModal.vue
@@ -40,9 +40,8 @@
class="icon"
v-if="role.id"
v-bind:style="{
- backgroundImage: `url(${require('../../assets/icons/' +
- role.id +
- '.png')})`
+ backgroundImage: `url(${role.image ||
+ require('../../assets/icons/' + role.id + '.png')})`
}"
>
{{ role.ability }}
diff --git a/src/components/modals/ReminderModal.vue b/src/components/modals/ReminderModal.vue
index 5692b0c..147a658 100644
--- a/src/components/modals/ReminderModal.vue
+++ b/src/components/modals/ReminderModal.vue
@@ -16,9 +16,8 @@
{{ reminder.name }}
@@ -42,7 +41,11 @@ export default {
if (players.some(p => p.role.id === role.id)) {
reminders = [
...reminders,
- ...role.reminders.map(name => ({ role: role.id, name }))
+ ...role.reminders.map(name => ({
+ role: role.id,
+ image: role.image,
+ name
+ }))
];
}
});
diff --git a/src/components/modals/RolesModal.vue b/src/components/modals/RolesModal.vue
index ef919e6..793319d 100644
--- a/src/components/modals/RolesModal.vue
+++ b/src/components/modals/RolesModal.vue
@@ -96,11 +96,13 @@ export default {
const composition = this.game[playerCount - 5];
Object.keys(composition).forEach(team => {
for (let x = 0; x < composition[team]; x++) {
- const available = this.roleSelection[team].filter(
- role => role.selected !== true
- );
- if (available.length) {
- randomElement(available).selected = true;
+ if (this.roleSelection[team]) {
+ const available = this.roleSelection[team].filter(
+ role => role.selected !== true
+ );
+ if (available.length) {
+ randomElement(available).selected = true;
+ }
}
}
});
diff --git a/src/store/index.js b/src/store/index.js
index 3355e9d..02ba61b 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -98,14 +98,37 @@ export default new Vuex.Store({
grimoire.isScreenshot = false;
}
},
- setRoles(state, roleIds) {
+ /**
+ * Store custom roles
+ * @param state
+ * @param roles Array of role IDs or full role definitions
+ */
+ setCustomRoles(state, roles) {
+ const customRole = {
+ image: "",
+ edition: "custom",
+ firstNight: 0,
+ firstNightReminder: "",
+ otherNight: 0,
+ otherNightReminder: "",
+ reminders: [],
+ setup: false
+ };
state.roles = new Map(
- roleIds
- .filter(roleId => rolesJSONbyId.has(roleId))
- .sort((a, b) =>
- rolesJSONbyId.get(b).team.localeCompare(rolesJSONbyId.get(a).team)
+ roles
+ // map existing roles to base definition or pre-populate custom roles to ensure all properties
+ .map(
+ role =>
+ rolesJSONbyId.get(role.id) || Object.assign({}, customRole, role)
)
- .map(roleId => [roleId, rolesJSONbyId.get(roleId)])
+ // filter out roles that don't match an existing role and also don't have name/ability/team
+ .filter(role => role.name && role.ability && role.team)
+ // sort by team
+ .sort((a, b) =>
+ b.team.localeCompare(a.team)
+ )
+ // convert to Map
+ .map(role => [role.id, role])
);
},
setEdition(state, edition) {
diff --git a/src/store/persistence.js b/src/store/persistence.js
index 968ffbf..24b37cc 100644
--- a/src/store/persistence.js
+++ b/src/store/persistence.js
@@ -14,7 +14,7 @@ module.exports = store => {
store.commit("setEdition", localStorage.edition);
}
if (localStorage.roles !== undefined) {
- store.commit("setRoles", JSON.parse(localStorage.roles));
+ store.commit("setCustomRoles", JSON.parse(localStorage.roles));
}
if (localStorage.bluffs !== undefined) {
JSON.parse(localStorage.bluffs).forEach((role, index) => {
@@ -74,7 +74,7 @@ module.exports = store => {
localStorage.removeItem("roles");
}
break;
- case "setRoles":
+ case "setCustomRoles":
if (!payload.length) {
localStorage.removeItem("roles");
} else {
diff --git a/src/store/socket.js b/src/store/socket.js
index a71e862..3e89ab7 100644
--- a/src/store/socket.js
+++ b/src/store/socket.js
@@ -256,7 +256,7 @@ class LiveSession {
if (!this._isSpectator) return;
this._store.commit("setEdition", edition);
if (roles) {
- this._store.commit("setRoles", roles);
+ this._store.commit("setCustomRoles", roles);
}
}