Add torch timer, fog atmosphere, DM card redesign, and avatars
- Torch timer: 60min countdown per character, visual warnings at 10m/5m/1m - Fog overlay: CSS radial gradient layers with seamless infinite drift - Fog synced across all clients via socket, adapts to light/dark themes - DM card redesign: compact layout with HP/AC/luck/torch + modifier row - Grid changed to 3-up (from 4) with larger fonts - DiceBear avatars on cards and character sheets with style picker - Campaign name shown in header - Server: JSON.stringify fix for object fields in PATCH handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b12482921
commit
33032bcd07
18 changed files with 601 additions and 48 deletions
BIN
client/public/textures/fog1.png
Normal file
BIN
client/public/textures/fog1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
client/public/textures/fog2.png
Normal file
BIN
client/public/textures/fog2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -30,10 +30,27 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(var(--gold-rgb), 0.2);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin-bottom: 0.5rem;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
|
|
@ -41,31 +58,38 @@
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.level {
|
.level {
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.6rem;
|
||||||
|
margin-left: 3.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hpAcRow {
|
.vitalsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac {
|
.ac {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acLabel {
|
.acLabel {
|
||||||
|
|
@ -77,19 +101,47 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.acValue {
|
.acValue {
|
||||||
font-size: 1.1rem;
|
font-size: 1.15rem;
|
||||||
color: var(--ac);
|
color: var(--ac);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gearSummary {
|
.luck {
|
||||||
font-size: 0.75rem;
|
font-size: 1rem;
|
||||||
color: var(--text-tertiary);
|
line-height: 1;
|
||||||
text-align: right;
|
flex-shrink: 0;
|
||||||
margin-top: 0.5rem;
|
}
|
||||||
|
|
||||||
|
.modRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modLabel {
|
||||||
|
font-family: "Cinzel", Georgia, serif;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modValue {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.xp {
|
.xp {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,33 @@
|
||||||
import type { Character } from "../types";
|
import type { Character } from "../types";
|
||||||
import HpBar from "./HpBar";
|
import HpBar from "./HpBar";
|
||||||
import StatBlock from "./StatBlock";
|
import TorchTimer from "./TorchTimer";
|
||||||
import styles from "./CharacterCard.module.css";
|
import styles from "./CharacterCard.module.css";
|
||||||
import { calculateAC } from "../utils/derived-ac";
|
import { calculateAC } from "../utils/derived-ac";
|
||||||
import { getTalentHpBonus } from "../utils/talent-effects";
|
import { getTalentHpBonus } from "../utils/talent-effects";
|
||||||
|
import { getEffectiveStat } from "../utils/talent-effects";
|
||||||
|
import { getModifier, formatModifier } from "../utils/modifiers";
|
||||||
|
|
||||||
|
function getAvatarUrl(character: Character): string {
|
||||||
|
const style = (character.overrides?.avatar_style as string) || "micah";
|
||||||
|
const seed = encodeURIComponent(character.name || "hero");
|
||||||
|
return `https://api.dicebear.com/9.x/${style}/svg?seed=${seed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||||||
|
|
||||||
interface CharacterCardProps {
|
interface CharacterCardProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
onHpChange: (characterId: number, hp: number) => void;
|
onHpChange: (characterId: number, hp: number) => void;
|
||||||
onStatChange: (characterId: number, statName: string, value: number) => void;
|
onUpdate: (characterId: number, data: Partial<Character>) => void;
|
||||||
onClick: (characterId: number) => void;
|
onClick: (characterId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CharacterCard({
|
export default function CharacterCard({
|
||||||
character,
|
character,
|
||||||
onHpChange,
|
onHpChange,
|
||||||
onStatChange,
|
onUpdate,
|
||||||
onClick,
|
onClick,
|
||||||
}: CharacterCardProps) {
|
}: CharacterCardProps) {
|
||||||
const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.card}
|
className={styles.card}
|
||||||
|
|
@ -27,16 +35,20 @@ export default function CharacterCard({
|
||||||
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
|
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
|
||||||
>
|
>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
|
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||||
|
<div className={styles.nameRow}>
|
||||||
<span className={styles.name}>
|
<span className={styles.name}>
|
||||||
{character.name}
|
{character.name}
|
||||||
{character.title ? ` ${character.title}` : ""}
|
{character.title ? ` ${character.title}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.level}>Lvl {character.level}</span>
|
<span className={styles.level}>Lvl {character.level}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
{character.ancestry} {character.class}
|
{character.ancestry} {character.class}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hpAcRow} onClick={(e) => e.stopPropagation()}>
|
|
||||||
|
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
|
||||||
<HpBar
|
<HpBar
|
||||||
current={character.hp_current}
|
current={character.hp_current}
|
||||||
max={character.hp_max + getTalentHpBonus(character)}
|
max={character.hp_max + getTalentHpBonus(character)}
|
||||||
|
|
@ -48,20 +60,38 @@ export default function CharacterCard({
|
||||||
{calculateAC(character).effective}
|
{calculateAC(character).effective}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
className={styles.luck}
|
||||||
<StatBlock
|
title={character.luck_token ? "Luck available" : "Luck spent"}
|
||||||
stats={character.stats}
|
>
|
||||||
onStatChange={(statName, value) =>
|
{character.luck_token ? "\u2605" : "\u2606"}
|
||||||
onStatChange(character.id, statName, value)
|
</span>
|
||||||
}
|
<TorchTimer
|
||||||
|
torchLitAt={character.torch_lit_at}
|
||||||
|
onToggle={() => {
|
||||||
|
const isLit = character.torch_lit_at !== null;
|
||||||
|
onUpdate(character.id, {
|
||||||
|
torch_lit_at: isLit ? null : new Date().toISOString(),
|
||||||
|
} as Partial<Character>);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.gearSummary}>
|
|
||||||
{totalSlots} gear slot{totalSlots !== 1 ? "s" : ""} used
|
<div className={styles.modRow}>
|
||||||
|
{STATS.map((stat) => {
|
||||||
|
const value = getEffectiveStat(character, stat);
|
||||||
|
const mod = getModifier(value);
|
||||||
|
return (
|
||||||
|
<span key={stat} className={styles.mod}>
|
||||||
|
<span className={styles.modLabel}>{stat}</span>
|
||||||
|
<span className={styles.modValue}>{formatModifier(mod)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.xp}>
|
<div className={styles.xp}>
|
||||||
XP: {character.xp} / {character.level * 10}
|
XP {character.xp} / {character.level * 10}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,16 @@
|
||||||
inset 0 0 20px rgba(var(--shadow-rgb), 0.15);
|
inset 0 0 20px rgba(var(--shadow-rgb), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(var(--gold-rgb), 0.3);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.identity {
|
.identity {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,31 @@ import { calculateAC } from "../utils/derived-ac";
|
||||||
import { getTalentHpBonus } from "../utils/talent-effects";
|
import { getTalentHpBonus } from "../utils/talent-effects";
|
||||||
import AcDisplay from "./AcDisplay";
|
import AcDisplay from "./AcDisplay";
|
||||||
import InlineNumber from "./InlineNumber";
|
import InlineNumber from "./InlineNumber";
|
||||||
|
import SelectDropdown from "./SelectDropdown";
|
||||||
|
import TorchTimer from "./TorchTimer";
|
||||||
import StatsPanel from "./StatsPanel";
|
import StatsPanel from "./StatsPanel";
|
||||||
import InfoPanel from "./InfoPanel";
|
import InfoPanel from "./InfoPanel";
|
||||||
import GearPanel from "./GearPanel";
|
import GearPanel from "./GearPanel";
|
||||||
import styles from "./CharacterSheet.module.css";
|
import styles from "./CharacterSheet.module.css";
|
||||||
|
|
||||||
|
const AVATAR_STYLES = [
|
||||||
|
"micah",
|
||||||
|
"adventurer",
|
||||||
|
"adventurer-neutral",
|
||||||
|
"lorelei",
|
||||||
|
"lorelei-neutral",
|
||||||
|
"pixel-art",
|
||||||
|
"thumbs",
|
||||||
|
"personas",
|
||||||
|
"big-smile",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getAvatarUrl(character: Character): string {
|
||||||
|
const style = (character.overrides?.avatar_style as string) || "micah";
|
||||||
|
const seed = encodeURIComponent(character.name || "hero");
|
||||||
|
return `https://api.dicebear.com/9.x/${style}/svg?seed=${seed}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface CharacterSheetProps {
|
interface CharacterSheetProps {
|
||||||
character: Character;
|
character: Character;
|
||||||
mode: "view" | "edit";
|
mode: "view" | "edit";
|
||||||
|
|
@ -86,6 +106,7 @@ export default function CharacterSheet({
|
||||||
className={styles.banner}
|
className={styles.banner}
|
||||||
style={{ borderLeft: `3px solid ${character.color}` }}
|
style={{ borderLeft: `3px solid ${character.color}` }}
|
||||||
>
|
>
|
||||||
|
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||||
<div className={styles.identity}>
|
<div className={styles.identity}>
|
||||||
{mode === "edit" ? (
|
{mode === "edit" ? (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -105,12 +126,29 @@ export default function CharacterSheet({
|
||||||
title="Character color"
|
title="Character color"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.colorRow}>
|
||||||
<input
|
<input
|
||||||
className={styles.titleInput}
|
className={styles.titleInput}
|
||||||
defaultValue={character.title}
|
defaultValue={character.title}
|
||||||
placeholder="title..."
|
placeholder="title..."
|
||||||
onChange={(e) => handleNameField("title", e.target.value)}
|
onChange={(e) => handleNameField("title", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<SelectDropdown
|
||||||
|
value={
|
||||||
|
(character.overrides?.avatar_style as string) || "micah"
|
||||||
|
}
|
||||||
|
options={AVATAR_STYLES}
|
||||||
|
onChange={(v) => {
|
||||||
|
const overrides = {
|
||||||
|
...(character.overrides || {}),
|
||||||
|
avatar_style: v,
|
||||||
|
};
|
||||||
|
onUpdate(character.id, {
|
||||||
|
overrides,
|
||||||
|
} as Partial<Character>);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.name}>
|
<div className={styles.name}>
|
||||||
|
|
@ -199,6 +237,19 @@ export default function CharacterSheet({
|
||||||
{character.luck_token ? "\u2605" : "\u2606"}
|
{character.luck_token ? "\u2605" : "\u2606"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Torch timer */}
|
||||||
|
<div className={styles.vital}>
|
||||||
|
<TorchTimer
|
||||||
|
torchLitAt={character.torch_lit_at}
|
||||||
|
onToggle={() => {
|
||||||
|
const isLit = character.torch_lit_at !== null;
|
||||||
|
onUpdate(character.id, {
|
||||||
|
torch_lit_at: isLit ? null : new Date().toISOString(),
|
||||||
|
} as Partial<Character>);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,9 @@
|
||||||
.tray :global(canvas) {
|
.tray :global(canvas) {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray.active :global(canvas) {
|
||||||
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
client/src/components/FogOverlay.module.css
Normal file
163
client/src/components/FogOverlay.module.css
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 3s ease-in;
|
||||||
|
filter: var(--fog-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer1,
|
||||||
|
.layer2,
|
||||||
|
.layer3 {
|
||||||
|
position: absolute;
|
||||||
|
width: 200%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each layer has blobs duplicated at +50% offset so translating by -50% is seamless */
|
||||||
|
|
||||||
|
.layer1 {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 18% 30% at 8% 50%,
|
||||||
|
rgba(220, 220, 235, 0.45),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 13% 40% at 22% 40%,
|
||||||
|
rgba(210, 215, 230, 0.35),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 15% 35% at 38% 55%,
|
||||||
|
rgba(220, 220, 240, 0.4),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 18% 30% at 58% 50%,
|
||||||
|
rgba(220, 220, 235, 0.45),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 13% 40% at 72% 40%,
|
||||||
|
rgba(210, 215, 230, 0.35),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 15% 35% at 88% 55%,
|
||||||
|
rgba(220, 220, 240, 0.4),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: drift1 50s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer2 {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 20% 25% at 5% 60%,
|
||||||
|
rgba(200, 205, 220, 0.35),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 15% 35% at 25% 35%,
|
||||||
|
rgba(215, 215, 235, 0.3),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 18% 30% at 42% 50%,
|
||||||
|
rgba(205, 210, 225, 0.35),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 20% 25% at 55% 60%,
|
||||||
|
rgba(200, 205, 220, 0.35),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 15% 35% at 75% 35%,
|
||||||
|
rgba(215, 215, 235, 0.3),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 18% 30% at 92% 50%,
|
||||||
|
rgba(205, 210, 225, 0.35),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: drift2 70s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer3 {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 22% 20% at 12% 45%,
|
||||||
|
rgba(220, 220, 240, 0.3),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 15% 30% at 32% 55%,
|
||||||
|
rgba(210, 215, 230, 0.25),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 13% 35% at 45% 40%,
|
||||||
|
rgba(220, 220, 235, 0.3),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 22% 20% at 62% 45%,
|
||||||
|
rgba(220, 220, 240, 0.3),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 15% 30% at 82% 55%,
|
||||||
|
rgba(210, 215, 230, 0.25),
|
||||||
|
transparent
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 13% 35% at 95% 40%,
|
||||||
|
rgba(220, 220, 235, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: drift3 40s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Translate exactly -50% = one full pattern repeat = seamless loop */
|
||||||
|
@keyframes drift1 {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift2 {
|
||||||
|
from {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift3 {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
client/src/components/FogOverlay.tsx
Normal file
17
client/src/components/FogOverlay.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import styles from "./FogOverlay.module.css";
|
||||||
|
|
||||||
|
interface FogOverlayProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FogOverlay({ active }: FogOverlayProps) {
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.layer1} />
|
||||||
|
<div className={styles.layer2} />
|
||||||
|
<div className={styles.layer3} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
client/src/components/TorchTimer.module.css
Normal file
79
client/src/components/TorchTimer.module.css
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
.torch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torch:hover {
|
||||||
|
background: rgba(var(--gold-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit .icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.out .icon {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-family: "Cinzel", Georgia, serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gold);
|
||||||
|
min-width: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning .time {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .time {
|
||||||
|
color: var(--danger);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical .time {
|
||||||
|
color: var(--danger);
|
||||||
|
animation: pulse 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical .icon {
|
||||||
|
animation: flicker 0.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flicker {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
client/src/components/TorchTimer.tsx
Normal file
70
client/src/components/TorchTimer.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import styles from "./TorchTimer.module.css";
|
||||||
|
|
||||||
|
const TORCH_DURATION_MS = 60 * 60 * 1000; // 60 minutes
|
||||||
|
|
||||||
|
interface TorchTimerProps {
|
||||||
|
torchLitAt: string | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TorchTimer({ torchLitAt, onToggle }: TorchTimerProps) {
|
||||||
|
const [remaining, setRemaining] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!torchLitAt) {
|
||||||
|
setRemaining(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const elapsed = Date.now() - new Date(torchLitAt!).getTime();
|
||||||
|
const left = TORCH_DURATION_MS - elapsed;
|
||||||
|
setRemaining(left > 0 ? left : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const interval = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [torchLitAt]);
|
||||||
|
|
||||||
|
const isLit = torchLitAt !== null && remaining !== null && remaining > 0;
|
||||||
|
const isBurnedOut = torchLitAt !== null && remaining === 0;
|
||||||
|
|
||||||
|
let urgency = "";
|
||||||
|
if (isLit && remaining !== null) {
|
||||||
|
const mins = remaining / 60000;
|
||||||
|
if (mins <= 1) urgency = styles.critical;
|
||||||
|
else if (mins <= 5) urgency = styles.danger;
|
||||||
|
else if (mins <= 10) urgency = styles.warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ms: number): string {
|
||||||
|
const totalSec = Math.ceil(ms / 1000);
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
if (m === 0) return `${s}s`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${styles.torch} ${isLit ? styles.lit : ""} ${isBurnedOut ? styles.out : ""} ${urgency}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
title={
|
||||||
|
isLit
|
||||||
|
? `Torch: ${formatTime(remaining!)} remaining (click to extinguish)`
|
||||||
|
: isBurnedOut
|
||||||
|
? "Torch burned out (click to light a new one)"
|
||||||
|
: "Light a torch (1 hour)"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
{isLit ? "\uD83D\uDD25" : "\uD83E\uDE94"}
|
||||||
|
</span>
|
||||||
|
{isLit && remaining !== null && (
|
||||||
|
<span className={styles.time}>{formatTime(remaining)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,37 @@
|
||||||
text-shadow: 0 1px 3px rgba(var(--shadow-rgb), 0.4);
|
text-shadow: 0 1px 3px rgba(var(--shadow-rgb), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerBtns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fogBtn {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(var(--gold-rgb), 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition:
|
||||||
|
opacity 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fogBtn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
border-color: rgba(var(--gold-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fogBtnActive {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(var(--gold-rgb), 0.5);
|
||||||
|
background: rgba(var(--gold-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.addBtn {
|
.addBtn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--btn-gold-bg);
|
background: var(--btn-gold-bg);
|
||||||
|
|
@ -111,7 +142,7 @@
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import CharacterCard from "../components/CharacterCard";
|
||||||
import CharacterDetail from "../components/CharacterDetail";
|
import CharacterDetail from "../components/CharacterDetail";
|
||||||
import RollLog from "../components/RollLog";
|
import RollLog from "../components/RollLog";
|
||||||
import DiceTray from "../components/DiceTray";
|
import DiceTray from "../components/DiceTray";
|
||||||
|
import FogOverlay from "../components/FogOverlay";
|
||||||
import SelectDropdown from "../components/SelectDropdown";
|
import SelectDropdown from "../components/SelectDropdown";
|
||||||
import styles from "./CampaignView.module.css";
|
import styles from "./CampaignView.module.css";
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ export default function CampaignView() {
|
||||||
id: number;
|
id: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const pendingRollRef = useRef<RollResult | null>(null);
|
const pendingRollRef = useRef<RollResult | null>(null);
|
||||||
|
const [fogActive, setFogActive] = useState(false);
|
||||||
|
|
||||||
// Fetch characters and join socket room
|
// Fetch characters and join socket room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -240,6 +242,9 @@ export default function CampaignView() {
|
||||||
socket.on("talent:added", onTalentAdded);
|
socket.on("talent:added", onTalentAdded);
|
||||||
socket.on("talent:removed", onTalentRemoved);
|
socket.on("talent:removed", onTalentRemoved);
|
||||||
socket.on("roll:result", onRollResult);
|
socket.on("roll:result", onRollResult);
|
||||||
|
socket.on("atmosphere:update", (data: { fog: boolean }) => {
|
||||||
|
setFogActive(data.fog);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("character:created", onCharacterCreated);
|
socket.off("character:created", onCharacterCreated);
|
||||||
|
|
@ -251,6 +256,7 @@ export default function CampaignView() {
|
||||||
socket.off("talent:added", onTalentAdded);
|
socket.off("talent:added", onTalentAdded);
|
||||||
socket.off("talent:removed", onTalentRemoved);
|
socket.off("talent:removed", onTalentRemoved);
|
||||||
socket.off("roll:result", onRollResult);
|
socket.off("roll:result", onRollResult);
|
||||||
|
socket.off("atmosphere:update");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -337,10 +343,29 @@ export default function CampaignView() {
|
||||||
<span className={styles.campaignName}>
|
<span className={styles.campaignName}>
|
||||||
{campaignName || "Campaign"}
|
{campaignName || "Campaign"}
|
||||||
</span>
|
</span>
|
||||||
<button className={styles.addBtn} onClick={() => setShowCreate(true)}>
|
<div className={styles.headerBtns}>
|
||||||
|
<button
|
||||||
|
className={`${styles.fogBtn} ${fogActive ? styles.fogBtnActive : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
const next = !fogActive;
|
||||||
|
setFogActive(next);
|
||||||
|
socket.emit("atmosphere:update", {
|
||||||
|
campaignId,
|
||||||
|
fog: next,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title={fogActive ? "Clear fog" : "Summon fog"}
|
||||||
|
>
|
||||||
|
🌫
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.addBtn}
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
>
|
||||||
+ Add Character
|
+ Add Character
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{characters.length === 0 && (
|
{characters.length === 0 && (
|
||||||
|
|
@ -353,7 +378,7 @@ export default function CampaignView() {
|
||||||
key={char.id}
|
key={char.id}
|
||||||
character={char}
|
character={char}
|
||||||
onHpChange={handleHpChange}
|
onHpChange={handleHpChange}
|
||||||
onStatChange={handleStatChange}
|
onUpdate={handleUpdate}
|
||||||
onClick={setSelectedId}
|
onClick={setSelectedId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -448,6 +473,7 @@ export default function CampaignView() {
|
||||||
</div>
|
</div>
|
||||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
||||||
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
||||||
|
<FogOverlay active={fogActive} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
--texture-surface-size: 256px 256px;
|
--texture-surface-size: 256px 256px;
|
||||||
--texture-speckle-size: 128px 128px;
|
--texture-speckle-size: 128px 128px;
|
||||||
--texture-body-size: 512px 512px;
|
--texture-body-size: 512px 512px;
|
||||||
|
--fog-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|
@ -95,6 +96,7 @@
|
||||||
--crit: #b8960a;
|
--crit: #b8960a;
|
||||||
--copper: #8a5520;
|
--copper: #8a5520;
|
||||||
--silver: #707070;
|
--silver: #707070;
|
||||||
|
--fog-filter: invert(0.85) sepia(0.2);
|
||||||
|
|
||||||
--texture-surface: url("/textures/parchment-noise-light.png");
|
--texture-surface: url("/textures/parchment-noise-light.png");
|
||||||
--texture-speckle: url("/textures/speckle-light.png");
|
--texture-speckle: url("/textures/speckle-light.png");
|
||||||
|
|
@ -139,6 +141,7 @@
|
||||||
--crit: #8a7010;
|
--crit: #8a7010;
|
||||||
--copper: #8a5520;
|
--copper: #8a5520;
|
||||||
--silver: #707070;
|
--silver: #707070;
|
||||||
|
--fog-filter: invert(0.85) sepia(0.2);
|
||||||
|
|
||||||
--texture-surface: none;
|
--texture-surface: none;
|
||||||
--texture-speckle: none;
|
--texture-speckle: none;
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export interface Character {
|
||||||
overrides: Record<string, unknown>;
|
overrides: Record<string, unknown>;
|
||||||
color: string;
|
color: string;
|
||||||
luck_token: number;
|
luck_token: number;
|
||||||
|
torch_lit_at: string | null;
|
||||||
stats: Stat[];
|
stats: Stat[];
|
||||||
gear: Gear[];
|
gear: Gear[];
|
||||||
talents: Talent[];
|
talents: Talent[];
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ const v2Columns: Array<[string, string, string]> = [
|
||||||
["character_talents", "game_talent_id", "INTEGER"],
|
["character_talents", "game_talent_id", "INTEGER"],
|
||||||
["characters", "color", "TEXT DEFAULT ''"],
|
["characters", "color", "TEXT DEFAULT ''"],
|
||||||
["characters", "luck_token", "INTEGER DEFAULT 1"],
|
["characters", "luck_token", "INTEGER DEFAULT 1"],
|
||||||
|
["characters", "torch_lit_at", "TEXT"],
|
||||||
["roll_log", "nat20", "INTEGER DEFAULT 0"],
|
["roll_log", "nat20", "INTEGER DEFAULT 0"],
|
||||||
["roll_log", "character_color", "TEXT DEFAULT ''"],
|
["roll_log", "character_color", "TEXT DEFAULT ''"],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ router.patch("/:id", (req, res) => {
|
||||||
"overrides",
|
"overrides",
|
||||||
"color",
|
"color",
|
||||||
"luck_token",
|
"luck_token",
|
||||||
|
"torch_lit_at",
|
||||||
];
|
];
|
||||||
|
|
||||||
const updates: string[] = [];
|
const updates: string[] = [];
|
||||||
|
|
@ -158,7 +159,11 @@ router.patch("/:id", (req, res) => {
|
||||||
for (const field of allowedFields) {
|
for (const field of allowedFields) {
|
||||||
if (req.body[field] !== undefined) {
|
if (req.body[field] !== undefined) {
|
||||||
updates.push(`${field} = ?`);
|
updates.push(`${field} = ?`);
|
||||||
values.push(req.body[field]);
|
const val = req.body[field];
|
||||||
|
// JSON-stringify object fields for SQLite TEXT storage
|
||||||
|
values.push(
|
||||||
|
typeof val === "object" && val !== null ? JSON.stringify(val) : val,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,15 @@ export function setupSocket(io: Server) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
"atmosphere:update",
|
||||||
|
(data: { campaignId: number; fog: boolean }) => {
|
||||||
|
io.to(`campaign:${data.campaignId}`).emit("atmosphere:update", {
|
||||||
|
fog: data.fog,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
// Rooms are cleaned up automatically by Socket.IO
|
// Rooms are cleaned up automatically by Socket.IO
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue