diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 334fb5a..1e7d0e4 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -37,6 +37,10 @@ object TestTags { const val ATTRIBUTE_EDIT_DISMISS = "attribute_edit_dismiss" 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 --- const val CHARACTER_DATA_EDIT_DIALOG = "character_data_edit_dialog" const val CHARACTER_DATA_EDIT_NAME = "character_data_edit_name" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index a24f97d..4fe5bd1 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -148,15 +148,16 @@ fun CharacterSheetPage( CharacterTab.Overview -> ExpandedOverviewContent(character, spacing, onEditCharacterData) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) CharacterTab.Talents -> TalentsContent(character, spacing, onDiceRoll, onEditTalent) - CharacterTab.Combat -> CombatContent(character, spacing) + CharacterTab.Combat -> CombatContent(character, spacing, onUpdateCharacter) } } else -> { + when (selectedTab) { CharacterTab.Overview -> OverviewContent(character, spacing, onEditCharacterData) CharacterTab.Attributes -> AttributesContent(character, spacing, onDiceRoll, onEditAttribute) 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 -private fun CombatContent(character: ShadowrunCharacter, spacing: Dp) { +private fun CombatContent( + character: ShadowrunCharacter, + spacing: Dp, + onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit +) { Column( modifier = Modifier .fillMaxSize() @@ -304,6 +309,13 @@ private fun CombatContent(character: ShadowrunCharacter, spacing: Dp) { .padding(spacing), verticalArrangement = Arrangement.spacedBy(spacing) ) { - DamageMonitorPanel(character.damageMonitor) + DamageMonitorPanel( + damageMonitor = character.damageMonitor, + onDamageChanged = { newDamageMonitor -> + onUpdateCharacter { char -> + char.copy(damageMonitor = newDamageMonitor) + } + } + ) } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DamageMonitorPanel.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DamageMonitorPanel.kt index 4fc4e13..933f6bd 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DamageMonitorPanel.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DamageMonitorPanel.kt @@ -2,6 +2,7 @@ package org.shahondin1624.lib.components.charactermodel import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* 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.UiConstants import org.shahondin1624.model.charactermodel.DamageMonitor +import org.shahondin1624.model.charactermodel.DamageTrackType import org.shahondin1624.theme.LocalWindowSizeClass import org.shahondin1624.theme.WindowSizeClass /** * 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 -fun DamageMonitorPanel(damageMonitor: DamageMonitor) { +fun DamageMonitorPanel( + damageMonitor: DamageMonitor, + onDamageChanged: ((DamageMonitor) -> Unit)? = null +) { val windowSizeClass = LocalWindowSizeClass.current val padding = UiConstants.Spacing.medium(windowSizeClass) val spacing = UiConstants.Spacing.small(windowSizeClass) @@ -45,12 +50,47 @@ fun DamageMonitorPanel(damageMonitor: DamageMonitor) { 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) { WindowSizeClass.Compact -> { Column(verticalArrangement = Arrangement.spacedBy(spacing)) { - DamageTrack("Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax()) - DamageTrack("Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax()) - DamageTrack("Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax()) + DamageTrack( + "Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax(), + 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 -> { @@ -58,9 +98,30 @@ fun DamageMonitorPanel(damageMonitor: DamageMonitor) { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(spacing) ) { - DamageTrack("Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax(), Modifier.weight(1f)) - DamageTrack("Physical", damageMonitor.physicalCurrent(), damageMonitor.physicalMax(), Modifier.weight(1f)) - DamageTrack("Overflow", damageMonitor.overflowCurrent(), damageMonitor.overflowMax(), Modifier.weight(1f)) + DamageTrack( + "Stun", damageMonitor.stunCurrent(), damageMonitor.stunMax(), 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, current: Int, max: Int, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onBoxClick: ((Int) -> Unit)? = null ) { val filledColor = MaterialTheme.colorScheme.error val emptyColor = MaterialTheme.colorScheme.surfaceVariant @@ -123,6 +185,14 @@ private fun DamageTrack( color = borderColor, 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 if (i % 3 == 0 && i < max) { diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/DamageMonitor.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/DamageMonitor.kt index 1fe80d1..815545a 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/DamageMonitor.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/model/charactermodel/DamageMonitor.kt @@ -9,6 +9,10 @@ import org.shahondin1624.model.attributes.Attributes import org.shahondin1624.model.modifier.AttributeModifier import org.shahondin1624.model.modifier.ModifierCache +enum class DamageTrackType { + Stun, Physical, Overflow +} + private val EMPTY_ATTRIBUTES = Attributes( body = Attribute(AttributeType.Body, 0), agility = Attribute(AttributeType.Agility, 0), @@ -69,14 +73,66 @@ data class DamageMonitor( return overflowCurrent } - fun getCurrentModifiers(modifiers: List = emptyList()): List { //TODO this does add the negative modifier every time the function is invoked - val lastRow = physicalMax(modifiers) % 3 - val subtrahend = ((physicalCurrent(modifiers) - lastRow) / 3.0).toInt() - return modifiers.toMutableList().apply { - //TODO + /** + * Returns the wound modifier penalty based on filled damage boxes. + * Every 3 boxes of physical damage apply a cumulative -1 modifier. + */ + fun woundModifier(): Int { + return -(physicalCurrent / 3) + } + + fun getCurrentModifiers(modifiers: List = emptyList()): List { + // 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) + } } } - + fun setAttributes(attributes: Attributes) { this.attributes = attributes }