Create a renderer for Candela Obscura character sheets #275
|
@ -17,6 +17,7 @@
|
|||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"classnames": "^2.5.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^6.28.0",
|
||||
|
@ -5257,6 +5258,11 @@
|
|||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz",
|
||||
"integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA=="
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||
},
|
||||
"node_modules/clean-css": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"classnames": "^2.5.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^6.28.0",
|
||||
|
@ -19,8 +20,8 @@
|
|||
"react-scripts": "5.0.1",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
"visions-types": "../visions-types"
|
||||
"visions-types": "../visions-types",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
interface GuageProps {
|
||||
current: number,
|
||||
max: number,
|
||||
}
|
||||
|
||||
export const SimpleGuage = ({ current, max }: GuageProps) => <> {current} / {max}</>
|
|
@ -1,7 +1,3 @@
|
|||
.playing-field__background {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.playing-field__background > img {
|
||||
.tabletop > img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
|
@ -10,5 +10,5 @@ interface TabletopElementProps {
|
|||
export const TabletopElement = ({ backgroundColor, backgroundUrl }: TabletopElementProps) => {
|
||||
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
|
||||
|
||||
return <div> {backgroundUrl && <img src={backgroundUrl.toString()} alt="playing field" />} </div>
|
||||
return <div className="tabletop"> {backgroundUrl && <img src={backgroundUrl.toString()} alt="playing field" />} </div>
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ interface ThumbnailProps {
|
|||
onclick?: () => void;
|
||||
}
|
||||
|
||||
export const ThumbnailComponent = ({ id, url, onclick }: ThumbnailProps) => {
|
||||
export const ThumbnailElement = ({ id, url, onclick }: ThumbnailProps) => {
|
||||
const clickHandler = () => {
|
||||
if (onclick) { onclick(); }
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { ThumbnailElement } from './Thumbnail/Thumbnail'
|
||||
import { TabletopElement } from './Tabletop/Tabletop'
|
||||
import { SimpleGuage } from './Guages/SimpleGuage'
|
||||
|
||||
export default { ThumbnailElement, TabletopElement, SimpleGuage }
|
|
@ -1,74 +1,8 @@
|
|||
import React from 'react';
|
||||
import { assertNever } from '.';
|
||||
import './Charsheet.css';
|
||||
import { DriveGuage } from './DriveGuage/DriveGuage';
|
||||
|
||||
function assertNever(value: never) {
|
||||
throw new Error("Unexpected value: " + value);
|
||||
}
|
||||
|
||||
export type Guage = {
|
||||
current: number,
|
||||
max: number,
|
||||
}
|
||||
|
||||
export type Action = {
|
||||
gilded: boolean,
|
||||
score: number,
|
||||
}
|
||||
|
||||
export type Actions = { [key: string]: Action }
|
||||
|
||||
export type ActionGroup = {
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
actions: Actions,
|
||||
}
|
||||
|
||||
type Nerve = {
|
||||
type_: "nerve",
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
move: Action,
|
||||
strike: Action,
|
||||
control: Action,
|
||||
}
|
||||
|
||||
type Cunning = {
|
||||
type_: "cunning",
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
sway: Action,
|
||||
read: Action,
|
||||
hide: Action,
|
||||
}
|
||||
|
||||
type Intuition = {
|
||||
type_: "intuition",
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
survey: Action,
|
||||
focus: Action,
|
||||
sense: Action,
|
||||
}
|
||||
|
||||
export type Charsheet = {
|
||||
type_: string,
|
||||
name: string,
|
||||
pronouns: string
|
||||
circle: string
|
||||
style: string,
|
||||
catalyst: string,
|
||||
question: string,
|
||||
|
||||
nerve: Nerve,
|
||||
cunning: Cunning,
|
||||
intuition: Intuition,
|
||||
|
||||
role: string,
|
||||
role_abilities: string[],
|
||||
specialty: string,
|
||||
specialty_abilities: string[],
|
||||
}
|
||||
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||
|
||||
interface CharsheetProps {
|
||||
sheet: Charsheet,
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
.candela-panel-actions {
|
||||
border: 1px solid black;
|
||||
padding: 4px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.candela-panel-actions__header {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
justify-content: space-between;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.candela-panel-actions__action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.candela-panel-actions__action_gilded {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import React from 'react';
|
||||
import { assertNever } from '.';
|
||||
import { SimpleGuage } from '../../components/Guages/SimpleGuage';
|
||||
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||
import './CharsheetPanel.css';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface CharsheetPanelProps {
|
||||
sheet: Charsheet;
|
||||
}
|
||||
|
||||
interface ActionElementProps {
|
||||
name: string,
|
||||
gilded: boolean,
|
||||
value: number,
|
||||
}
|
||||
|
||||
const ActionElement = ({ name, gilded, value }: ActionElementProps) => {
|
||||
const className = gilded ? "candela-panel-actions__action_gilded" : "candela-panel-actions__action";
|
||||
return (<div className={classNames({
|
||||
"candela-panel-actions__action": true,
|
||||
"candela-panel-actions__action_gilded": gilded,
|
||||
})}> <div> {name} </div> <div> {value} </div> </div>);
|
||||
}
|
||||
|
||||
|
||||
interface ActionGroupElementProps {
|
||||
group: Nerve | Cunning | Intuition;
|
||||
}
|
||||
|
||||
const ActionGroupElement = ({ group }: ActionGroupElementProps) => {
|
||||
var title;
|
||||
var elements = [];
|
||||
|
||||
switch (group.type_) {
|
||||
case "nerve": {
|
||||
title = <div className="candela-panel-actions__header"> <div> Nerve </div> <SimpleGuage current={group.drives.current} max={group.drives.max} /> </div>
|
||||
elements.push(<ActionElement name="Move" gilded={group.move.gilded} value={group.move.score} />);
|
||||
elements.push(<ActionElement name="Strike" gilded={group.strike.gilded} value={group.strike.score} />);
|
||||
elements.push(<ActionElement name="Control" gilded={group.control.gilded} value={group.control.score} />);
|
||||
break
|
||||
}
|
||||
case "cunning": {
|
||||
title = <div className="candela-panel-actions__header"> <div> Cunning </div> <SimpleGuage current={group.drives.current} max={group.drives.max} /> </div>
|
||||
elements.push(<ActionElement name="Sway" gilded={group.sway.gilded} value={group.sway.score} />);
|
||||
elements.push(<ActionElement name="Read" gilded={group.read.gilded} value={group.read.score} />);
|
||||
elements.push(<ActionElement name="Hide" gilded={group.hide.gilded} value={group.hide.score} />);
|
||||
break
|
||||
}
|
||||
case "intuition": {
|
||||
title = <div className="candela-panel-actions__header"> <div> Intuition </div> <SimpleGuage current={group.drives.current} max={group.drives.max} /> </div>
|
||||
elements.push(<ActionElement name="Survey" gilded={group.survey.gilded} value={group.survey.score} />);
|
||||
elements.push(<ActionElement name="Focus" gilded={group.focus.gilded} value={group.focus.score} />);
|
||||
elements.push(<ActionElement name="Sense" gilded={group.sense.gilded} value={group.sense.score} />);
|
||||
break
|
||||
}
|
||||
default: {
|
||||
assertNever(group);
|
||||
}
|
||||
}
|
||||
|
||||
return (<div className="candela-panel-actions">
|
||||
{title}
|
||||
{elements}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const CharsheetPanelElement_ = ({ sheet }: CharsheetPanelProps) => {
|
||||
return (<div className="candela-panel">
|
||||
<div className="candela-panel__header">
|
||||
<p> {sheet.name} ({sheet.pronouns}) </p>
|
||||
<p> {sheet.specialty} </p>
|
||||
</div>
|
||||
|
||||
<div className="candela-panel__action-groups">
|
||||
<ActionGroupElement group={sheet.nerve} />
|
||||
<ActionGroupElement group={sheet.cunning} />
|
||||
<ActionGroupElement group={sheet.intuition} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul>
|
||||
{sheet.role_abilities.map((ability) => <li> {ability} </li>)}
|
||||
{sheet.specialty_abilities.map((ability) => <li> {ability} </li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
export const CharsheetPanelElement = () => {
|
||||
const sheet = {
|
||||
type_: 'Candela',
|
||||
name: "Soren Jensen",
|
||||
pronouns: 'he/him',
|
||||
circle: 'Circle of the Bluest Sky',
|
||||
style: 'dapper gentleman',
|
||||
catalyst: 'a cursed book',
|
||||
question: 'What were the contents of that book?',
|
||||
nerve: {
|
||||
type_: "nerve",
|
||||
drives: { current: 1, max: 2 },
|
||||
resistances: { current: 0, max: 3 },
|
||||
move: { gilded: false, score: 2 },
|
||||
strike: { gilded: false, score: 1 },
|
||||
control: { gilded: true, score: 0 },
|
||||
} as Nerve,
|
||||
cunning: {
|
||||
type_: "cunning",
|
||||
drives: { current: 1, max: 1 },
|
||||
resistances: { current: 0, max: 3 },
|
||||
sway: { gilded: false, score: 0 },
|
||||
read: { gilded: false, score: 0 },
|
||||
hide: { gilded: false, score: 0 },
|
||||
} as Cunning,
|
||||
intuition: {
|
||||
type_: "intuition",
|
||||
drives: { current: 0, max: 0 },
|
||||
resistances: { current: 0, max: 3 },
|
||||
survey: { gilded: false, score: 0 },
|
||||
focus: { gilded: false, score: 0 },
|
||||
sense: { gilded: false, score: 0 },
|
||||
} as Intuition,
|
||||
role: 'Slink',
|
||||
role_abilities: [
|
||||
'Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?',
|
||||
],
|
||||
specialty: 'Detective',
|
||||
specialty_abilities: [
|
||||
"Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you've deduced.",
|
||||
],
|
||||
};
|
||||
|
||||
return <CharsheetPanelElement_ sheet={sheet} />
|
||||
}
|
|
@ -1,4 +1,9 @@
|
|||
import { Charsheet, CharsheetElement } from './Charsheet';
|
||||
import { CharsheetElement } from './Charsheet';
|
||||
import { CharsheetPanelElement } from './CharsheetPanel';
|
||||
|
||||
export default { CharsheetElement };
|
||||
export function assertNever(value: never) {
|
||||
throw new Error("Unexpected value: " + value);
|
||||
}
|
||||
|
||||
export default { CharsheetElement, CharsheetPanelElement };
|
||||
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
|
||||
export type Guage = {
|
||||
current: number,
|
||||
max: number,
|
||||
}
|
||||
|
||||
export type Action = {
|
||||
gilded: boolean,
|
||||
score: number,
|
||||
}
|
||||
|
||||
export type Actions = { [key: string]: Action }
|
||||
|
||||
export type ActionGroup = {
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
actions: Actions,
|
||||
}
|
||||
|
||||
export type Nerve = {
|
||||
type_: "nerve",
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
move: Action,
|
||||
strike: Action,
|
||||
control: Action,
|
||||
}
|
||||
|
||||
export type Cunning = {
|
||||
type_: "cunning",
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
sway: Action,
|
||||
read: Action,
|
||||
hide: Action,
|
||||
}
|
||||
|
||||
export type Intuition = {
|
||||
type_: "intuition",
|
||||
drives: Guage,
|
||||
resistances: Guage,
|
||||
survey: Action,
|
||||
focus: Action,
|
||||
sense: Action,
|
||||
}
|
||||
|
||||
export type Charsheet = {
|
||||
type_: string,
|
||||
name: string,
|
||||
pronouns: string
|
||||
circle: string
|
||||
style: string,
|
||||
catalyst: string,
|
||||
question: string,
|
||||
|
||||
nerve: Nerve,
|
||||
cunning: Cunning,
|
||||
intuition: Intuition,
|
||||
|
||||
role: string,
|
||||
role_abilities: string[],
|
||||
specialty: string,
|
||||
specialty_abilities: string[],
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Client, PlayingField } from '../../client';
|
||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||
import { ThumbnailComponent } from '../../components/Thumbnail/Thumbnail';
|
||||
import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail';
|
||||
import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||
import './GmView.css';
|
||||
|
||||
|
@ -20,7 +20,7 @@ export const GmView = ({ client }: GmViewProps) => {
|
|||
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
||||
return (<div className="gm-view">
|
||||
<div>
|
||||
{images.map((imageName) => <ThumbnailComponent id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
||||
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
||||
</div>
|
||||
<TabletopElement backgroundColor={tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
||||
</div>)
|
||||
|
|
|
@ -4,15 +4,12 @@
|
|||
}
|
||||
|
||||
.player-view__left-panel {
|
||||
flex-grow: 0;
|
||||
min-width: 100px;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
.player-view__right-panel {
|
||||
flex-grow: 0;
|
||||
min-width: 100px;
|
||||
max-width: 20%;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import './PlayerView.css';
|
|||
import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||
import { Client } from '../../client';
|
||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||
import Candela from '../../plugins/Candela';
|
||||
|
||||
interface PlayerViewProps {
|
||||
client: Client;
|
||||
|
@ -15,10 +16,9 @@ export const PlayerView = ({ client }: PlayerViewProps) => {
|
|||
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
|
||||
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
||||
|
||||
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle}}>
|
||||
<div className="player-view__left-panel"> Left Side </div>
|
||||
<TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} />
|
||||
<div className="player-view__right-panel"> Right Side </div>
|
||||
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
|
||||
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
|
||||
<div className="player-view__right-panel"> <Candela.CharsheetPanelElement /> </div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue