Skip to content

Commit 76d8e3f

Browse files
authored
Character token rendering (#398)
1 parent c9e88c3 commit 76d8e3f

13 files changed

Lines changed: 348 additions & 120 deletions

public/botc/clockface.webp

23.6 KB
Loading

public/botc/leaf-left.webp

1.93 KB
Loading

public/botc/leaf-right.webp

1.81 KB
Loading

public/botc/token-noise.webp

1.76 KB
Loading

src/app/botc/blood-on-the-clocktower.tsx

Lines changed: 24 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ import {
1212
BOTCPlayer,
1313
CharacterID,
1414
EDITIONS,
15-
FIRST_NIGHT_TEXT,
16-
OTHER_NIGHTS_TEXT,
15+
getAllCharacters,
1716
} from './characters';
18-
import NightOrderEntry from './night-order-entry';
19-
import Switch from 'components/input/switch';
17+
import NightOrder from './night-order';
18+
import Grimoire from './grimoire';
2019

2120
export const metadata: Metadata = {
2221
title: 'Blood on the Clocktower',
@@ -52,28 +51,23 @@ const BloodOnTheClocktowerElement = ({
5251
_setSearchParamsGameState(JSON.stringify(newGameState));
5352
};
5453

55-
const [showFirstNight, setShowFirstNight] = useState(true);
56-
const [showDeadCharacters, setShowDeadCharacters] = useState(false);
57-
const [showCharactersNotInPlay, setShowCharactersNotInPlay] = useState(false);
58-
5954
const edition = EDITIONS.find(
6055
(edition) => edition.id === gameState.editionId,
6156
);
62-
const isTeensyville = gameState.numberOfPlayers < 7;
6357

6458
if (!edition) {
6559
return <div>NO EDITION FOUND</div>;
6660
}
6761

68-
const demon = gameState.characters.find((c) => edition.demons.includes(c));
62+
const detailsStartOpen = true;
6963

7064
// TODO: Change this to alive players
7165
const alivePlayers = gameState.characters;
7266

7367
return (
7468
<div className='flex max-w-3xl flex-col gap-2'>
75-
<h2>Blood On The Clocktower</h2>
76-
<details className='border p-2 shadow-md'>
69+
<h2>Bleck on the Corpstower</h2>
70+
<details open={detailsStartOpen} className='border p-2 shadow-md'>
7771
<summary className='select-none'>Setup</summary>
7872

7973
<div className='h-2' />
@@ -83,7 +77,7 @@ const BloodOnTheClocktowerElement = ({
8377
[{ value: 'custom', label: 'Custom Script' }],
8478
)}
8579
onChange={(v) => {
86-
setGameState({ ...gameState, editionId: v });
80+
setGameState({ ...gameState, characters: [], editionId: v });
8781
}}
8882
value={gameState.editionId}
8983
/>
@@ -110,66 +104,29 @@ const BloodOnTheClocktowerElement = ({
110104
/>
111105
</Modal>
112106
<div className='h-2' />
107+
<Button
108+
onClick={() => {
109+
setGameState(newGameState);
110+
}}
111+
>
112+
Clear cache
113+
</Button>
113114
</details>
114-
<details className='border p-2 shadow-md'>
115+
<details open={detailsStartOpen} className='border p-2 shadow-md'>
115116
<summary className='select-none'>Grimoire</summary>
117+
<Grimoire
118+
players={gameState.players}
119+
characters={gameState.characters}
120+
/>
116121
</details>
117-
<details className='border p-2 shadow-md'>
122+
<details open={detailsStartOpen} className='border p-2 shadow-md'>
118123
<summary className='select-none'>Night Order</summary>
119124
<div className='h-2' />
120-
<Select
121-
label='Show'
122-
options={[
123-
{ value: 'first', label: 'First night' },
124-
{ value: 'other', label: 'Other nights' },
125-
]}
126-
onChange={(v) => {
127-
setShowFirstNight(v === 'first');
128-
}}
125+
<NightOrder
126+
alivePlayers={alivePlayers}
127+
numberOfPlayers={gameState.numberOfPlayers}
128+
allCharacters={getAllCharacters(edition)}
129129
/>
130-
<div className='h-2' />
131-
<div className='flex gap-4'>
132-
<Switch
133-
label='Show dead characters'
134-
value={showDeadCharacters}
135-
onChange={() => {
136-
setShowDeadCharacters(!showDeadCharacters);
137-
}}
138-
/>
139-
<Switch
140-
label='Show characters not in play'
141-
value={showCharactersNotInPlay}
142-
onChange={() => {
143-
setShowCharactersNotInPlay(!showCharactersNotInPlay);
144-
}}
145-
/>
146-
</div>
147-
<div className='h-2' />
148-
{!isTeensyville && showFirstNight && (
149-
<>
150-
{demon && (
151-
<>
152-
<NightOrderEntry
153-
name='Minions'
154-
text='Wake all the Minions and show them the Demon.'
155-
/>
156-
<NightOrderEntry
157-
name='Demon'
158-
text='Wake the Demon, show them their minions and their 3 bluffs (characters not in play).'
159-
/>
160-
</>
161-
)}
162-
</>
163-
)}
164-
{(showFirstNight ? FIRST_NIGHT_TEXT : OTHER_NIGHTS_TEXT)
165-
.filter(([id, _]) => alivePlayers.includes(id))
166-
.map(([id, text]) => (
167-
<NightOrderEntry
168-
key={`${id}night${showFirstNight ? 1 : 2}`}
169-
characterId={id}
170-
text={text}
171-
/>
172-
))}
173130
</details>
174131
</div>
175132
);

src/app/botc/character-panel.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1-
import { cn } from "utils/class-names";
2-
import { BOTCCharacter } from "./characters";
3-
41
interface BOTCCharacterPanelProps {
52
name: string;
3+
imgSrc: string;
64
description: string;
75
showDescription: boolean;
8-
};
6+
}
97

10-
const BOTCCharacterPanel = ({name, description, showDescription}: BOTCCharacterPanelProps) => {
8+
const BOTCCharacterPanel = ({
9+
name,
10+
imgSrc,
11+
description,
12+
showDescription,
13+
}: BOTCCharacterPanelProps) => {
1114
return (
12-
<div className="flex flex-col gap-2">
13-
<h4>{name}</h4>
14-
{showDescription && <p className="text-sm">{description}</p>}
15+
<div className='flex flex-col gap-2'>
16+
<div className='flex items-center gap-4'>
17+
<img className='h-12 w-12 scale-150' loading='lazy' src={imgSrc} />
18+
<h4>{name}</h4>
19+
</div>
20+
{showDescription && <p className='text-sm'>{description}</p>}
1521
</div>
16-
)
17-
}
22+
);
23+
};
1824

1925
export default BOTCCharacterPanel;

src/app/botc/character-select.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
CHARACTERS,
1010
CharacterType,
1111
Edition,
12+
getImagePathFromId,
13+
isEvil,
14+
isGood,
1215
MAX_PLAYERS,
1316
MIN_PLAYERS,
1417
} from './characters';
@@ -51,9 +54,7 @@ const generateCharacterRow = (
5154
character: CharacterType,
5255
selectedColumn: number,
5356
) => {
54-
const color = ['townsfolk', 'outsiders'].includes(character)
55-
? 'text-blue-500'
56-
: 'text-red-600';
57+
const color = isGood(character) ? 'text-blue-500' : 'text-red-600';
5758
return (
5859
<tr className={color} key={character + 'amount'}>
5960
<td
@@ -90,7 +91,7 @@ const selectRandom = (
9091
selected.push(...copy.slice(0, numberOfCharacters[characterType]));
9192
}
9293

93-
// Correct if characters which change character amounts are picked
94+
// Correct errors if characters which change character amounts are picked
9495
for (const [type, diff] of Object.entries(
9596
findSelectionError(selected, numberOfCharacters),
9697
)) {
@@ -119,7 +120,7 @@ const selectRandom = (
119120
}
120121
}
121122

122-
return selected;
123+
return shuffle(selected);
123124
};
124125

125126
const findSelectionError = (
@@ -138,8 +139,12 @@ const findSelectionError = (
138139
break;
139140

140141
case 'godfather':
141-
// Add 1 outsider if number of outsiders is less than 2, otherwise remove 1
142-
addOutsiders(numberOfCharacters['outsiders'] < 2 ? 1 : -1);
142+
// Remove 1 outsider if number of outsiders is 2 and Fang Gu is in the game, otherwise add 1
143+
addOutsiders(
144+
numberOfCharacters['outsiders'] == 2 && characters.includes('fanggu')
145+
? -1
146+
: 1,
147+
);
143148
break;
144149

145150
case 'fanggu':
@@ -264,22 +269,48 @@ const BOTCCharacterSelect = ({
264269
</Button>
265270
</div>
266271
{CHARACTER_TYPES.map((characterType) => {
272+
const border = isGood(characterType)
273+
? 'border-blue-500'
274+
: isEvil(characterType)
275+
? 'border-red-600'
276+
: 'border-neutral-500';
277+
const subtleBorder = isGood(characterType)
278+
? 'border-blue-500/30'
279+
: isEvil(characterType)
280+
? 'border-red-600/30'
281+
: 'border-neutral-500/30';
282+
const bg = isGood(characterType)
283+
? 'bg-blue-500'
284+
: isEvil(characterType)
285+
? 'bg-red-600'
286+
: 'bg-neutral-500';
287+
const bgShade = isGood(characterType)
288+
? 'bg-blue-500/20'
289+
: isEvil(characterType)
290+
? 'bg-red-600/20'
291+
: 'bg-neutral-500/20';
267292
return (
268293
<React.Fragment key={edition.id + characterType}>
269-
<div className='flex flex-col rounded border-2 border-red-600'>
270-
<div className='flex w-full justify-between gap-4 bg-red-600 px-2 text-white'>
294+
<div className={cn('flex flex-col rounded border-2', border)}>
295+
<div
296+
className={cn(
297+
'flex w-full justify-between gap-4 px-2 text-white',
298+
bg,
299+
)}
300+
>
271301
<h3 className='first-letter:capitalize'>{characterType}</h3>
272302
<h3>{`${numberOfSelectedCharacters[characterType]} / ${numberOfCharacters[characterType]}`}</h3>
273303
</div>
274-
<div className='grid grid-cols-3 lg:grid-cols-5'>
304+
<div className='grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'>
275305
{edition[characterType]
276306
.map((id) => CHARACTERS[id])
277307
.map(({ id, name, description }) => (
278308
<div
279309
key={id}
280310
className={cn(
281-
'border border-red-600/30 px-2 py-1',
282-
selectedCharacters.includes(id) && 'bg-red-600/20',
311+
'border px-2 py-1',
312+
subtleBorder,
313+
selectedCharacters.includes(id) && bgShade,
283314
)}
284315
onClick={() => {
285316
const newSelected = selectedCharacters.slice();
@@ -294,6 +325,7 @@ const BOTCCharacterSelect = ({
294325
>
295326
<BOTCCharacterPanel
296327
name={name}
328+
imgSrc={getImagePathFromId(id)}
297329
description={description}
298330
showDescription={showDescriptions}
299331
/>

src/app/botc/character-token.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {
2+
CharacterID,
3+
CHARACTERS,
4+
FIRST_NIGHT_TEXT,
5+
getImagePathFromId,
6+
OTHER_NIGHTS_TEXT,
7+
} from './characters';
8+
9+
interface CharacterTokenProps {
10+
playerName?: string;
11+
characterId?: CharacterID;
12+
}
13+
14+
const FIRST_NIGHT_CHARACTERS = new Set(FIRST_NIGHT_TEXT.map(([id, _]) => id));
15+
const OTHER_NIGHT_CHARACTERS = new Set(OTHER_NIGHTS_TEXT.map(([id, _]) => id));
16+
17+
const CharacterToken = ({ playerName, characterId }: CharacterTokenProps) => {
18+
const character = characterId ? CHARACTERS[characterId] : undefined;
19+
const hasLeftLeaf = characterId && FIRST_NIGHT_CHARACTERS.has(characterId);
20+
const hasRightLeaf = characterId && OTHER_NIGHT_CHARACTERS.has(characterId);
21+
22+
return (
23+
<div className='relative mt-1 h-32 w-32 rounded-full bg-[repeat] bg-[url(/botc/token-noise.webp)] bg-auto text-center shadow-md'>
24+
<h5 className='absolute -top-1 left-1/2 -translate-x-1/2 rounded bg-red-600 px-2 text-white'>
25+
{playerName}
26+
</h5>
27+
<img
28+
className='absolute left-1/2 top-1/2 w-24 -translate-x-1/2 -translate-y-1/2'
29+
src='/botc/clockface.webp'
30+
loading='lazy'
31+
/>
32+
{hasLeftLeaf && (
33+
<img className='absolute h-full w-full' src='/botc/leaf-left.webp' />
34+
)}
35+
{hasRightLeaf && (
36+
<img className='absolute h-full w-full' src='/botc/leaf-right.webp' />
37+
)}
38+
39+
{character && (
40+
<>
41+
<img
42+
className='absolute h-full w-full'
43+
src={getImagePathFromId(character.id)}
44+
loading='lazy'
45+
/>
46+
<svg viewBox='0 0 150 150'>
47+
<path
48+
d='M 13 75 C 13 160, 138 160, 138 75'
49+
id='curve'
50+
fill='transparent'
51+
></path>
52+
<text textAnchor='middle'>
53+
<textPath startOffset='50%' href='#curve'>
54+
{character.name}
55+
</textPath>
56+
</text>
57+
</svg>
58+
</>
59+
)}
60+
</div>
61+
);
62+
};
63+
64+
export default CharacterToken;

0 commit comments

Comments
 (0)