Create a renderer for Candela Obscura character sheets #275
|
@ -0,0 +1,14 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
cmds:
|
||||||
|
- cargo build
|
||||||
|
|
||||||
|
test:
|
||||||
|
cmds:
|
||||||
|
- cargo watch -x test
|
||||||
|
|
||||||
|
dev:
|
||||||
|
cmds:
|
||||||
|
- cargo watch -x run
|
|
@ -1,12 +1,11 @@
|
||||||
use std::{
|
use std::{
|
||||||
collections::{hash_map::Iter, HashMap},
|
collections::{hash_map::Iter, HashMap}, fmt::{self, Display}, fs, io::Read, path::PathBuf
|
||||||
fmt::{self, Display},
|
|
||||||
io::Read,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use mime::Mime;
|
use mime::Mime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use typeshare::typeshare;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
@ -32,8 +31,15 @@ impl From<std::io::Error> for Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
pub struct AssetId(String);
|
pub struct AssetId(String);
|
||||||
|
|
||||||
|
impl AssetId {
|
||||||
|
pub fn as_str<'a>(&'a self) -> &'a str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Display for AssetId {
|
impl Display for AssetId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "AssetId({})", self.0)
|
write!(f, "AssetId({})", self.0)
|
||||||
|
@ -73,19 +79,25 @@ pub struct FsAssets {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsAssets {
|
impl FsAssets {
|
||||||
pub fn new() -> Self {
|
pub fn new(path: PathBuf) -> Self {
|
||||||
Self {
|
let dir = fs::read_dir(path).unwrap();
|
||||||
assets: HashMap::new(),
|
let mut assets = HashMap::new();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assets<'a>(&'a self) -> impl Iterator<Item = &'a AssetId> {
|
for dir_ent in dir {
|
||||||
self.assets.keys()
|
println!("{:?}", dir_ent);
|
||||||
|
let path = dir_ent.unwrap().path();
|
||||||
|
let file_name = path.file_name().unwrap().to_str().unwrap();
|
||||||
|
assets.insert(AssetId::from(file_name), path.to_str().unwrap().to_owned());
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
assets,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Assets for FsAssets {
|
impl Assets for FsAssets {
|
||||||
fn assets<'a>(&'a self) -> AssetIter<'a> {
|
fn assets<'a>(&'a self) -> AssetIter<'a> {
|
||||||
|
println!("FsAssets assets: {:?}", self.assets);
|
||||||
AssetIter(self.assets.iter())
|
AssetIter(self.assets.iter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -99,6 +99,7 @@ impl Core {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn available_images(&self) -> Vec<AssetId> {
|
pub fn available_images(&self) -> Vec<AssetId> {
|
||||||
|
println!("available_images");
|
||||||
self.0
|
self.0
|
||||||
.read()
|
.read()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
@ -63,7 +63,7 @@ pub async fn handle_available_images(core: Core) -> impl Reply {
|
||||||
let image_paths: Vec<String> = core
|
let image_paths: Vec<String> = core
|
||||||
.available_images()
|
.available_images()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|path| format!("{}", path))
|
.map(|path| format!("{}", path.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
|
@ -116,6 +116,7 @@ pub async fn handle_connect_websocket(
|
||||||
client_id: String,
|
client_id: String,
|
||||||
) -> impl Reply {
|
) -> impl Reply {
|
||||||
ws.on_upgrade(move |socket| {
|
ws.on_upgrade(move |socket| {
|
||||||
|
println!("upgrading websocket");
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
async move {
|
async move {
|
||||||
let (mut ws_sender, _) = socket.split();
|
let (mut ws_sender, _) = socket.split();
|
||||||
|
|
|
@ -5,7 +5,7 @@ use handlers::{
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
convert::Infallible,
|
convert::Infallible,
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf,
|
||||||
};
|
};
|
||||||
use warp::{
|
use warp::{
|
||||||
// header,
|
// header,
|
||||||
|
@ -96,7 +96,7 @@ async fn handle_rejection(err: warp::Rejection) -> Result<impl Reply, Infallible
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
pub async fn main() {
|
pub async fn main() {
|
||||||
let core = core::Core::new(FsAssets::new());
|
let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")));
|
||||||
let log = warp::log("visions::api");
|
let log = warp::log("visions::api");
|
||||||
|
|
||||||
let route_image = warp::path!("api" / "v1" / "image" / String)
|
let route_image = warp::path!("api" / "v1" / "image" / String)
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
dev:
|
||||||
|
cmds:
|
||||||
|
- npm run start
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-router": "^5.1.20",
|
"@types/react-router": "^5.1.20",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router": "^6.28.0",
|
"react-router": "^6.28.0",
|
||||||
|
@ -5257,6 +5258,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz",
|
||||||
"integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA=="
|
"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": {
|
"node_modules/clean-css": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
"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-dom": "^18.3.1",
|
||||||
"@types/react-router": "^5.1.20",
|
"@types/react-router": "^5.1.20",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router": "^6.28.0",
|
"react-router": "^6.28.0",
|
||||||
|
@ -19,8 +20,8 @@
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-use-websocket": "^4.11.1",
|
"react-use-websocket": "^4.11.1",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"web-vitals": "^2.1.4",
|
"visions-types": "../visions-types",
|
||||||
"visions-types": "../visions-types"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
.App {
|
.App {
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-logo {
|
.App-logo {
|
||||||
|
|
|
@ -2,9 +2,11 @@ import React, { useEffect, useState } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { Client } from './client';
|
import { Client } from './client';
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
import { DesignPage } from './views/Design/Design';
|
||||||
import { GmView } from './views/GmView/GmView';
|
import { GmView } from './views/GmView/GmView';
|
||||||
import { WebsocketProvider } from './components/WebsocketProvider';
|
import { WebsocketProvider } from './components/WebsocketProvider';
|
||||||
import { PlayerView } from './views/PlayerView/PlayerView';
|
import { PlayerView } from './views/PlayerView/PlayerView';
|
||||||
|
import Candela from './plugins/Candela';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
client: Client;
|
client: Client;
|
||||||
|
@ -27,6 +29,14 @@ const App = ({ client }: AppProps) => {
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <PlayerView client={client} /> </WebsocketProvider> : <div> </div>
|
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <PlayerView client={client} /> </WebsocketProvider> : <div> </div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/candela",
|
||||||
|
element: <Candela.CharsheetElement />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/design",
|
||||||
|
element: <DesignPage />
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -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 {
|
.tabletop > img {
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playing-field__background > img {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,5 +10,5 @@ interface TabletopElementProps {
|
||||||
export const TabletopElement = ({ backgroundColor, backgroundUrl }: TabletopElementProps) => {
|
export const TabletopElement = ({ backgroundColor, backgroundUrl }: TabletopElementProps) => {
|
||||||
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
|
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;
|
onclick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThumbnailComponent = ({ id, url, onclick }: ThumbnailProps) => {
|
export const ThumbnailElement = ({ id, url, onclick }: ThumbnailProps) => {
|
||||||
const clickHandler = () => {
|
const clickHandler = () => {
|
||||||
if (onclick) { onclick(); }
|
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 }
|
|
@ -0,0 +1,110 @@
|
||||||
|
.charsheet__header {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charsheet__header > div {
|
||||||
|
margin: 8px;
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charsheet__body {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charsheet__body > div {
|
||||||
|
margin: 8px;
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid black;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group:before {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
border: 2px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__header {
|
||||||
|
display: flex;
|
||||||
|
font-size: xx-large;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 4px;
|
||||||
|
margin-left: -4px;
|
||||||
|
padding-left: 4px;
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__action {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__dots {
|
||||||
|
margin: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-column {
|
||||||
|
width: 10px;
|
||||||
|
height: 100%;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-top {
|
||||||
|
position: relative;
|
||||||
|
background-color: white;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-top:before {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-top_filled {
|
||||||
|
background-color: black;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-bottom {
|
||||||
|
background-color: white;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-bottom_filled {
|
||||||
|
background-color: black;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage-bottom:before {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group__guage {
|
||||||
|
display: flex;
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { assertNever } from '.';
|
||||||
|
import './Charsheet.css';
|
||||||
|
import { DriveGuage } from './DriveGuage/DriveGuage';
|
||||||
|
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||||
|
|
||||||
|
interface CharsheetProps {
|
||||||
|
sheet: Charsheet,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionElementProps {
|
||||||
|
name: string,
|
||||||
|
gilded: boolean,
|
||||||
|
value: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionElement = ({ name, gilded, value }: ActionElementProps) => {
|
||||||
|
let dots = [];
|
||||||
|
for (let i = 0; i < value; i++) {
|
||||||
|
dots.push("\u25ef");
|
||||||
|
}
|
||||||
|
let diamond = gilded ? "\u25c6" : "\u25c7";
|
||||||
|
return (<div>
|
||||||
|
<h2 className="action-group__action"> {diamond} {name} </h2>
|
||||||
|
<div className="action-group__dots"> {dots} </div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionGroupElementProps {
|
||||||
|
group: Nerve | Cunning | Intuition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionGroupElement = ({ group }: ActionGroupElementProps) => {
|
||||||
|
var title;
|
||||||
|
var elements = [];
|
||||||
|
|
||||||
|
switch (group.type_) {
|
||||||
|
case "nerve": {
|
||||||
|
title = <div className="action-group__header"> Nerve <DriveGuage 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="action-group__header"> Cunning <DriveGuage 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="action-group__header"> Intuition <DriveGuage 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="action-group">
|
||||||
|
{title}
|
||||||
|
{elements}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AbilitiesElementProps {
|
||||||
|
role: string
|
||||||
|
role_abilities: string[]
|
||||||
|
specialty: string
|
||||||
|
specialty_abilities: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const AbilitiesElement = ({ role, role_abilities, specialty, specialty_abilities }: AbilitiesElementProps) => {
|
||||||
|
return (<div>
|
||||||
|
<h1> ROLE: {role} </h1>
|
||||||
|
<ul>
|
||||||
|
{role_abilities.map((ability) => <li>{ability}</li>)}
|
||||||
|
</ul>
|
||||||
|
<h1> SPECIALTY: {role} </h1>
|
||||||
|
<ul>
|
||||||
|
{specialty_abilities.map((ability) => <li>{ability}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharsheetElement_ = ({ sheet }: CharsheetProps) => {
|
||||||
|
return (<div>
|
||||||
|
<div className="charsheet__header">
|
||||||
|
<div> Candela Obscura </div>
|
||||||
|
<div>
|
||||||
|
<p> {sheet.name} </p>
|
||||||
|
<p> {sheet.pronouns} </p>
|
||||||
|
<p> {sheet.circle} </p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p> {sheet.style} </p>
|
||||||
|
<p> {sheet.catalyst} </p>
|
||||||
|
<p> {sheet.question} </p>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
<div className="charsheet__body">
|
||||||
|
<div>
|
||||||
|
<ActionGroupElement group={sheet.nerve} />
|
||||||
|
<ActionGroupElement group={sheet.cunning} />
|
||||||
|
<ActionGroupElement group={sheet.intuition} />
|
||||||
|
</div>
|
||||||
|
<div> <AbilitiesElement {...sheet} /> </div>
|
||||||
|
<div> Marks, Scars, Relationships </div>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CharsheetElement = () => {
|
||||||
|
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 <CharsheetElement_ sheet={sheet} />
|
||||||
|
}
|
|
@ -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} />
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
.drive-guages_on-light {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage__element {
|
||||||
|
border: 1px solid black;
|
||||||
|
margin: 1px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage__spacer {
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage__top {
|
||||||
|
margin: 1px;
|
||||||
|
width: 8px;
|
||||||
|
border: 1px solid black;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage__top_filled {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage__bottom {
|
||||||
|
margin: 1px;
|
||||||
|
border: 1px solid black;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guage__bottom_filled {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-guages_on-dark {
|
||||||
|
background-color: black;
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
import './DriveGuage.css';
|
||||||
|
|
||||||
|
interface Guage {
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DriveGuage = ({ current, max }: Guage) => {
|
||||||
|
let components = [];
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
components.push(<div className="drive-guage__element">
|
||||||
|
{i < current ? <div className="drive-guage__top drive-guage__top_filled"> </div> :
|
||||||
|
<div className="drive-guage__top"> </div>}
|
||||||
|
{i < max ? <div className="drive-guage__bottom drive-guage__bottom_filled"> </div> :
|
||||||
|
<div className="drive-guage__bottom"> </div>}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
components.splice(3, 0, <div className="drive-guage__spacer"> </div>);
|
||||||
|
components.splice(7, 0, <div className="drive-guage__spacer"> </div>);
|
||||||
|
return (<div className="drive-guage">
|
||||||
|
{components}
|
||||||
|
</div>)
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { CharsheetElement } from './Charsheet';
|
||||||
|
import { CharsheetPanelElement } from './CharsheetPanel';
|
||||||
|
|
||||||
|
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[],
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
.section {
|
||||||
|
border: 2px solid black;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { DriveGuage } from '../../plugins/Candela/DriveGuage/DriveGuage';
|
||||||
|
|
||||||
|
const DriveGuages = () => {
|
||||||
|
return (<div className="section">
|
||||||
|
<div className="drive-guages_on-light">
|
||||||
|
<DriveGuage current={2} max={4} />
|
||||||
|
</div>
|
||||||
|
<div className="drive-guages_on-dark">
|
||||||
|
<DriveGuage current={2} max={4} />
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesignPage = () => {
|
||||||
|
return (<div>
|
||||||
|
<DriveGuages />
|
||||||
|
</div>);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import { Client, PlayingField } from '../../client';
|
import { Client, PlayingField } from '../../client';
|
||||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||||
import { ThumbnailComponent } from '../../components/Thumbnail/Thumbnail';
|
import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail';
|
||||||
import { WebsocketContext } from '../../components/WebsocketProvider';
|
import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||||
import './GmView.css';
|
import './GmView.css';
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ export const GmView = ({ client }: GmViewProps) => {
|
||||||
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
||||||
return (<div className="gm-view">
|
return (<div className="gm-view">
|
||||||
<div>
|
<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>
|
</div>
|
||||||
<TabletopElement backgroundColor={tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
<TabletopElement backgroundColor={tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
||||||
</div>)
|
</div>)
|
||||||
|
|
|
@ -4,15 +4,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-view__left-panel {
|
.player-view__left-panel {
|
||||||
flex-grow: 0;
|
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
max-width: 20%;
|
max-width: 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-view__right-panel {
|
.player-view__right-panel {
|
||||||
flex-grow: 0;
|
width: 25%;
|
||||||
min-width: 100px;
|
|
||||||
max-width: 20%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import './PlayerView.css';
|
||||||
import { WebsocketContext } from '../../components/WebsocketProvider';
|
import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||||
import { Client } from '../../client';
|
import { Client } from '../../client';
|
||||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||||
|
import Candela from '../../plugins/Candela';
|
||||||
|
|
||||||
interface PlayerViewProps {
|
interface PlayerViewProps {
|
||||||
client: Client;
|
client: Client;
|
||||||
|
@ -16,9 +17,8 @@ export const PlayerView = ({ client }: PlayerViewProps) => {
|
||||||
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
||||||
|
|
||||||
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
|
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
|
||||||
<div className="player-view__left-panel"> Left Side </div>
|
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
|
||||||
<TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} />
|
<div className="player-view__right-panel"> <Candela.CharsheetPanelElement /> </div>
|
||||||
<div className="player-view__right-panel"> Right Side </div>
|
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue