Skip to content

Commit af11147

Browse files
authored
feat(app): encounter tracker with combat flow
* feat: encounter page * feat: make encounter much better * chore: tests * fix: eslint * chore: add changeset for encounter tracker
1 parent cf641cb commit af11147

31 files changed

+4106
-6
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@dm-hero/app": minor
3+
---
4+
5+
Add encounter tracker with full combat flow: initiative, turn tracking, HP management, effects system, dice roller, and 49 unit tests (#247)
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
<template>
2+
<v-dialog v-model="internalShow" max-width="500" scrollable>
3+
<v-card>
4+
<v-card-title class="d-flex align-center">
5+
<v-icon class="mr-2">mdi-account-plus</v-icon>
6+
{{ $t('encounters.addParticipants') }}
7+
<v-spacer />
8+
<v-btn icon="mdi-close" variant="text" size="small" @click="close" />
9+
</v-card-title>
10+
11+
<v-divider />
12+
13+
<v-card-text>
14+
<div class="d-flex ga-2 mb-3">
15+
<v-select
16+
v-model="selectedType"
17+
:items="entityTypes"
18+
:label="$t('encounters.selectType')"
19+
variant="outlined"
20+
density="compact"
21+
hide-details
22+
style="max-width: 180px;"
23+
/>
24+
<v-text-field
25+
v-model="searchQuery"
26+
:placeholder="$t('common.search')"
27+
prepend-inner-icon="mdi-magnify"
28+
variant="outlined"
29+
density="compact"
30+
clearable
31+
hide-details
32+
/>
33+
</div>
34+
35+
<div v-if="loading" class="text-center py-6">
36+
<v-progress-circular indeterminate color="primary" />
37+
</div>
38+
39+
<v-list v-else-if="entities.length > 0" density="compact" style="max-height: 350px; overflow-y: auto;">
40+
<v-list-item
41+
v-for="entity in entities"
42+
:key="entity.id"
43+
>
44+
<template #prepend>
45+
<v-avatar :color="selectedType === 'Player' ? 'cyan-lighten-4' : 'blue-lighten-4'" size="36">
46+
<v-img v-if="entity.image_url" :src="`/uploads/${entity.image_url}`" />
47+
<v-icon v-else size="20">{{ selectedType === 'Player' ? 'mdi-account-star' : 'mdi-account' }}</v-icon>
48+
</v-avatar>
49+
</template>
50+
51+
<v-list-item-title>{{ entity.name }}</v-list-item-title>
52+
53+
<v-list-item-subtitle v-if="entity.resources.length > 0">
54+
<!-- Auto-detected HP indicator -->
55+
<span v-if="entity.autoHpField" class="text-success">
56+
<v-icon size="12" color="success">mdi-check-circle</v-icon>
57+
{{ $t('encounters.hp') }}: {{ getResource(entity, entity.autoHpField)?.current }}/{{ getResource(entity, entity.autoHpField)?.max }}
58+
</span>
59+
<span v-else class="text-warning">
60+
<v-icon size="12" color="warning">mdi-alert</v-icon>
61+
{{ $t('encounters.noHpDetected') }}
62+
</span>
63+
</v-list-item-subtitle>
64+
<v-list-item-subtitle v-else>
65+
<span class="text-error">
66+
<v-icon size="12" color="error">mdi-alert-circle</v-icon>
67+
{{ $t('encounters.noResources') }}
68+
</span>
69+
</v-list-item-subtitle>
70+
71+
<template #append>
72+
<div class="d-flex align-center ga-1">
73+
<!-- Initiative input during combat -->
74+
<v-text-field
75+
v-if="requireInitiative"
76+
v-model.number="initiativeInputs[entity.id]"
77+
type="number"
78+
density="comfortable"
79+
variant="outlined"
80+
hide-details
81+
:label="$t('encounters.initiative')"
82+
style="width: 100px; font-size: 18px; font-weight: 700;"
83+
@keydown.enter="onAddClick(entity)"
84+
/>
85+
86+
<!-- Resource picker if multiple -->
87+
<v-menu v-if="entity.resources.length > 1">
88+
<template #activator="{ props: menuProps }">
89+
<v-btn icon="mdi-heart-cog" size="x-small" variant="text" v-bind="menuProps" :title="$t('encounters.selectHpResource')" />
90+
</template>
91+
<v-list density="compact">
92+
<v-list-item
93+
v-for="r in entity.resources"
94+
:key="r.name"
95+
@click="addWithResource(entity, r.name)"
96+
>
97+
<v-list-item-title>{{ translateLabel(r.label) }} ({{ r.current }}/{{ r.max }})</v-list-item-title>
98+
</v-list-item>
99+
</v-list>
100+
</v-menu>
101+
102+
<v-btn
103+
icon="mdi-plus"
104+
size="small"
105+
color="primary"
106+
variant="text"
107+
:disabled="requireInitiative && initiativeInputs[entity.id] == null"
108+
@click="onAddClick(entity)"
109+
/>
110+
</div>
111+
</template>
112+
</v-list-item>
113+
</v-list>
114+
115+
<div v-else class="text-center py-6 text-medium-emphasis">
116+
<div>{{ $t('common.noResults') }}</div>
117+
<div class="text-body-2 mt-1">{{ $t('encounters.onlyWithStats') }}</div>
118+
</div>
119+
</v-card-text>
120+
</v-card>
121+
</v-dialog>
122+
</template>
123+
124+
<script setup lang="ts">
125+
import type { EncounterParticipant } from '~~/types/encounter'
126+
127+
const { t } = useI18n()
128+
const campaignStore = useCampaignStore()
129+
const encounterStore = useEncounterStore()
130+
const snackbarStore = useSnackbarStore()
131+
132+
interface ResourceField {
133+
name: string
134+
label: string
135+
current: number
136+
max: number
137+
}
138+
139+
interface EncounterEntity {
140+
id: number
141+
name: string
142+
image_url?: string | null
143+
type_name: string
144+
resources: ResourceField[]
145+
autoHpField: string | null
146+
}
147+
148+
const props = withDefaults(defineProps<{
149+
modelValue: boolean
150+
encounterId: number
151+
existingParticipants?: EncounterParticipant[]
152+
requireInitiative?: boolean
153+
}>(), {
154+
existingParticipants: () => [],
155+
requireInitiative: false,
156+
})
157+
158+
const emit = defineEmits<{
159+
'update:modelValue': [boolean]
160+
}>()
161+
162+
const internalShow = computed({
163+
get: () => props.modelValue,
164+
set: v => emit('update:modelValue', v),
165+
})
166+
167+
const entityTypes = [
168+
{ title: t('npcs.title'), value: 'NPC' },
169+
{ title: t('players.title'), value: 'Player' },
170+
]
171+
172+
const selectedType = ref('NPC')
173+
const searchQuery = ref('')
174+
const loading = ref(false)
175+
const entities = ref<EncounterEntity[]>([])
176+
const initiativeInputs = ref<Record<number, number | undefined>>({})
177+
178+
let searchTimeout: ReturnType<typeof setTimeout> | null = null
179+
180+
watch(selectedType, () => loadEntities())
181+
watch(searchQuery, (q) => {
182+
if (searchTimeout) clearTimeout(searchTimeout)
183+
searchTimeout = setTimeout(() => loadEntities(q), 300)
184+
})
185+
watch(() => props.modelValue, (visible) => {
186+
if (visible) {
187+
selectedType.value = 'NPC'
188+
searchQuery.value = ''
189+
initiativeInputs.value = {}
190+
loadEntities()
191+
}
192+
})
193+
194+
async function loadEntities(search?: string) {
195+
if (!campaignStore.activeCampaignId) return
196+
loading.value = true
197+
try {
198+
const query: Record<string, string | number> = {
199+
campaignId: campaignStore.activeCampaignId,
200+
type: selectedType.value,
201+
}
202+
if (search?.trim()) query.search = search.trim()
203+
entities.value = await $fetch<EncounterEntity[]>('/api/encounters/entities', { query })
204+
}
205+
catch {
206+
entities.value = []
207+
}
208+
finally {
209+
loading.value = false
210+
}
211+
}
212+
213+
function translateLabel(label: string): string {
214+
return label.startsWith('statPresets.') ? t(label) : label
215+
}
216+
217+
function getResource(entity: EncounterEntity, fieldName: string): ResourceField | undefined {
218+
return entity.resources.find(r => r.name === fieldName)
219+
}
220+
221+
function onAddClick(entity: EncounterEntity) {
222+
// Use auto-detected HP, or first resource, or nothing
223+
const hpField = entity.autoHpField ?? entity.resources[0]?.name ?? null
224+
addWithResource(entity, hpField)
225+
}
226+
227+
async function addWithResource(entity: EncounterEntity, hpFieldName: string | null) {
228+
let currentHp = 0
229+
let maxHp = 0
230+
231+
if (hpFieldName) {
232+
const resource = entity.resources.find(r => r.name === hpFieldName)
233+
if (resource) {
234+
currentHp = resource.current
235+
maxHp = resource.max
236+
}
237+
}
238+
239+
const initiative = props.requireInitiative ? initiativeInputs.value[entity.id] ?? null : null
240+
241+
const ok = await encounterStore.addParticipantsWithHp(props.encounterId, [{ entityId: entity.id, currentHp, maxHp }])
242+
if (!ok) return
243+
244+
// Set initiative and re-sort into correct position
245+
if (initiative != null) {
246+
const newParticipant = encounterStore.participants.findLast(p => p.entity_id === entity.id)
247+
if (newParticipant) {
248+
await encounterStore.updateParticipant(props.encounterId, newParticipant.id, { initiative })
249+
await encounterStore.sortByInitiative()
250+
}
251+
}
252+
253+
initiativeInputs.value[entity.id] = undefined
254+
snackbarStore.success(t('encounters.participantAdded'))
255+
}
256+
257+
function close() {
258+
emit('update:modelValue', false)
259+
}
260+
</script>

0 commit comments

Comments
 (0)