diff --git a/client/public/textures/fog1.png b/client/public/textures/fog1.png new file mode 100644 index 0000000..3a420d7 Binary files /dev/null and b/client/public/textures/fog1.png differ diff --git a/client/public/textures/fog2.png b/client/public/textures/fog2.png new file mode 100644 index 0000000..363638a Binary files /dev/null and b/client/public/textures/fog2.png differ diff --git a/client/src/components/CharacterCard.module.css b/client/src/components/CharacterCard.module.css index 6c687c2..a876e62 100644 --- a/client/src/components/CharacterCard.module.css +++ b/client/src/components/CharacterCard.module.css @@ -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; } diff --git a/client/src/components/CharacterCard.tsx b/client/src/components/CharacterCard.tsx index c42438d..51dad84 100644 --- a/client/src/components/CharacterCard.tsx +++ b/client/src/components/CharacterCard.tsx @@ -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) => 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 (
- - {character.name} - {character.title ? ` ${character.title}` : ""} - - Lvl {character.level} + +
+ + {character.name} + {character.title ? ` ${character.title}` : ""} + + Lvl {character.level} +
{character.ancestry} {character.class}
-
e.stopPropagation()}> + +
e.stopPropagation()}>
-
-
e.stopPropagation()}> - - onStatChange(character.id, statName, value) - } + + {character.luck_token ? "\u2605" : "\u2606"} + + { + const isLit = character.torch_lit_at !== null; + onUpdate(character.id, { + torch_lit_at: isLit ? null : new Date().toISOString(), + } as Partial); + }} />
-
- {totalSlots} gear slot{totalSlots !== 1 ? "s" : ""} used + +
+ {STATS.map((stat) => { + const value = getEffectiveStat(character, stat); + const mod = getModifier(value); + return ( + + {stat} + {formatModifier(mod)} + + ); + })}
+
- XP: {character.xp} / {character.level * 10} + XP {character.xp} / {character.level * 10}
); diff --git a/client/src/components/CharacterSheet.module.css b/client/src/components/CharacterSheet.module.css index 18ade56..246c380 100644 --- a/client/src/components/CharacterSheet.module.css +++ b/client/src/components/CharacterSheet.module.css @@ -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; } diff --git a/client/src/components/CharacterSheet.tsx b/client/src/components/CharacterSheet.tsx index fdd8a58..9343e12 100644 --- a/client/src/components/CharacterSheet.tsx +++ b/client/src/components/CharacterSheet.tsx @@ -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}` }} > +
{mode === "edit" ? (
@@ -105,12 +126,29 @@ export default function CharacterSheet({ title="Character color" />
- handleNameField("title", e.target.value)} - /> +
+ handleNameField("title", e.target.value)} + /> + { + const overrides = { + ...(character.overrides || {}), + avatar_style: v, + }; + onUpdate(character.id, { + overrides, + } as Partial); + }} + /> +
) : (
@@ -199,6 +237,19 @@ export default function CharacterSheet({ {character.luck_token ? "\u2605" : "\u2606"}
+ + {/* Torch timer */} +
+ { + const isLit = character.torch_lit_at !== null; + onUpdate(character.id, { + torch_lit_at: isLit ? null : new Date().toISOString(), + } as Partial); + }} + /> +
diff --git a/client/src/components/DiceTray.module.css b/client/src/components/DiceTray.module.css index 69ed0ed..c7ec80c 100644 --- a/client/src/components/DiceTray.module.css +++ b/client/src/components/DiceTray.module.css @@ -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; } diff --git a/client/src/components/FogOverlay.module.css b/client/src/components/FogOverlay.module.css new file mode 100644 index 0000000..e8f4bc0 --- /dev/null +++ b/client/src/components/FogOverlay.module.css @@ -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%); + } +} diff --git a/client/src/components/FogOverlay.tsx b/client/src/components/FogOverlay.tsx new file mode 100644 index 0000000..16d7962 --- /dev/null +++ b/client/src/components/FogOverlay.tsx @@ -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 ( +
+
+
+
+
+ ); +} diff --git a/client/src/components/TorchTimer.module.css b/client/src/components/TorchTimer.module.css new file mode 100644 index 0000000..64c80a7 --- /dev/null +++ b/client/src/components/TorchTimer.module.css @@ -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; + } +} diff --git a/client/src/components/TorchTimer.tsx b/client/src/components/TorchTimer.tsx new file mode 100644 index 0000000..a767d35 --- /dev/null +++ b/client/src/components/TorchTimer.tsx @@ -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(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 ( + + ); +} diff --git a/client/src/pages/CampaignView.module.css b/client/src/pages/CampaignView.module.css index 78f7034..f1bd6bd 100644 --- a/client/src/pages/CampaignView.module.css +++ b/client/src/pages/CampaignView.module.css @@ -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; } diff --git a/client/src/pages/CampaignView.tsx b/client/src/pages/CampaignView.tsx index ea455d8..c236d9a 100644 --- a/client/src/pages/CampaignView.tsx +++ b/client/src/pages/CampaignView.tsx @@ -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(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,9 +343,28 @@ export default function CampaignView() { {campaignName || "Campaign"} - +
+ + +
@@ -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() {
+
); } diff --git a/client/src/theme.css b/client/src/theme.css index 6e107ff..2f19d31 100644 --- a/client/src/theme.css +++ b/client/src/theme.css @@ -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; diff --git a/client/src/types.ts b/client/src/types.ts index 7b33926..7ba0950 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -55,6 +55,7 @@ export interface Character { overrides: Record; color: string; luck_token: number; + torch_lit_at: string | null; stats: Stat[]; gear: Gear[]; talents: Talent[]; diff --git a/server/src/db.ts b/server/src/db.ts index eb19117..5049f41 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -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 ''"], ]; diff --git a/server/src/routes/characters.ts b/server/src/routes/characters.ts index d5a2863..dc58e3d 100644 --- a/server/src/routes/characters.ts +++ b/server/src/routes/characters.ts @@ -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, + ); } } diff --git a/server/src/socket.ts b/server/src/socket.ts index 1db2b4b..39827ce 100644 --- a/server/src/socket.ts +++ b/server/src/socket.ts @@ -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 });