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 {
|
||||
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;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
|
@ -41,31 +58,38 @@
|
|||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.level {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
margin-left: 3.4rem;
|
||||
}
|
||||
|
||||
.hpAcRow {
|
||||
.vitalsRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.ac {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 0.2rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.acLabel {
|
||||
|
|
@ -77,19 +101,47 @@
|
|||
}
|
||||
|
||||
.acValue {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.15rem;
|
||||
color: var(--ac);
|
||||
}
|
||||
|
||||
.gearSummary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
margin-top: 0.5rem;
|
||||
.luck {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,33 @@
|
|||
import type { Character } from "../types";
|
||||
import HpBar from "./HpBar";
|
||||
import StatBlock from "./StatBlock";
|
||||
import TorchTimer from "./TorchTimer";
|
||||
import styles from "./CharacterCard.module.css";
|
||||
import { calculateAC } from "../utils/derived-ac";
|
||||
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 {
|
||||
character: Character;
|
||||
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;
|
||||
}
|
||||
|
||||
export default function CharacterCard({
|
||||
character,
|
||||
onHpChange,
|
||||
onStatChange,
|
||||
onUpdate,
|
||||
onClick,
|
||||
}: CharacterCardProps) {
|
||||
const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.card}
|
||||
|
|
@ -27,16 +35,20 @@ export default function CharacterCard({
|
|||
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
|
||||
>
|
||||
<div className={styles.cardHeader}>
|
||||
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||
<div className={styles.nameRow}>
|
||||
<span className={styles.name}>
|
||||
{character.name}
|
||||
{character.title ? ` ${character.title}` : ""}
|
||||
</span>
|
||||
<span className={styles.level}>Lvl {character.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
{character.ancestry} {character.class}
|
||||
</div>
|
||||
<div className={styles.hpAcRow} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
|
||||
<HpBar
|
||||
current={character.hp_current}
|
||||
max={character.hp_max + getTalentHpBonus(character)}
|
||||
|
|
@ -48,20 +60,38 @@ export default function CharacterCard({
|
|||
{calculateAC(character).effective}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<StatBlock
|
||||
stats={character.stats}
|
||||
onStatChange={(statName, value) =>
|
||||
onStatChange(character.id, statName, value)
|
||||
}
|
||||
<span
|
||||
className={styles.luck}
|
||||
title={character.luck_token ? "Luck available" : "Luck spent"}
|
||||
>
|
||||
{character.luck_token ? "\u2605" : "\u2606"}
|
||||
</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 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 className={styles.xp}>
|
||||
XP: {character.xp} / {character.level * 10}
|
||||
XP {character.xp} / {character.level * 10}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@
|
|||
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 {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,31 @@ import { calculateAC } from "../utils/derived-ac";
|
|||
import { getTalentHpBonus } from "../utils/talent-effects";
|
||||
import AcDisplay from "./AcDisplay";
|
||||
import InlineNumber from "./InlineNumber";
|
||||
import SelectDropdown from "./SelectDropdown";
|
||||
import TorchTimer from "./TorchTimer";
|
||||
import StatsPanel from "./StatsPanel";
|
||||
import InfoPanel from "./InfoPanel";
|
||||
import GearPanel from "./GearPanel";
|
||||
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 {
|
||||
character: Character;
|
||||
mode: "view" | "edit";
|
||||
|
|
@ -86,6 +106,7 @@ export default function CharacterSheet({
|
|||
className={styles.banner}
|
||||
style={{ borderLeft: `3px solid ${character.color}` }}
|
||||
>
|
||||
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||
<div className={styles.identity}>
|
||||
{mode === "edit" ? (
|
||||
<div>
|
||||
|
|
@ -105,12 +126,29 @@ export default function CharacterSheet({
|
|||
title="Character color"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.colorRow}>
|
||||
<input
|
||||
className={styles.titleInput}
|
||||
defaultValue={character.title}
|
||||
placeholder="title..."
|
||||
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 className={styles.name}>
|
||||
|
|
@ -199,6 +237,19 @@ export default function CharacterSheet({
|
|||
{character.luck_token ? "\u2605" : "\u2606"}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,9 @@
|
|||
.tray :global(canvas) {
|
||||
width: 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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--btn-gold-bg);
|
||||
|
|
@ -111,7 +142,7 @@
|
|||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import CharacterCard from "../components/CharacterCard";
|
|||
import CharacterDetail from "../components/CharacterDetail";
|
||||
import RollLog from "../components/RollLog";
|
||||
import DiceTray from "../components/DiceTray";
|
||||
import FogOverlay from "../components/FogOverlay";
|
||||
import SelectDropdown from "../components/SelectDropdown";
|
||||
import styles from "./CampaignView.module.css";
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ export default function CampaignView() {
|
|||
id: number;
|
||||
} | null>(null);
|
||||
const pendingRollRef = useRef<RollResult | null>(null);
|
||||
const [fogActive, setFogActive] = useState(false);
|
||||
|
||||
// Fetch characters and join socket room
|
||||
useEffect(() => {
|
||||
|
|
@ -240,6 +242,9 @@ export default function CampaignView() {
|
|||
socket.on("talent:added", onTalentAdded);
|
||||
socket.on("talent:removed", onTalentRemoved);
|
||||
socket.on("roll:result", onRollResult);
|
||||
socket.on("atmosphere:update", (data: { fog: boolean }) => {
|
||||
setFogActive(data.fog);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off("character:created", onCharacterCreated);
|
||||
|
|
@ -251,6 +256,7 @@ export default function CampaignView() {
|
|||
socket.off("talent:added", onTalentAdded);
|
||||
socket.off("talent:removed", onTalentRemoved);
|
||||
socket.off("roll:result", onRollResult);
|
||||
socket.off("atmosphere:update");
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -337,10 +343,29 @@ export default function CampaignView() {
|
|||
<span className={styles.campaignName}>
|
||||
{campaignName || "Campaign"}
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{characters.length === 0 && (
|
||||
|
|
@ -353,7 +378,7 @@ export default function CampaignView() {
|
|||
key={char.id}
|
||||
character={char}
|
||||
onHpChange={handleHpChange}
|
||||
onStatChange={handleStatChange}
|
||||
onUpdate={handleUpdate}
|
||||
onClick={setSelectedId}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -448,6 +473,7 @@ export default function CampaignView() {
|
|||
</div>
|
||||
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
|
||||
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
|
||||
<FogOverlay active={fogActive} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
--texture-surface-size: 256px 256px;
|
||||
--texture-speckle-size: 128px 128px;
|
||||
--texture-body-size: 512px 512px;
|
||||
--fog-filter: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
|
@ -95,6 +96,7 @@
|
|||
--crit: #b8960a;
|
||||
--copper: #8a5520;
|
||||
--silver: #707070;
|
||||
--fog-filter: invert(0.85) sepia(0.2);
|
||||
|
||||
--texture-surface: url("/textures/parchment-noise-light.png");
|
||||
--texture-speckle: url("/textures/speckle-light.png");
|
||||
|
|
@ -139,6 +141,7 @@
|
|||
--crit: #8a7010;
|
||||
--copper: #8a5520;
|
||||
--silver: #707070;
|
||||
--fog-filter: invert(0.85) sepia(0.2);
|
||||
|
||||
--texture-surface: none;
|
||||
--texture-speckle: none;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export interface Character {
|
|||
overrides: Record<string, unknown>;
|
||||
color: string;
|
||||
luck_token: number;
|
||||
torch_lit_at: string | null;
|
||||
stats: Stat[];
|
||||
gear: Gear[];
|
||||
talents: Talent[];
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ const v2Columns: Array<[string, string, string]> = [
|
|||
["character_talents", "game_talent_id", "INTEGER"],
|
||||
["characters", "color", "TEXT DEFAULT ''"],
|
||||
["characters", "luck_token", "INTEGER DEFAULT 1"],
|
||||
["characters", "torch_lit_at", "TEXT"],
|
||||
["roll_log", "nat20", "INTEGER DEFAULT 0"],
|
||||
["roll_log", "character_color", "TEXT DEFAULT ''"],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ router.patch("/:id", (req, res) => {
|
|||
"overrides",
|
||||
"color",
|
||||
"luck_token",
|
||||
"torch_lit_at",
|
||||
];
|
||||
|
||||
const updates: string[] = [];
|
||||
|
|
@ -158,7 +159,11 @@ router.patch("/:id", (req, res) => {
|
|||
for (const field of allowedFields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
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", () => {
|
||||
// Rooms are cleaned up automatically by Socket.IO
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue