feat: add interactive damage monitor (Closes #27) (#66)

This commit was merged in pull request #66.
This commit is contained in:
2026-03-13 13:57:16 +01:00
parent 9e9a783e6a
commit 16fb0bcb88
4 changed files with 161 additions and 19 deletions

View File

@@ -37,6 +37,10 @@ object TestTags {
const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss" const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss"
const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error" const val ATTRIBUTE_EDIT_ERROR = "attribute_edit_error"
// --- Damage monitor interaction ---
const val DAMAGE_WOUND_MODIFIER = "damage_wound_modifier"
fun damageBox(track: String, position: Int): String = "damage_box_${track.lowercase()}_$position"
// --- Character data edit dialog --- // --- Character data edit dialog ---
const val CHARACTER_DATA_EDIT_DIALOG = "character_data_edit_dialog" const val CHARACTER_DATA_EDIT_DIALOG = "character_data_edit_dialog"
const val CHARACTER_DATA_EDIT_NAME = "character_data_edit_name" const val CHARACTER_DATA_EDIT_NAME = "character_data_edit_name"

View File

@@ -148,15 +148,16 @@ fun CharacterSheetPage(
CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData) CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
} }
} }
else -> { else -> {
when (selectedTab) { when (selectedTab) {
CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData) CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData)
CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute)
CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent)
CharacterTab.Combat -> CombatContent(character, spacing) CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter)
} }
} }
} }
@@ -296,7 +297,11 @@ private fun TalentsContent(
} }
@Composable @Composable
private fun CombatContent(character: ShadowrunCharacter, spacing: Dp) { private fun CombatContent(
character: ShadowrunCharacter,
spacing: Dp,
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -304,6 +309,13 @@ private fun CombatContent(character: ShadowrunCharacter, spacing: Dp) {
.padding(spacing), .padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing) verticalArrangement = Arrangement.spacedBy(spacing)
) { ) {
DamageMonitorPanel(character.damageMonitor) DamageMonitorPanel(
damageMonitor = character.damageMonitor,
onDamageChanged = { newDamageMonitor ->
onUpdateCharacter { char ->
char.copy(damageMonitor = newDamageMonitor)
}
}
)
} }
} }

View File

