added support for (local) custom characters (closes #4)

This commit is contained in:
Steffen 2020-06-30 13:50:35 +02:00
parent 71dc48284a
commit 75d08e2e7e
No known key found for this signature in database
GPG Key ID: 764D74E98267DFC6
9 changed files with 111 additions and 33 deletions

View File

@ -16,6 +16,45 @@ It is supposed to aid storytellers and allow them to quickly set up and capture
- Night sheet and reminder text for each character ability to help storytellers - Night sheet and reminder text for each character ability to help storytellers
- Many other customization options! - 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) ## [Code of Conduct](CODE_OF_CONDUCT.md)
## [Contributing](CONTRIBUTING.md) ## [Contributing](CONTRIBUTING.md)

View File

@ -4,9 +4,8 @@
class="icon" class="icon"
v-if="role.id" v-if="role.id"
v-bind:style="{ v-bind:style="{
backgroundImage: `url(${require('../assets/icons/' + backgroundImage: `url(${role.image ||
role.id + require('../assets/icons/' + role.id + '.png')})`
'.png')})`
}" }"
></span> ></span>
<span class="leaf-left" v-if="role.firstNight"></span> <span class="leaf-left" v-if="role.firstNight"></span>

View File

@ -17,12 +17,12 @@
{{ edition.name }} {{ edition.name }}
</li> </li>
<li class="edition edition-custom" @click="isCustom = true"> <li class="edition edition-custom" @click="isCustom = true">
Custom Script Custom Script / Characters
</li> </li>
</ul> </ul>
</div> </div>
<div class="custom" v-else> <div class="custom" v-else>
<h3>Upload a custom script</h3> <h3>Load custom script / characters</h3>
To play with a custom script, you need to select the characters you want To play with a custom script, you need to select the characters you want
to play with in the official to play with in the official
<a href="https://bloodontheclocktower.com/script-tool/" target="_blank" <a href="https://bloodontheclocktower.com/script-tool/" target="_blank"
@ -30,6 +30,14 @@
> >
and then upload the generated "custom-list.json" either directly here or and then upload the generated "custom-list.json" either directly here or
provide a URL to such a hosted JSON file.<br /> provide a URL to such a hosted JSON file.<br />
<br />
To play with custom characters, please read
<a
href="https://github.com/bra1n/townsquare#custom-characters"
target="_blank"
>the documentation</a
>
on how to write a custom character definition file.
<h3>Some popular custom scripts:</h3> <h3>Some popular custom scripts:</h3>
<ul class="scripts"> <ul class="scripts">
<li <li
@ -108,7 +116,7 @@ export default {
if (file && file.size) { if (file && file.size) {
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener("load", () => { reader.addEventListener("load", () => {
this.parseScript(JSON.parse(reader.result)); this.parseRoles(JSON.parse(reader.result));
}); });
reader.readAsText(file); reader.readAsText(file);
} }
@ -123,13 +131,18 @@ export default {
const res = await fetch(url); const res = await fetch(url);
if (res && res.json) { if (res && res.json) {
const script = await res.json(); const script = await res.json();
this.parseScript(script); this.parseRoles(script);
} }
}, },
parseScript(script) { parseRoles(roles) {
if (!script || !script.length) return; if (!roles || !roles.length) return;
const roles = script.map(({ id }) => id.replace(/[^a-z]/g, "")); this.$store.commit(
this.$store.commit("setRoles", roles); "setCustomRoles",
roles.map(role => {
role.id = role.id.toLocaleLowerCase().replace(/[^a-z0-9-]/g, "");
return role;
})
);
this.$store.commit("setEdition", "custom"); this.$store.commit("setEdition", "custom");
this.isCustom = false; this.isCustom = false;
}, },

View File

@ -40,9 +40,8 @@
class="icon" class="icon"
v-if="role.id" v-if="role.id"
v-bind:style="{ v-bind:style="{
backgroundImage: `url(${require('../../assets/icons/' + backgroundImage: `url(${role.image ||
role.id + require('../../assets/icons/' + role.id + '.png')})`
'.png')})`
}" }"
></span> ></span>
<span class="ability">{{ role.ability }}</span> <span class="ability">{{ role.ability }}</span>

View File

@ -16,9 +16,8 @@
<span <span
class="icon" class="icon"
:style="{ :style="{
backgroundImage: `url(${require('../../assets/icons/' + backgroundImage: `url(${reminder.image ||
reminder.role + require('../../assets/icons/' + reminder.role + '.png')})`
'.png')})`
}" }"
></span> ></span>
<span class="text">{{ reminder.name }}</span> <span class="text">{{ reminder.name }}</span>
@ -42,7 +41,11 @@ export default {
if (players.some(p => p.role.id === role.id)) { if (players.some(p => p.role.id === role.id)) {
reminders = [ reminders = [
...reminders, ...reminders,
...role.reminders.map(name => ({ role: role.id, name })) ...role.reminders.map(name => ({
role: role.id,
image: role.image,
name
}))
]; ];
} }
}); });

View File

@ -96,6 +96,7 @@ export default {
const composition = this.game[playerCount - 5]; const composition = this.game[playerCount - 5];
Object.keys(composition).forEach(team => { Object.keys(composition).forEach(team => {
for (let x = 0; x < composition[team]; x++) { for (let x = 0; x < composition[team]; x++) {
if (this.roleSelection[team]) {
const available = this.roleSelection[team].filter( const available = this.roleSelection[team].filter(
role => role.selected !== true role => role.selected !== true
); );
@ -103,6 +104,7 @@ export default {
randomElement(available).selected = true; randomElement(available).selected = true;
} }
} }
}
}); });
}, },
assignRoles() { assignRoles() {

View File

@ -98,14 +98,37 @@ export default new Vuex.Store({
grimoire.isScreenshot = false; 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( state.roles = new Map(
roleIds roles
.filter(roleId => rolesJSONbyId.has(roleId)) // map existing roles to base definition or pre-populate custom roles to ensure all properties
.sort((a, b) => .map(
rolesJSONbyId.get(b).team.localeCompare(rolesJSONbyId.get(a).team) 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) { setEdition(state, edition) {

View File

@ -14,7 +14,7 @@ module.exports = store => {
store.commit("setEdition", localStorage.edition); store.commit("setEdition", localStorage.edition);
} }
if (localStorage.roles !== undefined) { if (localStorage.roles !== undefined) {
store.commit("setRoles", JSON.parse(localStorage.roles)); store.commit("setCustomRoles", JSON.parse(localStorage.roles));
} }
if (localStorage.bluffs !== undefined) { if (localStorage.bluffs !== undefined) {
JSON.parse(localStorage.bluffs).forEach((role, index) => { JSON.parse(localStorage.bluffs).forEach((role, index) => {
@ -74,7 +74,7 @@ module.exports = store => {
localStorage.removeItem("roles"); localStorage.removeItem("roles");
} }
break; break;
case "setRoles": case "setCustomRoles":
if (!payload.length) { if (!payload.length) {
localStorage.removeItem("roles"); localStorage.removeItem("roles");
} else { } else {

View File

@ -256,7 +256,7 @@ class LiveSession {
if (!this._isSpectator) return; if (!this._isSpectator) return;
this._store.commit("setEdition", edition); this._store.commit("setEdition", edition);
if (roles) { if (roles) {
this._store.commit("setRoles", roles); this._store.commit("setCustomRoles", roles);
} }
} }