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_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"

View File

@@ -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)
}
}
)
}
}

View File

@@ -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) {

View File

@@ -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<AttributeModifier> = emptyList()): List<AttributeModifier> { //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<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)
}
}
}
fun setAttributes(attributes: Attributes) {
this.attributes = attributes
}