diff --git a/.gitignore b/.gitignore
index d8d1c1d..028aaa6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,10 @@ app/src/main/res/values/keys.xml
app/src/test/resources/com/github/nacabaro/vbhelper/source/com.bandai.vitalbraceletarena.apk
app/src/test/resources/com/github/nacabaro/vbhelper/source/classes.dex
+
+app/src/main/java/com/github/nacabaro/vbhelper/battle/Battle_Sprites_Reference/
+
+app/src/main/assets/battle_sprites
+app/src/main/assets/extracted_audio
+
+API-ACR122USAM-2.01.pdf
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0ff1234..99c898f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -103,4 +103,19 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
+
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+ implementation("androidx.navigation:navigation-compose:2.7.0")
+ implementation("com.google.android.material:material:1.2.0")
+ implementation(libs.protobuf.javalite)
+ implementation("androidx.compose.material:material")
+ implementation("androidx.datastore:datastore-preferences:1.1.7")
+
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.9.0")
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // HTTP request logging
+ implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d3a5163..8e59f52 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/APIBattleCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/APIBattleCharacter.kt
new file mode 100644
index 0000000..d2b9330
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/APIBattleCharacter.kt
@@ -0,0 +1,14 @@
+package com.github.nacabaro.vbhelper.battle
+
+data class APIBattleCharacter(
+ val name: String,
+ val namekey: String,
+ val charaId: String,
+ val stage: Int,
+ val attribute: Int,
+ val baseHp: Int,
+ val currentHp: Int,
+ val baseBp: Float,
+ val baseAp: Float,
+ val displayName: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
new file mode 100644
index 0000000..f75b935
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
@@ -0,0 +1,82 @@
+package com.github.nacabaro.vbhelper.battle
+
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.delay
+
+@Composable
+fun AnimatedSpriteImage(
+ characterId: String,
+ animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Fit,
+ reloadMappings: Boolean = false,
+ animationOffset: Long = 0L // New parameter for offsetting animation timing
+) {
+ val context = LocalContext.current
+ val spriteManager = remember { IndividualSpriteManager(context) }
+
+ // Calculate frame offset based on animation offset
+ // 750ms is the idle animation duration, so we calculate how many frames to offset
+ val frameOffset = if (animationOffset > 0L) {
+ // Convert time offset to frame offset (2 frames per cycle, 750ms per frame)
+ ((animationOffset / 750L) * 2).toInt()
+ } else {
+ 0
+ }
+
+ val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
+ val coroutineScope = rememberCoroutineScope()
+
+ var bitmap by remember { mutableStateOf(null) }
+
+ // Reload mappings when reloadMappings parameter changes
+ LaunchedEffect(reloadMappings) {
+ if (reloadMappings) {
+ animationStateMachine.reloadMappings()
+ }
+ }
+
+ // Start the animation when the component is first created
+ LaunchedEffect(characterId) {
+ coroutineScope.launch {
+ animationStateMachine.playIdleAnimation()
+ }
+ }
+
+ // Change animation when animationType changes
+ LaunchedEffect(animationType) {
+ coroutineScope.launch {
+ if (animationType == DigimonAnimationType.IDLE) {
+ animationStateMachine.playIdleAnimation()
+ } else {
+ animationStateMachine.playAnimation(animationType)
+ }
+ }
+ }
+
+ // Update sprite when animation state changes
+ LaunchedEffect(animationStateMachine.currentFrameNumber) {
+ val frameNumber = animationStateMachine.getCurrentFrame()
+
+ bitmap = spriteManager.loadSpriteFrame(characterId, frameNumber)
+
+ if (bitmap == null) {
+ println("Failed to load animated sprite frame: $frameNumber for character: $characterId")
+ }
+ }
+
+ bitmap?.let { bmp ->
+ Image(
+ bitmap = bmp.asImageBitmap(),
+ contentDescription = "Animated Sprite: $characterId - ${animationStateMachine.currentAnimation}",
+ modifier = modifier,
+ contentScale = contentScale
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
new file mode 100644
index 0000000..cac56be
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
@@ -0,0 +1,385 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.util.Log
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.State
+
+class ArenaBattleSystem {
+ companion object {
+ private const val TAG = "ArenaBattleSystem"
+ }
+
+ // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
+ // 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
+ private var _attackPhase by mutableStateOf(0)
+ val attackPhase: Int get() = _attackPhase
+
+ private var _attackProgress by mutableStateOf(0f)
+ val attackProgress: Float get() = _attackProgress
+
+ private var _isPlayerAttacking by mutableStateOf(false)
+ val isPlayerAttacking: Boolean get() = _isPlayerAttacking
+
+ private var _attackIsHit by mutableStateOf(false)
+ val attackIsHit: Boolean get() = _attackIsHit
+
+ private var _isAttackButtonEnabled by mutableStateOf(true)
+ val isAttackButtonEnabled: Boolean get() = _isAttackButtonEnabled
+
+ private var _currentView by mutableStateOf(0)
+ val currentView: Int get() = _currentView
+
+ private var _playerHP by mutableStateOf(100f)
+ val playerHP: Float get() = _playerHP
+
+ private var _opponentHP by mutableStateOf(100f)
+ val opponentHP: Float get() = _opponentHP
+
+ private var _isBattleOver by mutableStateOf(false)
+ val isBattleOver: Boolean get() = _isBattleOver
+
+ private var _critBarProgress by mutableStateOf(0)
+ val critBarProgress: Int get() = _critBarProgress
+
+ // Dodge animation states
+ private var _isDodging by mutableStateOf(false)
+ val isDodging: Boolean get() = _isDodging
+
+ private var _dodgeProgress by mutableStateOf(0f)
+ val dodgeProgress: Float get() = _dodgeProgress
+
+ private var _dodgeDirection by mutableStateOf(1f) // 1f = up, -1f = down
+ val dodgeDirection: Float get() = _dodgeDirection
+
+ private var _isHit by mutableStateOf(false)
+ val isHit: Boolean get() = _isHit
+
+ private var _hitProgress by mutableStateOf(0f)
+ val hitProgress: Float get() = _hitProgress
+
+ // Separate states for player and opponent
+ private var _isPlayerDodging by mutableStateOf(false)
+ val isPlayerDodging: Boolean get() = _isPlayerDodging
+
+ private var _isOpponentDodging by mutableStateOf(false)
+ val isOpponentDodging: Boolean get() = _isOpponentDodging
+
+ // Separate dodge progress and direction for player and opponent
+ private var _playerDodgeProgress by mutableStateOf(0f)
+ val playerDodgeProgress: Float get() = _playerDodgeProgress
+
+ private var _playerDodgeDirection by mutableStateOf(1f)
+ val playerDodgeDirection: Float get() = _playerDodgeDirection
+
+ private var _opponentDodgeProgress by mutableStateOf(0f)
+ val opponentDodgeProgress: Float get() = _opponentDodgeProgress
+
+ private var _opponentDodgeDirection by mutableStateOf(1f)
+ val opponentDodgeDirection: Float get() = _opponentDodgeDirection
+
+ private var _isPlayerHit by mutableStateOf(false)
+ val isPlayerHit: Boolean get() = _isPlayerHit
+
+ private var _isOpponentHit by mutableStateOf(false)
+ val isOpponentHit: Boolean get() = _isOpponentHit
+
+ // Delayed hit states for SLEEP animation timing
+ private var _isPlayerHitDelayed by mutableStateOf(false)
+ val isPlayerHitDelayed: Boolean get() = _isPlayerHitDelayed
+
+ private var _isOpponentHitDelayed by mutableStateOf(false)
+ val isOpponentHitDelayed: Boolean get() = _isOpponentHitDelayed
+
+ // Delayed shake states for shake animation timing
+ private var _isPlayerShakeDelayed by mutableStateOf(false)
+ val isPlayerShakeDelayed: Boolean get() = _isPlayerShakeDelayed
+
+ private var _isOpponentShakeDelayed by mutableStateOf(false)
+ val isOpponentShakeDelayed: Boolean get() = _isOpponentShakeDelayed
+
+ // Counter-attack tracking
+ private var _shouldCounterAttack by mutableStateOf(false)
+ val shouldCounterAttack: Boolean get() = _shouldCounterAttack
+
+ private var _counterAttackIsHit by mutableStateOf(false)
+ val counterAttackIsHit: Boolean get() = _counterAttackIsHit
+
+ // Separate tracking for opponent attack result
+ private var _opponentAttackIsHit by mutableStateOf(false)
+ val opponentAttackIsHit: Boolean get() = _opponentAttackIsHit
+
+ fun startPlayerAttack() {
+ _attackPhase = 1
+ _attackProgress = 0f
+ _isPlayerAttacking = true
+ _isAttackButtonEnabled = false
+ _currentView = 0
+ }
+
+ fun startOpponentAttack() {
+ _attackPhase = 3
+ _attackProgress = 0f
+ _isPlayerAttacking = false
+ _currentView = 1
+ }
+
+ fun advanceAttackPhase() {
+ _attackPhase++
+ _attackProgress = 0f
+ }
+
+ fun setAttackProgress(progress: Float) {
+ _attackProgress = progress
+ }
+
+ fun setAttackHitState(isHit: Boolean) {
+ _attackIsHit = isHit
+ }
+
+ fun switchToView(view: Int) {
+ _currentView = view
+ }
+
+ fun enableAttackButton() {
+ _isAttackButtonEnabled = true
+ }
+
+ fun applyDamage(isPlayer: Boolean, damage: Float) {
+ if (isPlayer) {
+ _playerHP = (_playerHP - damage).coerceAtLeast(0f)
+ } else {
+ _opponentHP = (_opponentHP - damage).coerceAtLeast(0f)
+ }
+ }
+
+ fun updateHPFromAPI(playerHP: Float, opponentHP: Float) {
+ _playerHP = playerHP
+ _opponentHP = opponentHP
+ }
+
+ fun initializeHP(playerHP: Float, opponentHP: Float) {
+ _playerHP = playerHP
+ _opponentHP = opponentHP
+ }
+
+ fun completeAttackAnimation(playerDamage: Float = 0f, opponentDamage: Float = 0f) {
+ if (playerDamage > 0f) {
+ applyDamage(true, playerDamage)
+ }
+ if (opponentDamage > 0f) {
+ applyDamage(false, opponentDamage)
+ }
+ }
+
+ fun resetAttackState() {
+ _attackPhase = 0
+ _attackProgress = 0f
+ _isPlayerAttacking = false
+ _attackIsHit = false
+ _currentView = 0
+ _isDodging = false
+ _dodgeProgress = 0f
+ _dodgeDirection = 1f
+ _isHit = false
+ _hitProgress = 0f
+ _isPlayerDodging = false
+ _isOpponentDodging = false
+ _playerDodgeProgress = 0f
+ _playerDodgeDirection = 1f
+ _opponentDodgeProgress = 0f
+ _opponentDodgeDirection = 1f
+ _isPlayerHit = false
+ _isOpponentHit = false
+ _isPlayerHitDelayed = false
+ _isOpponentHitDelayed = false
+ _isPlayerShakeDelayed = false
+ _isOpponentShakeDelayed = false
+ _shouldCounterAttack = false
+ _counterAttackIsHit = false
+ _opponentAttackIsHit = false
+ }
+
+ fun checkBattleOver(): Boolean {
+ return _playerHP <= 0f || _opponentHP <= 0f
+ }
+
+ fun endBattle() {
+ _isBattleOver = true
+ }
+
+ fun updateCritBarProgress(progress: Int) {
+ _critBarProgress = progress
+ //Log.d(TAG, "Updated crit bar progress: $progress")
+ }
+
+ // Dodge animation methods
+ fun startDodge() {
+ _isDodging = true
+ _dodgeProgress = 0f
+ _dodgeDirection = 1f // Start moving up
+ }
+
+ fun setDodgeProgress(progress: Float) {
+ _dodgeProgress = progress
+ }
+
+ fun setDodgeDirection(direction: Float) {
+ _dodgeDirection = direction
+ }
+
+ fun endDodge() {
+ _isDodging = false
+ _dodgeProgress = 0f
+ }
+
+ // Hit animation methods
+ fun startHit() {
+ _isHit = true
+ _hitProgress = 0f
+ }
+
+ fun setHitProgress(progress: Float) {
+ _hitProgress = progress
+ }
+
+ fun endHit() {
+ _isHit = false
+ _hitProgress = 0f
+ }
+
+ // Player-specific dodge methods
+ fun startPlayerDodge() {
+ _isPlayerDodging = true
+ _playerDodgeProgress = 0f
+ _playerDodgeDirection = 1f
+ }
+
+ fun endPlayerDodge() {
+ _isPlayerDodging = false
+ _playerDodgeProgress = 0f
+ }
+
+ fun setPlayerDodgeProgress(progress: Float) {
+ _playerDodgeProgress = progress
+ }
+
+ fun setPlayerDodgeDirection(direction: Float) {
+ _playerDodgeDirection = direction
+ }
+
+ // Opponent-specific dodge methods
+ fun startOpponentDodge() {
+ _isOpponentDodging = true
+ _opponentDodgeProgress = 0f
+ _opponentDodgeDirection = 1f
+ }
+
+ fun endOpponentDodge() {
+ _isOpponentDodging = false
+ _opponentDodgeProgress = 0f
+ }
+
+ fun setOpponentDodgeProgress(progress: Float) {
+ _opponentDodgeProgress = progress
+ }
+
+ fun setOpponentDodgeDirection(direction: Float) {
+ _opponentDodgeDirection = direction
+ }
+
+ // Player-specific hit methods
+ fun startPlayerHit() {
+ _isPlayerHit = true
+ _hitProgress = 0f
+ }
+
+ fun startPlayerHitDelayed() {
+ _isPlayerHitDelayed = true
+ }
+
+ fun endPlayerHit() {
+ _isPlayerHit = false
+ _hitProgress = 0f
+ }
+
+ fun endPlayerHitDelayed() {
+ _isPlayerHitDelayed = false
+ }
+
+ // Opponent-specific hit methods
+ fun startOpponentHit() {
+ _isOpponentHit = true
+ _hitProgress = 0f
+ }
+
+ fun startOpponentHitDelayed() {
+ _isOpponentHitDelayed = true
+ }
+
+ fun endOpponentHit() {
+ _isOpponentHit = false
+ _hitProgress = 0f
+ }
+
+ fun endOpponentHitDelayed() {
+ _isOpponentHitDelayed = false
+ }
+
+ // Delayed shake methods
+ fun startPlayerShakeDelayed() {
+ _isPlayerShakeDelayed = true
+ }
+
+ fun endPlayerShakeDelayed() {
+ _isPlayerShakeDelayed = false
+ }
+
+ fun startOpponentShakeDelayed() {
+ _isOpponentShakeDelayed = true
+ }
+
+ fun endOpponentShakeDelayed() {
+ _isOpponentShakeDelayed = false
+ }
+
+ // Combined method to handle attack result
+ fun handleAttackResult(isHit: Boolean) {
+ _attackIsHit = isHit
+ if (isHit) {
+ // Player attack hit - opponent gets hit
+ startOpponentHit()
+ } else {
+ // Player attack missed - opponent dodges
+ startOpponentDodge()
+ }
+ }
+
+ // Method to handle opponent attack result
+ fun handleOpponentAttackResult(isHit: Boolean) {
+ _opponentAttackIsHit = isHit
+ if (isHit) {
+ // Opponent attack hit - player gets hit
+ startPlayerHit()
+ } else {
+ // Opponent attack missed - player dodges
+ startPlayerDodge()
+ }
+ }
+
+ // Counter-attack methods
+ fun setupCounterAttack(isHit: Boolean) {
+ _shouldCounterAttack = true
+ _counterAttackIsHit = isHit
+ }
+
+ fun startCounterAttack() {
+ _attackPhase = 3
+ _attackProgress = 0f
+ _isPlayerAttacking = false
+ _currentView = 1
+ _opponentAttackIsHit = _counterAttackIsHit
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AttackSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AttackSpriteManager.kt
new file mode 100644
index 0000000..e47db7e
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AttackSpriteManager.kt
@@ -0,0 +1,151 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Environment
+import com.google.gson.Gson
+import java.io.File
+
+data class CharacterData(
+ val name: String,
+ val charaId: String,
+ val smalefilename: String,
+ val laugeFileName: String
+)
+
+data class CharacterDataResponse(
+ val name: String,
+ val type: String,
+ val source_file: String,
+ val collection: String,
+ val unity_collection_id: String,
+ val relative_path: String,
+ val all_attributes: CharacterDataAttributes
+)
+
+data class CharacterDataAttributes(
+ val DataList: List
+)
+
+class AttackSpriteManager(private val context: Context) {
+ private val gson = Gson()
+ private val characterDataCache = mutableMapOf()
+
+ // Get the external storage directory for attack sprites
+ private fun getAttackTexturesBaseDir(): File {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ return File(externalDir, "VBHelper/battle_sprites/extracted_atksprites")
+ }
+
+ fun getAttackSprite(characterId: String, isLarge: Boolean = false): Bitmap? {
+ println("AttackSpriteManager: Getting attack sprite for characterId=$characterId, isLarge=$isLarge")
+ try {
+ // Get character data
+ val characterData = getCharacterData(characterId) ?: return null
+ // Determine which attack file to use
+ val attackFileName = if (isLarge) {
+ characterData.laugeFileName
+ } else {
+ characterData.smalefilename
+ }
+
+ // Skip if no attack file
+ if (attackFileName == "0") {
+ println("AttackSpriteManager: Skipping attack file (filename is '0')")
+ return null
+ }
+
+ // Load the attack sprite from external storage
+ val attackFile = File(getAttackTexturesBaseDir(), "$attackFileName.png")
+
+ return if (attackFile.exists()) {
+ val bitmap = BitmapFactory.decodeFile(attackFile.absolutePath)
+ bitmap
+ } else {
+ println("AttackSpriteManager: Attack file does not exist")
+ null
+ }
+ } catch (e: Exception) {
+ println("AttackSpriteManager: Exception occurred: ${e.message}")
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ private fun getCharacterData(characterId: String): CharacterData? {
+ // Check cache first
+ if (characterDataCache.containsKey(characterId)) {
+ return characterDataCache[characterId]
+ }
+
+ try {
+ // Load character data from JSON file in external storage
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ val characterDataFile = File(externalDir, "VBHelper/battle_sprites/extracted_digimon_stats/character_data/CharacterData.json")
+
+ if (!characterDataFile.exists()) {
+ println("AttackSpriteManager: Character data file does not exist, using default data")
+ // For now, return a default character data
+ val characterData = CharacterData(
+ name = characterId,
+ charaId = characterId,
+ smalefilename = "atk_s_02", // Default small attack
+ laugeFileName = "atk_l_04" // Default large attack
+ )
+
+ characterDataCache[characterId] = characterData
+ return characterData
+ }
+
+ val jsonContent = characterDataFile.readText()
+
+ // Parse the JSON response
+ val response = gson.fromJson(jsonContent, CharacterDataResponse::class.java)
+
+ // Search through the DataList for the matching characterId
+ for (characterString in response.all_attributes.DataList) {
+ // Extract charaId from the string format: " id=0, charaId='dim000_mon03', ...>"
+ val charaIdMatch = Regex("charaId='([^']+)'").find(characterString)
+ if (charaIdMatch != null) {
+ val foundCharaId = charaIdMatch.groupValues[1]
+ if (foundCharaId == characterId) {
+ // Extract smalefilename and laugeFileName
+ val smallFileMatch = Regex("smalefilename='([^']+)'").find(characterString)
+ val largeFileMatch = Regex("laugeFileName='([^']+)'").find(characterString)
+
+ val smallFileName = smallFileMatch?.groupValues?.get(1) ?: "0"
+ val largeFileName = largeFileMatch?.groupValues?.get(1) ?: "0"
+
+ val characterData = CharacterData(
+ name = characterId,
+ charaId = characterId,
+ smalefilename = smallFileName,
+ laugeFileName = largeFileName
+ )
+
+ characterDataCache[characterId] = characterData
+ return characterData
+ }
+ }
+ }
+
+ // If character not found, return default data
+ println("AttackSpriteManager: Character not found in JSON, using default data")
+ val characterData = CharacterData(
+ name = characterId,
+ charaId = characterId,
+ smalefilename = "atk_s_02", // Default small attack
+ laugeFileName = "atk_l_04" // Default large attack
+ )
+
+ characterDataCache[characterId] = characterData
+ return characterData
+
+ } catch (e: Exception) {
+ println("AttackSpriteManager: Exception in getCharacterData: ${e.message}")
+ e.printStackTrace()
+ return null
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
new file mode 100644
index 0000000..df03762
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
@@ -0,0 +1,31 @@
+package com.github.nacabaro.vbhelper.battle
+
+import okhttp3.Interceptor
+import okhttp3.Response
+
+/**
+ * OkHttp interceptor that adds Authorization header to API requests.
+ * Skips adding header for auth endpoints.
+ */
+class AuthInterceptor(private val token: String) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ // Skip adding auth header for auth endpoints
+ if (originalRequest.url.encodedPath.startsWith("/api/auth")) {
+ return chain.proceed(originalRequest)
+ }
+
+ // Add authentication header for game endpoints
+ // Use X-Session-Token header (preferred) or Authorization: Bearer
+ val authenticatedRequest = originalRequest.newBuilder()
+ .header("X-Session-Token", token)
+ .build()
+
+ // Debug: Log which header is being used (first few chars of token for security)
+ val tokenPreview = if (token.length > 8) "${token.take(4)}...${token.takeLast(4)}" else "***"
+ println("AuthInterceptor: Adding X-Session-Token header (token: $tokenPreview)")
+
+ return chain.proceed(authenticatedRequest)
+ }
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt
new file mode 100644
index 0000000..72659cc
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthService.kt
@@ -0,0 +1,14 @@
+package com.github.nacabaro.vbhelper.battle
+
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface AuthService {
+ @POST("api/auth/validate")
+ fun validate(@Body request: AuthenticateRequest): Call
+
+ @POST("api/auth/login")
+ fun login(@Body request: AuthenticateRequest): Call
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateRequest.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateRequest.kt
new file mode 100644
index 0000000..d1391c3
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateRequest.kt
@@ -0,0 +1,6 @@
+package com.github.nacabaro.vbhelper.battle
+
+data class AuthenticateRequest(
+ val userToken: String
+)
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
new file mode 100644
index 0000000..cb1f4fd
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
@@ -0,0 +1,21 @@
+package com.github.nacabaro.vbhelper.battle
+
+data class AdditionalInfo(
+ val avatar: String? = null,
+ val id: Long? = null,
+ val name: String? = null,
+ val status: String? = null
+)
+
+data class UserInfo(
+ val userId: String? = null,
+ val additionalInfo: AdditionalInfo? = null
+)
+
+data class AuthenticateResponse(
+ val success: Boolean,
+ val message: String? = null,
+ val userInfo: UserInfo? = null,
+ val sessionToken: String? = null
+)
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleAuthContainer.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleAuthContainer.kt
new file mode 100644
index 0000000..6024048
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleAuthContainer.kt
@@ -0,0 +1,16 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import com.github.nacabaro.vbhelper.source.AuthRepository
+
+private const val BATTLE_AUTH_PREFERENCES_NAME = "battle_auth_preferences"
+val Context.battleAuthStore: androidx.datastore.core.DataStore by preferencesDataStore(
+ name = BATTLE_AUTH_PREFERENCES_NAME
+)
+
+class BattleAuthContainer(private val context: Context) {
+ val authRepository: AuthRepository = AuthRepository(context.battleAuthStore)
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleSpriteManager.kt
new file mode 100644
index 0000000..1f1224f
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/BattleSpriteManager.kt
@@ -0,0 +1,181 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Environment
+import com.google.gson.Gson
+import java.io.File
+
+data class SpriteMapping(
+ val atlas_name: String,
+ val atlas_file: String,
+ val texture: TextureInfo,
+ val sprites: List
+)
+
+data class TextureInfo(
+ val name: String,
+ val file: String,
+ val path_id: Long
+)
+
+data class SpriteData(
+ val name: String,
+ val atlas_name: String,
+ val m_Name: String,
+ val texture_rect: TextureRect
+)
+
+data class TextureRect(
+ val height: Float,
+ val width: Float,
+ val x: Float,
+ val y: Float
+)
+
+class BattleSpriteManager(private val context: Context) {
+ private val gson = Gson()
+ private val spriteCache = mutableMapOf()
+
+ // Get the external storage directory for sprite files
+ private fun getSpriteBaseDir(): File {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ return File(externalDir, "VBHelper/battle_sprites/extracted_assets")
+ }
+
+ fun loadSprite(spriteName: String, atlasName: String): Bitmap? {
+ val cacheKey = "${spriteName}_${atlasName}"
+
+ // Check cache first
+ if (spriteCache.containsKey(cacheKey)) {
+ return spriteCache[cacheKey]
+ }
+
+ // Debug: Check if base directory exists
+ val spriteBaseDir = getSpriteBaseDir()
+ if (!spriteBaseDir.exists()) {
+ println("Sprite base directory does not exist: ${spriteBaseDir.absolutePath}")
+ return null
+ }
+
+ println("Sprite base directory exists: ${spriteBaseDir.absolutePath}")
+ println("Available directories: ${spriteBaseDir.listFiles()?.map { it.name }}")
+
+ try {
+ // Load the PNG texture file directly using the atlas name
+ val textureFile = File(spriteBaseDir, "extracted_textures/${atlasName}.png")
+
+ if (!textureFile.exists()) {
+ println("Texture file not found: ${textureFile.absolutePath}")
+ return null
+ }
+
+ val fullBitmap = BitmapFactory.decodeFile(textureFile.absolutePath)
+ if (fullBitmap == null) {
+ println("Failed to decode texture file: ${textureFile.absolutePath}")
+ return null
+ }
+
+ // Load the specific sprite data file
+ val spriteDataFile = File(spriteBaseDir, "sprites/${spriteName}.json")
+ if (!spriteDataFile.exists()) {
+ println("Sprite data file not found: ${spriteDataFile.absolutePath}")
+ return null
+ }
+
+ val spriteDataJson = spriteDataFile.readText()
+ val spriteData = gson.fromJson(spriteDataJson, SpriteData::class.java)
+
+ // Debug: Print sprite coordinates
+ println("Sprite coordinates: x=${spriteData.texture_rect.x}, y=${spriteData.texture_rect.y}, width=${spriteData.texture_rect.width}, height=${spriteData.texture_rect.height}")
+ println("Texture dimensions: width=${fullBitmap.width}, height=${fullBitmap.height}")
+
+ // Calculate the correct Y coordinate (inverted coordinate system)
+ val correctedY = fullBitmap.height - spriteData.texture_rect.y.toInt() - spriteData.texture_rect.height.toInt()
+
+ // Extract the sprite from the atlas using texture_rect coordinates
+ val spriteBitmap = Bitmap.createBitmap(
+ fullBitmap,
+ spriteData.texture_rect.x.toInt(),
+ correctedY,
+ spriteData.texture_rect.width.toInt(),
+ spriteData.texture_rect.height.toInt()
+ )
+
+ // Ensure the bitmap is not scaled and has proper quality
+ val finalBitmap = if (spriteBitmap.width != spriteData.texture_rect.width.toInt() ||
+ spriteBitmap.height != spriteData.texture_rect.height.toInt()) {
+ // If the bitmap was scaled during creation, create a new one with exact dimensions
+ Bitmap.createScaledBitmap(spriteBitmap,
+ spriteData.texture_rect.width.toInt(),
+ spriteData.texture_rect.height.toInt(),
+ false) // false = no filtering/interpolation
+ } else {
+ spriteBitmap
+ }
+
+ println("Extracted sprite dimensions: ${finalBitmap.width}x${finalBitmap.height}")
+
+ // Cache the result
+ spriteCache[cacheKey] = finalBitmap
+
+ return finalBitmap
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ fun clearCache() {
+ spriteCache.clear()
+ }
+
+ // Helper method to get available sprites for an atlas
+ fun getAvailableSprites(atlasName: String): List {
+ try {
+ val spritesDir = File(getSpriteBaseDir(), "sprites")
+ if (!spritesDir.exists()) {
+ return emptyList()
+ }
+
+ val spriteFiles = spritesDir.listFiles { file ->
+ file.name.startsWith("${atlasName}_sprite_") && file.name.endsWith(".json")
+ } ?: emptyArray()
+
+ return spriteFiles.map { file ->
+ // Extract sprite number from filename (e.g., "dim000_mon01_sprite_00.json" -> "00")
+ val spriteNumber = file.name.substringAfter("_sprite_").substringBefore(".json")
+ spriteNumber
+ }.sorted()
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return emptyList()
+ }
+ }
+
+ // Helper method to get available atlases
+ fun getAvailableAtlases(): List {
+ try {
+ val texturesDir = File(getSpriteBaseDir(), "extracted_textures")
+ if (!texturesDir.exists()) {
+ return emptyList()
+ }
+
+ val textureFiles = texturesDir.listFiles { file ->
+ file.name.endsWith(".png")
+ } ?: emptyArray()
+
+ return textureFiles.map { file ->
+ // Extract atlas name from filename (e.g., "dim000_mon01.png" -> "dim000_mon01")
+ file.name.substringBefore(".png")
+ }.sorted()
+
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return emptyList()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
new file mode 100644
index 0000000..c2ee41e
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
@@ -0,0 +1,167 @@
+package com.github.nacabaro.vbhelper.battle
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.delay
+import android.content.Context
+import java.io.File
+
+enum class DigimonAnimationType {
+ IDLE,
+ IDLE2,
+ WALK,
+ WALK2,
+ RUN,
+ RUN2,
+ WORKOUT,
+ WORKOUT2,
+ HAPPY,
+ SLEEP,
+ ATTACK,
+ FLEE
+}
+
+data class AnimationState(
+ val type: DigimonAnimationType,
+ val frameNumber: Int, // 1-12 for individual PNG files
+ val duration: Long = 100L, // Duration in milliseconds
+ val loop: Boolean = true
+)
+
+class DigimonAnimationStateMachine(
+ private val characterId: String,
+ private val context: Context,
+ private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
+ private val timingOffset: Long = 0L // New parameter for offsetting the timing
+) {
+ var currentAnimation by mutableStateOf(DigimonAnimationType.IDLE)
+ private set
+
+ var currentFrameNumber by mutableStateOf(1)
+ private set
+
+ var isPlaying by mutableStateOf(false)
+ private set
+
+ // Direct mapping of frame numbers (1-12) to animation types
+ // This is based on the standard Digimon sprite frame order
+ private val frameToAnimationType = mapOf(
+ 1 to DigimonAnimationType.IDLE,
+ 2 to DigimonAnimationType.IDLE2,
+ 3 to DigimonAnimationType.WALK,
+ 4 to DigimonAnimationType.WALK2,
+ 5 to DigimonAnimationType.RUN,
+ 6 to DigimonAnimationType.RUN2,
+ 7 to DigimonAnimationType.WORKOUT,
+ 8 to DigimonAnimationType.WORKOUT2,
+ 9 to DigimonAnimationType.HAPPY,
+ 10 to DigimonAnimationType.SLEEP,
+ 11 to DigimonAnimationType.ATTACK,
+ 12 to DigimonAnimationType.FLEE
+ )
+
+ // Reverse mapping for getting frame numbers for each animation type
+ private val animationTypeToFrames = frameToAnimationType.entries.groupBy({ it.value }, { it.key })
+
+ // Animation durations for each type
+ private val animationDurations = mapOf(
+ DigimonAnimationType.IDLE to 750L,
+ DigimonAnimationType.IDLE2 to 750L,
+ DigimonAnimationType.WALK to 200L,
+ DigimonAnimationType.WALK2 to 200L,
+ DigimonAnimationType.RUN to 150L,
+ DigimonAnimationType.RUN2 to 150L,
+ DigimonAnimationType.WORKOUT to 300L,
+ DigimonAnimationType.WORKOUT2 to 300L,
+ DigimonAnimationType.HAPPY to 400L,
+ DigimonAnimationType.SLEEP to 1500L,
+ DigimonAnimationType.ATTACK to 650L,
+ DigimonAnimationType.FLEE to 150L
+ )
+
+ /*
+ init {
+ println("Initialized DigimonAnimationStateMachine for character: $characterId with frame offset: $initialFrameOffset, timing offset: $timingOffset")
+ println("Available animation types: ${animationTypeToFrames.keys}")
+ }
+ */
+
+ suspend fun playAnimation(animationType: DigimonAnimationType) {
+ if (currentAnimation == animationType && isPlaying) {
+ return // Already playing this animation
+ }
+
+ currentAnimation = animationType
+ isPlaying = true
+
+ val frameNumbers = animationTypeToFrames[animationType] ?: listOf(1)
+ val duration = animationDurations[animationType] ?: 100L
+
+ // For non-looping animations like ATTACK, play once and return to IDLE
+ if (animationType == DigimonAnimationType.ATTACK) {
+ currentFrameNumber = frameNumbers.firstOrNull() ?: 1
+ delay(duration)
+ playAnimation(DigimonAnimationType.IDLE)
+ } else {
+ // For looping animations, cycle through frames
+ var frameIndex = 0
+ while (isPlaying && currentAnimation == animationType) {
+ val frameNumber = frameNumbers[frameIndex % frameNumbers.size]
+ currentFrameNumber = frameNumber
+ delay(duration)
+ frameIndex++
+ }
+ }
+ }
+
+ // Special method for idle animation that cycles between IDLE and IDLE2
+ suspend fun playIdleAnimation() {
+ if (currentAnimation == DigimonAnimationType.IDLE && isPlaying) {
+ return // Already playing idle animation
+ }
+
+ currentAnimation = DigimonAnimationType.IDLE
+ isPlaying = true
+
+ val idleFrames = animationTypeToFrames[DigimonAnimationType.IDLE] ?: listOf(1)
+ val idle2Frames = animationTypeToFrames[DigimonAnimationType.IDLE2] ?: listOf(2)
+
+ // Combine frames for cycling idle animation
+ val combinedFrames = (idleFrames + idle2Frames).distinct()
+
+ val duration = animationDurations[DigimonAnimationType.IDLE] ?: 500L
+
+ // Apply initial timing offset
+ if (timingOffset > 0L) {
+ delay(timingOffset)
+ }
+
+ // Cycle through idle frames, starting from the offset
+ var frameIndex = initialFrameOffset
+ while (isPlaying && currentAnimation == DigimonAnimationType.IDLE) {
+ val frameNumber = combinedFrames[frameIndex % combinedFrames.size]
+ currentFrameNumber = frameNumber
+ delay(duration)
+ frameIndex++
+ }
+ }
+
+ fun stopAnimation() {
+ isPlaying = false
+ }
+
+ fun getCurrentFrame(): Int {
+ return currentFrameNumber
+ }
+
+ fun getCurrentCharacterId(): String {
+ return characterId
+ }
+
+ // Method to reload mappings (useful for testing)
+ fun reloadMappings() {
+ println("Reloading mappings for character: $characterId")
+ // No need to reload since we use direct frame mapping
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
new file mode 100644
index 0000000..e4a3383
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
@@ -0,0 +1,120 @@
+package com.github.nacabaro.vbhelper.battle
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun HitEffectOverlay(
+ isVisible: Boolean,
+ modifier: Modifier = Modifier,
+ isPlayerScreen: Boolean = false,
+ onAnimationComplete: () -> Unit = {}
+) {
+ if (!isVisible) return
+
+ val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
+ val hitEffectManager = remember { HitEffectSpriteManager(context) }
+ val coroutineScope = rememberCoroutineScope()
+
+ var currentFrame by remember { mutableStateOf(0) }
+ var currentSprite by remember { mutableStateOf(null) }
+ var animationProgress by remember { mutableStateOf(0f) }
+ var scale by remember { mutableStateOf(0.5f) }
+ var alpha by remember { mutableStateOf(1f) }
+
+ LaunchedEffect(isVisible) {
+ if (isVisible) {
+
+ // Add delay before starting hit effect animation
+ delay(400) // Increased from 200ms to 400ms delay before hit effect appears
+
+ // Randomly choose between hit_01, hit_02, and hit_02_white
+ val hitSpriteName = when (kotlin.random.Random.nextInt(3)) {
+ 0 -> "hit_01"
+ 1 -> "hit_02"
+ else -> "hit_02_white"
+ }
+ currentSprite = hitEffectManager.loadHitSprite(hitSpriteName)
+
+ if (currentSprite != null) {
+ // Animate the hit effect
+ animationProgress = 0f
+ scale = 0.5f
+ alpha = 1f
+
+ // Scale up animation - slowed down
+ while (scale < 1.2f) {
+ scale += 0.05f // Reduced from 0.1f to 0.05f
+ delay(32) // Increased from 16ms to 32ms
+ }
+
+ // Hold for a moment - increased duration
+ delay(300) // Increased from 100ms to 300ms
+
+ // Fade out - slowed down
+ while (alpha > 0f) {
+ alpha -= 0.03f // Reduced from 0.05f to 0.03f
+ delay(32) // Increased from 16ms to 32ms
+ }
+
+ println("DEBUG: Hit effect animation completed")
+ onAnimationComplete()
+ } else {
+ println("DEBUG: Failed to load hit sprite")
+ onAnimationComplete()
+ }
+ }
+ }
+
+ currentSprite?.let { sprite ->
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ bitmap = sprite.asImageBitmap(),
+ contentDescription = "Hit Effect",
+ modifier = Modifier
+ .size((sprite.width * scale).dp, (sprite.height * scale).dp)
+ .offset(
+ x = if (isPlayerScreen) {
+ // On player screen, position further to the left
+ if (isLandscapeMode) {
+ // In landscape mode, move even further left for player screen
+ (-sprite.width * scale / 2 - 300).dp
+ } else {
+ // In portrait mode, use original positioning
+ (-sprite.width * scale / 2 - 100).dp
+ }
+ } else {
+ // On enemy screen, position further to the right
+ if (isLandscapeMode) {
+ // In landscape mode, move even further right for enemy screen
+ (-sprite.width * scale / 2 + 350).dp
+ } else {
+ // In portrait mode, use original positioning
+ (-sprite.width * scale / 2 + 150).dp
+ }
+ },
+ y = (-sprite.height * scale / 2 + 40).dp // Position lower on screen (was -60, now +40)
+ ),
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
new file mode 100644
index 0000000..fa116be
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
@@ -0,0 +1,172 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Rect
+import android.os.Environment
+import java.io.File
+
+class HitEffectSpriteManager(private val context: Context) {
+ private val spriteCache = mutableMapOf()
+
+ // Get the external storage directory for hit effect sprites
+ private fun getHitSpritesDir(): File {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ return File(externalDir, "VBHelper/battle_sprites/extracted_hit_sprites")
+ }
+
+ /**
+ * Load a hit sprite (hit_01.png, hit_02.png, hit_02_white.png)
+ * @param spriteName The sprite name (e.g., "hit_01", "hit_02", "hit_02_white")
+ * @return Bitmap of the hit sprite, or null if not found
+ */
+ fun loadHitSprite(spriteName: String): Bitmap? {
+ val cacheKey = "hit_$spriteName"
+
+ // Check cache first
+ if (spriteCache.containsKey(cacheKey)) {
+ return spriteCache[cacheKey]
+ }
+
+ try {
+ val hitSpritesDir = getHitSpritesDir()
+ val spriteFile = File(hitSpritesDir, "$spriteName.png")
+
+ if (!spriteFile.exists()) {
+ println("Hit sprite file not found: ${spriteFile.absolutePath}")
+ return null
+ }
+
+ val bitmap = BitmapFactory.decodeFile(spriteFile.absolutePath)
+ if (bitmap == null) {
+ println("Failed to decode hit sprite file: ${spriteFile.absolutePath}")
+ return null
+ }
+
+ // Cache the result
+ spriteCache[cacheKey] = bitmap
+
+ return bitmap
+
+ } catch (e: Exception) {
+ println("Error loading hit sprite: ${e.message}")
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ /**
+ * Load a damage effect sprite from spritesheet
+ * @param spritesheetName The spritesheet name (e.g., "dmg_ef1", "dmg_ef2")
+ * @param frameIndex The frame index (0-3 for dmg_ef1 and dmg_ef2, 0 for dmg_ef3)
+ * @return Bitmap of the damage effect frame, or null if not found
+ */
+ fun loadDamageEffectSprite(spritesheetName: String, frameIndex: Int = 0): Bitmap? {
+ val cacheKey = "dmg_${spritesheetName}_frame_${frameIndex}"
+
+ // Check cache first
+ if (spriteCache.containsKey(cacheKey)) {
+ return spriteCache[cacheKey]
+ }
+
+ try {
+ val spritesheetFile = File(getHitSpritesDir(), "$spritesheetName.png")
+
+ if (!spritesheetFile.exists()) {
+ println("Damage effect spritesheet not found: ${spritesheetFile.absolutePath}")
+ return null
+ }
+
+ val spritesheet = BitmapFactory.decodeFile(spritesheetFile.absolutePath)
+ if (spritesheet == null) {
+ println("Failed to decode damage effect spritesheet: ${spritesheetFile.absolutePath}")
+ return null
+ }
+
+ // Extract frame from spritesheet
+ val frameBitmap = when (spritesheetName) {
+ "dmg_ef1", "dmg_ef2" -> {
+ // These are 2x2 spritesheets (4 frames)
+ val frameWidth = spritesheet.width / 2
+ val frameHeight = spritesheet.height / 2
+ val row = frameIndex / 2
+ val col = frameIndex % 2
+ val x = col * frameWidth
+ val y = row * frameHeight
+ Bitmap.createBitmap(spritesheet, x, y, frameWidth, frameHeight)
+ }
+ "dmg_ef3" -> {
+ // This is a single sprite
+ spritesheet
+ }
+ else -> {
+ println("Unknown spritesheet name: $spritesheetName")
+ return null
+ }
+ }
+
+ println("Successfully loaded damage effect frame: $spritesheetName frame $frameIndex (${frameBitmap.width}x${frameBitmap.height})")
+
+ // Cache the result
+ spriteCache[cacheKey] = frameBitmap
+
+ return frameBitmap
+
+ } catch (e: Exception) {
+ println("Error loading damage effect sprite: ${e.message}")
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ /**
+ * Get all available hit sprites
+ * @return List of hit sprite names (without .png extension)
+ */
+ fun getAvailableHitSprites(): List {
+ val hitSpritesDir = getHitSpritesDir()
+
+ if (!hitSpritesDir.exists()) {
+ return emptyList()
+ }
+
+ return hitSpritesDir.listFiles { file ->
+ file.name.startsWith("hit_") && file.name.endsWith(".png")
+ }?.map { file ->
+ file.name.substringBefore(".png")
+ }?.sorted() ?: emptyList()
+ }
+
+ /**
+ * Get available damage effect spritesheet names
+ * @return List of available damage effect spritesheet names
+ */
+ fun getAvailableDamageEffectSpritesheets(): List {
+ try {
+ if (!getHitSpritesDir().exists()) {
+ return emptyList()
+ }
+
+ val dmgFiles = getHitSpritesDir().listFiles { file ->
+ file.name.startsWith("dmg_ef") && file.name.endsWith(".png")
+ } ?: emptyArray()
+
+ return dmgFiles.map { file ->
+ file.name.substringBefore(".png")
+ }.sorted()
+
+ } catch (e: Exception) {
+ println("Error getting available damage effect spritesheets: ${e.message}")
+ e.printStackTrace()
+ return emptyList()
+ }
+ }
+
+ /**
+ * Clear the sprite cache
+ */
+ fun clearCache() {
+ spriteCache.clear()
+ }
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
new file mode 100644
index 0000000..84c68a7
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
@@ -0,0 +1,132 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Environment
+import java.io.File
+
+class IndividualSpriteManager(private val context: Context) {
+ private val spriteCache = mutableMapOf()
+
+ // Get the external storage directory for sprite files
+ private fun getSpriteBaseDir(): File {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ return File(externalDir, "VBHelper/battle_sprites/extracted_assets/sprites")
+ }
+
+ /**
+ * Load a specific sprite frame for a character
+ * @param characterId The character ID (e.g., "dim012_mon03")
+ * @param frameNumber The frame number (1-12)
+ * @return Bitmap of the sprite frame, or null if not found
+ */
+ fun loadSpriteFrame(characterId: String, frameNumber: Int): Bitmap? {
+ val cacheKey = "${characterId}_frame_${frameNumber}"
+
+ // Check cache first
+ if (spriteCache.containsKey(cacheKey)) {
+ return spriteCache[cacheKey]
+ }
+
+ // Debug: Check if base directory exists
+ val spriteBaseDir = getSpriteBaseDir()
+ if (!spriteBaseDir.exists()) {
+ println("Sprite base directory does not exist: ${spriteBaseDir.absolutePath}")
+ return null
+ }
+
+ try {
+ // Construct the sprite file path
+ val spriteFileName = "${characterId}_${String.format("%02d", frameNumber)}.png"
+ val spriteFile = File(spriteBaseDir, "$characterId/$spriteFileName")
+
+ if (!spriteFile.exists()) {
+ println("Sprite file not found: ${spriteFile.absolutePath}")
+ return null
+ }
+
+ // Load the PNG file directly
+ val bitmap = BitmapFactory.decodeFile(spriteFile.absolutePath)
+ if (bitmap == null) {
+ println("Failed to decode sprite file: ${spriteFile.absolutePath}")
+ return null
+ }
+
+ // Cache the result
+ spriteCache[cacheKey] = bitmap
+
+ return bitmap
+
+ } catch (e: Exception) {
+ println("Error loading sprite frame: ${e.message}")
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ /**
+ * Get all available sprite frames for a character
+ * @param characterId The character ID
+ * @return List of frame numbers (1-12) that exist for this character
+ */
+ fun getAvailableFrames(characterId: String): List {
+ val spriteBaseDir = getSpriteBaseDir()
+ val characterDir = File(spriteBaseDir, characterId)
+
+ if (!characterDir.exists()) {
+ return emptyList()
+ }
+
+ val spriteFiles = characterDir.listFiles { file ->
+ file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
+ } ?: emptyArray()
+
+ return spriteFiles.mapNotNull { file ->
+ val fileName = file.name
+ val frameMatch = Regex("${characterId}_(\\d{2})\\.png").find(fileName)
+ frameMatch?.groupValues?.get(1)?.toIntOrNull()
+ }.sorted()
+ }
+
+ /**
+ * Get all available character IDs
+ * @return List of character IDs that have sprite directories
+ */
+ fun getAvailableCharacters(): List {
+ val spriteBaseDir = getSpriteBaseDir()
+
+ if (!spriteBaseDir.exists()) {
+ return emptyList()
+ }
+
+ return spriteBaseDir.listFiles { file ->
+ file.isDirectory && file.listFiles()?.any { it.name.endsWith(".png") } == true
+ }?.map { it.name }?.sorted() ?: emptyList()
+ }
+
+ /**
+ * Clear the sprite cache
+ */
+ fun clearCache() {
+ spriteCache.clear()
+ }
+
+ /**
+ * Check if a character has sprite files
+ * @param characterId The character ID to check
+ * @return true if the character has sprite files, false otherwise
+ */
+ fun hasCharacterSprites(characterId: String): Boolean {
+ val characterDir = File(getSpriteBaseDir(), characterId)
+ if (!characterDir.exists()) {
+ return false
+ }
+
+ val spriteFiles = characterDir.listFiles { file ->
+ file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
+ } ?: emptyArray()
+
+ return spriteFiles.isNotEmpty()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/OpponentService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/OpponentService.kt
new file mode 100644
index 0000000..43dc60b
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/OpponentService.kt
@@ -0,0 +1,13 @@
+package com.github.nacabaro.vbhelper.battle
+
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface OpponentService {
+ @GET("api/opponents")
+ // This method returns a Call object with a generic
+ // type of DataModel, which represents
+ // the data model for the response.
+ fun getopponents(@Query("stage") stage: String): Call
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/OpponentsDataModel.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/OpponentsDataModel.kt
new file mode 100644
index 0000000..8bff492
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/OpponentsDataModel.kt
@@ -0,0 +1,5 @@
+package com.github.nacabaro.vbhelper.battle
+
+data class OpponentsDataModel (
+ val opponentsList: ArrayList
+):java.io.Serializable
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/PVPDataModel.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/PVPDataModel.kt
new file mode 100644
index 0000000..9e0c237
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/PVPDataModel.kt
@@ -0,0 +1,16 @@
+package com.github.nacabaro.vbhelper.battle
+
+data class PVPDataModel (
+ val status: String,
+ val state: Int,
+ val currentRound: Int,
+ val playerHP: Int,
+ val opponentHP: Int,
+ val playerAttackHit: Boolean,
+ val playerAttackDamage: Int,
+ val opponentAttackDamage: Int,
+ val winner: String,
+ val opponentCharaId: String? = null, // Server provides opponent's charaId from the match
+ val playerMaxHP: Int? = null, // Server should provide max HP for resumed matches
+ val opponentMaxHP: Int? = null // Server should provide max HP for resumed matches
+):java.io.Serializable
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/PVPService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/PVPService.kt
new file mode 100644
index 0000000..6b51431
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/PVPService.kt
@@ -0,0 +1,22 @@
+package com.github.nacabaro.vbhelper.battle
+
+import retrofit2.Call
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface PVPService {
+ @GET("api/pvp")
+ // This method returns a Call object with a generic
+ // type of DataModel, which represents
+ // the data model for the response.
+ fun getwinner(
+ @Query("apiStage") apiStage: Int,
+ @Query("playerID") playerID: Long,
+ @Query("playerDigi") playerDigi: String,
+ @Query("playerStage") playerStage: Int,
+ @Query("critBar") critBar: Int,
+ @Query("opponentDigi") opponentDigi: String,
+ @Query("opponentStage") opponentStage: Int,
+ @Query("action") action: String? = null // Optional: "quit" or "rejoin"
+ ): Call
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
new file mode 100644
index 0000000..b465ddc
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
@@ -0,0 +1,377 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import retrofit2.Retrofit
+import android.widget.Toast
+import retrofit2.*
+import retrofit2.converter.gson.GsonConverterFactory
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
+
+class RetrofitHelper {
+
+ /**
+ * Creates an OkHttpClient with authentication interceptor for game endpoints.
+ * Requires a non-null, non-empty token.
+ */
+ private fun createAuthenticatedClient(token: String): OkHttpClient {
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ return OkHttpClient.Builder()
+ .addInterceptor(AuthInterceptor(token))
+ .addInterceptor(loggingInterceptor)
+ .build()
+ }
+
+ /**
+ * Gets the session token from AuthRepository for API calls.
+ * Falls back to nacatech token if session token is not available (backward compatibility).
+ */
+ private fun getAuthToken(context: Context): String? {
+ return try {
+ val authContainer = BattleAuthContainer(context)
+ runBlocking {
+ // Prefer session token, fall back to nacatech token for backward compatibility
+ val sessionToken = authContainer.authRepository.sessionToken.first()
+ if (!sessionToken.isNullOrEmpty()) {
+ println("RetrofitHelper: Using sessionToken for API call")
+ sessionToken
+ } else {
+ // Fallback to nacatech token (slower, but works)
+ val nacatechToken = authContainer.authRepository.authToken.first()
+ if (!nacatechToken.isNullOrEmpty()) {
+ println("RetrofitHelper: No sessionToken found, falling back to nacatechToken")
+ }
+ nacatechToken
+ }
+ }
+ } catch (e: Exception) {
+ println("RetrofitHelper: Error getting auth token: ${e.message}")
+ null
+ }
+ }
+
+ /**
+ * Creates a Retrofit instance with authentication for game endpoints.
+ */
+ private fun createAuthenticatedRetrofit(context: Context): Retrofit? {
+ val token = getAuthToken(context)
+ if (token.isNullOrEmpty()) {
+ println("RetrofitHelper: No auth token available")
+ return null
+ }
+
+ val client = createAuthenticatedClient(token)
+ return Retrofit.Builder()
+ .baseUrl("http://battle.io-void.com:8080/")
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ /**
+ * Handles HTTP error responses (401, 403, 429).
+ * For 401/403, clears authentication state to trigger re-authentication.
+ */
+ private fun handleErrorResponse(context: Context, response: Response<*>, errorMessage: String) {
+ when (response.code()) {
+ 401 -> {
+ println("RetrofitHelper: Authentication failed (401) - token may be expired")
+ clearAuthAndNotify(context, "Authentication failed. Please log in again.")
+ }
+ 403 -> {
+ println("RetrofitHelper: Access forbidden (403) - token may be expired or invalid")
+ // 403 could mean expired token, so clear auth state to trigger re-authentication
+ clearAuthAndNotify(context, "Session expired. Please log in again.")
+ }
+ 429 -> {
+ println("RetrofitHelper: Rate limit exceeded (429)")
+ Toast.makeText(context, "Too many requests. Please wait a moment.", Toast.LENGTH_SHORT).show()
+ }
+ else -> {
+ println("RetrofitHelper: API error (${response.code()}): $errorMessage")
+ Toast.makeText(context, "Request failed: ${response.code()}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ /**
+ * Clears authentication state and shows a message.
+ * This will trigger BattlesScreen to detect the auth state change and open the login page.
+ */
+ private fun clearAuthAndNotify(context: Context, message: String) {
+ try {
+ val authContainer = BattleAuthContainer(context)
+ CoroutineScope(Dispatchers.IO).launch {
+ authContainer.authRepository.logout()
+ println("RetrofitHelper: Cleared authentication state due to expired/invalid token")
+ }
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ } catch (e: Exception) {
+ println("RetrofitHelper: Error clearing auth state: ${e.message}")
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
+ //println("RetrofitHelper: Starting API call for stage: $stage")
+
+ try {
+ // Create an authenticated Retrofit instance
+ val retrofit = createAuthenticatedRetrofit(context)
+ if (retrofit == null) {
+ println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
+ Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ // Create an ApiService instance from the Retrofit instance.
+ val service: OpponentService = retrofit.create(OpponentService::class.java)
+ //println("RetrofitHelper: Service created")
+
+ // Call the getopponents() method of the ApiService
+ // to make an API request.
+ val call: Call = service.getopponents(stage)
+ //println("RetrofitHelper: API call created, enqueueing...")
+
+ // Use the enqueue() method of the Call object to
+ // make an asynchronous API request.
+ call.enqueue(object : Callback {
+ override fun onFailure(call: Call, t: Throwable) {
+ println("RetrofitHelper: API call failed: ${t.message}")
+ t.printStackTrace()
+ Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ println("RetrofitHelper: API response received - Code: ${response.code()}")
+ println("RetrofitHelper: Response body: ${response.body()}")
+
+ if(response.isSuccessful){
+ //println("RetrofitHelper: Response successful, calling callback")
+ val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
+ callback(opponentsList)
+ } else {
+ val errorBody = response.errorBody()?.string()
+ println("RetrofitHelper: Response not successful - Error: $errorBody")
+ handleErrorResponse(context, response, errorBody ?: "Unknown error")
+ }
+ }
+ })
+ } catch (e: Exception) {
+ println("RetrofitHelper: Exception in getOpponents: ${e.message}")
+ e.printStackTrace()
+ Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ /*
+ fun getCombatWinner(context: Context, stage: String, callback: (CombatDataModel) -> Unit) {
+
+ // Create a Retrofit instance with the base URL and
+ // a GsonConverterFactory for parsing the response.
+ val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://battle.io-void.com:8080/").addConverterFactory(
+ GsonConverterFactory.create()).build()
+
+ // Create an ApiService instance from the Retrofit instance.
+ val service: CombatService = retrofit.create(CombatService::class.java)
+
+ // Call the getwinner() method of the ApiService
+ // to make an API request.
+ val call: Call = service.getwinner(stage)
+
+ // Use the enqueue() method of the Call object to
+ // make an asynchronous API request.
+ call.enqueue(object : Callback {
+ // This is an anonymous inner class that implements the Callback interface.
+
+ override fun onFailure(call: Call, t: Throwable) {
+ // This method is called when the API request fails.
+ Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ // This method is called when the API response is received successfully.
+
+ if(response.isSuccessful){
+ // If the response is successful, parse the
+ // response body to a DataModel object.
+ val winner: CombatDataModel = response.body() as CombatDataModel
+
+ // Call the callback function with the DataModel
+ // object as a parameter.
+ callback(winner)
+ }
+ }
+ })
+ }
+
+ fun getBattleWinner(context: Context, playerDigi: String, playerStage: Int, opponentDigi: String, opponentStage: Int, callback: (BattleDataModel) -> Unit) {
+
+ // Create a Retrofit instance with the base URL and
+ // a GsonConverterFactory for parsing the response.
+ val retrofit: Retrofit = Retrofit.Builder().baseUrl("http://battle.io-void.com:8080/").addConverterFactory(
+ GsonConverterFactory.create()).build()
+
+ // Create an ApiService instance from the Retrofit instance.
+ val service: BattleService = retrofit.create(BattleService::class.java)
+
+ // Call the getwinner() method of the ApiService
+ // to make an API request.
+ val call: Call = service.getwinner(playerDigi, playerStage, opponentDigi, opponentStage)
+
+ // Use the enqueue() method of the Call object to
+ // make an asynchronous API request.
+ call.enqueue(object : Callback {
+ // This is an anonymous inner class that implements the Callback interface.
+
+ override fun onFailure(call: Call, t: Throwable) {
+ // This method is called when the API request fails.
+ Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ // This method is called when the API response is received successfully.
+
+ if(response.isSuccessful){
+ // If the response is successful, parse the
+ // response body to a DataModel object.
+ val winner: BattleDataModel = response.body() as BattleDataModel
+
+ // Call the callback function with the DataModel
+ // object as a parameter.
+ callback(winner)
+ }
+ }
+ })
+ }
+ */
+
+ fun getPVPWinner(context: Context, apiStage: Int, playerID: Long, playerDigi: String, playerStage: Int, critBar: Int, opponentDigi: String, opponentStage: Int, callback: (PVPDataModel) -> Unit) {
+ getPVPWinner(context, apiStage, playerID, playerDigi, playerStage, critBar, opponentDigi, opponentStage, null, callback)
+ }
+
+ fun getPVPWinner(context: Context, apiStage: Int, playerID: Long, playerDigi: String, playerStage: Int, critBar: Int, opponentDigi: String, opponentStage: Int, action: String?, callback: (PVPDataModel) -> Unit) {
+
+ try {
+ // Create an authenticated Retrofit instance
+ val retrofit = createAuthenticatedRetrofit(context)
+ if (retrofit == null) {
+ println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
+ Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ // Create an ApiService instance from the Retrofit instance.
+ val service: PVPService = retrofit.create(PVPService::class.java)
+
+ // Call the getwinner() method of the ApiService
+ // to make an API request.
+ val call: Call = service.getwinner(apiStage, playerID, playerDigi, playerStage, critBar, opponentDigi, opponentStage, action)
+
+ // Use the enqueue() method of the Call object to
+ // make an asynchronous API request.
+ call.enqueue(object : Callback {
+ // This is an anonymous inner class that implements the Callback interface.
+
+ override fun onFailure(call: Call, t: Throwable) {
+ // This method is called when the API request fails.
+ println("RetrofitHelper: PVP API call failed: ${t.message}")
+ t.printStackTrace()
+ Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ // This method is called when the API response is received successfully.
+ println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
+
+ if(response.isSuccessful){
+ // If the response is successful, parse the
+ // response body to a DataModel object.
+ val apiResults: PVPDataModel = response.body() as PVPDataModel
+
+ // Call the callback function with the DataModel
+ // object as a parameter.
+ callback(apiResults)
+ } else {
+ val errorBody = response.errorBody()?.string()
+ println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
+ handleErrorResponse(context, response, errorBody ?: "Unknown error")
+ }
+ }
+ })
+ } catch (e: Exception) {
+ println("RetrofitHelper: Exception in getPVPWinner: ${e.message}")
+ e.printStackTrace()
+ Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
+ //println("RetrofitHelper: Starting validate API call with token: $token")
+
+ if (token.isEmpty()) {
+ println("RetrofitHelper: ERROR - Token is empty!")
+ Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ try {
+ // Add logging interceptor to see the actual HTTP request
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ val okHttpClient = OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .build()
+
+ val retrofit: Retrofit = Retrofit.Builder()
+ .baseUrl("http://battle.io-void.com:8080/")
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+ val service: AuthService = retrofit.create(AuthService::class.java)
+ val request = AuthenticateRequest(userToken = token)
+ // Use login endpoint instead of validate to get sessionToken
+ val call: Call = service.login(request)
+
+ call.enqueue(object : Callback {
+ override fun onFailure(call: Call, t: Throwable) {
+ println("RetrofitHelper: Validate API call failed: ${t.message}")
+ t.printStackTrace()
+ Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+
+ if (response.isSuccessful) {
+ val authResponse: AuthenticateResponse? = response.body()
+ if (authResponse != null) {
+ callback(authResponse)
+ } else {
+ println("RetrofitHelper: Validation failed: Invalid response body")
+ Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ val errorBody = response.errorBody()?.string()
+ println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
+ Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ })
+ } catch (e: Exception) {
+ println("RetrofitHelper: Exception in validate: ${e.message}")
+ e.printStackTrace()
+ Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
new file mode 100644
index 0000000..65238e4
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
@@ -0,0 +1,328 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.content.Context
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.FileInputStream
+
+class SpriteFileManager(private val context: Context) {
+
+ // Get the external storage directory where files are already located
+ fun getExternalSpriteBaseDir(): File {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ return File(externalDir, "VBHelper/battle_sprites")
+ }
+
+ // Get the internal storage directory for sprite files
+ private fun getInternalSpriteBaseDir(): File {
+ return File(context.filesDir, "battle_sprites")
+ }
+
+ fun copySpriteFilesToInternalStorage() {
+ try {
+ println("Starting sprite file copy process from external storage to internal storage...")
+
+ val externalDir = getExternalSpriteBaseDir()
+ val internalDir = getInternalSpriteBaseDir()
+
+ // Check if external directory exists
+ if (!externalDir.exists()) {
+ println("External sprite directory does not exist: ${externalDir.absolutePath}")
+ return
+ }
+
+ println("External sprite directory exists: ${externalDir.absolutePath}")
+ println("Copying to internal storage: ${internalDir.absolutePath}")
+
+ // Create internal directory if it doesn't exist
+ if (!internalDir.exists()) {
+ val created = internalDir.mkdirs()
+ println("Created internal sprite directory: $created")
+ }
+
+ // Copy all subdirectories from external to internal storage
+ val externalFiles = externalDir.listFiles()
+ if (externalFiles != null) {
+ println("Found ${externalFiles.size} items in external directory")
+ externalFiles.forEach { item ->
+ val targetItem = File(internalDir, item.name)
+ if (item.isDirectory) {
+ println("Copying directory: ${item.name}")
+ copyDirectory(item, targetItem)
+ } else {
+ println("Copying file: ${item.name}")
+ copyFile(item, targetItem)
+ }
+ }
+ }
+
+ println("Sprite files copied successfully to internal storage: ${internalDir.absolutePath}")
+
+ } catch (e: Exception) {
+ println("Error copying sprite files to internal storage: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+
+ fun copySpriteFilesToExternalStorage() {
+ try {
+ println("Starting sprite file copy process to external storage...")
+
+ // Debug: List what's in the assets directory
+ val assetManager = context.assets
+ val battleSpritesFiles = assetManager.list("battle_sprites")
+ println("battle_sprites directory in assets contains: ${battleSpritesFiles?.joinToString(", ")}")
+
+ val extractedAssetsFiles = assetManager.list("battle_sprites/extracted_assets")
+ println("battle_sprites/extracted_assets directory in assets contains: ${extractedAssetsFiles?.joinToString(", ")}")
+
+ // Check specifically for extracted_atksprites in assets (now directly under battle_sprites)
+ val atkspritesInAssets = assetManager.list("battle_sprites/extracted_atksprites")
+ println("extracted_atksprites in assets contains: ${atkspritesInAssets?.size ?: 0} files")
+ if (atkspritesInAssets != null && atkspritesInAssets.isNotEmpty()) {
+ println("First few attack files in assets: ${atkspritesInAssets.take(5).joinToString(", ")}")
+ }
+
+ // Check for extracted_battlebgs in assets (now directly under battle_sprites)
+ val battlebgsInAssets = assetManager.list("battle_sprites/extracted_battlebgs")
+ println("extracted_battlebgs in assets contains: ${battlebgsInAssets?.size ?: 0} files")
+ if (battlebgsInAssets != null && battlebgsInAssets.isNotEmpty()) {
+ println("First few battle background files in assets: ${battlebgsInAssets.take(5).joinToString(", ")}")
+ }
+
+ // Try to list all possible subdirectories in battle_sprites
+ println("Checking all possible subdirectories in battle_sprites...")
+ battleSpritesFiles?.forEach { subdir ->
+ try {
+ val subdirFiles = assetManager.list("battle_sprites/$subdir")
+ println(" $subdir contains: ${subdirFiles?.size ?: 0} files")
+ if (subdirFiles != null && subdirFiles.isNotEmpty()) {
+ println(" First few files: ${subdirFiles.take(3).joinToString(", ")}")
+ }
+ } catch (e: Exception) {
+ println(" Error listing $subdir: ${e.message}")
+ }
+ }
+
+ // Create the base directory for battle_sprites in external storage
+ val battleSpritesDir = getExternalSpriteBaseDir()
+ if (!battleSpritesDir.exists()) {
+ battleSpritesDir.mkdirs()
+ println("Created battle_sprites directory in external storage: ${battleSpritesDir.absolutePath}")
+ } else {
+ println("battle_sprites directory already exists in external storage: ${battleSpritesDir.absolutePath}")
+ }
+
+ // Copy all subdirectories from battle_sprites assets to external storage
+ println("Copying all battle_sprites subdirectories to external storage...")
+ battleSpritesFiles?.forEach { subdir ->
+ val sourcePath = "battle_sprites/$subdir"
+ val targetDir = File(battleSpritesDir, subdir)
+ println("Copying $sourcePath to ${targetDir.absolutePath}")
+ copyAssetDirectory(sourcePath, targetDir)
+ }
+
+ println("Sprite files copied successfully to external storage: ${battleSpritesDir.absolutePath}")
+
+ // Verify that attack sprites were copied
+ val atkspritesDir = File(battleSpritesDir, "extracted_atksprites")
+ if (atkspritesDir.exists()) {
+ val attackFiles = atkspritesDir.listFiles()
+ println("Attack sprites directory exists with ${attackFiles?.size ?: 0} files")
+ if (attackFiles != null && attackFiles.isNotEmpty()) {
+ println("First few attack files: ${attackFiles.take(5).map { it.name }}")
+ }
+ } else {
+ println("WARNING: extracted_atksprites directory does not exist!")
+ // List what's actually in the battle_sprites directory
+ val battleSpritesContents = battleSpritesDir.listFiles()
+ println("battle_sprites directory contains: ${battleSpritesContents?.map { it.name }?.joinToString(", ")}")
+ }
+
+ // Verify that battle backgrounds were copied
+ val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs")
+ if (battlebgsDir.exists()) {
+ val bgFiles = battlebgsDir.listFiles()
+ println("Battle backgrounds directory exists with ${bgFiles?.size ?: 0} files")
+ if (bgFiles != null && bgFiles.isNotEmpty()) {
+ println("First few battle background files: ${bgFiles.take(5).map { it.name }}")
+ }
+ } else {
+ println("WARNING: extracted_battlebgs directory does not exist!")
+ }
+
+ } catch (e: Exception) {
+ println("Error copying sprite files to external storage: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+
+ private fun copyAssetDirectory(assetPath: String, targetDir: File) {
+ try {
+ val assetManager = context.assets
+ val files = assetManager.list(assetPath) ?: return
+
+ println("Copying asset directory: $assetPath (${files.size} items)")
+ println("Files found: ${files.joinToString(", ")}")
+
+ for (file in files) {
+ val assetFilePath = if (assetPath.isEmpty()) file else "$assetPath/$file"
+ val targetFile = File(targetDir, file)
+
+ // Create subdirectories if needed
+ if (targetFile.parentFile != null && !targetFile.parentFile!!.exists()) {
+ targetFile.parentFile!!.mkdirs()
+ }
+
+ // Check if it's a directory by trying to list its contents
+ try {
+ val subFiles = assetManager.list(assetFilePath)
+ if (subFiles != null && subFiles.isNotEmpty()) {
+ // It's a directory, create it and copy contents
+ println("Copying subdirectory: $assetFilePath (${subFiles.size} files)")
+ if (!targetFile.exists()) {
+ targetFile.mkdirs()
+ }
+ copyAssetDirectory(assetFilePath, targetFile)
+ } else {
+ // It's a file, copy it
+ copyAssetFile(assetFilePath, targetFile)
+ }
+ } catch (e: Exception) {
+ // If we can't list contents, it's probably a file
+ println("Treating $assetFilePath as file (could not list contents)")
+ copyAssetFile(assetFilePath, targetFile)
+ }
+ }
+
+ // Special handling for extracted_atksprites - try to copy it directly if it wasn't found
+ if (assetPath == "battle_sprites/extracted_assets") {
+ println("Special handling: Checking for extracted_atksprites directory...")
+ try {
+ val atkspritesFiles = assetManager.list("battle_sprites/extracted_assets/extracted_atksprites")
+ if (atkspritesFiles != null && atkspritesFiles.isNotEmpty()) {
+ println("Found extracted_atksprites with ${atkspritesFiles.size} files")
+ val atkspritesDir = File(targetDir, "extracted_atksprites")
+ if (!atkspritesDir.exists()) {
+ atkspritesDir.mkdirs()
+ }
+ copyAssetDirectory("battle_sprites/extracted_assets/extracted_atksprites", atkspritesDir)
+ } else {
+ println("extracted_atksprites directory not found in assets")
+ }
+ } catch (e: Exception) {
+ println("Error checking extracted_atksprites: ${e.message}")
+ }
+ }
+
+ } catch (e: Exception) {
+ println("Error copying asset directory $assetPath: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+
+ private fun copyDirectory(sourceDir: File, targetDir: File) {
+ if (!targetDir.exists()) {
+ targetDir.mkdirs()
+ }
+
+ val files = sourceDir.listFiles()
+ if (files != null) {
+ files.forEach { file ->
+ val targetFile = File(targetDir, file.name)
+ if (file.isDirectory) {
+ copyDirectory(file, targetFile)
+ } else {
+ copyFile(file, targetFile)
+ }
+ }
+ }
+ }
+
+ private fun copyFile(sourceFile: File, targetFile: File) {
+ try {
+ val inputStream = FileInputStream(sourceFile)
+ val outputStream = FileOutputStream(targetFile)
+
+ inputStream.copyTo(outputStream)
+ inputStream.close()
+ outputStream.close()
+
+ println("Copied: ${sourceFile.name} -> ${targetFile.absolutePath}")
+ } catch (e: IOException) {
+ println("Error copying file ${sourceFile.name}: ${e.message}")
+ }
+ }
+
+ private fun copyAssetFile(assetPath: String, targetFile: File) {
+ try {
+ val inputStream = context.assets.open(assetPath)
+ val outputStream = FileOutputStream(targetFile)
+
+ inputStream.copyTo(outputStream)
+ inputStream.close()
+ outputStream.close()
+
+ println("Copied: $assetPath -> ${targetFile.absolutePath}")
+ } catch (e: IOException) {
+ println("Error copying asset file $assetPath: ${e.message}")
+ }
+ }
+
+ fun checkSpriteFilesExist(): Boolean {
+ val battleSpritesDir = getExternalSpriteBaseDir()
+ val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
+ val extractedStatsDir = File(battleSpritesDir, "extracted_digimon_stats")
+ val atkspritesDir = File(battleSpritesDir, "extracted_atksprites")
+ val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs")
+
+ val battleSpritesExist = battleSpritesDir.exists() && battleSpritesDir.listFiles()?.isNotEmpty() == true
+ val assetsExist = extractedAssetsDir.exists() && extractedAssetsDir.listFiles()?.isNotEmpty() == true
+ val statsExist = extractedStatsDir.exists() && extractedStatsDir.listFiles()?.isNotEmpty() == true
+ val atkspritesExist = atkspritesDir.exists() && atkspritesDir.listFiles()?.isNotEmpty() == true
+ val battlebgsExist = battlebgsDir.exists() && battlebgsDir.listFiles()?.isNotEmpty() == true
+
+ /*
+ println("Checking sprite files exist in external storage:")
+ println(" battle_sprites exists: $battleSpritesExist")
+ println(" extracted_assets exists: $assetsExist")
+ println(" extracted_digimon_stats exists: $statsExist")
+ println(" extracted_atksprites exists: $atkspritesExist")
+ println(" extracted_battlebgs exists: $battlebgsExist")
+ */
+
+ return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist
+ }
+
+ fun clearSpriteFiles() {
+ try {
+ val battleSpritesDir = getInternalSpriteBaseDir()
+
+ if (battleSpritesDir.exists()) {
+ deleteDirectory(battleSpritesDir)
+ println("Cleared battle_sprites directory from internal storage")
+ }
+
+ } catch (e: Exception) {
+ println("Error clearing sprite files: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+
+ private fun deleteDirectory(directory: File) {
+ if (directory.exists()) {
+ val files = directory.listFiles()
+ if (files != null) {
+ for (file in files) {
+ if (file.isDirectory) {
+ deleteDirectory(file)
+ } else {
+ file.delete()
+ }
+ }
+ }
+ directory.delete()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
new file mode 100644
index 0000000..5e1c141
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
@@ -0,0 +1,38 @@
+package com.github.nacabaro.vbhelper.battle
+
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun SpriteImage(
+ characterId: String,
+ frameNumber: Int,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Fit
+) {
+ val context = LocalContext.current
+ val spriteManager = remember { IndividualSpriteManager(context) }
+
+ var bitmap by remember { mutableStateOf(null) }
+
+ LaunchedEffect(characterId, frameNumber) {
+ println("Loading sprite frame: $frameNumber for character: $characterId")
+ bitmap = spriteManager.loadSpriteFrame(characterId, frameNumber)
+ if (bitmap == null) {
+ println("Failed to load sprite frame: $frameNumber for character: $characterId")
+ }
+ }
+
+ bitmap?.let { bmp ->
+ Image(
+ bitmap = bmp.asImageBitmap(),
+ contentDescription = "Sprite: $characterId frame $frameNumber",
+ modifier = modifier,
+ contentScale = contentScale
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/components/AttackSpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/components/AttackSpriteImage.kt
new file mode 100644
index 0000000..6bcf434
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/components/AttackSpriteImage.kt
@@ -0,0 +1,45 @@
+package com.github.nacabaro.vbhelper.battle
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import com.github.nacabaro.vbhelper.battle.AttackSpriteManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun AttackSpriteImage(
+ characterId: String,
+ isLarge: Boolean = false,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Fit
+) {
+ var bitmap by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ LaunchedEffect(characterId, isLarge) {
+ //println("AttackSpriteImage: Loading attack sprite for characterId=$characterId, isLarge=$isLarge")
+ coroutineScope.launch {
+ val attackSpriteManager = AttackSpriteManager(context)
+ val loadedBitmap = withContext(Dispatchers.IO) {
+ attackSpriteManager.getAttackSprite(characterId, isLarge)
+ }
+ bitmap = loadedBitmap
+ }
+ }
+
+ bitmap?.let { bmp ->
+ Image(
+ bitmap = bmp.asImageBitmap(),
+ contentDescription = "Attack Sprite",
+ modifier = modifier,
+ contentScale = contentScale
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
index 0e5ad06..dad71ac 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
@@ -4,23 +4,2195 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+//import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.material3.Button
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.material3.LinearProgressIndicator
+//import androidx.compose.material3.ExposedDropdownMenuBox
+//import androidx.compose.material3.ExposedDropdownMenuDefaults
+//import androidx.compose.material3.OutlinedTextField
+//import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.DisposableEffect
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.activity.ComponentActivity
+import android.os.Build
+import android.provider.Settings
+import android.content.Intent
+import android.net.Uri
+import android.media.MediaPlayer
+import android.os.Environment
+//import androidx.compose.animation.core.animateFloatAsState
+//import androidx.compose.animation.core.tween
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.foundation.background
+import androidx.compose.ui.graphics.Color
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
+import android.util.Log
import com.github.nacabaro.vbhelper.components.TopBanner
-import androidx.compose.ui.res.stringResource
-import com.github.nacabaro.vbhelper.R
-
+import com.github.nacabaro.vbhelper.battle.RetrofitHelper
+import com.github.nacabaro.vbhelper.battle.AttackSpriteImage
+import com.github.nacabaro.vbhelper.battle.SpriteFileManager
+import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
+import com.github.nacabaro.vbhelper.battle.DigimonAnimationType
+import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
+import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
+import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.collect
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+//import kotlin.math.sin
+//import kotlin.math.PI
+//import androidx.compose.animation.core.animateDpAsState
+//import androidx.compose.animation.core.animateIntAsState
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.foundation.Image
+import androidx.compose.ui.graphics.asImageBitmap
+import android.graphics.BitmapFactory
+//import android.os.Environment
+import java.io.File
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.foundation.layout.width
+import com.github.nacabaro.vbhelper.di.VBHelper
+import kotlinx.coroutines.Dispatchers
+//import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.material3.AlertDialog
+@Composable
+fun isLandscapeMode(): Boolean {
+ val configuration = LocalConfiguration.current
+ return configuration.screenWidthDp > configuration.screenHeightDp
+}
+
+@Composable
+fun getLandscapeModifier(): Modifier {
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.screenWidthDp > configuration.screenHeightDp
+ return if (isLandscape) {
+ Modifier.width(200.dp).height(8.dp)
+ } else {
+ Modifier.fillMaxWidth().height(10.dp)
+ }
+}
+
+@Composable
+fun getLandscapeAlignment(): Alignment {
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.screenWidthDp > configuration.screenHeightDp
+ return if (isLandscape) Alignment.Center else Alignment.TopStart
+}
+
+@Composable
+fun getLandscapeHorizontalAlignment(): Alignment.Horizontal {
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.screenWidthDp > configuration.screenHeightDp
+ return if (isLandscape) Alignment.CenterHorizontally else Alignment.Start
+}
+
+@Composable
+fun getLandscapeFontSize(): androidx.compose.ui.unit.TextUnit {
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.screenWidthDp > configuration.screenHeightDp
+ return if (isLandscape) 14.sp else 16.sp
+}
+
+@Composable
+fun getLandscapeBoxModifier(): Modifier {
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.screenWidthDp > configuration.screenHeightDp
+ return if (isLandscape) {
+ Modifier
+ .width(220.dp) // Slightly wider than the progress bar to accommodate padding
+ .background(
+ color = Color.Gray.copy(alpha = 0.6f),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
+ )
+ .padding(8.dp)
+ } else {
+ Modifier
+ .fillMaxWidth()
+ .background(
+ color = Color.Gray.copy(alpha = 0.6f),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
+ )
+ .padding(8.dp)
+ }
+}
+
+@Composable
+fun AnimatedDamageNumber(
+ damage: Int,
+ isVisible: Boolean,
+ modifier: Modifier = Modifier
+) {
+ if (!isVisible) return
+
+ var animationProgress by remember { mutableStateOf(0f) }
+ var scale by remember { mutableStateOf(1f) }
+ var alpha by remember { mutableStateOf(1f) }
+ var yOffset by remember { mutableStateOf(0.dp) }
+
+ LaunchedEffect(isVisible) {
+ if (isVisible) {
+ // Start animation
+ animationProgress = 0f
+ scale = 0.5f
+ alpha = 1f
+ yOffset = 0.dp
+
+ // Animate scale up
+ while (scale < 1.5f) {
+ scale += 0.1f
+ delay(16)
+ }
+
+ // Hold at max scale briefly
+ delay(200)
+
+ // Animate fade out and move up
+ while (alpha > 0f) {
+ alpha -= 0.05f
+ yOffset -= 1.dp
+ delay(16)
+ }
+ }
+ }
+
+ Text(
+ text = "-$damage",
+ fontSize = 32.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.Red,
+ textAlign = TextAlign.Center,
+ style = TextStyle(
+ shadow = Shadow(
+ color = Color.Black,
+ offset = androidx.compose.ui.geometry.Offset(2f, 2f),
+ blurRadius = 4f
+ )
+ ),
+ modifier = modifier
+ .scale(scale)
+ .alpha(alpha)
+ .offset(y = yOffset)
+ )
+}
+
+@Composable
+fun BattleScreen(
+ userId: Long? = null,
+ stage: String,
+ playerName: String,
+ opponentName: String,
+ activeCharacter: APIBattleCharacter?,
+ opponentCharacter: APIBattleCharacter?,
+ onAttackClick: () -> Unit,
+ context: android.content.Context? = null,
+ selectedBackgroundSet: Int = 0
+) {
+ // Capture userId parameter for use in lambdas - use remember to ensure it's accessible in all scopes
+ val currentUserId = remember { userId }
+
+ val battleSystem = remember { ArenaBattleSystem() }
+ val coroutineScope = rememberCoroutineScope()
+
+ // Background music MediaPlayer
+ var mediaPlayer by remember { mutableStateOf(null) }
+
+ // Initialize HP when battle starts
+ // Use currentHp if available (for resumed matches), otherwise use baseHp (for new matches)
+ LaunchedEffect(activeCharacter, opponentCharacter) {
+ val playerHP = activeCharacter?.currentHp?.toFloat() ?: activeCharacter?.baseHp?.toFloat() ?: 100f
+ val opponentHP = opponentCharacter?.currentHp?.toFloat() ?: opponentCharacter?.baseHp?.toFloat() ?: 100f
+ battleSystem.initializeHP(playerHP, opponentHP)
+ }
+
+ // Start background music when battle starts
+ LaunchedEffect(Unit) {
+ context?.let { ctx ->
+ try {
+ // Get external storage directory
+ val externalDir = Environment.getExternalStorageDirectory()
+ val musicDir = File(externalDir, "VBHelper/extracted_audio/background_music")
+
+ // Pick a random BGM file (bgm_001.wav to bgm_004.wav)
+ val bgmNumber = kotlin.random.Random.nextInt(1, 5) // 1 to 4
+ val bgmFileName = String.format("bgm_%03d.wav", bgmNumber)
+ val bgmFile = File(musicDir, bgmFileName)
+
+ if (bgmFile.exists()) {
+ println("BATTLESCREEN: Starting background music: $bgmFileName")
+ val player = MediaPlayer().apply {
+ setDataSource(bgmFile.absolutePath)
+ prepare()
+ setOnCompletionListener {
+ // Stop after one playthrough
+ println("BATTLESCREEN: Background music completed, stopping")
+ it.release()
+ mediaPlayer = null
+ }
+ start()
+ }
+ mediaPlayer = player
+ } else {
+ println("BATTLESCREEN: Background music file not found: ${bgmFile.absolutePath}")
+ }
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error starting background music: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ }
+
+ // Clean up MediaPlayer when battle ends or composable is disposed
+ DisposableEffect(Unit) {
+ onDispose {
+ mediaPlayer?.let { player ->
+ try {
+ if (player.isPlaying) {
+ player.stop()
+ }
+ player.release()
+ println("BATTLESCREEN: Background music stopped and released")
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error stopping background music: ${e.message}")
+ }
+ }
+ mediaPlayer = null
+ }
+ }
+
+ // Pending damage state for API integration
+ var pendingPlayerDamage by remember { mutableStateOf(0f) }
+ var pendingOpponentDamage by remember { mutableStateOf(0f) }
+
+ // Damage number animation state
+ var showPlayerDamageNumber by remember { mutableStateOf(false) }
+ var showOpponentDamageNumber by remember { mutableStateOf(false) }
+ var playerDamageValue by remember { mutableStateOf(0) }
+ var opponentDamageValue by remember { mutableStateOf(0) }
+
+ // Hit effect animation state
+ var showPlayerHitEffect by remember { mutableStateOf(false) }
+ var showOpponentHitEffect by remember { mutableStateOf(false) }
+
+ // Attack sprite visibility state
+ var hidePlayerAttackSprite by remember { mutableStateOf(false) }
+ var hideEnemyAttackSprite by remember { mutableStateOf(false) }
+
+ // Reset hit effect states when attack phase returns to idle
+ LaunchedEffect(battleSystem.attackPhase) {
+ if (battleSystem.attackPhase == 0) {
+ // Reset hit effect states when returning to idle
+ showPlayerHitEffect = false
+ showOpponentHitEffect = false
+ hidePlayerAttackSprite = false
+ hideEnemyAttackSprite = false
+ battleSystem.endPlayerHitDelayed()
+ battleSystem.endOpponentHitDelayed()
+ battleSystem.endPlayerShakeDelayed()
+ battleSystem.endOpponentShakeDelayed()
+ }
+ }
+
+ // Critical bar timer
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(30)
+ if (battleSystem.attackPhase == 0) { // Only update when not attacking
+ battleSystem.updateCritBarProgress((battleSystem.critBarProgress + 5) % 101)
+ }
+ }
+ }
+
+ // Animation for attack phases
+ LaunchedEffect(battleSystem.attackPhase) {
+ when (battleSystem.attackPhase) {
+ 1 -> {
+ // Phase 1: Both attacks from middle screen
+ var progress = 0f
+ while (progress < 1f) {
+ progress += 0.016f // 60 FPS
+ battleSystem.setAttackProgress(progress)
+ delay(16) // 60 FPS
+ }
+ battleSystem.advanceAttackPhase()
+ }
+ 2 -> {
+ // Phase 2: Player attack on enemy screen
+ battleSystem.switchToView(2) // Enemy screen
+ var progress = 0f
+ while (progress < 1f) {
+ progress += 0.016f // 60 FPS
+ battleSystem.setAttackProgress(progress)
+
+ // Trigger animation when attack reaches the enemy (around 50% progress for enemy dodge)
+ if (progress >= 0.50f && !battleSystem.isOpponentHit && !battleSystem.isOpponentDodging) {
+ if (battleSystem.attackIsHit) {
+ // Player attack hits enemy
+ battleSystem.startOpponentHit()
+ // Show hit effect and damage effect
+ showOpponentHitEffect = true
+ // Delay hiding the attack sprite to match hit effect timing
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ hideEnemyAttackSprite = true
+ }
+ // Delay showing damage number to match hit effect timing
+ if (pendingOpponentDamage > 0) {
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ showOpponentDamageNumber = true
+ }
+ }
+ // Delay SLEEP animation to match hit effect timing
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ battleSystem.startOpponentHitDelayed()
+ }
+ // Delay shake animation to match hit effect timing
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ battleSystem.startOpponentShakeDelayed()
+ }
+ } else {
+ // Player attack misses, enemy dodges
+ battleSystem.startOpponentDodge()
+ }
+ }
+
+ delay(16) // 60 FPS
+ }
+ battleSystem.completeAttackAnimation(opponentDamage = pendingOpponentDamage)
+
+ // Hide damage number and reset pending damage after animation
+ if (showOpponentDamageNumber) {
+ delay(800) // Wait for damage number animation (scale up + hold + fade out)
+ showOpponentDamageNumber = false
+ pendingOpponentDamage = 0f
+ }
+
+ delay(100)
+
+ // Check if there should be a counter-attack
+ if (battleSystem.shouldCounterAttack) {
+ battleSystem.startCounterAttack()
+ } else {
+ battleSystem.advanceAttackPhase()
+ }
+ }
+ 3 -> {
+ // Phase 3: Enemy attack on player screen
+ battleSystem.switchToView(1) // Player screen
+ var progress = 0f
+ while (progress < 1f) {
+ progress += 0.016f // 60 FPS
+ battleSystem.setAttackProgress(progress)
+
+ // Trigger animation when attack reaches the player (around 50% progress for player dodge)
+ if (progress >= 0.50f && !battleSystem.isPlayerHit && !battleSystem.isPlayerDodging) {
+ if (battleSystem.opponentAttackIsHit) {
+ // Enemy attack hits player
+ battleSystem.startPlayerHit()
+ // Show hit effect and damage effect
+ showPlayerHitEffect = true
+ // Delay hiding the attack sprite to match hit effect timing
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ hidePlayerAttackSprite = true
+ }
+ // Delay showing damage number to match hit effect timing
+ if (pendingPlayerDamage > 0) {
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ showPlayerDamageNumber = true
+ }
+ }
+ // Delay SLEEP animation to match hit effect timing
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ battleSystem.startPlayerHitDelayed()
+ }
+ // Delay shake animation to match hit effect timing
+ coroutineScope.launch {
+ delay(400) // Match the hit effect delay
+ battleSystem.startPlayerShakeDelayed()
+ }
+ } else {
+ // Enemy attack misses, player dodges
+ battleSystem.startPlayerDodge()
+ }
+ }
+
+ delay(16) // 60 FPS
+ }
+ battleSystem.completeAttackAnimation(playerDamage = pendingPlayerDamage)
+
+ // Hide damage number and reset pending damage after animation
+ if (showPlayerDamageNumber) {
+ delay(800) // Wait for damage number animation (scale up + hold + fade out)
+ showPlayerDamageNumber = false
+ pendingPlayerDamage = 0f
+ }
+
+ battleSystem.resetAttackState()
+ battleSystem.enableAttackButton()
+
+ // Check if battle is over
+ if (battleSystem.checkBattleOver()) {
+ battleSystem.endBattle()
+ onAttackClick()
+ }
+ }
+ }
+ }
+
+ // Player dodge animation
+ LaunchedEffect(battleSystem.isPlayerDodging) {
+ if (battleSystem.isPlayerDodging) {
+ var dodgeProgress = 0f
+ var dodgeDirection = 1f // Start moving up
+
+ // Move up
+ while (dodgeProgress < 1f) {
+ dodgeProgress += 0.05f // Faster dodge movement
+ battleSystem.setPlayerDodgeProgress(dodgeProgress)
+ battleSystem.setPlayerDodgeDirection(dodgeDirection)
+ delay(16) // 60 FPS
+ }
+
+ // Wait at the top
+ delay(200)
+
+ // Move back down
+ dodgeDirection = -1f
+ dodgeProgress = 0f
+ while (dodgeProgress < 1f) {
+ dodgeProgress += 0.05f
+ battleSystem.setPlayerDodgeProgress(dodgeProgress)
+ battleSystem.setPlayerDodgeDirection(dodgeDirection)
+ delay(16)
+ }
+
+ battleSystem.endPlayerDodge()
+ }
+ }
+
+ // Opponent dodge animation
+ LaunchedEffect(battleSystem.isOpponentDodging) {
+ if (battleSystem.isOpponentDodging) {
+ var dodgeProgress = 0f
+ var dodgeDirection = 1f // Start moving up
+
+ // Move up
+ while (dodgeProgress < 1f) {
+ dodgeProgress += 0.05f // Faster dodge movement
+ battleSystem.setOpponentDodgeProgress(dodgeProgress)
+ battleSystem.setOpponentDodgeDirection(dodgeDirection)
+ delay(16) // 60 FPS
+ }
+
+ // Wait at the top
+ delay(200)
+
+ // Move back down
+ dodgeDirection = -1f
+ dodgeProgress = 0f
+ while (dodgeProgress < 1f) {
+ dodgeProgress += 0.05f
+ battleSystem.setOpponentDodgeProgress(dodgeProgress)
+ battleSystem.setOpponentDodgeDirection(dodgeDirection)
+ delay(16)
+ }
+
+ battleSystem.endOpponentDodge()
+ }
+ }
+
+ // Player hit animation
+ LaunchedEffect(battleSystem.isPlayerHit) {
+ if (battleSystem.isPlayerHit) {
+ var hitProgress = 0f
+
+ // Quick hit effect
+ while (hitProgress < 1f) {
+ hitProgress += 0.1f // Fast hit effect
+ battleSystem.setHitProgress(hitProgress)
+ delay(16)
+ }
+
+ delay(100) // Brief pause
+
+ battleSystem.endPlayerHit()
+ }
+ }
+
+ // Player delayed shake animation
+ LaunchedEffect(battleSystem.isPlayerShakeDelayed) {
+ if (battleSystem.isPlayerShakeDelayed) {
+ var hitProgress = 0f
+
+ // Quick hit effect
+ while (hitProgress < 1f) {
+ hitProgress += 0.1f // Fast hit effect
+ battleSystem.setHitProgress(hitProgress)
+ delay(16)
+ }
+
+ delay(100) // Brief pause
+
+ battleSystem.endPlayerShakeDelayed()
+ }
+ }
+
+ // Opponent hit animation
+ LaunchedEffect(battleSystem.isOpponentHit) {
+ if (battleSystem.isOpponentHit) {
+ var hitProgress = 0f
+
+ // Quick hit effect
+ while (hitProgress < 1f) {
+ hitProgress += 0.1f // Fast hit effect
+ battleSystem.setHitProgress(hitProgress)
+ delay(16)
+ }
+
+ delay(100) // Brief pause
+
+ battleSystem.endOpponentHit()
+ }
+ }
+
+ // Opponent delayed shake animation
+ LaunchedEffect(battleSystem.isOpponentShakeDelayed) {
+ if (battleSystem.isOpponentShakeDelayed) {
+ var hitProgress = 0f
+
+ // Quick hit effect
+ while (hitProgress < 1f) {
+ hitProgress += 0.1f // Fast hit effect
+ battleSystem.setHitProgress(hitProgress)
+ delay(16)
+ }
+
+ delay(100) // Brief pause
+
+ battleSystem.endOpponentShakeDelayed()
+ }
+ }
+
+ // Damage number handling - store pending damage but don't show immediately
+ LaunchedEffect(pendingPlayerDamage) {
+ if (pendingPlayerDamage > 0) {
+ playerDamageValue = pendingPlayerDamage.toInt()
+ // Don't show immediately - wait for attack animation to reach the Digimon
+ }
+ }
+
+ LaunchedEffect(pendingOpponentDamage) {
+ if (pendingOpponentDamage > 0) {
+ opponentDamageValue = pendingOpponentDamage.toInt()
+ // Don't show immediately - wait for attack animation to reach the Digimon
+ }
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ when (battleSystem.currentView) {
+ 0 -> {
+ // Middle screen - both Digimon
+ MiddleBattleView(
+ userId = currentUserId,
+ battleSystem = battleSystem,
+ stage = stage,
+ playerName = playerName,
+ opponentName = opponentName,
+ attackAnimationProgress = battleSystem.attackProgress,
+ onAttackClick = {
+ battleSystem.startPlayerAttack()
+ },
+ activeCharacter = activeCharacter,
+ opponentCharacter = opponentCharacter,
+ context = context,
+ onSetPendingDamage = { playerDamage, opponentDamage ->
+ pendingPlayerDamage = playerDamage
+ pendingOpponentDamage = opponentDamage
+ },
+ coroutineScope = coroutineScope,
+ hidePlayerAttackSprite = hidePlayerAttackSprite,
+ hideEnemyAttackSprite = hideEnemyAttackSprite,
+ selectedBackgroundSet = selectedBackgroundSet
+ )
+ }
+ 1 -> {
+ // Player screen - enemy attack
+ PlayerBattleView(
+ battleSystem = battleSystem,
+ stage = stage,
+ playerName = playerName,
+ attackAnimationProgress = battleSystem.attackProgress,
+ onAttackClick = {
+ battleSystem.startPlayerAttack()
+ },
+ activeCharacter = activeCharacter,
+ context = context,
+ opponent = opponentCharacter,
+ onSetPendingDamage = { playerDamage, opponentDamage ->
+ pendingPlayerDamage = playerDamage
+ pendingOpponentDamage = opponentDamage
+ },
+ coroutineScope = coroutineScope,
+ hidePlayerAttackSprite = hidePlayerAttackSprite,
+ selectedBackgroundSet = selectedBackgroundSet
+ )
+ }
+ 2 -> {
+ // Enemy screen - player attack
+ EnemyBattleView(
+ battleSystem = battleSystem,
+ stage = stage,
+ opponentName = opponentName,
+ attackAnimationProgress = battleSystem.attackProgress,
+ activeCharacter = opponentCharacter,
+ playerCharacter = activeCharacter,
+ hideEnemyAttackSprite = hideEnemyAttackSprite,
+ selectedBackgroundSet = selectedBackgroundSet
+ )
+ }
+ }
+
+ // Damage number overlays - moved inside the Box for proper positioning
+ when (battleSystem.currentView) {
+ 0 -> {
+ // Middle screen - NO damage numbers should show here
+ // This screen is for the initial attack phase only
+ }
+ 1 -> {
+ // Player screen - show player damage (when opponent attacks player)
+ AnimatedDamageNumber(
+ damage = playerDamageValue,
+ isVisible = showPlayerDamageNumber,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .offset(y = (-50).dp)
+ //.background(Color.Yellow.copy(alpha = 0.3f)) // Debug background
+ )
+
+ // Player hit effects
+ HitEffectOverlay(
+ isVisible = showPlayerHitEffect,
+ modifier = Modifier.fillMaxSize(),
+ isPlayerScreen = true,
+ onAnimationComplete = {
+ showPlayerHitEffect = false
+ hidePlayerAttackSprite = false // Show attack sprite again
+ }
+ )
+
+
+ }
+ 2 -> {
+ // Enemy screen - show opponent damage (when player attacks opponent)
+ AnimatedDamageNumber(
+ damage = opponentDamageValue,
+ isVisible = showOpponentDamageNumber,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .offset(y = (-50).dp)
+ )
+
+ // Enemy hit effects
+ HitEffectOverlay(
+ isVisible = showOpponentHitEffect,
+ modifier = Modifier.fillMaxSize(),
+ isPlayerScreen = false,
+ onAnimationComplete = {
+ showOpponentHitEffect = false
+ hideEnemyAttackSprite = false // Show attack sprite again
+ }
+ )
+
+
+ }
+ }
+ }
+}
+
+@Composable
+fun MiddleBattleView(
+ userId: Long? = null,
+ battleSystem: ArenaBattleSystem,
+ stage: String,
+ playerName: String,
+ opponentName: String,
+ attackAnimationProgress: Float,
+ onAttackClick: () -> Unit,
+ activeCharacter: APIBattleCharacter?,
+ opponentCharacter: APIBattleCharacter?,
+ context: android.content.Context?,
+ onSetPendingDamage: (Float, Float) -> Unit,
+ coroutineScope: kotlinx.coroutines.CoroutineScope,
+ hidePlayerAttackSprite: Boolean,
+ hideEnemyAttackSprite: Boolean,
+ selectedBackgroundSet: Int = 0
+) {
+ // Track previous character ID to detect transitions
+ var previousCharacterId by remember { mutableStateOf(null) }
+ var previousAttackPhase by remember { mutableStateOf(null) }
+ var isTransitioning by remember { mutableStateOf(false) }
+ var lastApiResult by remember { mutableStateOf(null) }
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // Animated background - positioned underneath all other sprites
+ MultiLayerAnimatedBattleBackground(
+ modifier = Modifier.fillMaxSize(),
+ backgroundSetIndex = selectedBackgroundSet
+ )
+
+ // Top section: HP bars and HP numbers
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ // Enemy HP bar and text with background box
+ Box(
+ modifier = getLandscapeBoxModifier(),
+ contentAlignment = getLandscapeAlignment()
+ ) {
+ Column(
+ horizontalAlignment = getLandscapeHorizontalAlignment()
+ ) {
+ // Enemy HP bar (top)
+ LinearProgressIndicator(
+ progress = { battleSystem.opponentHP / (opponentCharacter?.baseHp?.toFloat() ?: 100f) },
+ modifier = getLandscapeModifier(),
+ color = Color.Red,
+ trackColor = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Enemy HP display numbers
+ Text(
+ text = "Enemy HP: ${battleSystem.opponentHP.toInt()}/${opponentCharacter?.baseHp ?: 100}",
+ fontSize = getLandscapeFontSize(),
+ color = Color.White,
+ style = TextStyle(
+ shadow = Shadow(
+ color = Color.Black,
+ offset = androidx.compose.ui.geometry.Offset(4f, 4f),
+ //blurRadius = 2f
+ )
+ )
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ }
+
+ // Middle section: Both Digimon with horizontal line separator
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.SpaceEvenly
+ ) {
+ // Enemy Digimon (top half)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ // Determine animation type for enemy
+ val enemyAnimationType = when {
+ battleSystem.attackPhase == 1 -> DigimonAnimationType.ATTACK // Both attacking in Phase 1
+ battleSystem.isOpponentDodging -> DigimonAnimationType.WALK
+ battleSystem.isOpponentHitDelayed -> DigimonAnimationType.SLEEP
+ else -> DigimonAnimationType.IDLE
+ }
+
+ // Calculate vertical offset for enemy dodge animation
+ val enemyVerticalOffset = if (battleSystem.isOpponentDodging) {
+ val dodgeHeight = 30.dp
+ val progress = battleSystem.opponentDodgeProgress
+ val direction = battleSystem.opponentDodgeDirection
+
+ if (direction > 0) {
+ -(progress * dodgeHeight.value).dp
+ } else {
+ -((1f - progress) * dodgeHeight.value).dp
+ }
+ } else {
+ 0.dp
+ }
+
+ // Calculate hit effect for enemy
+ val enemyHitOffset = if (battleSystem.isOpponentShakeDelayed) {
+ val shakeAmount = 5.dp
+ val progress = battleSystem.hitProgress
+ val shake = if (progress < 0.5f) progress * 2f else (1f - progress) * 2f
+ (shake * shakeAmount.value).dp
+ } else {
+ 0.dp
+ }
+
+ AnimatedSpriteImage(
+ characterId = opponentCharacter?.charaId ?: "dim011_mon01",
+ animationType = enemyAnimationType,
+ modifier = Modifier
+ .size(80.dp)
+ .offset(
+ x = enemyHitOffset,
+ y = enemyVerticalOffset + 40.dp
+ ),
+ contentScale = ContentScale.Fit,
+ reloadMappings = false,
+ animationOffset = 375L // Offset enemy animation by half the idle duration
+ )
+
+ // Enemy attack sprite (Phase 1 only)
+ if (battleSystem.attackPhase == 1 && !hideEnemyAttackSprite) {
+ val xOffset = (-attackAnimationProgress * 400).dp // Start at center, move left off screen
+ val yOffset = 30.dp // Lower enemy attack sprite by 30 pixels
+
+ AttackSpriteImage(
+ characterId = opponentCharacter?.charaId ?: "dim011_mon01",
+ isLarge = false,
+ modifier = Modifier
+ .size(60.dp)
+ .offset(
+ x = xOffset,
+ y = yOffset
+ ),
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
+
+ // Horizontal line separator (hidden in landscape mode)
+ val lineConfiguration = LocalConfiguration.current
+ val isLandscapeMode = lineConfiguration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
+
+ if (!isLandscapeMode) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(2.dp)
+ .background(Color.Black)
+ )
+ }
+
+ // Player Digimon (bottom half)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ // Determine animation type for player
+ val playerAnimationType = when {
+ battleSystem.attackPhase == 1 -> DigimonAnimationType.ATTACK // Both attacking in Phase 1
+ battleSystem.isPlayerDodging -> DigimonAnimationType.WALK
+ battleSystem.isPlayerHitDelayed -> DigimonAnimationType.SLEEP
+ else -> DigimonAnimationType.IDLE
+ }
+
+ // Calculate vertical offset for player dodge animation
+ val playerVerticalOffset = if (battleSystem.isPlayerDodging) {
+ val dodgeHeight = 30.dp
+ val progress = battleSystem.playerDodgeProgress
+ val direction = battleSystem.playerDodgeDirection
+
+ if (direction > 0) {
+ -(progress * dodgeHeight.value).dp
+ } else {
+ -((1f - progress) * dodgeHeight.value).dp
+ }
+ } else {
+ 0.dp
+ }
+
+ // Calculate hit effect for player
+ val playerHitOffset = if (battleSystem.isPlayerShakeDelayed) {
+ val shakeAmount = 5.dp
+ val progress = battleSystem.hitProgress
+ val shake = if (progress < 0.5f) progress * 2f else (1f - progress) * 2f
+ (shake * shakeAmount.value).dp
+ } else {
+ 0.dp
+ }
+
+ AnimatedSpriteImage(
+ characterId = activeCharacter?.charaId ?: "dim011_mon01",
+ animationType = playerAnimationType,
+ modifier = Modifier
+ .size(80.dp)
+ .scale(-1f, 1f) // Flip player Digimon horizontally
+ .offset(
+ x = playerHitOffset,
+ y = playerVerticalOffset - 40.dp
+ ),
+ contentScale = ContentScale.Fit,
+ reloadMappings = false,
+ animationOffset = 0L // Player animation starts immediately
+ )
+
+ // Player attack sprite (Phase 1 only)
+ if (battleSystem.attackPhase == 1 && !hidePlayerAttackSprite) {
+ val xOffset = (attackAnimationProgress * 400).dp // Start at center, move right off screen
+ val yOffset = (-30).dp // Raise player attack sprite by 30 pixels
+
+ AttackSpriteImage(
+ characterId = activeCharacter?.charaId ?: "dim011_mon01",
+ isLarge = false,
+ modifier = Modifier
+ .size(60.dp)
+ .offset(
+ x = xOffset,
+ y = yOffset
+ )
+ .scale(-1f, 1f), // Flip attack sprite
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
+ }
+ }
+
+ // Bottom section: Player HP bar, Critical bar and Attack button
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .align(Alignment.BottomCenter),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Critical bar
+ LinearProgressIndicator(
+ progress = { battleSystem.critBarProgress / 100f },
+ modifier = getLandscapeModifier(),
+ color = Color.Yellow,
+ trackColor = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(6.dp))
+
+ // Player HP bar and text with background box
+ Box(
+ modifier = getLandscapeBoxModifier(),
+ contentAlignment = getLandscapeAlignment()
+ ) {
+ Column(
+ horizontalAlignment = getLandscapeHorizontalAlignment()
+ ) {
+ // Player HP bar
+ LinearProgressIndicator(
+ progress = { battleSystem.playerHP / (activeCharacter?.baseHp?.toFloat() ?: 100f) },
+ modifier = getLandscapeModifier(),
+ color = Color.Green,
+ trackColor = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Player HP display numbers
+ Text(
+ text = "HP: ${battleSystem.playerHP.toInt()}/${activeCharacter?.baseHp ?: 100}",
+ fontSize = getLandscapeFontSize(),
+ color = Color.White,
+ style = TextStyle(
+ shadow = Shadow(
+ color = Color.Black,
+ offset = androidx.compose.ui.geometry.Offset(4f, 4f),
+ //blurRadius = 2f
+ )
+ )
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(6.dp))
+
+ // Attack button
+ Button(
+ onClick = {
+
+ // Capture userId for use in this lambda
+ val playerUserId = userId
+
+ // Get crit bar progress as float (0.0f to 100.0f)
+ val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
+
+ // Determine player and opponent stages
+ val playerStage = when (activeCharacter?.stage) {
+ 0 -> 0 // rookie
+ 1 -> 1 // champion
+ 2 -> 2 // ultimate
+ 3 -> 3 // mega
+ else -> 0
+ }
+
+ val opponentStage = when (opponentCharacter?.stage) {
+ 0 -> 0 // rookie
+ 1 -> 1 // champion
+ 2 -> 2 // ultimate
+ 3 -> 3 // mega
+ else -> 0
+ }
+
+ // Send API call with all parameters
+ context?.let { ctx ->
+ // Start both attacks simultaneously
+ battleSystem.startPlayerAttack()
+
+ RetrofitHelper().getPVPWinner(
+ ctx,
+ 1,
+ playerUserId ?: 2L,
+ activeCharacter?.name ?: "Player",
+ playerStage,
+ opponentStage,
+ opponentCharacter?.name ?: "Opponent",
+ opponentStage
+ ) { apiResult ->
+ // Handle API response here
+ println("API Result: $apiResult")
+ lastApiResult = apiResult // Store for debug display
+
+ // Update HP based on API response
+ when (apiResult.state) {
+ 1 -> {
+ // Match is still ongoing - update HP and continue
+ // Set pending damage based on API result
+ if (apiResult.playerAttackDamage > 0) {
+ // Player attack hit - enemy takes damage at end of player animation
+ onSetPendingDamage(0f, apiResult.playerAttackDamage.toFloat()) // Opponent takes damage
+ battleSystem.setAttackHitState(true)
+
+ // Also check if enemy counter-attacks and hits
+ if (apiResult.opponentAttackDamage > 0) {
+ onSetPendingDamage(apiResult.opponentAttackDamage.toFloat(), apiResult.playerAttackDamage.toFloat()) // Both take damage
+ }
+ } else {
+ // Player attack missed - enemy counter-attacks
+ battleSystem.setAttackHitState(false)
+ // Set up counter-attack - determine if it hits based on API result
+ val counterAttackHits = apiResult.opponentAttackDamage > 0
+
+ // Use opponentAttackDamage to determine counter-attack hit
+ val finalCounterAttackHits = counterAttackHits
+
+ if (finalCounterAttackHits) {
+ onSetPendingDamage(apiResult.opponentAttackDamage.toFloat(), 0f) // Player takes damage
+ } else {
+ onSetPendingDamage(0f, 0f) // No damage
+ }
+ battleSystem.setupCounterAttack(finalCounterAttackHits)
+ // Set the opponent attack hit state for Phase 3
+ battleSystem.handleOpponentAttackResult(finalCounterAttackHits)
+ }
+ }
+ 2 -> {
+ // Match is over - transition to results screen
+ println("Match is over! Winner: ${apiResult.winner}")
+ battleSystem.updateHPFromAPI(apiResult.playerHP.toFloat(), apiResult.opponentHP.toFloat())
+ onAttackClick() // This will transition to battle-results screen
+ }
+ -1 -> {
+ // Error occurred
+ println("API Error: ${apiResult.status}")
+ battleSystem.resetAttackState()
+ battleSystem.enableAttackButton()
+ }
+ }
+ }
+ }
+ },
+ enabled = battleSystem.isAttackButtonEnabled,
+ modifier = Modifier
+ .fillMaxWidth(0.5f)
+ .height(35.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color.Blue,
+ disabledContainerColor = Color.Gray
+ ),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text("Attack", color = Color.White, fontSize = 12.sp)
+ }
+ }
+ }
+}
+
+@Composable
+fun PlayerBattleView(
+ battleSystem: ArenaBattleSystem,
+ stage: String,
+ playerName: String,
+ attackAnimationProgress: Float,
+ onAttackClick: () -> Unit,
+ activeCharacter: APIBattleCharacter?,
+ context: android.content.Context?,
+ opponent: APIBattleCharacter?,
+ onSetPendingDamage: (Float, Float) -> Unit,
+ coroutineScope: kotlinx.coroutines.CoroutineScope,
+ hidePlayerAttackSprite: Boolean,
+ selectedBackgroundSet: Int = 0
+) {
+ // Track previous character ID to detect transitions
+ var previousCharacterId by remember { mutableStateOf(null) }
+ var previousAttackPhase by remember { mutableStateOf(null) }
+ var isTransitioning by remember { mutableStateOf(false) }
+ var lastApiResult by remember { mutableStateOf(null) }
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // Multi-layer animated battle background
+ MultiLayerAnimatedBattleBackground(modifier = Modifier.fillMaxSize(), backgroundSetIndex = selectedBackgroundSet)
+
+ // Top section: HP bar and HP numbers
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ // Health bar and text with background box
+ Box(
+ modifier = getLandscapeBoxModifier(),
+ contentAlignment = getLandscapeAlignment()
+ ) {
+ Column(
+ horizontalAlignment = getLandscapeHorizontalAlignment()
+ ) {
+ // Health bar
+ LinearProgressIndicator(
+ progress = { battleSystem.playerHP / (activeCharacter?.baseHp?.toFloat() ?: 100f) },
+ modifier = getLandscapeModifier(),
+ color = Color.Green,
+ trackColor = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Health display numbers
+ Text(
+ text = "HP: ${battleSystem.playerHP.toInt()}/${activeCharacter?.baseHp ?: 100}",
+ fontSize = getLandscapeFontSize(),
+ color = Color.White,
+ style = TextStyle(
+ shadow = Shadow(
+ color = Color.Black,
+ offset = androidx.compose.ui.geometry.Offset(4f, 4f),
+ //blurRadius = 2f
+ )
+ )
+ )
+ }
+ }
+ }
+
+ // Middle section: Player Digimon only
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ // Player Digimon (left side)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .size(80.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ // Determine animation type based on battle state
+ val animationType = when {
+ battleSystem.isPlayerDodging -> DigimonAnimationType.WALK // Use walk animation for dodge
+ battleSystem.isPlayerHitDelayed -> DigimonAnimationType.SLEEP // Use sleep animation for hit effect (injured sprite)
+ battleSystem.attackPhase == 1 -> DigimonAnimationType.ATTACK // Player attack on player screen
+ battleSystem.attackPhase == 2 -> DigimonAnimationType.ATTACK // Player attack on opponent screen
+ battleSystem.attackPhase == 3 -> DigimonAnimationType.IDLE // Opponent attack on opponent screen
+ battleSystem.attackPhase == 4 -> DigimonAnimationType.IDLE // Opponent attack on player screen
+ else -> DigimonAnimationType.IDLE
+ }
+
+ // Calculate vertical offset for dodge animation
+ val verticalOffset = if (battleSystem.isPlayerDodging) {
+ val dodgeHeight = 30.dp
+ val progress = battleSystem.playerDodgeProgress
+ val direction = battleSystem.playerDodgeDirection
+
+ if (direction > 0) {
+ // Moving up (negative offset to move UP visually)
+ -(progress * dodgeHeight.value).dp
+ } else {
+ // Moving back down (from negative peak to 0)
+ -((1f - progress) * dodgeHeight.value).dp
+ }
+ } else {
+ 0.dp
+ }
+
+ // Calculate hit effect (slight shake)
+ val hitOffset = if (battleSystem.isPlayerShakeDelayed) {
+ val shakeAmount = 5.dp
+ val progress = battleSystem.hitProgress
+ // Simple shake effect without complex math
+ val shake = if (progress < 0.5f) progress * 2f else (1f - progress) * 2f
+ (shake * shakeAmount.value).dp
+ } else {
+ 0.dp
+ }
+
+ AnimatedSpriteImage(
+ characterId = activeCharacter?.charaId ?: "dim011_mon01",
+ animationType = animationType,
+ modifier = Modifier
+ .size(80.dp)
+ .scale(-1f, 1f) // Flip player Digimon horizontally
+ .offset(
+ x = hitOffset,
+ y = verticalOffset
+ ),
+ contentScale = ContentScale.Fit,
+ reloadMappings = false,
+ animationOffset = 0L // Player animation starts immediately
+ )
+
+ // Attack sprite visibility and positioning based on attack phase
+ val shouldShowAttack = when (battleSystem.attackPhase) {
+ 1 -> false // Both attacks from middle screen
+ 2 -> false // Player attack on enemy screen
+ 3 -> true // Enemy attack on player screen
+ else -> false
+ }
+
+ if (shouldShowAttack) {
+ val xOffset = when (battleSystem.attackPhase) {
+ 3 -> (-attackAnimationProgress * 400 + 350).dp // Enemy attack on player screen - start more to the right
+ else -> 0.dp
+ }
+
+ // Use opponent character ID for Phase 3 (enemy attack)
+ val characterId = when (battleSystem.attackPhase) {
+ 3 -> opponent?.charaId ?: "dim011_mon01" // Use opponent's character ID
+ else -> activeCharacter?.charaId ?: "dim011_mon01" // Use player's character ID
+ }
+
+ // Handle sprite transition
+ LaunchedEffect(characterId, battleSystem.attackPhase) {
+ if ((previousCharacterId != null && previousCharacterId != characterId) ||
+ (previousAttackPhase != null && previousAttackPhase != battleSystem.attackPhase)) {
+ // Character ID or attack phase changed, start transition
+ isTransitioning = true
+ delay(100) // Brief invisibility period
+ isTransitioning = false
+ }
+ previousCharacterId = characterId
+ previousAttackPhase = battleSystem.attackPhase
+ }
+
+ if (!isTransitioning && !hidePlayerAttackSprite) {
+ AttackSpriteImage(
+ characterId = characterId,
+ isLarge = false,
+ modifier = Modifier
+ .size(60.dp)
+ .offset(
+ x = xOffset,
+ y = 0.dp
+ )
+ .scale(1f, 1f), // Don't flip enemy attacks on player screen
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
+ }
+ }
+
+
+ }
+}
+
+@Composable
+fun EnemyBattleView(
+ battleSystem: ArenaBattleSystem,
+ stage: String,
+ opponentName: String,
+ attackAnimationProgress: Float,
+ activeCharacter: APIBattleCharacter? = null,
+ playerCharacter: APIBattleCharacter? = null,
+ hideEnemyAttackSprite: Boolean,
+ selectedBackgroundSet: Int = 0
+) {
+ // Track previous character ID to detect transitions
+ var previousCharacterId by remember { mutableStateOf(null) }
+ var previousAttackPhase by remember { mutableStateOf(null) }
+ var isTransitioning by remember { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // Multi-layer animated battle background
+ MultiLayerAnimatedBattleBackground(modifier = Modifier.fillMaxSize(), backgroundSetIndex = selectedBackgroundSet)
+
+ // Top section: Enemy HP bar and HP numbers
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ // Enemy HP bar and text with background box
+ Box(
+ modifier = getLandscapeBoxModifier(),
+ contentAlignment = getLandscapeAlignment()
+ ) {
+ Column(
+ horizontalAlignment = getLandscapeHorizontalAlignment()
+ ) {
+ // Enemy HP bar
+ LinearProgressIndicator(
+ progress = { battleSystem.opponentHP / (activeCharacter?.baseHp?.toFloat() ?: 100f) },
+ modifier = getLandscapeModifier(),
+ color = Color.Red,
+ trackColor = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Enemy HP display numbers
+ Text(
+ text = "Enemy HP: ${battleSystem.opponentHP.toInt()}/${activeCharacter?.baseHp ?: 100}",
+ fontSize = getLandscapeFontSize(),
+ color = Color.White,
+ style = TextStyle(
+ shadow = Shadow(
+ color = Color.Black,
+ offset = androidx.compose.ui.geometry.Offset(4f, 4f),
+ //blurRadius = 2f
+ )
+ )
+ )
+ }
+ }
+ }
+
+ // Middle section: Enemy Digimon
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ // Enemy Digimon
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .size(80.dp),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ // Determine animation type based on battle state
+ val animationType = when {
+ battleSystem.isOpponentDodging -> DigimonAnimationType.WALK // Use walk animation for dodge
+ battleSystem.isOpponentHitDelayed -> DigimonAnimationType.SLEEP // Use sleep animation for hit effect (injured sprite)
+ battleSystem.attackPhase == 2 -> DigimonAnimationType.IDLE // Player attack on enemy screen
+ else -> DigimonAnimationType.IDLE
+ }
+
+ // Calculate vertical offset for dodge animation
+ val verticalOffset = if (battleSystem.isOpponentDodging) {
+ val dodgeHeight = 30.dp
+ val progress = battleSystem.opponentDodgeProgress
+ val direction = battleSystem.opponentDodgeDirection
+
+ if (direction > 0) {
+ // Moving up (negative offset to move UP visually)
+ -(progress * dodgeHeight.value).dp
+ } else {
+ // Moving back down (from negative peak to 0)
+ -((1f - progress) * dodgeHeight.value).dp
+ }
+ } else {
+ 0.dp
+ }
+
+ // Calculate hit effect (slight shake)
+ val hitOffset = if (battleSystem.isOpponentShakeDelayed) {
+ val shakeAmount = 5.dp
+ val progress = battleSystem.hitProgress
+ // Simple shake effect without complex math
+ val shake = if (progress < 0.5f) progress * 2f else (1f - progress) * 2f
+ (shake * shakeAmount.value).dp
+ } else {
+ 0.dp
+ }
+
+ AnimatedSpriteImage(
+ characterId = activeCharacter?.charaId ?: "dim011_mon01",
+ animationType = animationType,
+ modifier = Modifier
+ .size(80.dp)
+ .offset(
+ x = hitOffset,
+ y = verticalOffset
+ ),
+ contentScale = ContentScale.Fit,
+ reloadMappings = false,
+ animationOffset = 375L // Offset enemy animation by half the idle duration
+ )
+
+ // Attack sprite visibility and positioning based on attack phase
+ val shouldShowAttack = when (battleSystem.attackPhase) {
+ 2 -> true // Player attack on enemy screen
+ else -> false
+ }
+
+ if (shouldShowAttack) {
+ val xOffset = (attackAnimationProgress * 400 - 350).dp // Player attack on enemy screen - start more to the left
+
+ // Use player's character ID for player attack
+ val characterId = playerCharacter?.charaId ?: "dim011_mon01"
+
+ // Handle sprite transition
+ LaunchedEffect(characterId, battleSystem.attackPhase) {
+ if ((previousCharacterId != null && previousCharacterId != characterId) ||
+ (previousAttackPhase != null && previousAttackPhase != battleSystem.attackPhase)) {
+ // Character ID or attack phase changed, start transition
+ isTransitioning = true
+ delay(100) // Brief invisibility period
+ isTransitioning = false
+ }
+ previousCharacterId = characterId
+ previousAttackPhase = battleSystem.attackPhase
+ }
+
+ if (!isTransitioning && !hideEnemyAttackSprite) {
+ AttackSpriteImage(
+ characterId = characterId,
+ isLarge = false,
+ modifier = Modifier
+ .size(60.dp)
+ .offset(
+ x = xOffset,
+ y = 0.dp
+ )
+ .scale(-1f, 1f), // Flip player attacks
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BattlesScreen() {
+ val TAG = "BattleScreen"
+ val context = LocalContext.current
+ val activity = context as? ComponentActivity
+
+ // Permission state
+ var hasStoragePermission by remember { mutableStateOf(false) }
+
+ // Check if permission is already granted
+ // For Android 11+ (API 30+), check MANAGE_EXTERNAL_STORAGE
+ // For Android 10 and below, check READ_EXTERNAL_STORAGE
+ val permissionCheck = remember {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ // Android 11+ - check for MANAGE_EXTERNAL_STORAGE
+ android.os.Environment.isExternalStorageManager()
+ } else {
+ // Android 10 and below - check for READ_EXTERNAL_STORAGE
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ // Permission launcher for READ_EXTERNAL_STORAGE (Android 10 and below)
+ val readStoragePermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ hasStoragePermission = isGranted
+ if (isGranted) {
+ } else {
+ println("BATTLESCREEN: READ_EXTERNAL_STORAGE permission denied")
+ }
+ }
+
+ // Launcher for opening settings to grant MANAGE_EXTERNAL_STORAGE (Android 11+)
+ val manageStorageSettingsLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult()
+ ) {
+ // Re-check permission after returning from settings
+ val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ android.os.Environment.isExternalStorageManager()
+ } else {
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+ hasStoragePermission = hasPermission
+ if (hasPermission) {
+ } else {
+ println("BATTLESCREEN: MANAGE_EXTERNAL_STORAGE permission not granted")
+ }
+ }
+
+ // Initialize permission state
+ LaunchedEffect(Unit) {
+ hasStoragePermission = permissionCheck
+ if (!permissionCheck && activity != null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ // Android 11+ - need to request MANAGE_EXTERNAL_STORAGE
+ // This requires user to go to settings
+ println("BATTLESCREEN: Android 11+ detected - opening settings for MANAGE_EXTERNAL_STORAGE")
+ try {
+ val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
+ intent.data = Uri.parse("package:${context.packageName}")
+ manageStorageSettingsLauncher.launch(intent)
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error opening settings: ${e.message}")
+ // Fallback: try the general manage external storage settings
+ try {
+ val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
+ manageStorageSettingsLauncher.launch(intent)
+ } catch (e2: Exception) {
+ println("BATTLESCREEN: Error opening fallback settings: ${e2.message}")
+ }
+ }
+ } else {
+ // Android 10 and below - request READ_EXTERNAL_STORAGE
+ println("BATTLESCREEN: Requesting READ_EXTERNAL_STORAGE permission...")
+ readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ } else if (permissionCheck) {
+ //println("BATTLESCREEN: Storage permission already granted")
+ }
+ }
+
+ var currentView by remember { mutableStateOf("main") }
+
+ // Create BattleAuthContainer
+ val battleAuthContainer = remember { BattleAuthContainer(context) }
+
+ // Auth state
+ var isAuthenticated by remember { mutableStateOf(false) }
+ var isCheckingAuth by remember { mutableStateOf(true) }
+ var userId by remember { mutableStateOf(null) }
+ // Track processed tokens to prevent duplicate API calls
+ // Use rememberSaveable to persist across configuration changes (screen rotation)
+ var processedTokens by rememberSaveable { mutableStateOf>(emptySet()) }
+
+ var opponentsList by remember { mutableStateOf(ArrayList()) }
+
+ var activeCharacter by remember { mutableStateOf(null) }
+ var selectedOpponent by remember { mutableStateOf(null) }
+ var activeUserCharacter by remember { mutableStateOf(null) }
+ var activeCardId by remember { mutableStateOf(null) }
+
+ var expanded by remember { mutableStateOf(false) }
+ var selectedStage by remember { mutableStateOf("") }
+ var currentStage by remember { mutableStateOf("rookie") }
+
+ // Random background set selection
+ var selectedBackgroundSet by remember { mutableStateOf(0) }
+
+ // Resume/Quit match dialog state
+ var showResumeDialog by remember { mutableStateOf(false) }
+ var existingMatchState by remember { mutableStateOf(null) }
+ var pendingOpponentForResume by remember { mutableStateOf(null) }
+ var pendingCardIdForResume by remember { mutableStateOf(null) }
+ var pendingApiStageForResume by remember { mutableStateOf(null) }
+ // Store the original opponent from the match (not the clicked one)
+ var originalMatchOpponent by remember { mutableStateOf(null) }
+
+ // Sprite animation tester state
+ /*
+ var showSpriteTester by remember { mutableStateOf(false) }
+ var spriteTesterView by remember { mutableStateOf("entry") } // "entry" or "testing"
+ var dimId by remember { mutableStateOf("") }
+ var monId by remember { mutableStateOf("") }
+ var currentTestAnimation by remember { mutableStateOf(DigimonAnimationType.IDLE) }
+ var testCharacterId by remember { mutableStateOf("") }
+ */
+
+ /*
+ // Create hardcoded character lists for each stage
+ val rookieCharacters = listOf(
+ APIBattleCharacter("AGUMON", "degimon_name_Dim012_003", "dim012_mon03", 0, 1, 1800, 1800, 2400.0f, 700.0f),
+ APIBattleCharacter("PULSEMON", "degimon_name_Dim000_003", "dim000_mon03", 0, 1, 1800, 1800, 2400.0f, 700.0f),
+ APIBattleCharacter("DORUMON", "degimon_name_dim137_mon03", "dim137_mon03", 0, 1, 3000, 3000, 5100.0f, 1050.0f)
+ )
+
+ val championCharacters = listOf(
+ APIBattleCharacter("GREYMON","degimon_name_Dim012_004","dim012_mon04",1,1,2000, 2000, 3000.0f,900.0f),
+ APIBattleCharacter("TYRANNOMON","degimon_name_Dim008_006","dim008_mon06",1,3,2000, 2000, 2400.0f,600.0f),
+ APIBattleCharacter("DORUGAMON","degimon_name_dim137_mon05","dim137_mon05",1,3,3500, 3500, 5200.0f,1200.0f)
+ )
+
+ val ultimateCharacters = listOf(
+ APIBattleCharacter("METALGREYMON (VIRUS)","degimon_name_Dim014_005","dim014_mon05",2,2,2640, 2640, 2450.0f,800.0f),
+ APIBattleCharacter("MAMEMON", "degimon_name_Dim012_011", "dim012_mon11", 2, 1, 3000, 3000, 4000.0f, 1000.0f),
+ APIBattleCharacter("DORUGREYMON","degimon_name_dim137_mon09","dim137_mon09",2,3,5000, 5000, 6400.0f,1400.0f)
+ )
+
+ val megaCharacters = listOf(
+ APIBattleCharacter("WARGREYMON","degimon_name_Dim012_014","dim012_mon14",3,1,3080, 3080, 3825.0f,800.0f),
+ APIBattleCharacter("SLAYERDRAMON","degimon_name_dim129_mon15","dim129_mon15",3,1,4800, 4800, 6300.0f,1950.0f),
+ APIBattleCharacter("BREAKDRAMON","degimon_name_dim129_mon17","dim129_mon17",3,2,6000, 6000, 4000.0f,1980.0f)
+ )
+ // Get the appropriate character list based on current stage
+ val characterList = when (currentStage.lowercase()) {
+ "rookie" -> rookieCharacters
+ "champion" -> championCharacters
+ "ultimate" -> ultimateCharacters
+ "mega" -> megaCharacters
+ else -> rookieCharacters
+ }
+ */
+
+ // Get the appropriate battle type based on player's stage (derived from activeUserCharacter)
+ val playerBattleType = activeUserCharacter?.stage?.let { stage ->
+ when (stage) {
+ 2 -> "rookie" // Player stage 2 → Rookie opponents (API stage 0)
+ 3 -> "champion" // Player stage 3 → Champion opponents (API stage 1)
+ 4 -> "ultimate" // Player stage 4 → Ultimate opponents (API stage 2)
+ 5 -> "mega" // Player stage 5 → Mega opponents (API stage 3)
+ else -> null
+ }
+ }
+
+ // Determine if player can battle based on stage (derived from activeUserCharacter)
+ val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
+
+ // Load opponents automatically based on player's stage
+ // Only load if authenticated and character is ready
+ LaunchedEffect(activeUserCharacter, isAuthenticated) {
+ // Wait for authentication to complete before loading opponents
+ if (!isAuthenticated) {
+ return@LaunchedEffect
+ }
+
+ val currentCharacter = activeUserCharacter
+ if (currentCharacter != null && canBattle && playerBattleType != null) {
+ try {
+ RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
+ try {
+ // Create a new list to trigger UI recomposition
+ opponentsList = ArrayList(opponents.opponentsList)
+ } catch (e: Exception) {
+ Log.d(TAG, "Error processing opponents data: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ } catch (e: Exception) {
+ Log.d(TAG,"Error calling getOpponents: ${e.message}")
+ e.printStackTrace()
+ }
+ } else {
+ println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
+ println("BATTLESCREEN: canBattle: $canBattle")
+ println("BATTLESCREEN: playerBattleType: $playerBattleType")
+ println("BATTLESCREEN: currentCharacter != null: ${currentCharacter != null}")
+ if (currentCharacter != null) {
+ println("BATTLESCREEN: currentCharacter.stage: ${currentCharacter.stage}")
+ println("BATTLESCREEN: currentCharacter.stage >= 2: ${currentCharacter.stage >= 2}")
+ }
+ }
+ }
+
+ // Helper lambda to extract token from URI and authenticate
+ // The token can be in 'c' parameter (from localhost:8080/authenticate?c=...) or 'token' parameter
+ val handleTokenFromUri: (Uri) -> Unit = { uri ->
+ // Try 'c' parameter first (from localhost:8080/authenticate?c=...)
+ var token = uri.getQueryParameter("c")
+ // Fall back to 'token' parameter if 'c' is not found
+ if (token == null || token.isEmpty()) {
+ token = uri.getQueryParameter("token")
+ }
+
+ if (token != null && token.isNotEmpty()) {
+ // Check if we've already processed this token (either successfully or currently processing)
+ if (!processedTokens.contains(token)) {
+ // Mark token as being processed IMMEDIATELY to prevent duplicate API calls
+ processedTokens = processedTokens + token
+
+ // Exchange token with battle server
+ RetrofitHelper().authenticate(context, token) { response ->
+ if (response.success) {
+ // Extract userId and sessionToken from response
+ val extractedUserId = response.userInfo?.userId?.toLongOrNull()
+ val sessionToken = response.sessionToken
+
+ println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
+
+ // Store both nacatech token (for re-auth) and sessionToken (for API calls)
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ battleAuthContainer.authRepository.setAuthenticated(
+ isAuthenticated = true,
+ nacatechToken = token,
+ sessionToken = sessionToken,
+ userId = extractedUserId
+ )
+ }
+ // Update UI state on main thread
+ kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = extractedUserId
+ println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
+ android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ println("BATTLESCREEN: Authentication failed: ${response.message}")
+ // If it's an "Invalid user nonce" error, the token was already used - keep it marked to prevent retries
+ if (response.message?.contains("Invalid user nonce") == true || response.message?.contains("nonce") == true) {
+ println("BATTLESCREEN: Token was already used (Invalid user nonce), keeping it marked to prevent retries")
+ // Token already marked as processed, just handle the error
+ // Clear authentication state and open login page
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ battleAuthContainer.authRepository.logout()
+ }
+ kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
+ isAuthenticated = false
+ isCheckingAuth = false
+ // Small delay to ensure state is updated
+ kotlinx.coroutines.delay(100)
+ // Open auth URL to get a fresh token
+ val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
+ val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
+ try {
+ context.startActivity(authIntent)
+ println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ } else {
+ // For other errors, remove from processed set to allow retry with a new token
+ println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry")
+ processedTokens = processedTokens - token
+ }
+ // Show toast on main thread
+ kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
+ android.widget.Toast.makeText(context, "Authentication failed: ${response.message}", android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ } else {
+ println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)")
+ }
+ }
+
+ // Check authentication status on load
+ LaunchedEffect(Unit) {
+ try {
+ val authRepository = battleAuthContainer.authRepository
+ val localAuthState = authRepository.isAuthenticated.first()
+ val storedToken = authRepository.authToken.first()
+ val storedUserId = authRepository.userId.first()
+
+ // Load stored userId if available
+ if (storedUserId != null) {
+ userId = storedUserId
+ }
+
+ // If we have a stored token, set authenticated state optimistically FIRST (before checking deep links)
+ // This must happen immediately to prevent UI from showing "Checking authentication"
+ if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
+ // Set authenticated state immediately to prevent redirect on rotation and show UI
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = storedUserId
+ }
+
+ // Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
+ // This prevents processing stale tokens from previous sessions
+ val activity = context as? ComponentActivity
+ val intent = activity?.intent
+ if (intent?.action == Intent.ACTION_VIEW) {
+ intent.data?.let { uri ->
+ if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
+ handleTokenFromUri(uri)
+ return@LaunchedEffect // Don't open auth URL if we're processing a token
+ }
+ }
+ }
+
+ // If we have a stored token and userId, assume session is still active
+ // The single-use token can't be re-validated, but the server maintains a session after initial validation
+ // We'll only re-authenticate if API calls fail with authentication errors
+ if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) {
+ // Session appears to be active - user is already authenticated
+ // No need to re-validate the single-use token, just restore the session
+ println("BATTLESCREEN: Restoring active session (userId: $storedUserId)")
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = storedUserId
+ // Session is restored, no need to validate token again
+ } else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
+ // We have a token but no userId - try to validate once to get userId
+ // This should only happen on first login or if userId was lost
+ println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
+ RetrofitHelper().authenticate(context, storedToken) { response ->
+ // Update UI on main thread
+ kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
+ if (response.success) {
+ val extractedUserId = response.userInfo?.userId?.toLongOrNull()
+ val sessionToken = response.sessionToken
+
+ // Update stored userId and sessionToken
+ if (extractedUserId != null) {
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ authRepository.setAuthenticated(
+ isAuthenticated = true,
+ nacatechToken = storedToken,
+ sessionToken = sessionToken,
+ userId = extractedUserId
+ )
+ }
+ }
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = extractedUserId
+ println("BATTLESCREEN: Got userId from validation: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
+ } else {
+ println("BATTLESCREEN: Token validation failed: ${response.message}")
+ // Check if it's a critical error that requires re-authentication
+ val isCriticalError = response.message?.contains("Invalid user nonce") == true ||
+ response.message?.contains("nonce") == true ||
+ response.message?.contains("invalid") == true ||
+ response.message?.contains("expired") == true
+
+ if (isCriticalError) {
+ // Critical error - token is invalid, need to re-authenticate
+ println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ authRepository.logout()
+ }
+ isAuthenticated = false
+ isCheckingAuth = false
+ // Open auth URL
+ val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
+ val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
+ context.startActivity(authIntent)
+ println("BATTLESCREEN: Opened auth URL after critical validation failure: $authUrl")
+ } else {
+ // Non-critical error (e.g., network issue) - keep authenticated state
+ println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
+ isAuthenticated = true
+ isCheckingAuth = false
+ }
+ }
+ }
+ }
+ } else {
+ // No stored token or not authenticated locally
+ isAuthenticated = false
+ isCheckingAuth = false
+ // If not authenticated and no fresh token in intent, open auth URL
+ val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
+ val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
+ context.startActivity(authIntent)
+ println("BATTLESCREEN: Opened auth URL: $authUrl")
+ }
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error checking authentication status: ${e.message}")
+ isAuthenticated = false
+ isCheckingAuth = false
+ }
+ }
+
+ // Handle deep link callback to get token
+ // Check intent data on initial load - handle both vbhelper:// and http://localhost:8080/authenticate?c=
+ // Only process if it's a fresh ACTION_VIEW intent (deep link)
+ LaunchedEffect(Unit) {
+ // Small delay to ensure activity is fully initialized
+ kotlinx.coroutines.delay(100)
+
+ val activity = context as? ComponentActivity
+ val intent = activity?.intent
+
+ // Only process if this is a fresh deep link (ACTION_VIEW)
+ if (intent?.action == Intent.ACTION_VIEW) {
+ val uri = intent.data
+ if (uri != null) {
+
+ // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
+ if (uri.scheme == "vbhelper" && uri.host == "auth") {
+ handleTokenFromUri(uri)
+ }
+ // Handle http://localhost:8080/authenticate?c= redirect
+ else if ((uri.scheme == "http" || uri.scheme == "https") &&
+ (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
+ handleTokenFromUri(uri)
+ }
+ // Also check if there's a 'c' or 'token' parameter in any URL
+ else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
+ handleTokenFromUri(uri)
+ } else {
+ println("BATTLESCREEN: URI found but no token parameter detected")
+ }
+ } else {
+ println("BATTLESCREEN: ACTION_VIEW intent but no URI found")
+ }
+ } else {
+ println("BATTLESCREEN: Not an ACTION_VIEW intent, skipping deep link processing")
+ }
+ }
+
+ // Check intent when screen becomes visible or when authentication state changes
+ // This handles cases where the app is already running and receives a deep link
+ DisposableEffect(Unit) {
+ val activity = context as? ComponentActivity
+ val lifecycleOwner = activity as? LifecycleOwner
+
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
+ // Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
+ val intent = activity?.intent
+ if (intent?.action == Intent.ACTION_VIEW) {
+ intent.data?.let { uri ->
+ if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
+ handleTokenFromUri(uri)
+ }
+ }
+ }
+ }
+ }
+
+ lifecycleOwner?.lifecycle?.addObserver(observer)
+
+ onDispose {
+ lifecycleOwner?.lifecycle?.removeObserver(observer)
+ }
+ }
+
+ // Watch auth repository state changes to detect when token is cleared (e.g., expired token)
+ LaunchedEffect(Unit) {
+ battleAuthContainer.authRepository.isAuthenticated.collect { authState ->
+ if (!authState && isAuthenticated) {
+ // Auth state was cleared (e.g., by RetrofitHelper due to expired token)
+ println("BATTLESCREEN: Auth state cleared, triggering re-authentication")
+ isAuthenticated = false
+ isCheckingAuth = false
+ // Open auth URL to get a fresh token
+ val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
+ val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
+ try {
+ context.startActivity(authIntent)
+ println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+
+ // Also check intent when authentication state changes
+ // Only process if it's a fresh ACTION_VIEW intent (deep link)
+ LaunchedEffect(isAuthenticated) {
+ if (!isAuthenticated) {
+ kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
+ val activity = context as? ComponentActivity
+ val intent = activity?.intent
+ // Only process if this is a fresh deep link (ACTION_VIEW)
+ if (intent?.action == Intent.ACTION_VIEW) {
+ intent.data?.let { uri ->
+ println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}")
+ // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
+ if (uri.scheme == "vbhelper" && uri.host == "auth") {
+ handleTokenFromUri(uri)
+ }
+ // Handle http://localhost:8080/authenticate?c= redirect
+ else if ((uri.scheme == "http" || uri.scheme == "https") &&
+ (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
+ handleTokenFromUri(uri)
+ }
+ // Also check if there's a 'c' or 'token' parameter in any URL
+ else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
+ handleTokenFromUri(uri)
+ }
+ }
+ }
+ }
+ }
+
+ // Initialize sprite files on first load - check that they exist in external storage
+ // Only check if permission is granted
+ LaunchedEffect(hasStoragePermission) {
+ if (hasStoragePermission) {
+ val spriteFileManager = SpriteFileManager(context)
+ if (spriteFileManager.checkSpriteFilesExist()) {
+ } else {
+ println("BATTLESCREEN: Sprite files not found in external storage")
+ }
+ } else {
+ println("BATTLESCREEN: Cannot check sprite files - storage permission not granted")
+ }
+ }
+
+ // Load active character from database
+ LaunchedEffect(Unit) {
+ try {
+ val application = context.applicationContext as VBHelper
+ val database = application.container.db
+
+ // Move database operations to background thread
+ kotlinx.coroutines.withContext(Dispatchers.IO) {
+ // First, let's check all characters to see what's in the database
+ val allCharacters = database.userCharacterDao().getAllCharacters()
+ /*
+ println("BATTLESCREEN: Found ${allCharacters.size} total characters in database")
+ allCharacters.forEach { char ->
+ println(" - Character ID: ${char.id}, CharId: ${char.charId}")
+ }
+ */
+
+ val activeChar = database.userCharacterDao().getActiveCharacter().first()
+ //println("BATTLESCREEN: getActiveCharacter() returned: $activeChar")
+
+ if (activeChar != null) {
+ // Get the character data using the charId from activeChar
+ val characterData = database.characterDao().getCharacterInfo(activeChar.charId)
+ /*
+ println("BATTLESCREEN: CharacterData from getCharacterInfo:")
+ println(" - cardId: ${characterData.cardId}")
+ println(" - charId: ${characterData.charId}")
+ println(" - stage: ${characterData.stage}")
+ println(" - attribute: ${characterData.attribute}")
+ */
+
+ // The cardId from getCharacterInfo is already the correct card ID we need!
+ val cardId = characterData.cardId
+ val charaIndex = characterData.charId // This is the charaIndex from the query
+
+ // Format as "dim" + cardId + "_mon" + (charaIndex + 1)
+ val formattedCardId = String.format("dim%03d_mon%02d", cardId, charaIndex + 1)
+
+ // Create APIBattleCharacter from database character
+ val playerCharacter = APIBattleCharacter(
+ name = "Player Digimon", // We could get this from the database if needed
+ namekey = "player_digimon", // Name key for the character
+ charaId = formattedCardId, // Use the formatted card ID for sprite loading
+ stage = characterData.stage,
+ attribute = characterData.attribute.ordinal, // Convert enum to int
+ baseHp = 1000, // Default values - API will provide correct values
+ currentHp = 1000,
+ baseBp = 1000.0f,
+ baseAp = 1000.0f
+ )
+
+ // Update UI state on main thread
+ withContext(Dispatchers.Main) {
+ activeUserCharacter = activeChar
+ activeCardId = formattedCardId
+ activeCharacter = playerCharacter // Set the active character for battle
+ }
+
+ /*
+ println("BATTLESCREEN: Loaded active character from database:")
+ println(" - UserCharacter ID: ${activeChar.id}")
+ println(" - CharId: ${activeChar.charId}")
+ println(" - Stage: ${activeChar.stage}")
+ println(" - CharacterData cardId: ${characterData.cardId}")
+ println(" - CharacterData charaIndex: $charaIndex")
+ println(" - Final cardId: $cardId")
+ println(" - Formatted as: $activeCardId")
+ println(" - Can battle: ${activeChar.stage >= 2}")
+ println(" - Battle type: ${when (activeChar.stage) { 2 -> "rookie"; 3 -> "champion"; 4 -> "ultimate"; 5 -> "mega"; else -> "none" }}")
+ */
+ } else {
+ println("BATTLESCREEN: No active character found in database")
+ withContext(Dispatchers.Main) {
+ activeCardId = null
+ }
+ }
+ }
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error loading active character: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+
+ val backButton = @Composable {
+ Button(
+ onClick = { currentView = "main" }
+ ) {
+ Text("Back")
+ }
+ }
+
Scaffold (
topBar = {
+ // Only show TopBanner when not in battle mode
+ if (currentView != "battle-main" && currentView != "battle-results") {
TopBanner(
- text = stringResource(R.string.battles_online_title)
+ text = "Online Battles"
)
+ }
}
) { contentPadding ->
Column(
@@ -30,7 +2202,840 @@ fun BattlesScreen() {
.padding(top = contentPadding.calculateTopPadding())
.fillMaxSize()
) {
- Text(stringResource(R.string.battles_coming_soon))
+ when (currentView) {
+ "main" -> {
+ // Show loading/authentication message if not authenticated
+ if (isCheckingAuth || !isAuthenticated) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp)
+ ) {
+ if (isCheckingAuth) {
+ Text(
+ text = "Checking authentication...",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+ } else {
+ Text(
+ text = "Please complete authentication in your browser",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ Text(
+ text = "You will be redirected back to the app after logging in",
+ fontSize = 14.sp,
+ color = Color.Gray,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ else {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Show active character info
+ activeUserCharacter?.let { character ->
+ Text("Active Digimon:")
+ Text("Stage: ${character.stage}")
+ activeCardId?.let { cardId ->
+ Text("Digimon ID: $cardId", fontSize = 14.sp, color = Color.Blue, fontWeight = FontWeight.Bold)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (canBattle) {
+ Text("Available Opponents:", fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ //Text("Debug: opponentsList.size = ${opponentsList.size}", fontSize = 12.sp, color = Color.Gray)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (opponentsList.isNotEmpty()) {
+ // Show scrollable list of opponents
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ items(opponentsList) { opponent ->
+ Button(
+ onClick = {
+ activeCardId?.let { cardId ->
+ selectedOpponent = opponent
+ // Randomly select background set (0, 1, or 2)
+ selectedBackgroundSet = kotlin.random.Random.nextInt(3)
+
+ // Determine the correct stage parameter for API call
+ val apiStage = when (playerBattleType) {
+ "rookie" -> 0
+ "champion" -> 1
+ "ultimate" -> 2
+ "mega" -> 3
+ else -> 0
+ }
+
+ RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
+ // Check if there's an existing match
+ when {
+ apiResult.status.contains("Existing match found", ignoreCase = true) -> {
+ // Show resume/quit dialog
+ // When resuming, we need to find the actual opponent from the match
+ // For now, we'll use the clicked opponent, but we need to look it up from opponentsList
+ // The server should return the opponent charaId in the response, but since it doesn't,
+ // we'll try to find it by matching the opponent HP or look it up after rejoin
+ existingMatchState = apiResult
+ pendingOpponentForResume = opponent
+ pendingCardIdForResume = cardId
+ pendingApiStageForResume = apiStage
+ // Store the clicked opponent temporarily, but we'll update it when resuming
+ originalMatchOpponent = null // Will be set when we rejoin
+ showResumeDialog = true
+ }
+ apiResult.status == "Match setup." -> {
+ // New match created - proceed normally
+ activeCharacter = activeCharacter?.copy(
+ baseHp = apiResult.playerHP,
+ currentHp = apiResult.playerHP
+ )
+ currentView = "battle-main"
+ }
+ apiResult.status == "Match resumed." -> {
+ // Match was resumed (shouldn't happen on first call, but handle it)
+ activeCharacter = activeCharacter?.copy(
+ baseHp = apiResult.playerHP,
+ currentHp = apiResult.playerHP
+ )
+ selectedOpponent = opponent.copy(
+ baseHp = apiResult.opponentHP,
+ currentHp = apiResult.opponentHP
+ )
+ currentView = "battle-main"
+ }
+ else -> {
+ // Other status - log and proceed
+ println("BATTLESCREEN: Unexpected status: ${apiResult.status}")
+ activeCharacter = activeCharacter?.copy(
+ baseHp = apiResult.playerHP,
+ currentHp = apiResult.playerHP
+ )
+ currentView = "battle-main"
+ }
+ }
+ }
+ } ?: run {
+ println("BATTLESCREEN: No active card ID found in database")
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Battle ${opponent.displayName?.takeIf { it.isNotBlank() } ?: opponent.name}")
+ }
+ }
+ }
+ } else {
+ Text("No opponents available for your stage",
+ fontSize = 16.sp,
+ color = Color(0xFFFFA500), // Orange color
+ textAlign = TextAlign.Center)
+ }
+ } else {
+ Text("Your Digimon must be at least Stage 2 to battle",
+ fontSize = 16.sp,
+ color = Color.Red,
+ textAlign = TextAlign.Center)
+ }
+ } ?: run {
+ Text("No active character found in database", fontSize = 16.sp, color = Color.Red)
+ }
+ }
+ }
+ }
+
+
+ "battle-main" -> {
+ BattleScreen(
+ userId = userId,
+ stage = currentStage,
+ playerName = activeCharacter?.name ?: "Player",
+ opponentName = selectedOpponent?.name ?: "Opponent",
+ activeCharacter = activeCharacter,
+ opponentCharacter = selectedOpponent,
+ onAttackClick = {
+ // This will be called when the battle is over
+ currentView = "battle-results"
+ },
+ context = context,
+ selectedBackgroundSet = selectedBackgroundSet
+ )
+ }
+
+ "battle-results" -> {
+ var winnerName by remember { mutableStateOf("") }
+ var isWinnerLoaded by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ // Determine player and opponent stages
+ val playerStage = when (activeCharacter?.stage) {
+ 0 -> 0 // rookie
+ 1 -> 1 // champion
+ 2 -> 2 // ultimate
+ 3 -> 3 // mega
+ else -> 0
+ }
+
+ val opponentStage = when (selectedOpponent?.stage) {
+ 0 -> 0 // rookie
+ 1 -> 1 // champion
+ 2 -> 2 // ultimate
+ 3 -> 3 // mega
+ else -> 0
+ }
+
+ // First get the winner info
+ RetrofitHelper().getPVPWinner(
+ context,
+ 1,
+ userId ?: 2L,
+ activeCharacter?.name ?: "Player",
+ playerStage,
+ opponentStage,
+ selectedOpponent?.name ?: "Opponent",
+ opponentStage
+ ) { apiResult ->
+ // Winner might be empty in first call, but we can check HP values
+ // If opponentHP is negative, player won. If playerHP is negative or 0, player lost.
+ val playerWonFromHP = apiResult.opponentHP <= 0 && apiResult.playerHP > 0
+
+ // Also check winner field if it's not empty
+ val playerWonFromWinner = activeCardId?.let { cardId ->
+ val winner = apiResult.winner ?: ""
+ if (winner.isNotEmpty()) {
+ if (winner.contains("|")) {
+ // Pipe-separated format: first part is the winner
+ val winnerParts = winner.split("|")
+ val winnerCharaId = winnerParts.getOrNull(0) ?: ""
+ winnerCharaId.contains(cardId, ignoreCase = true)
+ } else {
+ // Simple format: check if winner contains player's charaId or matches player name
+ winner.contains(cardId, ignoreCase = true) ||
+ winner.equals(activeCharacter?.name, ignoreCase = true)
+ }
+ } else {
+ false
+ }
+ } ?: false
+
+ // Use HP-based determination if winner field is empty, otherwise use winner field
+ val playerWon = if (apiResult.winner.isNullOrEmpty()) {
+ playerWonFromHP
+ } else {
+ playerWonFromWinner
+ }
+
+ println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
+
+ // Store winner name for display (will be updated in cleanup call if available)
+ winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
+ isWinnerLoaded = true
+
+ // Then send the cleanup call - this will have the actual winner name
+ RetrofitHelper().getPVPWinner(
+ context,
+ 2,
+ userId ?: 2L,
+ activeCharacter?.name ?: "Player",
+ playerStage,
+ opponentStage,
+ selectedOpponent?.name ?: "Opponent",
+ opponentStage
+ ) { cleanupResult ->
+ // Update winner name from cleanup call if available
+ if (cleanupResult.winner.isNotEmpty()) {
+ winnerName = cleanupResult.winner
+ }
+
+ // Determine final winner from cleanup call (most reliable)
+ // Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
+ // Secondary method: Check winner name (if winner doesn't match opponent, player won)
+ val opponentName = selectedOpponent?.name ?: ""
+ val winner = cleanupResult.winner ?: ""
+
+ // Primary: HP-based determination (most reliable)
+ // If opponentHP <= 0, opponent is dead = player won
+ // If playerHP <= 0, player is dead = player lost
+ val playerWonFromHP = cleanupResult.opponentHP <= 0 && cleanupResult.playerHP > 0
+
+ // Secondary: Winner name-based determination (only if HP check is inconclusive)
+ val playerWonFromName = if (winner.isNotEmpty() && opponentName.isNotEmpty()) {
+ // If winner matches opponent name, player lost. Otherwise, player won.
+ !winner.equals(opponentName, ignoreCase = true)
+ } else if (winner.isNotEmpty()) {
+ // Check if winner matches player's charaId
+ activeCardId?.let { cardId ->
+ if (winner.contains("|")) {
+ val winnerParts = winner.split("|")
+ val winnerCharaId = winnerParts.getOrNull(0) ?: ""
+ winnerCharaId.contains(cardId, ignoreCase = true)
+ } else {
+ winner.contains(cardId, ignoreCase = true)
+ }
+ } ?: false
+ } else {
+ false
+ }
+
+ // Use HP as primary (most reliable), name as fallback only if HP values are both positive
+ val finalPlayerWon = if (cleanupResult.opponentHP <= 0 || cleanupResult.playerHP <= 0) {
+ // HP clearly indicates winner
+ playerWonFromHP
+ } else {
+ // Both have HP (shouldn't happen in cleanup, but use name as fallback)
+ playerWonFromName
+ }
+
+ println("BATTLESCREEN: Battle result (cleanup call) - winner: '${cleanupResult.winner}', playerHP: ${cleanupResult.playerHP}, opponentHP: ${cleanupResult.opponentHP}, finalPlayerWon: $finalPlayerWon")
+
+ // Update battle stats in database using the most reliable determination
+ kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val application = context.applicationContext as VBHelper
+ val database = application.container.db
+ val activeChar = database.userCharacterDao().getActiveCharacter().first()
+
+ if (activeChar != null) {
+ // Get the full UserCharacter entity to update
+ val userCharacter = database.userCharacterDao().getCharacter(activeChar.id)
+
+ // Update battle stats
+ if (finalPlayerWon) {
+ userCharacter.currentPhaseBattlesWon += 1
+ userCharacter.totalBattlesWon += 1
+ println("BATTLESCREEN: Player won - updated wins: currentPhase=${userCharacter.currentPhaseBattlesWon}, total=${userCharacter.totalBattlesWon}")
+ } else {
+ userCharacter.currentPhaseBattlesLost += 1
+ userCharacter.totalBattlesLost += 1
+ println("BATTLESCREEN: Player lost - updated losses: currentPhase=${userCharacter.currentPhaseBattlesLost}, total=${userCharacter.totalBattlesLost}")
+ }
+
+ // Save updated character to database
+ database.userCharacterDao().updateCharacter(userCharacter)
+ println("BATTLESCREEN: Updated battle stats in database")
+ } else {
+ println("BATTLESCREEN: WARNING: Could not find active character to update battle stats")
+ }
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error updating battle stats: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Battle Complete!",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (isWinnerLoaded) {
+ Text(
+ text = "Winner: $winnerName",
+ fontSize = 20.sp,
+ color = Color.Gray
+ )
+ } else {
+ Text(
+ text = "Loading results...",
+ fontSize = 20.sp,
+ color = Color.Gray
+ )
+ }
+ }
+
+ // Exit button - stop music before exiting
+ Button(
+ onClick = {
+ // Stop background music before exiting
+ // Note: Music will also be stopped by DisposableEffect in BattleScreen
+ currentView = "main"
+ },
+ modifier = Modifier.align(Alignment.TopCenter),
+ colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
+ ) {
+ Text("Exit", color = Color.White)
+ }
+ }
+ }
+ }
+ }
+
+ // Resume/Quit Match Dialog
+ if (showResumeDialog && existingMatchState != null) {
+ ResumeMatchDialog(
+ matchState = existingMatchState!!,
+ onResume = {
+ // User chose to resume - call API with action="rejoin"
+ // Note: We need to pass the opponent, but the server should use the one from the stored match
+ // After rejoin, we need to find the actual opponent from the opponents list
+ pendingCardIdForResume?.let { cardId ->
+ pendingOpponentForResume?.let { clickedOpponent ->
+ pendingApiStageForResume?.let { apiStage ->
+ RetrofitHelper().getPVPWinner(
+ context,
+ 0,
+ userId ?: 2L,
+ cardId,
+ apiStage,
+ 0,
+ clickedOpponent.charaId,
+ apiStage,
+ "rejoin"
+ ) { apiResult ->
+ println("BATTLESCREEN: Resuming match - opponentHP from API: ${apiResult.opponentHP}")
+ println("BATTLESCREEN: Clicked opponent: ${clickedOpponent.name} (${clickedOpponent.charaId}), baseHp: ${clickedOpponent.baseHp}")
+
+ // Update player character HP from API response
+ // Use playerMaxHP from API if available, otherwise use current HP as fallback
+ // NOTE: Server should provide playerMaxHP for resumed matches since DB stats use different scaling
+ val playerMaxHp = apiResult.playerMaxHP ?: apiResult.playerHP
+ println("BATTLESCREEN: Resuming match - playerMaxHP from API: ${apiResult.playerMaxHP}, using: $playerMaxHp")
+
+ activeCharacter = activeCharacter?.copy(
+ baseHp = playerMaxHp, // Use max HP from API (or current HP as fallback)
+ currentHp = apiResult.playerHP // Current HP from API
+ )
+
+ // Find the actual opponent from the match
+ println("BATTLESCREEN: Checking for opponent charaId - opponentCharaId: ${apiResult.opponentCharaId}, winner: '${apiResult.winner}'")
+ val actualOpponent = if (apiResult.opponentCharaId != null) {
+ // Server provides opponent charaId - use it directly (most reliable)
+ println("BATTLESCREEN: Server provided opponent charaId: ${apiResult.opponentCharaId}")
+ opponentsList.find { it.charaId == apiResult.opponentCharaId } ?: run {
+ println("BATTLESCREEN: WARNING: Opponent charaId from server not found in opponentsList, using clicked opponent")
+ clickedOpponent
+ }
+ } else if (apiResult.winner.isNotEmpty() && apiResult.winner.contains("|")) {
+ println("BATTLESCREEN: Winner field contains pipe, attempting to parse: '${apiResult.winner}'")
+ // Try to extract opponent charaId from winner field
+ // Format appears to be: "playerDigi|playerStage|opponentDigi|opponentStage"
+ try {
+ val parts = apiResult.winner.split("|")
+ if (parts.size >= 3) {
+ val extractedOpponentCharaId = parts[2] // Third part is opponentDigi
+ println("BATTLESCREEN: Extracted opponent charaId from winner field: $extractedOpponentCharaId")
+ opponentsList.find { it.charaId == extractedOpponentCharaId } ?: run {
+ println("BATTLESCREEN: WARNING: Extracted opponent charaId not found in opponentsList, using clicked opponent")
+ clickedOpponent
+ }
+ } else {
+ println("BATTLESCREEN: Winner field format unexpected, falling back to HP matching")
+ null // Will fall through to HP matching
+ }
+ } catch (e: Exception) {
+ println("BATTLESCREEN: Error parsing winner field: ${e.message}")
+ null // Will fall through to HP matching
+ }
+ } else {
+ println("BATTLESCREEN: Winner field is empty or doesn't contain pipe, winner='${apiResult.winner}'")
+ null // Will fall through to HP matching
+ } ?: run {
+ // Fallback: Try to match by HP - but prioritize the clicked opponent if it matches
+ println("BATTLESCREEN: All opponent identification methods failed, using HP matching fallback")
+ run {
+ // First, check if the clicked opponent matches the HP criteria
+ // This is the most likely match since the user clicked on it
+ val clickedOpponentMatches = run {
+ val hpDiff = clickedOpponent.baseHp - apiResult.opponentHP
+ hpDiff >= 0 && hpDiff <= (clickedOpponent.baseHp * 0.5)
+ }
+
+ if (clickedOpponentMatches) {
+ println("BATTLESCREEN: Clicked opponent matches HP criteria, using it")
+ clickedOpponent
+ } else {
+ // If clicked opponent doesn't match, search for others
+ println("BATTLESCREEN: Clicked opponent doesn't match HP, searching for match")
+ opponentsList.filter { opp ->
+ // Only consider opponents of the same stage
+ opp.stage == clickedOpponent.stage
+ }.find { opp ->
+ // Match by checking if the opponent's baseHp is >= the current opponentHP
+ // and the difference is reasonable (opponent has taken some damage but not too much)
+ val hpDiff = opp.baseHp - apiResult.opponentHP
+ hpDiff >= 0 && hpDiff <= (opp.baseHp * 0.5) // Allow up to 50% damage
+ } ?: run {
+ // If we can't find a match, try a broader search
+ println("BATTLESCREEN: Could not find opponent by HP matching, trying broader search")
+ opponentsList.find { opp ->
+ opp.stage == clickedOpponent.stage &&
+ opp.baseHp >= apiResult.opponentHP
+ } ?: run {
+ println("BATTLESCREEN: Still no match, using clicked opponent as fallback")
+ clickedOpponent
+ }
+ }
+ }
+ }
+ }
+
+ println("BATTLESCREEN: Selected opponent for resume: ${actualOpponent.name} (${actualOpponent.charaId}), baseHp: ${actualOpponent.baseHp}, currentHp: ${apiResult.opponentHP}")
+
+ // Update opponent with correct HP from match
+ // Use opponentMaxHP from API if available, otherwise use opponent's baseHp from opponentsList
+ // NOTE: Server should provide opponentMaxHP for resumed matches since DB stats use different scaling
+ val opponentMaxHp = apiResult.opponentMaxHP ?: actualOpponent.baseHp
+ println("BATTLESCREEN: Resuming match - opponentMaxHP from API: ${apiResult.opponentMaxHP}, using: $opponentMaxHp")
+
+ selectedOpponent = actualOpponent.copy(
+ baseHp = opponentMaxHp, // Use max HP from API (or opponent's baseHp as fallback)
+ currentHp = apiResult.opponentHP // Use current HP from match
+ )
+
+ showResumeDialog = false
+ existingMatchState = null
+ originalMatchOpponent = selectedOpponent
+ currentView = "battle-main"
+ }
+ }
+ }
+ }
+ },
+ onQuit = {
+ // User chose to quit - call API with action="quit"
+ pendingCardIdForResume?.let { cardId ->
+ pendingOpponentForResume?.let { opponent ->
+ pendingApiStageForResume?.let { apiStage ->
+ RetrofitHelper().getPVPWinner(
+ context,
+ 0,
+ userId ?: 2L,
+ cardId,
+ apiStage,
+ 0,
+ opponent.charaId,
+ apiStage,
+ "quit"
+ ) { apiResult ->
+ // New match created - proceed normally
+ activeCharacter = activeCharacter?.copy(
+ baseHp = apiResult.playerHP,
+ currentHp = apiResult.playerHP
+ )
+ showResumeDialog = false
+ existingMatchState = null
+ currentView = "battle-main"
+ }
+ }
+ }
+ }
+ },
+ onDismiss = {
+ showResumeDialog = false
+ existingMatchState = null
+ pendingOpponentForResume = null
+ pendingCardIdForResume = null
+ pendingApiStageForResume = null
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun ResumeMatchDialog(
+ matchState: com.github.nacabaro.vbhelper.battle.PVPDataModel,
+ onResume: () -> Unit,
+ onQuit: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text("Ongoing Match Found", fontWeight = FontWeight.Bold)
+ },
+ text = {
+ Column {
+ Text("You have an ongoing match:")
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("Round: ${matchState.currentRound + 1}", fontWeight = FontWeight.Bold)
+ Text("Your HP: ${matchState.playerHP}")
+ Text("Opponent HP: ${matchState.opponentHP}")
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("What would you like to do?")
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = onResume,
+ colors = ButtonDefaults.buttonColors(containerColor = Color.Green)
+ ) {
+ Text("Resume Match")
+ }
+ },
+ dismissButton = {
+ Button(
+ onClick = onQuit,
+ colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
+ ) {
+ Text("Quit & Start New")
+ }
+ }
+ )
+}
+
+@Composable
+fun AnimatedBattleBackground(
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ var backgroundBitmap by remember { mutableStateOf(null) }
+ var xOffset by remember { mutableStateOf(0f) }
+ var screenWidth by remember { mutableStateOf(0.dp) }
+ var screenHeight by remember { mutableStateOf(0.dp) }
+
+ // Get screen dimensions
+ val density = LocalDensity.current
+ val configuration = LocalConfiguration.current
+ LaunchedEffect(Unit) {
+ screenWidth = with(density) { configuration.screenWidthDp.dp }
+ screenHeight = with(density) { configuration.screenHeightDp.dp }
+ println("DEBUG: Screen dimensions = ${screenWidth.value}x${screenHeight.value}dp")
+ }
+
+ // Load background image from external storage
+ LaunchedEffect(Unit) {
+ try {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ val backgroundFile = File(externalDir, "VBHelper/battle_sprites/extracted_battlebgs/BattleBg_0015_BattleBg_0012.png")
+ if (backgroundFile.exists()) {
+ backgroundBitmap = BitmapFactory.decodeFile(backgroundFile.absolutePath)
+ } else {
+ println("Battle background file not found: ${backgroundFile.absolutePath}")
+ }
+ } catch (e: Exception) {
+ println("Error loading battle background: ${e.message}")
+ }
+ }
+
+ // Animate horizontal movement to the left with perfect loop
+ LaunchedEffect(screenWidth) {
+ if (screenWidth > 0.dp) {
+ while (true) {
+ delay(50) // Update every 50ms for smooth animation
+ xOffset -= 1f // Move 1 pixel to the left
+
+ // Create perfect loop by resetting when one full screen width has moved
+ if (xOffset <= -screenWidth.value) {
+ xOffset = 0f
+ }
+ }
+ }
+ }
+
+ backgroundBitmap?.let { bitmap ->
+ Box(modifier = modifier.fillMaxSize()) {
+ // Calculate how many times to repeat the image to fill the screen width
+ val configuration = LocalConfiguration.current
+ val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
+ val imageWidth = if (isLandscapeMode) screenWidth.value * 1.5f else screenWidth.value
+ val repeatCount = (imageWidth / screenWidth.value).toInt() + 2 // Add 2 for seamless looping
+
+ repeat(repeatCount) { index ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Animated Battle Background ${index + 1}",
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(x = (xOffset + (index * screenWidth.value)).dp),
+ contentScale = ContentScale.FillBounds
+ )
+ }
}
}
}
+
+// Data class to define background sets
+data class BackgroundSet(
+ val backLayer: String,
+ val middleLayer: String,
+ val frontLayer: String
+)
+
+// Define the three background sets
+val backgroundSets = listOf(
+ BackgroundSet(
+ backLayer = "BattleBg_0018_BattleBg_0013.png",
+ middleLayer = "BattleBg_0015_BattleBg_0012.png",
+ frontLayer = "BattleBg_0005_BattleBg_0011.png"
+ ),
+ BackgroundSet(
+ backLayer = "BattleBg_0014_BattleBg_0013.png",
+ middleLayer = "BattleBg_0010_BattleBg_0012.png",
+ frontLayer = "BattleBg_0011_BattleBg_0011.png"
+ ),
+ BackgroundSet(
+ backLayer = "BattleBg_0019_BattleBg_0013.png",
+ middleLayer = "BattleBg_0004_BattleBg_0012.png",
+ frontLayer = "BattleBg_0009_BattleBg_0011.png"
+ )
+)
+
+@Composable
+fun MultiLayerAnimatedBattleBackground(
+ modifier: Modifier = Modifier,
+ backgroundSetIndex: Int = 0
+) {
+ val context = LocalContext.current
+ var backLayerBitmap by remember { mutableStateOf(null) }
+ var middleLayerBitmap by remember { mutableStateOf(null) }
+ var frontLayerBitmap by remember { mutableStateOf(null) }
+
+ var backLayerXOffset by remember { mutableStateOf(0f) }
+ var middleLayerXOffset by remember { mutableStateOf(0f) }
+ var frontLayerXOffset by remember { mutableStateOf(0f) }
+
+ var screenWidth by remember { mutableStateOf(0.dp) }
+ var screenHeight by remember { mutableStateOf(0.dp) }
+
+ // Get screen dimensions
+ val density = LocalDensity.current
+ val configuration = LocalConfiguration.current
+ LaunchedEffect(Unit) {
+ screenWidth = with(density) { configuration.screenWidthDp.dp }
+ screenHeight = with(density) { configuration.screenHeightDp.dp }
+ }
+
+ // Load all three background layers from external storage
+ LaunchedEffect(backgroundSetIndex) {
+ try {
+ val externalDir = android.os.Environment.getExternalStorageDirectory()
+ val selectedSet = backgroundSets[backgroundSetIndex]
+
+ // Back layer
+ val backLayerFile = File(externalDir, "VBHelper/battle_sprites/extracted_battlebgs/${selectedSet.backLayer}")
+ if (backLayerFile.exists()) {
+ backLayerBitmap = BitmapFactory.decodeFile(backLayerFile.absolutePath)
+ } else {
+ println("Back layer background file not found: ${backLayerFile.absolutePath}")
+ }
+
+ // Middle layer
+ val middleLayerFile = File(externalDir, "VBHelper/battle_sprites/extracted_battlebgs/${selectedSet.middleLayer}")
+ if (middleLayerFile.exists()) {
+ middleLayerBitmap = BitmapFactory.decodeFile(middleLayerFile.absolutePath)
+ } else {
+ println("Middle layer background file not found: ${middleLayerFile.absolutePath}")
+ }
+
+ // Front layer
+ val frontLayerFile = File(externalDir, "VBHelper/battle_sprites/extracted_battlebgs/${selectedSet.frontLayer}")
+ if (frontLayerFile.exists()) {
+ frontLayerBitmap = BitmapFactory.decodeFile(frontLayerFile.absolutePath)
+ } else {
+ println("Front layer background file not found: ${frontLayerFile.absolutePath}")
+ }
+ } catch (e: Exception) {
+ println("Error loading multi-layer battle backgrounds: ${e.message}")
+ }
+ }
+
+ // Animate all three layers with different speeds (slower in landscape mode)
+ val bgConfiguration = LocalConfiguration.current
+ val isLandscapeMode = bgConfiguration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
+
+ LaunchedEffect(screenWidth) {
+ if (screenWidth > 0.dp) {
+ while (true) {
+ delay(50) // Update every 50ms for smooth animation
+
+ // Adjust speed based on orientation
+ val speedMultiplier = if (isLandscapeMode) 0.5f else 1f
+
+ // Back layer moves slowest (parallax effect)
+ backLayerXOffset -= 0.5f * speedMultiplier
+ if (backLayerXOffset <= -screenWidth.value) {
+ backLayerXOffset = 0f
+ }
+
+ // Middle layer moves at medium speed
+ middleLayerXOffset -= 1f * speedMultiplier
+ if (middleLayerXOffset <= -screenWidth.value) {
+ middleLayerXOffset = 0f
+ }
+
+ // Front layer moves fastest
+ frontLayerXOffset -= 1.5f * speedMultiplier
+ if (frontLayerXOffset <= -screenWidth.value) {
+ frontLayerXOffset = 0f
+ }
+ }
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ // Calculate how many times to repeat the image to fill the screen width
+ val imageWidth = if (isLandscapeMode) screenWidth.value * 1.5f else screenWidth.value
+ val repeatCount = (imageWidth / screenWidth.value).toInt() + 2 // Add 2 for seamless looping
+
+ // Back layer (underneath everything)
+ backLayerBitmap?.let { bitmap ->
+ repeat(repeatCount) { index ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Back Layer Battle Background ${index + 1}",
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(x = (backLayerXOffset + (index * screenWidth.value)).dp),
+ contentScale = ContentScale.FillBounds
+ )
+ }
+ }
+
+ // Middle layer
+ middleLayerBitmap?.let { bitmap ->
+ repeat(repeatCount) { index ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Middle Layer Battle Background ${index + 1}",
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(x = (middleLayerXOffset + (index * screenWidth.value)).dp),
+ contentScale = ContentScale.FillBounds
+ )
+ }
+ }
+
+ // Front layer (on top of other backgrounds)
+ frontLayerBitmap?.let { bitmap ->
+ repeat(repeatCount) { index ->
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Front Layer Battle Background ${index + 1}",
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(x = (frontLayerXOffset + (index * screenWidth.value)).dp),
+ contentScale = ContentScale.FillBounds
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
new file mode 100644
index 0000000..53bc737
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
@@ -0,0 +1,66 @@
+package com.github.nacabaro.vbhelper.source
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class AuthRepository(
+ private val dataStore: DataStore
+) {
+ private companion object {
+ val IS_AUTHENTICATED = booleanPreferencesKey("is_authenticated")
+ val AUTH_TOKEN = stringPreferencesKey("auth_token") // Nacatech token (for re-authentication)
+ val SESSION_TOKEN = stringPreferencesKey("session_token") // Session token (for API calls)
+ val USER_ID = longPreferencesKey("user_id")
+ }
+
+ val isAuthenticated: Flow = dataStore.data
+ .map { preferences ->
+ preferences[IS_AUTHENTICATED] ?: false
+ }
+
+ val authToken: Flow = dataStore.data
+ .map { preferences ->
+ preferences[AUTH_TOKEN]
+ }
+
+ val sessionToken: Flow = dataStore.data
+ .map { preferences ->
+ preferences[SESSION_TOKEN]
+ }
+
+ val userId: Flow = dataStore.data
+ .map { preferences ->
+ preferences[USER_ID]
+ }
+
+ suspend fun setAuthenticated(isAuthenticated: Boolean, nacatechToken: String? = null, sessionToken: String? = null, userId: Long? = null) {
+ dataStore.edit { preferences ->
+ preferences[IS_AUTHENTICATED] = isAuthenticated
+ if (nacatechToken != null) {
+ preferences[AUTH_TOKEN] = nacatechToken
+ }
+ if (sessionToken != null) {
+ preferences[SESSION_TOKEN] = sessionToken
+ }
+ if (userId != null) {
+ preferences[USER_ID] = userId
+ }
+ }
+ }
+
+ suspend fun logout() {
+ dataStore.edit { preferences ->
+ preferences[IS_AUTHENTICATED] = false
+ preferences.remove(AUTH_TOKEN)
+ preferences.remove(SESSION_TOKEN)
+ preferences.remove(USER_ID)
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt
new file mode 100644
index 0000000..0519ecb
--- /dev/null
+++ b/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt
@@ -0,0 +1 @@
+
\ No newline at end of file