Skip to content
124 changes: 104 additions & 20 deletions app/src/main/java/com/messark/hawkerrush/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class MainViewModel @JvmOverloads constructor(
private val settingsRepository: SettingsRepository = SettingsRepository(application),
private val gameStateRepository: GameStateRepository = GameStateRepository(application)
) : AndroidViewModel(application) {
private val _gameState = MutableStateFlow(GameState())
internal val _gameState = MutableStateFlow(GameState())
val gameState: StateFlow<GameState> = _gameState.asStateFlow()

private val _logoVisible = MutableStateFlow(true)
Expand Down Expand Up @@ -217,7 +217,12 @@ class MainViewModel @JvmOverloads constructor(
}
}

private fun updateGame(currentTimeMs: Long) {
/**
* Updates game state by advancing spawning, movement, and combat.
*
* @param currentTimeMs Current game time in milliseconds.
*/
internal fun updateGame(currentTimeMs: Long) {
_gameState.update { state ->
var newState = state

Expand Down Expand Up @@ -304,8 +309,17 @@ class MainViewModel @JvmOverloads constructor(
return state
}

/**
* Handles movement for all active enemies and applies puddle effects.
*
* @param state Current game state.
* @param currentTimeMs Current game time.
* @return Updated state and list of enemies.
*/
private fun handleEnemyMovement(state: GameState, currentTimeMs: Long): Pair<GameState, List<Enemy>> {
var mutableState = state
val affectingStalls = mutableMapOf<Pair<AxialCoordinate, String>, MutableSet<String>>()

val updatedEnemies = state.enemies.mapNotNull { enemy ->
if (enemy.isDead) return@mapNotNull null

Expand Down Expand Up @@ -336,6 +350,16 @@ class MainViewModel @JvmOverloads constructor(
speedBoostDuration = Math.max(0, speedBoostDuration - 32)
}

state.puddles.forEach { puddle ->
if (axialDistance(enemy.position, puddle.position) < 0.8 &&
puddle.sourceStallCoord != null &&
puddle.sourceStallId != null
) {
affectingStalls
.getOrPut(puddle.sourceStallCoord to puddle.sourceStallId) { mutableSetOf() }
.add(enemy.id)
}
}
if (isStopped || freezeDuration > 0) {
return@mapNotNull enemy.copy(
isStopped = isStopped,
Expand All @@ -347,14 +371,14 @@ class MainViewModel @JvmOverloads constructor(
}

var speedMultiplier = 1.0f
val inPuddle = state.puddles.any { puddle ->
axialDistance(enemy.position, puddle.position) < 0.8
}
if (inPuddle) {
speedMultiplier = when (enemy.type) {
EnemyType.DELIVERY_RIDER -> 0.2f // double slow (80% reduction)
EnemyType.AUNTIE -> 0.8f // half slow (20% reduction)
else -> 0.6f // normal slow (40% reduction)
state.puddles.forEach { puddle ->
if (axialDistance(enemy.position, puddle.position) < 0.8) {
speedMultiplier = when (enemy.type) {
EnemyType.DELIVERY_RIDER -> 0.2f // double slow (80% reduction)
EnemyType.AUNTIE -> 0.8f // half slow (20% reduction)
else -> 0.6f // normal slow (40% reduction)
}

}
}

Expand Down Expand Up @@ -411,9 +435,31 @@ class MainViewModel @JvmOverloads constructor(
)
}
}

if (affectingStalls.isNotEmpty()) {
val updatedHexes = mutableState.hexes.toMutableMap()
affectingStalls.forEach { (source, enemyIds) ->
val (coord, stallId) = source
updatedHexes[coord]?.stall?.let { stall ->
if (stall.id == stallId) {
val newTargetIds = stall.uniqueTargetIds + enemyIds
updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy(uniqueTargetIds = newTargetIds))
}
}
}
mutableState = mutableState.copy(hexes = updatedHexes)
}

return Pair(mutableState, updatedEnemies)
}

/**
* Checks all stalls to see if they are ready to fire and creates projectiles/puddles.
*
* @param state Current game state.
* @param currentTimeMs Current game time.
* @return Updated state with new projectiles/puddles.
*/
private fun handleStallFiring(state: GameState, currentTimeMs: Long): GameState {
val newProjectiles = state.projectiles.toMutableList()
val newPuddles = state.puddles.toMutableList()
Expand Down Expand Up @@ -445,15 +491,19 @@ class MainViewModel @JvmOverloads constructor(
targetPosition = target.position,
damage = stall.damage,
color = stall.color,
sourceStallType = StallType.CHICKEN_RICE
sourceStallType = StallType.CHICKEN_RICE,
sourceStallCoord = coord,
sourceStallId = stall.id
))
}
StallType.TEH_TARIK -> {
newPuddles.add(StickyPuddle(
id = UUID.randomUUID().toString(),
position = target.position,
spawnTimeMs = currentTimeMs,
durationMs = stall.effectDurationMs
durationMs = stall.effectDurationMs,
sourceStallCoord = coord,
sourceStallId = stall.id
))
}
StallType.SATAY -> {
Expand All @@ -472,7 +522,9 @@ class MainViewModel @JvmOverloads constructor(
aoeRadius = stall.aoeRadius,
isArc = true,
startPosition = stallPos,
sourceStallType = StallType.SATAY
sourceStallType = StallType.SATAY,
sourceStallCoord = coord,
sourceStallId = stall.id
))
}
StallType.ICE_KACHANG -> {
Expand All @@ -485,7 +537,9 @@ class MainViewModel @JvmOverloads constructor(
color = stall.color,
isFreeze = true,
freezeDurationMs = stall.freezeDurationMs,
sourceStallType = StallType.ICE_KACHANG
sourceStallType = StallType.ICE_KACHANG,
sourceStallCoord = coord,
sourceStallId = stall.id
))
}
StallType.DURIAN -> {
Expand All @@ -497,7 +551,9 @@ class MainViewModel @JvmOverloads constructor(
damage = stall.damage,
color = stall.color,
aoeRadius = stall.aoeRadius,
sourceStallType = StallType.DURIAN
sourceStallType = StallType.DURIAN,
sourceStallCoord = coord,
sourceStallId = stall.id
))
}
}
Expand All @@ -508,6 +564,14 @@ class MainViewModel @JvmOverloads constructor(
return state.copy(hexes = updatedHexes, projectiles = newProjectiles, puddles = newPuddles)
}