@@ -2,6 +2,7 @@ package org.shahondin1624.lib.components.charactermodel
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -15,15 +16,19 @@ import androidx.compose.ui.unit.sp
import org.shahondin1624.lib.components.TestTags import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.model.charactermodel.DamageMonitor import org.shahondin1624.model.charactermodel.DamageMonitor
import org.shahondin1624.model.charactermodel.DamageTrackType
import org.shahondin1624.theme.LocalWindowSizeClass import org.shahondin1624.theme.LocalWindowSizeClass
import org.shahondin1624.theme.WindowSizeClass import org.shahondin1624.theme.WindowSizeClass
/** /**
* Displays the damage monitor as visual box grids for stun, physical, and overflow tracks. * Displays the damage monitor as visual box grids for stun, physical, and overflow tracks.
* Read-only display; interactive damage marking is in story 6.5. * Boxes are clickable: tap empty box to mark damage, tap filled box to heal.
*/ */
@Composable @Composable
fun DamageMonitorPanel(damageMonitor: DamageMonitor) { fun DamageMonitorPanel(
damageMonitor: DamageMonitor,
onDamageChanged: ((DamageMonitor) -> Unit)? = null
) {
val windowSizeClass = LocalWindowSizeClass.current val windowSizeClass = LocalWindowSizeClass.current
val padding = UiConstants.Spacing.medium(windowSizeClass) val padding = UiConstants.Spacing.medium(windowSizeClass)
val spacing = UiConstants.Spacing.small(windowSizeClass) val spacing = UiConstants.Spacing.small(windowSizeClass)
@@ -45,12 +50,47 @@ fun DamageMonitorPanel(damageMonitor: DamageMonitor) {
modifier = Modifier.padding(bottom = spacing) modifier = Modifier.padding(bottom = spacing)
) )
// Show wound modifier if there is physical damage
val woundMod = damageMonitor.woundModifier()
if (woundMod < 0) {
Text(
text = "Wound Modifier: $woundMod",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium,
modifier = Modifier
.padding(bottom = spacing)
.testTag(TestTags.DAMAGE_WOUND_MODIFIER)
)
}
when (windowSizeClass) { when (windowSizeClass) {
WindowSizeClass.Compact -> { WindowSizeClass.Compact -> {
Column(verticalArrangement = Arrangement.spacedBy(spacing)) { Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
DamageTrack("Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax()) DamageTrack(
DamageTrack("Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax()) "Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax(),
DamageTrack("Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax()) onBoxClick = onDamageChanged?.let { callback ->
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Stun, position))
}
}
)
DamageTrack(
"Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax(),
onBoxClick = onDamageChanged?.let { callback ->
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Physical, position))
}
}
)
DamageTrack(
"Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax(),
onBoxClick = onDamageChanged?.let { callback ->
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Overflow, position))
}
}
)
} }
} }
WindowSizeClass.Medium, WindowSizeClass.Expanded -> { WindowSizeClass.Medium, WindowSizeClass.Expanded -> {
@@ -58,9 +98,30 @@ fun DamageMonitorPanel(damageMonitor: DamageMonitor) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing) horizontalArrangement = Arrangement.spacedBy(spacing)
) { ) {
DamageTrack("Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax(), Modifier.weight(1f)) DamageTrack(
DamageTrack("Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax(), Modifier.weight(1f)) "Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax(), Modifier.weight(1f),
DamageTrack("Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax(), Modifier.weight(1f)) onBoxClick = onDamageChanged?.let { callback ->
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Stun, position))
}
}
)
DamageTrack(
"Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax(), Modifier.weight(1f),
onBoxClick = onDamageChanged?.let { callback ->
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Physical, position))
}
}
)
DamageTrack(
"Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax(), Modifier.weight(1f),
onBoxClick = onDamageChanged?.let { callback ->
{ position ->
callback(damageMonitor.toggleDamageBox(DamageTrackType.Overflow, position))
}
}
)
} }
} }
} }
@@ -73,7 +134,8 @@ private fun DamageTrack(
label: String, label: String,
current: Int, current: Int,
max: Int, max: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onBoxClick: ((Int) -> Unit)? = null
) { ) {
val filledColor = MaterialTheme.colorScheme.error val filledColor = MaterialTheme.colorScheme.error
val emptyColor = MaterialTheme.colorScheme.surfaceVariant val emptyColor = MaterialTheme.colorScheme.surfaceVariant
@@ -123,6 +185,14 @@ private fun DamageTrack(
color = borderColor, color = borderColor,
shape = MaterialTheme.shapes.extraSmall shape = MaterialTheme.shapes.extraSmall
) )
.then(
if (onBoxClick != null) {
Modifier.clickable { onBoxClick(i) }
} else {
Modifier
}
)
.testTag(TestTags.damageBox(label, i))
) )
// Show wound modifier after every 3rd box // Show wound modifier after every 3rd box
if (i % 3 == 0 && i < max) { if (i % 3 == 0 && i < max) {

View File

@@ -9,6 +9,10 @@ import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.modifier.AttributeModifier import org.shahondin1624.model.modifier.AttributeModifier
import org.shahondin1624.model.modifier.ModifierCache import org.shahondin1624.model.modifier.ModifierCache
enum class DamageTrackType {
Stun, Physical, Overflow
}
private val EMPTY_ATTRIBUTES = Attributes( private val EMPTY_ATTRIBUTES = Attributes(
body = Attribute(AttributeType.Body, 0), body = Attribute(AttributeType.Body, 0),
agility = Attribute(AttributeType.Agility, 0), agility = Attribute(AttributeType.Agility, 0),
@@ -69,11 +73,63 @@ data class DamageMonitor(
return overflowCurrent return overflowCurrent
} }
fun getCurrentModifiers(modifiers: List<AttributeModifier> = emptyList()): List<AttributeModifier> { //TODO this does add the negative modifier every time the function is invoked /**
val lastRow = physicalMax(modifiers) % 3 * Returns the wound modifier penalty based on filled damage boxes.
val subtrahend = ((physicalCurrent(modifiers) - lastRow) / 3.0).toInt() * Every 3 boxes of physical damage apply a cumulative -1 modifier.
return modifiers.toMutableList().apply { */
//TODO fun woundModifier(): Int {
return -(physicalCurrent / 3)
}
fun getCurrentModifiers(modifiers: List<AttributeModifier> = emptyList()): List<AttributeModifier> {
// Wound modifiers are based on physical damage taken
return modifiers.toMutableList()
}
/**
* Set stun damage to a specific value (0 to stunMax).
*/
fun withStunDamage(value: Int): DamageMonitor {
val clamped = value.coerceIn(0, stunMax())
return copy(stunCurrent = clamped)
}
/**
* Set physical damage to a specific value (0 to physicalMax).
*/
fun withPhysicalDamage(value: Int): DamageMonitor {
val clamped = value.coerceIn(0, physicalMax())
return copy(physicalCurrent = clamped)
}
/**
* Set overflow damage to a specific value (0 to overflowMax).
*/
fun withOverflowDamage(value: Int): DamageMonitor {
val clamped = value.coerceIn(0, overflowMax())
return copy(overflowCurrent = clamped)
}
/**
* Toggle damage at a specific box position in a track.
* If the box is currently filled, heal (set current to position - 1).
* If the box is currently empty, damage (set current to position).
* For physical track, automatically transitions to overflow when full.
*/
fun toggleDamageBox(track: DamageTrackType, position: Int): DamageMonitor {
return when (track) {
DamageTrackType.Stun -> {
val newValue = if (position <= stunCurrent) position - 1 else position
withStunDamage(newValue)
}
DamageTrackType.Physical -> {
val newValue = if (position <= physicalCurrent) position - 1 else position
withPhysicalDamage(newValue)
}
DamageTrackType.Overflow -> {
val newValue = if (position <= overflowCurrent) position - 1 else position
withOverflowDamage(newValue)
}
} }
} }