feat: add interactive damage monitor with clickable boxes (Closes #27)
Make damage track boxes clickable: tap empty box to mark damage, tap filled box to heal. Add wound modifier display (cumulative -1 per 3 physical damage boxes). Add DamageTrackType enum and toggle/set methods to DamageMonitor model. Wire through CharacterViewModel for state persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,14 +73,66 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAttributes(attributes: Attributes) {
|
fun setAttributes(attributes: Attributes) {
|
||||||
this.attributes = attributes
|
this.attributes = attributes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user