/**
* Updates projectile positions and handles impacts with enemies.
* Also attributes hits and kills to the source stalls.
*
* @param state Current game state.
* @param currentTimeMs Current game time.
* @return Updated state after projectile processing.
*/
private fun handleProjectiles(state: GameState, currentTimeMs: Long): GameState {
val finalProjectiles = mutableListOf<Projectile>()
val hitEnemiesDetails = mutableMapOf<String, MutableList<Projectile>>()
Expand Down Expand Up @@ -564,15 +628,18 @@ class MainViewModel @JvmOverloads constructor(

var updatedGold = state.gold
var updatedScore = state.score
val updatedHexes = state.hexes.toMutableMap()

val finalEnemies = state.enemies.map { enemy ->
val hits = hitEnemiesDetails[enemy.id]
if (hits != null) {
var totalDamage = 0
var currentHealth = enemy.health
var maxFreezeDuration = enemy.freezeDurationMs
var speedBoostDuration = enemy.speedBoostDurationMs

hits.forEach { proj ->
if (currentHealth <= 0) return@forEach

var damage = proj.damage.toFloat()
var freezeDuration = proj.freezeDurationMs

Expand All @@ -593,12 +660,28 @@ class MainViewModel @JvmOverloads constructor(
else -> {}
}

totalDamage += damage.toInt()
val damageDealt = damage.toInt()
currentHealth = Math.max(0, currentHealth - damageDealt)
maxFreezeDuration = Math.max(maxFreezeDuration, freezeDuration)

// Track hit and kill
if (proj.sourceStallCoord != null && proj.sourceStallId != null) {
val coord = proj.sourceStallCoord
updatedHexes[coord]?.stall?.let { stall ->
if (stall.id == proj.sourceStallId) {
val isKill = currentHealth <= 0
val newTargetIds = stall.uniqueTargetIds + enemy.id
val newKills = if (isKill) stall.kills + 1 else stall.kills
updatedHexes[coord] = updatedHexes[coord]!!.copy(stall = stall.copy(
uniqueTargetIds = newTargetIds,
kills = newKills
))
}
}
}
}

val newHealth = Math.max(0, enemy.health - totalDamage)
if (newHealth <= 0) {
if (currentHealth <= 0) {
updatedGold += enemy.reward
updatedScore += enemy.reward
val currentTime = System.currentTimeMillis()
Expand All @@ -610,12 +693,13 @@ class MainViewModel @JvmOverloads constructor(
}
enemy.copy(health = 0, isDead = true)
} else {
enemy.copy(health = newHealth, freezeDurationMs = maxFreezeDuration, speedBoostDurationMs = speedBoostDuration)
enemy.copy(health = currentHealth, freezeDurationMs = maxFreezeDuration, speedBoostDurationMs = speedBoostDuration)
}
} else enemy
}.filter { !it.isDead }

return state.copy(
hexes = updatedHexes,
enemies = finalEnemies,
projectiles = finalProjectiles,
visualEffects = newVisualEffects,
Expand Down
67 changes: 64 additions & 3 deletions app/src/main/java/com/messark/hawkerrush/model/GameModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ enum class TargetMode {
FIRST, CLOSEST, STRONGEST, WEAKEST
}

/**
* Represents a hawker stall (tower) in the game.
* Stalls can be placed on the board to attack enemies.
*
* @property id Unique identifier for this specific stall instance.
* @property name Display name of the stall.
* @property cost Gold cost to purchase the stall.
* @property color Color used for the stall's projectile and UI elements.
* @property range Attack range in grid units.
* @property damage Damage dealt per hit.
* @property fireRateMs Time between shots in milliseconds.
* @property lastFiredMs Timestamp of the last shot fired.
* @property stallType The type of stall, determining its behavior.
* @property rotation Rotation angle for direction-based attacks.
* @property description Short flavor text and behavior summary.
* @property upgradeCount Total number of upgrades applied.
* @property upgrades Map of specific upgrade categories to their levels.
* @property totalInvestment Total gold spent on this stall (cost + upgrades).
* @property targetMode Strategy used to select which enemy to attack.
* @property aoeRadius Radius for area-of-effect damage.
* @property effectDurationMs Duration of secondary effects (e.g. puddles).
* @property freezeDurationMs Duration of freeze effect in milliseconds.
* @property uniqueTargetIds Set of enemy IDs that this stall has hit.
* @property kills Total number of enemies killed by this stall.
*/
data class Stall(
val id: String,
val name: String,
Expand All @@ -56,7 +81,9 @@ data class Stall(
val targetMode: TargetMode = TargetMode.FIRST,
val aoeRadius: Float = 1.0f,
val effectDurationMs: Long = 3000L,
val freezeDurationMs: Long = 500L
val freezeDurationMs: Long = 500L,
val uniqueTargetIds: Set<String> = emptySet(),
val kills: Int = 0
) {
fun getUpgradeBenefit(category: String, level: Int, baseStall: Stall): String {
return when (category) {
Expand Down Expand Up @@ -120,6 +147,26 @@ data class Enemy(
val isFacingLeft: Boolean = false
)

/**
* Represents a projectile fired by a stall.
*
* @property id Unique identifier for the projectile.
* @property position Current precise axial coordinate.
* @property lastPosition Previous position for interpolation.
* @property targetEnemyId ID of the target enemy, if any.
* @property targetPosition Target coordinates.
* @property damage Damage to deal on impact.
* @property speed Movement speed in grid units per tick.
* @property color Color of the projectile.
* @property isFreeze Whether this projectile freezes enemies.
* @property aoeRadius Radius of area-of-effect damage.
* @property freezeDurationMs Duration of freeze effect.
* @property isArc Whether the projectile follows an arc path.
* @property startPosition Initial firing position.
* @property sourceStallType Type of the stall that fired this.
* @property sourceStallCoord Coordinate of the stall that fired this.
* @property sourceStallId Unique ID of the stall that fired this.
*/
data class Projectile(
val id: String,
val position: PreciseAxialCoordinate,
Expand All @@ -134,18 +181,32 @@ data class Projectile(
val freezeDurationMs: Long = 0L,
val isArc: Boolean = false,
val startPosition: PreciseAxialCoordinate? = null,
val sourceStallType: StallType? = null
val sourceStallType: StallType? = null,
val sourceStallCoord: AxialCoordinate? = null,
val sourceStallId: String? = null
)

enum class VisualEffectType {
EXPANDING_CIRCLE, GAS_CLOUD
}

/**
* Represents a sticky puddle (e.g. Teh Tarik) that slows enemies.
*
* @property id Unique identifier for the puddle.
* @property position Precise axial coordinate on the grid.
* @property spawnTimeMs Timestamp when the puddle was created.
* @property durationMs Total lifespan of the puddle.
* @property sourceStallCoord Coordinate of the stall that created this puddle.
* @property sourceStallId Unique ID of the stall that created this puddle.
*/
data class StickyPuddle(
val id: String,
val position: PreciseAxialCoordinate,
val spawnTimeMs: Long,
val durationMs: Long = 3000L
val durationMs: Long = 3000L,
val sourceStallCoord: AxialCoordinate? = null,
val sourceStallId: String? = null
)

data class VisualEffect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fun StallConsole(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Column(modifier = Modifier.weight(1f)) {
Text(text = stall.name.uppercase(), color = Color.White, fontSize = 14.sp)
if (stall.upgrades.isEmpty()) {
Text(text = "No upgrades", color = Color.Gray, fontSize = 10.sp)
Expand All @@ -48,7 +48,10 @@ fun StallConsole(
Text(text = upgradeText, color = Color.Gray, fontSize = 10.sp)
}
}
Text(text = "Target: ${stall.targetMode.name}", color = Color.Cyan, fontSize = 12.sp, modifier = Modifier.clickable { onCycleTarget() })
Column(horizontalAlignment = Alignment.End) {
Text(text = "Target: ${stall.targetMode.name}", color = Color.Cyan, fontSize = 12.sp, modifier = Modifier.clickable { onCycleTarget() })
Text(text = "Targets: ${stall.uniqueTargetIds.size} | Kills: ${stall.kills}", color = Color.Green, fontSize = 10.sp)
}
}

Row(
Expand Down
Loading
Loading