mirror of https://github.com/bra1n/townsquare.git
added support for (local) custom characters (closes #4)
This commit is contained in:
parent
71dc48284a
commit
75d08e2e7e
41
README.md
41
README.md
|
@ -10,12 +10,51 @@ It is supposed to aid storytellers and allow them to quickly set up and capture
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Public Town Square and Storyteller Grimoire (toggle with **shortcut \[G\]**)
|
- 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!
|
- Live Session for Storyteller / Players including live voting!
|
||||||
- Includes all 3 base editions and travelers
|
- Includes all 3 base editions and travelers
|
||||||
- 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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
}))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -96,11 +96,13 @@ 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++) {
|
||||||
const available = this.roleSelection[team].filter(
|
if (this.roleSelection[team]) {
|
||||||
role => role.selected !== true
|
const available = this.roleSelection[team].filter(
|
||||||
);
|
role => role.selected !== true
|
||||||
if (available.length) {
|
);
|
||||||
randomElement(available).selected = true;
|
if (available.length) {
|
||||||
|
randomElement(available).selected = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue