From fb39a4b62ed1ed233627057da6a495f93c712806 Mon Sep 17 00:00:00 2001 From: Arrowed Date: Fri, 3 Apr 2026 16:44:37 -0400 Subject: [PATCH] feat: add renaming, conditions, summon, and encounter controls Adversary Renaming: - Auto-suffix duplicate names on insert (Bladed Guard 2, 3, etc.) - Inline rename on rendered cards (double-click name to edit) - Per-instance naming for multi-count adversaries Condition Tracking: - Toggle badges for 8 standard Daggerheart conditions per instance - Custom conditions via YAML `conditions` field - Ad-hoc custom conditions via "+" button on condition bar Summon Buttons: - Features with `summon` field render insert buttons - Summoned adversaries get unique IDs and auto-suffixed names Encounter Controls: - Clickable "Mark a Stress" text with instance picker for multi-count - Per-stat +/- and clear buttons for HP and Stress rows All changes are backward-compatible with existing state data. --- src/main.ts | 10 +- src/ui.ts | 367 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/utils.ts | 31 +++++ styles.css | 160 ++++++++++++++++++++++ 4 files changed, 553 insertions(+), 15 deletions(-) diff --git a/src/main.ts b/src/main.ts index 836b3e6..2ee2395 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,9 @@ export type PluginState = { hp?: number; stress?: number; uses?: { [key: string]: number }; - countdown?: { [key: string]: number } + countdown?: { [key: string]: number }; + conditions?: string[]; + instanceName?: string; }; }; }; @@ -198,7 +200,7 @@ export default class BeastVault extends Plugin { this.updateState = debounce(() => this.saveData(this.state), 1000, true); this.registerMarkdownCodeBlockProcessor("daggerheart", (src, el, ctx) => { - const child = new AdversaryCard(el, tryParseYaml(src, false), this); + const child = new AdversaryCard(el, tryParseYaml(src, false), this, ctx); ctx.addChild(child); child.render(); // Track it so we can refresh on settings change: @@ -311,7 +313,7 @@ export default class BeastVault extends Plugin { } updateCard(keys: (string | number)[], value: string | number) { - type Data = { [key: string]: Data | number | string }; + type Data = { [key: string]: Data | number | string | string[] }; let data: Data = this.state.cards; const keysCopy = [...keys]; const lastKey = keysCopy.pop()!; @@ -324,7 +326,7 @@ export default class BeastVault extends Plugin { } getCardState(keys: (string | number)[]): number | undefined { - type Data = { [key: string]: Data | string | number } + type Data = { [key: string]: Data | string | number | string[] } let data: Data = this.state.cards; for (const [i, key] of keys.entries()) { if (!data[key]) return undefined; diff --git a/src/ui.ts b/src/ui.ts index 81928b8..5fe0a7b 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,7 +1,7 @@ -import { App, Editor, SuggestModal, Notice, MarkdownRenderChild, stringifyYaml, setIcon, MarkdownRenderer } from 'obsidian'; +import { App, Editor, SuggestModal, Notice, MarkdownRenderChild, stringifyYaml, setIcon, MarkdownRenderer, Menu, type MarkdownPostProcessorContext } from 'obsidian'; import { roll } from '@airjp73/dice-notation'; import BeastVault from './main'; -import { hexToRgb, DICE_PATTERN, processAdversary } from './utils'; +import { hexToRgb, DICE_PATTERN, processAdversary, DH_CONDITIONS, autoSuffixName } from './utils'; type Feature = { name?: string; @@ -10,6 +10,7 @@ type Feature = { uses?: number; countdown?: number; flavor?: string; + summon?: string | string[]; } // Stored in library, as entered by user. @@ -39,6 +40,9 @@ export type RawAdversary = { range?: string; damage?: string; + // custom conditions defined on this statblock + conditions?: string | string[]; + // these are not rendered source?: string; id?: string; @@ -86,7 +90,27 @@ export class AdversaryModal extends SuggestModal { copy.id = Math.random().toString(36).slice(2); delete copy.source; delete copy.raw; - const inserted = adv.raw ? adv.raw : stringifyYaml(copy); + + // Auto-suffix for adversaries (has hp or stress), not environments + if (adv.hp || adv.stress) { + const content = this.editor.getValue(); + const baseName = adv.name ?? ''; + const suffixed = autoSuffixName(baseName, content); + if (suffixed !== baseName) { + copy.name = suffixed; + } + } + + let inserted: string; + if (adv.raw) { + // For raw (homebrew) entries, string-replace the name: line + inserted = adv.raw; + if (copy.name !== adv.name) { + inserted = inserted.replace(/^(name:\s*).*$/m, `$1${copy.name}`); + } + } else { + inserted = stringifyYaml(copy); + } this.editor.replaceSelection(`\`\`\`daggerheart\n${inserted.trim()}\n\`\`\`\n`); } } @@ -99,7 +123,8 @@ export class AdversaryCard extends MarkdownRenderChild { constructor( private container: HTMLElement, public raw: RawAdversary, - private plugin: BeastVault + private plugin: BeastVault, + private ctx?: MarkdownPostProcessorContext ) { super(container); this.filePath = this.plugin.app.workspace.getActiveFile()?.path ?? '/'; @@ -110,8 +135,68 @@ export class AdversaryCard extends MarkdownRenderChild { createTitle(card: HTMLElement) { const title = card.createDiv({ cls: 'callout-title bv-spreadout' }); - title.createEl('b', { cls: 'bv-larger', text: `${this.adv.name || ''}` }); + const nameEl = title.createEl('b', { cls: 'bv-larger bv-renameable', text: `${this.adv.name || ''}` }); title.createEl('b', { cls: 'bv-smaller bv-padded', text: subTitle(this.adv.tier, this.adv.type) }); + + nameEl.addEventListener('dblclick', () => { + const input = createEl('input', { type: 'text', value: this.adv.name || '', cls: 'bv-rename-input' }); + nameEl.replaceWith(input); + input.focus(); + input.select(); + + const commit = () => { + const newName = input.value.trim(); + if (newName && newName !== this.adv.name) { + this.renameTo(newName); + } else { + // Re-render to restore the name element + this.render(); + } + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') this.render(); + }); + input.addEventListener('blur', commit); + }); + } + + private renameTo(newName: string) { + const sectionInfo = this.ctx?.getSectionInfo(this.container); + const editor = this.plugin.app.workspace.activeEditor?.editor; + if (!sectionInfo || !editor) return; + + const { lineStart, lineEnd } = sectionInfo; + for (let i = lineStart; i <= lineEnd; i++) { + const line = editor.getLine(i); + if (/^name:\s/.test(line)) { + editor.replaceRange( + `name: ${newName}\n`, + { line: i, ch: 0 }, + { line: i + 1, ch: 0 } + ); + break; + } + } + } + + private markStressOnInstance(index: number) { + const keys = [this.adv.id, index, 'stress']; + const current = this.plugin.getCardState(keys as (string | number)[]) ?? 0; + if (current >= this.adv.stress) { + new Notice('All stress slots already marked'); + return; + } + this.plugin.updateCard(keys as (string | number)[], current + 1); + this.render(); + + const cardState = this.plugin.state.cards[this.adv.id] as any; + const savedName = cardState?.[index]?.instanceName; + const label = this.count > 1 + ? (savedName || `${this.adv.name || 'Instance'} ${index + 1}`) + : (this.adv.name || 'Adversary'); + new Notice(`${label}: marked stress (${current + 1}/${this.adv.stress})`); } createHeaderEntry(header: HTMLElement, name: string, entry: string | string[] | undefined) { @@ -173,19 +258,166 @@ export class AdversaryCard extends MarkdownRenderChild { feature .desc .replace(/\b([sS])pend a [fF]ear\b/g, "$1pend a Fear") - .replace(/\b([mM])ark a [sS]tress\b/g, "$1ark a Stress") + .replace(/\b([mM])ark a [sS]tress\b/g, '$1ark a Stress') .replace(DICE_PATTERN, `$&`), featureDiv, this.filePath, this ); } + // Summon buttons + if (feature.summon) { + const summons = Array.isArray(feature.summon) ? feature.summon : [feature.summon]; + const summonDiv = paragraph.createDiv({ cls: 'bv-summon-bar' }); + for (const summonName of summons) { + const btn = summonDiv.createEl('button', { + text: `Summon: ${summonName}`, + cls: 'bv-summon-button', + }); + btn.addEventListener('click', () => this.summonAdversary(summonName)); + } + } + if (feature.flavor) { paragraph.createDiv().createEl('i', { cls: 'bv-muted', text: feature.flavor }); } } - createStatSlots(statBar: HTMLElement, name: string, stat: number, keys: (string | number)[]) { + private summonAdversary(name: string) { + const allAdv = this.plugin.allAdversaries(); + const allEnv = this.plugin.allEnvironments(); + const match = [...allAdv, ...allEnv].find( + a => a.name?.toLowerCase() === name.toLowerCase() + ); + + if (!match) { + new Notice(`"${name}" not found in library`); + return; + } + + const editor = this.plugin.app.workspace.activeEditor?.editor; + if (!editor) { + new Notice('No active editor'); + return; + } + + const copy = { ...match }; + copy.id = Math.random().toString(36).slice(2); + delete copy.source; + delete copy.raw; + + // Apply auto-suffix if this is an adversary + if (match.hp || match.stress) { + const content = editor.getValue(); + const baseName = match.name ?? ''; + const suffixed = autoSuffixName(baseName, content); + if (suffixed !== baseName) { + copy.name = suffixed; + } + } + + let yaml: string; + if (match.raw) { + yaml = match.raw; + if (copy.name !== match.name) { + yaml = yaml.replace(/^(name:\s*).*$/m, `$1${copy.name}`); + } + } else { + yaml = stringifyYaml(copy); + } + + const lastLine = editor.lastLine(); + const lastLineContent = editor.getLine(lastLine); + const insertPos = { line: lastLine, ch: lastLineContent.length }; + editor.replaceRange(`\n\n\`\`\`daggerheart\n${yaml.trim()}\n\`\`\`\n`, insertPos); + new Notice(`Summoned ${copy.name}`); + } + + createConditionBar(parent: HTMLElement, index: number) { + const condBar = parent.createDiv({ cls: 'bv-conditions' }); + const cardState = this.plugin.state.cards[this.adv.id]; + const currentRaw = cardState?.[index as keyof typeof cardState]; + const current: string[] = Array.isArray((currentRaw as any)?.conditions) ? (currentRaw as any).conditions : []; + + // Build full condition list: standard + YAML-defined custom + any ad-hoc from state + const yamlCustom = this.raw.conditions + ? (Array.isArray(this.raw.conditions) ? this.raw.conditions : [this.raw.conditions]) + : []; + const standardNames = DH_CONDITIONS as readonly string[]; + const allDefined = [...DH_CONDITIONS, ...yamlCustom.filter(c => !standardNames.includes(c))]; + // Also include any ad-hoc conditions that are in state but not in the defined list + const adHoc = current.filter(c => !allDefined.includes(c)); + const allConditions = [...allDefined, ...adHoc]; + + const addBadge = (condition: string) => { + const active = current.includes(condition); + const isCustom = !standardNames.includes(condition); + const badge = condBar.createEl('span', { + text: condition, + cls: `bv-condition-badge ${active ? 'bv-condition-active' : ''} ${isCustom ? 'bv-condition-custom' : ''}`, + }); + + badge.addEventListener('click', () => { + const isActive = badge.hasClass('bv-condition-active'); + const state = this.plugin.state.cards; + if (!state[this.adv.id]) state[this.adv.id] = {}; + const card = state[this.adv.id] as any; + if (!card[index]) card[index] = {}; + const inst = card[index]; + const conditions: string[] = Array.isArray(inst.conditions) ? [...inst.conditions] : []; + + if (isActive) { + inst.conditions = conditions.filter((c: string) => c !== condition); + } else { + inst.conditions = [...conditions, condition]; + } + this.plugin.updateState(); + badge.toggleClass('bv-condition-active', !isActive); + }); + + return badge; + }; + + for (const condition of allConditions) { + addBadge(condition); + } + + // "+" button for ad-hoc custom conditions + const addBtn = condBar.createEl('span', { + text: '+', + cls: 'bv-condition-badge bv-condition-add', + }); + addBtn.addEventListener('click', () => { + const input = createEl('input', { + type: 'text', + cls: 'bv-condition-input', + attr: { placeholder: 'Condition...' }, + }); + addBtn.replaceWith(input); + input.focus(); + + const commit = () => { + const name = input.value.trim(); + if (name && !allConditions.includes(name)) { + const badge = addBadge(name); + allConditions.push(name); + // Auto-activate the new condition + badge.click(); + input.replaceWith(addBtn); + } else { + input.replaceWith(addBtn); + } + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') input.replaceWith(addBtn); + }); + input.addEventListener('blur', commit); + }); + } + + createStatSlots(statBar: HTMLElement, name: string, stat: number, keys: (string | number)[], showControls = false) { const slots: HTMLInputElement[] = [] const marked = this.plugin.getCardState(keys) ?? 0; if (stat > 0) { @@ -197,11 +429,50 @@ export class AdversaryCard extends MarkdownRenderChild { } slots.push(slot); } + + const syncSlots = () => { + const count = slots.reduce((sum, slot) => sum + (slot.checked ? 1 : 0), 0); + this.plugin.updateCard(keys, count); + }; + + // Notify parent listeners (e.g. horde size) after programmatic changes + const notifyChange = () => { + if (slots.length > 0) slots[0].dispatchEvent(new Event('input', { bubbles: true })); + }; + + if (showControls) { + const controls = statBar.createSpan({ cls: 'bv-slot-controls' }); + const minus = controls.createEl('button', { text: '\u2212', cls: 'bv-slot-btn', attr: { 'aria-label': `Remove 1 ${name}` } }); + const plus = controls.createEl('button', { text: '+', cls: 'bv-slot-btn', attr: { 'aria-label': `Add 1 ${name}` } }); + const clear = controls.createEl('button', { text: '\u2715', cls: 'bv-slot-btn bv-slot-btn-clear', attr: { 'aria-label': `Clear ${name}` } }); + + plus.addEventListener('click', () => { + for (const slot of slots) { + if (!slot.checked) { slot.checked = true; break; } + } + syncSlots(); + notifyChange(); + }); + + minus.addEventListener('click', () => { + for (const slot of slots.toReversed()) { + if (slot.checked) { slot.checked = false; break; } + } + syncSlots(); + notifyChange(); + }); + + clear.addEventListener('click', () => { + for (const slot of slots) slot.checked = false; + syncSlots(); + notifyChange(); + }); + } + statBar.createEl('br'); statBar.addEventListener('input', (event) => { if (!slots.contains(event.target as HTMLInputElement)) return; - let marked = slots.reduce((sum, slot) => sum + (slot.checked ? 1 : 0), 0); - this.plugin.updateCard(keys, marked) + syncSlots(); }); } @@ -229,9 +500,61 @@ export class AdversaryCard extends MarkdownRenderChild { createStatBar(content: HTMLElement, index: number) { const statBar = content.createEl('p'); + + // Per-instance name label when count > 1 + if (this.count > 1) { + const cardState = this.plugin.state.cards[this.adv.id] as any; + const savedName = cardState?.[index]?.instanceName; + const defaultName = `${this.adv.name || 'Instance'} ${index + 1}`; + const displayName = savedName || defaultName; + + const nameEl = statBar.createEl('b', { + text: displayName, + cls: 'bv-instance-name', + }); + + nameEl.addEventListener('click', () => { + const input = createEl('input', { + type: 'text', + value: displayName, + cls: 'bv-instance-name-input', + }); + nameEl.replaceWith(input); + input.focus(); + input.select(); + + const commit = () => { + const newName = input.value.trim(); + if (newName && newName !== displayName) { + const state = this.plugin.state.cards; + if (!state[this.adv.id]) state[this.adv.id] = {}; + const card = state[this.adv.id] as any; + if (!card[index]) card[index] = {}; + card[index].instanceName = newName; + this.plugin.updateState(); + } + // Re-render to show updated name + this.render(); + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') this.render(); + }); + input.addEventListener('blur', commit); + }); + + statBar.createEl('br'); + } + const [minor, major, severe, massive] = this.createThresholdButtons(statBar); - const hpSlots = this.createStatSlots(statBar, 'HP', this.adv.hp, [this.adv.id, index, 'hp']); - this.createStatSlots(statBar, 'Stress', this.adv.stress, [this.adv.id, index, 'stress']); + const hpSlots = this.createStatSlots(statBar, 'HP', this.adv.hp, [this.adv.id, index, 'hp'], true); + this.createStatSlots(statBar, 'Stress', this.adv.stress, [this.adv.id, index, 'stress'], true); + + // Condition badges for adversaries only (not environments) + if (this.adv.hp || this.adv.stress) { + this.createConditionBar(statBar, index); + } if (this.count > 1) { for (const [featureIndex, feature] of this.adv.features.entries()) { @@ -359,6 +682,28 @@ export class AdversaryCard extends MarkdownRenderChild { card.addEventListener('click', (event) => { const elt = event.target as HTMLElement; + + // "Mark a Stress" clickable action + if (elt.classList.contains('bv-mark-stress')) { + if (this.adv.stress <= 0) return; + if (this.count === 1) { + this.markStressOnInstance(0); + } else { + const menu = new Menu(); + for (let i = 0; i < this.count; i++) { + const cardState = this.plugin.state.cards[this.adv.id] as any; + const savedName = cardState?.[i]?.instanceName; + const label = savedName || `${this.adv.name || 'Instance'} ${i + 1}`; + menu.addItem((item) => item + .setTitle(label) + .onClick(() => this.markStressOnInstance(i))); + } + menu.showAtMouseEvent(event as MouseEvent); + } + return; + } + + // Dice rolling if (!elt.classList.contains('bv-rollable')) return; const dice = elt.classList.contains('bv-rollable-attack') ? `1d20${this.adv.attack == '0' ? '' : this.adv.attack}` diff --git a/src/utils.ts b/src/utils.ts index 8667e76..4e2d1cd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -52,6 +52,37 @@ features: export const DICE_PATTERN = /(\b\d+d\d+(?:\+\d+d\d+)*(?:\+\d+)?\b)/g; +export const DH_CONDITIONS = [ + 'Vulnerable', + 'Restrained', + 'Frightened', + 'Disoriented', + 'Weakened', + 'Hidden', + 'Empowered', + 'Slowed', +] as const; + +export type Condition = typeof DH_CONDITIONS[number]; + +export function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function autoSuffixName(baseName: string, editorContent: string): string { + const pattern = new RegExp(`^name:\\s*${escapeRegex(baseName)}(?:\\s+(\\d+))?\\s*$`, 'gmi'); + let maxSuffix = 0; + let match; + while ((match = pattern.exec(editorContent)) !== null) { + const suffix = match[1] ? parseInt(match[1]) : 1; + maxSuffix = Math.max(maxSuffix, suffix); + } + if (maxSuffix > 0) { + return `${baseName} ${maxSuffix + 1}`; + } + return baseName; +} + export function hexToRgb(hex: string) { hex = hex.replace(/^#/, ''); if (hex.length === 3) hex = hex.split('').map(x => x + x).join(''); diff --git a/styles.css b/styles.css index ff362ed..0792ed6 100644 --- a/styles.css +++ b/styles.css @@ -83,6 +83,166 @@ input[type=checkbox].bv-slot { margin: 0 !important; } +/* HP/Stress slot controls */ +.bv-slot-controls { + display: inline-flex; + gap: 2px; + margin-left: 6px; + vertical-align: middle; +} + +.bv-slot-btn { + font-size: 0.75em; + width: 1.8em; + height: 1.8em; + padding: 0; + line-height: 1; + cursor: pointer; + border-radius: 3px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.bv-slot-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.bv-slot-btn-clear:hover { + color: var(--text-error); +} + +/* Mark a Stress action */ +.bv-mark-stress { + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; +} + +.bv-mark-stress:hover { + color: rgb(var(--callout-color)); +} + +/* Rename */ +.bv-renameable { + cursor: pointer; +} + +.bv-renameable:hover { + text-decoration: underline dotted; +} + +.bv-rename-input { + font-size: inherit; + font-weight: bold; + background: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + color: inherit; + padding: 2px 4px; + text-transform: uppercase; + width: 100%; +} + +/* Instance names */ +.bv-instance-name { + cursor: pointer; + font-size: 0.9em; + color: rgb(var(--callout-color)); +} + +.bv-instance-name:hover { + text-decoration: underline dotted; +} + +.bv-instance-name-input { + font-size: 0.9em; + font-weight: bold; + background: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + color: inherit; + padding: 2px 4px; + width: 12em; +} + +/* Conditions */ +.bv-conditions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 6px 0; +} + +.bv-condition-badge { + font-size: 0.75em; + padding: 2px 8px; + border-radius: 12px; + border: 1px solid var(--background-modifier-border); + cursor: pointer; + opacity: 0.5; + user-select: none; + transition: opacity 0.15s, background-color 0.15s; +} + +.bv-condition-badge:hover { + opacity: 0.8; +} + +.bv-condition-active { + opacity: 1; + background-color: rgba(var(--callout-color), 0.25); + border-color: rgba(var(--callout-color), 0.5); + font-weight: 600; +} + +.bv-condition-custom { + border-style: dashed; +} + +.bv-condition-add { + opacity: 0.4; + font-weight: bold; + min-width: 1.5em; + text-align: center; +} + +.bv-condition-add:hover { + opacity: 0.8; +} + +.bv-condition-input { + font-size: 0.75em; + padding: 2px 8px; + border-radius: 12px; + border: 1px dashed var(--background-modifier-border); + background: var(--background-modifier-form-field); + color: inherit; + width: 8em; +} + +/* Summon */ +.bv-summon-bar { + margin: 4px 0; +} + +.bv-summon-button { + font-size: 0.8em; + padding: 2px 10px; + margin-right: 4px; + cursor: pointer; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-normal); +} + +.bv-summon-button:hover { + background: var(--background-modifier-hover); +} + .block-language-daggerheart + .edit-block-button { opacity: var(--icon-opacity) !important; }