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:
Aaron Wood 2026-04-10 14:41:22 -04:00
parent 9b12482921
commit 33032bcd07
18 changed files with 601 additions and 48 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -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;
} }

View file

@ -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>
); );

View file

@ -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;
} }

View file

@ -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>

View file

@ -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;
} }

View 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%);
}
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View file

@ -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;
} }

View file

@ -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>
); );
} }

View file

@ -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;

View file

@ -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[];

View file

@ -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 ''"],
]; ];

View file

@ -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,
);
} }
} }

View file

@ -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
}); });