fix: dice overlay no longer blocks clicks, fog alpha wired to intensity, title auto-updates from class/alignment/level

- DiceTray: remove pointer-events:auto from .active so UI remains clickable during dice animation
- FogOverlay: accept intensity prop, map 0-100 to 0.15-1.0 opacity
- CampaignView: pass fog intensity to FogOverlay
- InfoPanel: auto-derive Shadowdark title when class/alignment/level changes
- Add shadowdark-titles.ts utility with full title lookup table from Player Quickstart
- Add CLAUDE.md with project instructions and pre-approved permissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 03:53:00 -04:00
parent b6ca67ff8b
commit 768c55c6b9
6 changed files with 108 additions and 11 deletions

27
CLAUDE.md Normal file
View file

@ -0,0 +1,27 @@
# Darkwatch — Claude Code Instructions
## Permissions
All tool uses are pre-approved for this project. No confirmation needed for:
- Reading, writing, or editing any file in this project
- Running bash commands (npm, git, docker, curl, lsof, kill, pkill, etc.)
- Spawning subagents
- Searching files (Glob, Grep)
## Project Overview
This is **Darkwatch**, a real-time session manager for the Shadowdark TTRPG.
- **Client:** `client/` — React + Vite + TypeScript + CSS Modules
- **Server:** `server/` — Express + Socket.IO + MariaDB (mysql2/promise)
- **DB:** MariaDB 11 in Docker (`darkwatch-maria` container, port 3307)
- **Worktrees:** `.worktrees/` (gitignored)
- **Roadmap:** `docs/ROADMAP.md`
## Important Rules
- NEVER touch the `mysql` Docker container — only `darkwatch-*` containers
- DB port is 3307 (not 3306) to avoid conflicts
- Local git repo only — commits are fine, NEVER push
- All imports use `.js` extensions (ES modules)
- Dev seed accounts: `dm@darkwatch.test` / `player@darkwatch.test` (password: `password`)

View file

@ -8,7 +8,6 @@
} }
.tray.active { .tray.active {
pointer-events: auto;
opacity: 1; opacity: 1;
} }
@ -17,7 +16,3 @@
height: 100% !important; height: 100% !important;
pointer-events: none !important; pointer-events: none !important;
} }
.tray.active :global(canvas) {
pointer-events: auto !important;
}

View file

@ -2,13 +2,17 @@ import styles from "./FogOverlay.module.css";
interface FogOverlayProps { interface FogOverlayProps {
active: boolean; active: boolean;
intensity: number; // 0100
} }
export default function FogOverlay({ active }: FogOverlayProps) { export default function FogOverlay({ active, intensity }: FogOverlayProps) {
if (!active) return null; if (!active) return null;
// Map 0100 to 0.151.0 so fog is always at least faintly visible when active
const opacity = 0.15 + (intensity / 100) * 0.85;
return ( return (
<div className={styles.overlay}> <div className={styles.overlay} style={{ opacity }}>
<div className={styles.layer1} /> <div className={styles.layer1} />
<div className={styles.layer2} /> <div className={styles.layer2} />
<div className={styles.layer3} /> <div className={styles.layer3} />

View file

@ -2,6 +2,7 @@ import { useRef, useEffect } from "react";
import type { Character } from "../types"; import type { Character } from "../types";
import TalentList from "./TalentList"; import TalentList from "./TalentList";
import SelectDropdown from "./SelectDropdown"; import SelectDropdown from "./SelectDropdown";
import { getShadowdarkTitle } from "../utils/shadowdark-titles.js";
import styles from "./InfoPanel.module.css"; import styles from "./InfoPanel.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
@ -40,13 +41,24 @@ export default function InfoPanel({
}, []); }, []);
function handleField(field: string, value: string | number) { function handleField(field: string, value: string | number) {
if (typeof value === "string") { const update: Partial<Character> = { [field]: value };
// Auto-update title when class, alignment, or level changes
if (field === "class" || field === "alignment" || field === "level") {
const newClass = field === "class" ? String(value) : character.class;
const newAlignment = field === "alignment" ? String(value) : character.alignment;
const newLevel = field === "level" ? Number(value) : character.level;
const derived = getShadowdarkTitle(newClass, newAlignment, newLevel);
if (derived) update.title = derived;
}
if (typeof value === "string" && field !== "class" && field !== "alignment") {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value }); onUpdate(character.id, update);
}, 400); }, 400);
} else { } else {
onUpdate(character.id, { [field]: value }); onUpdate(character.id, update);
} }
} }

View file

@ -509,7 +509,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={atmosphere.fog.active} /> <FogOverlay active={atmosphere.fog.active} intensity={atmosphere.fog.intensity} />
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} /> <ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
<ParticleOverlay atmosphere={atmosphere} /> <ParticleOverlay atmosphere={atmosphere} />
</div> </div>

View file

@ -0,0 +1,59 @@
type Alignment = "Lawful" | "Neutral" | "Chaotic";
type CharClass = "Fighter" | "Priest" | "Thief" | "Wizard";
// Title table from the Shadowdark Player Quickstart
// [level range min, level range max, lawful, chaotic, neutral]
const TITLES: Record<CharClass, [number, number, string, string, string][]> = {
Fighter: [
[1, 2, "Squire", "Knave", "Warrior"],
[3, 4, "Cavalier", "Bandit", "Barbarian"],
[5, 6, "Knight", "Slayer", "Battlerager"],
[7, 8, "Thane", "Reaver", "Warchief"],
[9, 10, "Lord", "Warlord", "Chieftain"],
],
Priest: [
[1, 2, "Acolyte", "Initiate", "Seeker"],
[3, 4, "Crusader", "Zealot", "Invoker"],
[5, 6, "Templar", "Cultist", "Haruspex"],
[7, 8, "Champion", "Scourge", "Mystic"],
[9, 10, "Paladin", "Chaos Knight", "Oracle"],
],
Thief: [
[1, 2, "Footpad", "Thug", "Robber"],
[3, 4, "Burglar", "Cutthroat", "Outlaw"],
[5, 6, "Rook", "Shadow", "Rogue"],
[7, 8, "Underboss", "Assassin", "Renegade"],
[9, 10, "Boss", "Wraith", "Bandit King"],
],
Wizard: [
[1, 2, "Apprentice", "Adept", "Shaman"],
[3, 4, "Conjurer", "Channeler", "Seer"],
[5, 6, "Arcanist", "Witch", "Warden"],
[7, 8, "Mage", "Diabolist", "Sage"],
[9, 10, "Archmage", "Sorcerer", "Druid"],
],
};
const ALIGNMENT_INDEX: Record<Alignment, 0 | 1 | 2> = {
Lawful: 0,
Chaotic: 1,
Neutral: 2,
};
export function getShadowdarkTitle(
charClass: string,
alignment: string,
level: number
): string | null {
const classTable = TITLES[charClass as CharClass];
if (!classTable) return null;
const alignIndex = ALIGNMENT_INDEX[alignment as Alignment];
if (alignIndex === undefined) return null;
const clampedLevel = Math.min(Math.max(level, 1), 10);
const row = classTable.find(([min, max]) => clampedLevel >= min && clampedLevel <= max);
if (!row) return null;
return row[2 + alignIndex] as string;
}