Initial commit

This commit is contained in:
2026-06-16 10:57:01 +03:00
commit 1cd5c8b03c
277 changed files with 42921 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[compileJava, compileTestJava]*.options*.encoding = 'UTF-8'
eclipse.project.name = appName + '-core'
dependencies {
api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion"
api "com.badlogicgames.gdx:gdx:$gdxVersion"
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.assertj:assertj-core:3.26.3'
if(enableGraalNative == 'true') {
implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion"
}
}
test {
useJUnitPlatform()
}
@@ -0,0 +1,17 @@
package ru.project.tower;
import com.badlogic.gdx.Gdx;
public final class DebugLog {
public static final boolean DEBUG = false;
private DebugLog() {}
public static void log(String tag, String message) {
if (DEBUG) Gdx.app.log(tag, message);
}
public static void log(String tag, String message, Throwable t) {
if (DEBUG) Gdx.app.log(tag, message, t);
}
}
@@ -0,0 +1,61 @@
package ru.project.tower;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.utils.Disposable;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider;
import ru.project.tower.support.GameAssetService;
import ru.project.tower.support.LibGdxAssetService;
import ru.project.tower.talents.TalentTree;
public class GameContext implements Disposable {
public final Preferences playerDataPrefs;
public final Preferences abilitiesPrefs;
public final GameAssetService assetService;
public final SafeAreaInsetsProvider safeAreaInsetsProvider;
public final PlayerStats playerStats;
public final PlayerAbilities playerAbilities;
public final GameState gameState;
public final TalentTree talentTree;
public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) {
this(
playerDataPrefs,
abilitiesPrefs,
new LibGdxAssetService(),
SafeAreaInsetsProvider.none());
}
public GameContext(
Preferences playerDataPrefs,
Preferences abilitiesPrefs,
GameAssetService assetService) {
this(playerDataPrefs, abilitiesPrefs, assetService, SafeAreaInsetsProvider.none());
}
public GameContext(
Preferences playerDataPrefs,
Preferences abilitiesPrefs,
GameAssetService assetService,
SafeAreaInsetsProvider safeAreaInsetsProvider) {
this.playerDataPrefs = playerDataPrefs;
this.abilitiesPrefs = abilitiesPrefs;
this.assetService = assetService;
this.safeAreaInsetsProvider = safeAreaInsetsProvider;
this.playerStats = new PlayerStats(playerDataPrefs);
this.playerAbilities = new PlayerAbilities(abilitiesPrefs);
this.gameState = new GameState();
this.talentTree = new TalentTree(this.playerStats);
}
@Override
public void dispose() {
assetService.dispose();
}
}
@@ -0,0 +1,367 @@
package ru.project.tower;
import com.badlogic.gdx.utils.Array;
import java.util.Objects;
import java.util.Optional;
import ru.project.tower.abilities.AbilityKind;
public class GameState {
private boolean returnedFromShop = false;
private Array<AbilityKind> selectedAbilities = new Array<>();
private RunSnapshot savedRunSnapshot;
private Array<String> spawnedBosses = new Array<>();
public GameState() {}
public void saveGameState(Snapshot snapshot) {
Snapshot nextSnapshot = Objects.requireNonNull(snapshot, "snapshot");
returnedFromShop = true;
savedRunSnapshot = new RunSnapshot(nextSnapshot, selectedAbilities, spawnedBosses);
}
public Snapshot restoreGameState() {
if (!returnedFromShop) {
return null;
}
if (savedRunSnapshot == null) {
return null;
}
selectedAbilities.clear();
selectedAbilities.addAll(savedRunSnapshot.selectedAbilities);
spawnedBosses.clear();
spawnedBosses.addAll(savedRunSnapshot.spawnedBosses);
return savedRunSnapshot.snapshot;
}
public void resetGameState() {
DebugLog.log("GameState", "Resetting game state");
returnedFromShop = false;
selectedAbilities.clear();
spawnedBosses.clear();
savedRunSnapshot = null;
DebugLog.log("GameState", "Cleared selected abilities");
}
public boolean isReturnedFromShop() {
return returnedFromShop;
}
public void setSelectedAbilities(Array<AbilityKind> abilities) {
DebugLog.log("GameState", "Setting selected abilities, count: " + abilities.size);
selectedAbilities.clear();
selectedAbilities.addAll(abilities);
for (AbilityKind ability : selectedAbilities) {
DebugLog.log("GameState", "Added ability: " + ability.persistedId());
}
}
public void setSelectedAbilityIds(Array<String> abilityIds) {
DebugLog.log("GameState", "Setting selected ability ids, count: " + abilityIds.size);
selectedAbilities.clear();
for (String abilityId : abilityIds) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(abilityId);
if (kind.isPresent()) {
selectedAbilities.add(kind.get());
DebugLog.log("GameState", "Added ability: " + abilityId);
} else {
DebugLog.log("GameState", "Ignoring unknown ability id: " + abilityId);
}
}
}
public Array<AbilityKind> getSelectedAbilities() {
DebugLog.log("GameState", "Getting selected abilities, count: " + selectedAbilities.size);
return copyOf(selectedAbilities);
}
public Array<String> getSelectedAbilityIds() {
Array<String> abilityIds = new Array<>();
for (AbilityKind kind : selectedAbilities) {
abilityIds.add(kind.persistedId());
}
return abilityIds;
}
public Array<String> getSpawnedBosses() {
return copyOf(spawnedBosses);
}
public void setSpawnedBosses(Array<String> spawnedBosses) {
this.spawnedBosses.clear();
this.spawnedBosses.addAll(spawnedBosses);
}
private static <T> Array<T> copyOf(Array<T> source) {
Array<T> copy = new Array<>();
copy.addAll(source);
return copy;
}
private static final class RunSnapshot {
private final Snapshot snapshot;
private final Array<AbilityKind> selectedAbilities;
private final Array<String> spawnedBosses;
private RunSnapshot(
Snapshot snapshot,
Array<AbilityKind> selectedAbilities,
Array<String> spawnedBosses) {
this.snapshot = Objects.requireNonNull(snapshot, "snapshot");
this.selectedAbilities = copyOf(selectedAbilities);
this.spawnedBosses = copyOf(spawnedBosses);
}
}
public static final class Snapshot {
private final int money;
private final int runCurrencyEarned;
private final HealthSnapshot health;
private final BulletSnapshot bulletStats;
private final UpgradeSnapshot upgrades;
private final WaveSnapshot wave;
private final ru.project.tower.run.RunRngStreams.Snapshot rng;
public Snapshot(
int money,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave) {
this(money, health, bulletStats, upgrades, wave, null);
}
public Snapshot(
int money,
int runCurrencyEarned,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave) {
this(money, runCurrencyEarned, health, bulletStats, upgrades, wave, null);
}
public Snapshot(
int money,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave,
ru.project.tower.run.RunRngStreams.Snapshot rng) {
this(money, 0, health, bulletStats, upgrades, wave, rng);
}
public Snapshot(
int money,
int runCurrencyEarned,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave,
ru.project.tower.run.RunRngStreams.Snapshot rng) {
this.money = money;
this.runCurrencyEarned = Math.max(0, runCurrencyEarned);
this.health = Objects.requireNonNull(health, "health");
this.bulletStats = Objects.requireNonNull(bulletStats, "bulletStats");
this.upgrades = Objects.requireNonNull(upgrades, "upgrades");
this.wave = Objects.requireNonNull(wave, "wave");
this.rng = rng;
}
public int getMoney() {
return money;
}
public int getRunCurrencyEarned() {
return runCurrencyEarned;
}
public HealthSnapshot getHealth() {
return health;
}
public BulletSnapshot getBulletStats() {
return bulletStats;
}
public UpgradeSnapshot getUpgrades() {
return upgrades;
}
public WaveSnapshot getWave() {
return wave;
}
public ru.project.tower.run.RunRngStreams.Snapshot getRng() {
return rng;
}
}
public static final class HealthSnapshot {
private final int squareHealth;
private final int maxSquareHealth;
private final int healthRegenRate;
public HealthSnapshot(int squareHealth, int maxSquareHealth, int healthRegenRate) {
this.squareHealth = squareHealth;
this.maxSquareHealth = maxSquareHealth;
this.healthRegenRate = healthRegenRate;
}
public int getSquareHealth() {
return squareHealth;
}
public int getMaxSquareHealth() {
return maxSquareHealth;
}
public int getHealthRegenRate() {
return healthRegenRate;
}
}
public static final class BulletSnapshot {
private final float bulletSpeed;
private final int bulletDamage;
private final float shotCooldown;
public BulletSnapshot(float bulletSpeed, int bulletDamage, float shotCooldown) {
this.bulletSpeed = bulletSpeed;
this.bulletDamage = bulletDamage;
this.shotCooldown = shotCooldown;
}
public float getBulletSpeed() {
return bulletSpeed;
}
public int getBulletDamage() {
return bulletDamage;
}
public float getShotCooldown() {
return shotCooldown;
}
}
public static final class UpgradeSnapshot {
private final int healthUpgradeLevel;
private final int damageUpgradeLevel;
private final int speedUpgradeLevel;
private final int cooldownUpgradeLevel;
private final int regenUpgradeLevel;
private final int critChanceUpgradeLevel;
private final int critMultiplierUpgradeLevel;
public UpgradeSnapshot(
int healthUpgradeLevel,
int damageUpgradeLevel,
int speedUpgradeLevel,
int cooldownUpgradeLevel,
int regenUpgradeLevel,
int critChanceUpgradeLevel,
int critMultiplierUpgradeLevel) {
this.healthUpgradeLevel = healthUpgradeLevel;
this.damageUpgradeLevel = damageUpgradeLevel;
this.speedUpgradeLevel = speedUpgradeLevel;
this.cooldownUpgradeLevel = cooldownUpgradeLevel;
this.regenUpgradeLevel = regenUpgradeLevel;
this.critChanceUpgradeLevel = critChanceUpgradeLevel;
this.critMultiplierUpgradeLevel = critMultiplierUpgradeLevel;
}
public int getHealthUpgradeLevel() {
return healthUpgradeLevel;
}
public int getDamageUpgradeLevel() {
return damageUpgradeLevel;
}
public int getSpeedUpgradeLevel() {
return speedUpgradeLevel;
}
public int getCooldownUpgradeLevel() {
return cooldownUpgradeLevel;
}
public int getRegenUpgradeLevel() {
return regenUpgradeLevel;
}
public int getCritChanceUpgradeLevel() {
return critChanceUpgradeLevel;
}
public int getCritMultiplierUpgradeLevel() {
return critMultiplierUpgradeLevel;
}
}
public static final class WaveSnapshot {
private final int currentWave;
private final boolean waveActive;
private final long waveStartTime;
private final long enemiesSpawnedThisWave;
private final long enemiesPerWave;
public WaveSnapshot(
int currentWave,
boolean waveActive,
long waveStartTime,
long enemiesSpawnedThisWave,
long enemiesPerWave) {
this.currentWave = currentWave;
this.waveActive = waveActive;
this.waveStartTime = waveStartTime;
this.enemiesSpawnedThisWave = enemiesSpawnedThisWave;
this.enemiesPerWave = enemiesPerWave;
}
public int getCurrentWave() {
return currentWave;
}
public boolean isWaveActive() {
return waveActive;
}
public long getWaveStartTime() {
return waveStartTime;
}
public long getEnemiesSpawnedThisWave() {
return enemiesSpawnedThisWave;
}
public long getEnemiesPerWave() {
return enemiesPerWave;
}
}
}
@@ -0,0 +1,65 @@
package ru.project.tower;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import java.util.Objects;
import ru.project.tower.navigation.ScreenNavigation;
import ru.project.tower.navigation.ScreenNavigator;
import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider;
public class Main extends Game {
private static final String PLAYER_DATA_PREFS_NAME = "tower_attack_player_data";
private static final String ABILITIES_PREFS_NAME = "tower_attack_player_abilities";
private GameContext ctx;
private ScreenNavigator screenNavigator;
private final SafeAreaInsetsProvider safeAreaInsetsProvider;
public Main() {
this(SafeAreaInsetsProvider.none());
}
public Main(SafeAreaInsetsProvider safeAreaInsetsProvider) {
this.safeAreaInsetsProvider =
Objects.requireNonNull(safeAreaInsetsProvider, "safeAreaInsetsProvider");
}
protected final ScreenNavigator screenNavigator() {
return screenNavigator;
}
protected final GameContext gameContext() {
return ctx;
}
@Override
public void create() {
ctx =
new GameContext(
Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME),
Gdx.app.getPreferences(ABILITIES_PREFS_NAME),
new ru.project.tower.support.LibGdxAssetService(),
safeAreaInsetsProvider);
screenNavigator = ScreenNavigation.forGame(this, ctx);
screenNavigator.showMainMenu();
}
@Override
public void dispose() {
if (getScreen() != null) {
getScreen().dispose();
}
if (ctx != null) {
ctx.dispose();
}
super.dispose();
}
}
@@ -0,0 +1,75 @@
package ru.project.tower.abilities;
import ru.project.tower.support.GameClock;
public class AbilityEffect {
public float x, y;
public float radius;
public float duration;
public long startTime;
public String type;
private final GameClock clock;
public AbilityEffect(float x, float y, float radius, float duration, int typeId) {
this(x, y, radius, duration, typeId, GameClock.SYSTEM.nowMs(), GameClock.SYSTEM);
}
public AbilityEffect(
float x,
float y,
float radius,
float duration,
int typeId,
long startTime,
GameClock clock) {
this.x = x;
this.y = y;
this.radius = radius;
this.duration = duration;
this.startTime = startTime;
this.clock = clock;
switch (typeId) {
case 0:
this.type = "freeze";
break;
case 1:
this.type = "explosion";
break;
case 2:
this.type = "shield";
break;
case 3:
this.type = "impulse";
break;
default:
this.type = "unknown";
}
}
public boolean isExpired() {
return isExpired(clock.nowMs());
}
public boolean isExpired(long now) {
return now - startTime > duration;
}
public float getAlpha() {
return getAlpha(clock.nowMs());
}
public float getAlpha(long now) {
float progress = (now - startTime) / duration;
return 1f - progress;
}
public float getRemainingPercentage() {
return getRemainingPercentage(clock.nowMs());
}
public float getRemainingPercentage(long now) {
float elapsed = now - startTime;
return Math.max(0f, 1f - (elapsed / duration));
}
}
@@ -0,0 +1,41 @@
package ru.project.tower.abilities;
import com.badlogic.gdx.graphics.Color;
import ru.project.tower.entities.circles.BaseCircleData;
public interface AbilityHost {
void addFloatingText(float x, float y, String text, Color color);
void createExplosion(float x, float y, Color color);
void triggerKillShake();
void triggerCritHitstop();
int effectiveBulletDamage();
float abilityScalingFactor();
float gameScale();
long abilityCooldownReductionMs();
boolean isBoss(BaseCircleData circle);
void addMoney(int amount);
void killEnemy(BaseCircleData enemy);
void onTurretShot(Turret turret, BaseCircleData target, long now);
}
@@ -0,0 +1,83 @@
package ru.project.tower.abilities;
import java.util.Optional;
public enum AbilityKind {
FREEZE("freeze", "has_freeze_ability", true, 8_000L, "Заморозка", "ЗАМОРОЗКА", 50, 0),
EXPLOSION("explosion", "has_explosion_ability", true, 12_000L, "Взрыв", "ВЗРЫВ", 75, 0),
SHIELD("shield", "has_shield_ability", false, 20_000L, "Щит", "ЩИТ", 60, 12),
IMPULSE("impulse", "has_impulse_ability", false, 15_000L, "Импульс", "ИМПУЛЬС", 65, 16),
DASH("dash", "has_dash_ability", false, 5_000L, "Рывок", "РЫВОК", 80, 20),
PULL("pull", "has_pull_ability", false, 10_000L, "Притяжение", "ТЯГА", 90, 24),
TURRET("turret", "has_turret_ability", false, 25_000L, "Турель", "ТУРЕЛЬ", 120, 28);
private static final AbilityKind[] ABILITY_KINDS = values();
private final String persistedId;
private final String preferenceKey;
private final boolean defaultUnlocked;
private final long baseCooldownMs;
private final String selectionName;
private final String hudLabel;
private final int shopPrice;
private final int unlockWaveRequirement;
AbilityKind(
String persistedId,
String preferenceKey,
boolean defaultUnlocked,
long baseCooldownMs,
String selectionName,
String hudLabel,
int shopPrice,
int unlockWaveRequirement) {
this.persistedId = persistedId;
this.preferenceKey = preferenceKey;
this.defaultUnlocked = defaultUnlocked;
this.baseCooldownMs = baseCooldownMs;
this.selectionName = selectionName;
this.hudLabel = hudLabel;
this.shopPrice = shopPrice;
this.unlockWaveRequirement = unlockWaveRequirement;
}
public String persistedId() {
return persistedId;
}
public String preferenceKey() {
return preferenceKey;
}
public boolean defaultUnlocked() {
return defaultUnlocked;
}
public long baseCooldownMs() {
return baseCooldownMs;
}
public String selectionName() {
return selectionName;
}
public String hudLabel() {
return hudLabel;
}
public int shopPrice() {
return shopPrice;
}
public int unlockWaveRequirement() {
return unlockWaveRequirement;
}
public static Optional<AbilityKind> fromPersistedId(String persistedId) {
if (persistedId == null) return Optional.empty();
for (AbilityKind kind : ABILITY_KINDS) {
if (kind.persistedId.equals(persistedId)) return Optional.of(kind);
}
return Optional.empty();
}
}
@@ -0,0 +1,460 @@
package ru.project.tower.abilities;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import java.util.EnumMap;
import java.util.Optional;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.RandomSource;
import ru.project.tower.support.RangeChecks;
import ru.project.tower.systems.CircleSpatialIndex;
public final class AbilitySystem {
public static final long FREEZE_COOLDOWN = AbilityKind.FREEZE.baseCooldownMs();
public static final long EXPLOSION_COOLDOWN = AbilityKind.EXPLOSION.baseCooldownMs();
public static final long SHIELD_COOLDOWN = AbilityKind.SHIELD.baseCooldownMs();
public static final long IMPULSE_COOLDOWN = AbilityKind.IMPULSE.baseCooldownMs();
public static final long DASH_COOLDOWN = AbilityKind.DASH.baseCooldownMs();
public static final long PULL_COOLDOWN = AbilityKind.PULL.baseCooldownMs();
public static final long TURRET_COOLDOWN = AbilityKind.TURRET.baseCooldownMs();
public static final long ABILITY_COOLDOWN_MIN = 1_500L;
public static final float FREEZE_RADIUS = 150f;
public static final int FREEZE_DURATION = 3_000;
public static final int EXPLOSION_DAMAGE = 15;
public static final float EXPLOSION_RADIUS = 150f;
public static final int SHIELD_DURATION = 5_000;
public static final float IMPULSE_RADIUS = 200f;
public static final float IMPULSE_FORCE = 10_000f;
public static final int IMPULSE_DURATION = 500;
public static final int DASH_IFRAME_DURATION = 600;
public static final float DASH_REPULSE_RADIUS = 180f;
public static final float DASH_REPULSE_FORCE = 3_000f;
public static final float PULL_RADIUS = 260f;
public static final float PULL_FORCE = 6_000f;
public static final int PULL_DURATION = 700;
public static final int TURRET_LIFETIME = 12_000;
public static final long TURRET_SHOT_INTERVAL = 450L;
public static final float TURRET_RANGE = 450f;
private static final float FORCE_FALLOFF_MIN = 0.15f;
public static final float TURRET_DEPLOY_DISTANCE = 100f;
private static final Color NEON_CYAN = new Color(0.2f, 1f, 1f, 1f);
private static final Color NEON_GREEN = new Color(0.2f, 1f, 0.2f, 1f);
private static final AbilityKind[] ABILITY_KINDS = AbilityKind.values();
private final Rectangle playerSquare;
private final Array<BaseCircleData> circles;
private final PlayerAbilities playerAbilities;
private final Array<AbilityKind> selectedAbilities;
private final RandomSource random;
private final AbilityHost host;
private final GameClock clock;
private final CircleSpatialIndex turretTargetIndex = new CircleSpatialIndex();
private int lastTurretTargetCandidateChecks;
private final EnumMap<AbilityKind, Long> lastActivationTimes = new EnumMap<>(AbilityKind.class);
private boolean shieldActive = false;
private float activeShieldDuration = 0f;
private long dashIFramesUntil = 0L;
private long lastFreezeCastTime = 0L;
private float activeFreezeDuration = 0f;
private final Array<AbilityEffect> activeEffects = new Array<>();
private final Array<Turret> activeTurrets = new Array<>();
public AbilitySystem(
Rectangle playerSquare,
Array<BaseCircleData> circles,
PlayerAbilities playerAbilities,
Array<AbilityKind> selectedAbilities,
RandomSource random,
AbilityHost host) {
this(
playerSquare,
circles,
playerAbilities,
selectedAbilities,
random,
host,
GameClock.SYSTEM);
}
public AbilitySystem(
Rectangle playerSquare,
Array<BaseCircleData> circles,
PlayerAbilities playerAbilities,
Array<AbilityKind> selectedAbilities,
RandomSource random,
AbilityHost host,
GameClock clock) {
this.playerSquare = playerSquare;
this.circles = circles;
this.playerAbilities = playerAbilities;
this.selectedAbilities = selectedAbilities;
this.random = random;
this.host = host;
this.clock = clock;
for (AbilityKind kind : ABILITY_KINDS) {
lastActivationTimes.put(kind, 0L);
}
}
public void activateFreeze(float x, float y, long now) {
if (!canActivate(AbilityKind.FREEZE, now)) return;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
float scaling = host.abilityScalingFactor();
float radius = FREEZE_RADIUS * (1 + (scaling - 1) * 0.3f);
float duration = FREEZE_DURATION * scaling;
activeEffects.add(new AbilityEffect(centerX, centerY, radius, duration, 0, now, clock));
setLastActivationTime(AbilityKind.FREEZE, now);
float radius2 = RangeChecks.radiusSquared(radius);
for (BaseCircleData c : circles) {
if (RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y) <= radius2) {
c.velocity.set(0f, 0f);
c.setFrozen(true);
}
}
lastFreezeCastTime = now;
activeFreezeDuration = duration;
}
private long getCooldown(AbilityKind ability) {
if (ability == null) return 0L;
return Math.max(
ABILITY_COOLDOWN_MIN, ability.baseCooldownMs() - host.abilityCooldownReductionMs());
}
private boolean canActivate(AbilityKind ability, long now) {
return playerAbilities.hasAbility(ability)
&& selectedAbilities.contains(ability, true)
&& now - lastActivationTime(ability) >= getCooldown(ability);
}
private long lastActivationTime(AbilityKind ability) {
Long last = lastActivationTimes.get(ability);
return last == null ? 0L : last;
}
private void setLastActivationTime(AbilityKind ability, long now) {
lastActivationTimes.put(ability, now);
}
public void activateExplosion(float x, float y, long now) {
if (!canActivate(AbilityKind.EXPLOSION, now)) return;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
float scaling = host.abilityScalingFactor();
float radius = EXPLOSION_RADIUS * (1 + (scaling - 1) * 0.5f);
int damage = Math.round(EXPLOSION_DAMAGE * scaling);
activeEffects.add(new AbilityEffect(centerX, centerY, radius, 500, 1, now, clock));
setLastActivationTime(AbilityKind.EXPLOSION, now);
float radius2 = RangeChecks.radiusSquared(radius);
for (int i = circles.size - 1; i >= 0; i--) {
BaseCircleData c = circles.get(i);
if (RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y) <= radius2) {
int actual = c.isFrozen() ? damage * 2 : damage;
c.health -= actual;
if (c.isFrozen()) {
host.addFloatingText(c.getX(), c.getY(), "SHATTER!", NEON_CYAN);
}
if (c.health <= 0) {
host.killEnemy(c);
}
}
}
host.triggerKillShake();
}
public void activateShield(long now) {
if (!canActivate(AbilityKind.SHIELD, now)) return;
shieldActive = true;
float scaling = host.abilityScalingFactor();
float duration = SHIELD_DURATION * scaling;
activeShieldDuration = duration;
activeEffects.add(
new AbilityEffect(
playerSquare.x + playerSquare.width / 2f,
playerSquare.y + playerSquare.height / 2f,
playerSquare.width * 1.5f,
duration,
2,
now,
clock));
setLastActivationTime(AbilityKind.SHIELD, now);
}
public void grantShieldPulse(long now, int durationMs) {
if (durationMs <= 0) return;
shieldActive = true;
activeShieldDuration = Math.max(activeShieldDuration, durationMs);
setLastActivationTime(AbilityKind.SHIELD, now);
activeEffects.add(
new AbilityEffect(
playerSquare.x + playerSquare.width / 2f,
playerSquare.y + playerSquare.height / 2f,
playerSquare.width,
durationMs,
2,
now,
clock));
}
public void reduceAllCooldowns(long amountMs) {
if (amountMs <= 0) return;
for (AbilityKind kind : ABILITY_KINDS) {
lastActivationTimes.put(kind, lastActivationTime(kind) - amountMs);
}
}
public void activateImpulse(long now) {
if (!canActivate(AbilityKind.IMPULSE, now)) return;
float scaling = host.abilityScalingFactor();
float radius = IMPULSE_RADIUS * (1 + (scaling - 1) * 0.4f);
float force = IMPULSE_FORCE * scaling;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
activeEffects.add(
new AbilityEffect(centerX, centerY, radius, IMPULSE_DURATION, 3, now, clock));
setLastActivationTime(AbilityKind.IMPULSE, now);
boolean shatteredAny = false;
float radius2 = RangeChecks.radiusSquared(radius);
for (int i = circles.size - 1; i >= 0; i--) {
BaseCircleData c = circles.get(i);
float dx = c.circle.x - centerX;
float dy = c.circle.y - centerY;
float d2 = RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y);
if (d2 <= radius2) {
if (c.isFrozen() && c.health <= 50 && !host.isBoss(c)) {
host.addFloatingText(c.getX(), c.getY(), "SHATTER!", NEON_CYAN);
host.killEnemy(c);
shatteredAny = true;
continue;
}
float d = (float) Math.sqrt(d2);
float dirX = dx / Math.max(0.1f, d);
float dirY = dy / Math.max(0.1f, d);
float forceFactor = radialForceFactor(d, radius);
float actualForce = force * forceFactor;
c.velocity.set(dirX * actualForce, dirY * actualForce);
}
}
if (shatteredAny) host.triggerCritHitstop();
}
public void activateDash(long now) {
if (!canActivate(AbilityKind.DASH, now)) return;
setLastActivationTime(AbilityKind.DASH, now);
dashIFramesUntil = now + DASH_IFRAME_DURATION;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
activeEffects.add(
new AbilityEffect(
centerX,
centerY,
DASH_REPULSE_RADIUS,
DASH_IFRAME_DURATION,
2,
now,
clock));
float radius2 = RangeChecks.radiusSquared(DASH_REPULSE_RADIUS);
for (BaseCircleData c : circles) {
float dx = c.circle.x - centerX;
float dy = c.circle.y - centerY;
float d2 = RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y);
if (d2 <= radius2) {
float d = (float) Math.sqrt(d2);
float dirX = dx / Math.max(0.1f, d);
float dirY = dy / Math.max(0.1f, d);
float f = DASH_REPULSE_FORCE * radialForceFactor(d, DASH_REPULSE_RADIUS);
c.velocity.set(dirX * f, dirY * f);
}
}
host.addFloatingText(centerX, centerY + 30, "DASH!", NEON_CYAN);
host.triggerKillShake();
}
public void activatePull(long now) {
if (!canActivate(AbilityKind.PULL, now)) return;
setLastActivationTime(AbilityKind.PULL, now);
float scaling = host.abilityScalingFactor();
float radius = PULL_RADIUS * (1f + (scaling - 1f) * 0.3f);
float force = PULL_FORCE * scaling;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
activeEffects.add(
new AbilityEffect(centerX, centerY, radius, PULL_DURATION, 3, now, clock));
float radius2 = RangeChecks.radiusSquared(radius);
for (BaseCircleData c : circles) {
if (host.isBoss(c)) continue;
float d2 = RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y);
if (d2 <= radius2 && d2 > 1f) {
float d = (float) Math.sqrt(d2);
float dirX = (centerX - c.circle.x) / d;
float dirY = (centerY - c.circle.y) / d;
float forceFactor = radialForceFactor(d, radius);
c.velocity.set(dirX * force * forceFactor, dirY * force * forceFactor);
}
}
}
private static float radialForceFactor(float distance, float radius) {
return Math.max(FORCE_FALLOFF_MIN, 1f - distance / radius);
}
public void activateTurret(long now) {
if (!canActivate(AbilityKind.TURRET, now)) return;
setLastActivationTime(AbilityKind.TURRET, now);
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
float angle = random.angle();
float dist = TURRET_DEPLOY_DISTANCE * host.gameScale();
float tx = centerX + MathUtils.cos(angle) * dist;
float ty = centerY + MathUtils.sin(angle) * dist;
int dmg = Math.max(1, host.effectiveBulletDamage());
activeTurrets.add(new Turret(tx, ty, now, dmg));
host.addFloatingText(tx, ty + 20, "TURRET!", NEON_GREEN);
host.triggerKillShake();
}
public void tick(long now) {
lastTurretTargetCandidateChecks = 0;
if (shieldActive && now - lastActivationTime(AbilityKind.SHIELD) > activeShieldDuration)
shieldActive = false;
if (activeFreezeDuration > 0f && now - lastFreezeCastTime > activeFreezeDuration) {
for (BaseCircleData c : circles) c.setFrozen(false);
activeFreezeDuration = 0f;
}
for (int i = activeEffects.size - 1; i >= 0; i--) {
if (activeEffects.get(i).isExpired(now)) activeEffects.removeIndex(i);
}
if (activeTurrets.size > 0) {
turretTargetIndex.build(circles);
}
for (int i = activeTurrets.size - 1; i >= 0; i--) {
Turret t = activeTurrets.get(i);
if (now - t.spawnedAt > TURRET_LIFETIME) {
activeTurrets.removeIndex(i);
continue;
}
if (now - t.lastShot < TURRET_SHOT_INTERVAL) continue;
BaseCircleData target = null;
float best = Float.POSITIVE_INFINITY;
float range2 = RangeChecks.radiusSquared(TURRET_RANGE);
int candidateCount = turretTargetIndex.queryRadius(t.x, t.y, TURRET_RANGE);
for (int candidate = 0; candidate < candidateCount; candidate++) {
BaseCircleData c = circles.get(turretTargetIndex.candidateAt(candidate));
lastTurretTargetCandidateChecks++;
float d2 = RangeChecks.distanceSquared(t.x, t.y, c.circle.x, c.circle.y);
if (d2 < range2 && d2 < best) {
target = c;
best = d2;
}
}
if (target == null) continue;
t.lastShot = now;
host.onTurretShot(t, target, now);
}
}
int lastTurretTargetCandidateChecksForTests() {
return lastTurretTargetCandidateChecks;
}
public void resetForNewRun() {
shieldActive = false;
activeShieldDuration = 0f;
dashIFramesUntil = 0L;
lastFreezeCastTime = 0L;
activeFreezeDuration = 0f;
activeEffects.clear();
activeTurrets.clear();
for (AbilityKind kind : ABILITY_KINDS) {
lastActivationTimes.put(kind, 0L);
}
}
public long remainingCooldownMs(String ability, long now) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(ability);
return kind.isPresent() ? remainingCooldownMs(kind.get(), now) : 0L;
}
public long remainingCooldownMs(AbilityKind ability, long now) {
if (ability == null) return 0L;
return Math.max(0L, getCooldown(ability) - (now - lastActivationTime(ability)));
}
public boolean isShieldActive(long now) {
return shieldActive;
}
public boolean isDashInvulnerable(long now) {
return now < dashIFramesUntil;
}
public boolean isFreezeZoneActive(long now) {
return activeFreezeDuration > 0f;
}
public Array<AbilityEffect> getActiveEffects() {
return activeEffects;
}
public Array<Turret> getActiveTurrets() {
return activeTurrets;
}
}
@@ -0,0 +1,170 @@
package ru.project.tower.abilities;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.utils.Array;
import java.util.EnumMap;
import java.util.Optional;
import ru.project.tower.DebugLog;
public class PlayerAbilities {
private static final AbilityKind[] ABILITY_KINDS = AbilityKind.values();
private final EnumMap<AbilityKind, Boolean> ownedAbilities = new EnumMap<>(AbilityKind.class);
private final Preferences preferences;
public PlayerAbilities(Preferences preferences) {
try {
DebugLog.log("PlayerAbilities", "Initializing PlayerAbilities");
this.preferences = preferences;
DebugLog.log("PlayerAbilities", "Got preferences");
for (AbilityKind kind : ABILITY_KINDS) {
ownedAbilities.put(
kind, preferences.getBoolean(kind.preferenceKey(), kind.defaultUnlocked()));
}
logLoadedAbilities();
} catch (Exception e) {
Gdx.app.error("PlayerAbilities", "Error in constructor: ", e);
throw e;
}
}
private void logLoadedAbilities() {
if (DebugLog.DEBUG) {
DebugLog.log(
"PlayerAbilities",
"Loaded abilities - Freeze: "
+ hasAbility(AbilityKind.FREEZE)
+ ", Explosion: "
+ hasAbility(AbilityKind.EXPLOSION)
+ ", Shield: "
+ hasAbility(AbilityKind.SHIELD)
+ ", Impulse: "
+ hasAbility(AbilityKind.IMPULSE)
+ ", Dash: "
+ hasAbility(AbilityKind.DASH)
+ ", Pull: "
+ hasAbility(AbilityKind.PULL)
+ ", Turret: "
+ hasAbility(AbilityKind.TURRET));
}
}
public Array<AbilityKind> getPurchasedAbilities() {
try {
Array<AbilityKind> abilities = new Array<>();
for (AbilityKind kind : ABILITY_KINDS) {
if (hasAbility(kind)) abilities.add(kind);
}
if (DebugLog.DEBUG) {
DebugLog.log(
"PlayerAbilities", "Getting purchased abilities, count: " + abilities.size);
for (AbilityKind ability : abilities) {
DebugLog.log("PlayerAbilities", "Available ability: " + ability.persistedId());
}
}
return abilities;
} catch (Exception e) {
Gdx.app.error("PlayerAbilities", "Error in getPurchasedAbilities: ", e);
throw e;
}
}
public int purchasedAbilityCount() {
int count = 0;
for (AbilityKind kind : ABILITY_KINDS) {
if (hasAbility(kind)) count++;
}
return count;
}
public Array<String> getPurchasedAbilityIds() {
Array<String> abilityIds = new Array<>();
for (AbilityKind kind : ABILITY_KINDS) {
if (hasAbility(kind)) abilityIds.add(kind.persistedId());
}
return abilityIds;
}
public boolean hasAbility(AbilityKind abilityType) {
return abilityType != null && Boolean.TRUE.equals(ownedAbilities.get(abilityType));
}
public boolean hasAbility(String abilityType) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(abilityType);
return kind.isPresent() && hasAbility(kind.get());
}
public void purchaseAbility(AbilityKind abilityType) {
if (abilityType == null) return;
ownedAbilities.put(abilityType, true);
saveData();
}
public boolean canUnlock(AbilityKind abilityType, int maxWaveReached) {
if (abilityType == null) return false;
return maxWaveReached >= abilityType.unlockWaveRequirement();
}
public boolean purchaseAbilityIfUnlocked(AbilityKind abilityType, int maxWaveReached) {
if (!canUnlock(abilityType, maxWaveReached)) {
return false;
}
purchaseAbility(abilityType);
return true;
}
public void purchaseAbility(String abilityType) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(abilityType);
if (kind.isPresent()) {
purchaseAbility(kind.get());
}
}
private void saveData() {
for (AbilityKind kind : ABILITY_KINDS) {
preferences.putBoolean(kind.preferenceKey(), hasAbility(kind));
}
preferences.flush();
}
public void clearAll() {
for (AbilityKind kind : ABILITY_KINDS) {
ownedAbilities.put(kind, false);
}
saveData();
}
}
@@ -0,0 +1,23 @@
package ru.project.tower.abilities;
public final class Turret {
public float x, y;
public long spawnedAt;
public long lastShot;
public int damage;
public Turret(float x, float y, long now, int damage) {
this.x = x;
this.y = y;
this.spawnedAt = now;
this.lastShot = now;
this.damage = damage;
}
}
@@ -0,0 +1,147 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class BalanceEffectRegistry {
private static final float DEFAULT_RATING_FLOOR = 0f;
private static final BalanceEffectSpec[] EFFECTS = {
effect(
"attack.core_damage",
PowerAxis.DAMAGE,
BalanceEffectSpec.Owner.SHOP,
"Урон ядра",
"Базовая сила попадания.",
95f),
effect(
"attack.cadence",
PowerAxis.CADENCE,
BalanceEffectSpec.Owner.SHOP,
"Скорострельность",
"Темп стрельбы с общим floor по cooldown.",
90f),
effect(
"attack.crit",
PowerAxis.CRIT,
BalanceEffectSpec.Owner.SHOP,
"Критический контур",
"Шанс и сила критических попаданий.",
75f),
effect(
"attack.pierce",
PowerAxis.PIERCE,
BalanceEffectSpec.Owner.CARD,
"Пробитие",
"Зачистка плотных волн без прямого boss-DPS.",
70f),
effect(
"attack.aoe",
PowerAxis.AOE,
BalanceEffectSpec.Owner.CARD,
"Осколочный урон",
"Площадной урон по скоплениям.",
70f),
effect(
"attack.controlled",
PowerAxis.CONTROL,
BalanceEffectSpec.Owner.ABILITY,
"Эксплуатация контроля",
"Сила по замедленным, стянутым и замороженным целям.",
70f),
effect(
"attack.bossing",
PowerAxis.BOSSING,
BalanceEffectSpec.Owner.SHOP,
"Анти-босс модуль",
"Отдельная ось урона по элитам и боссам.",
65f),
effect(
"defense.health",
PowerAxis.HEALTH,
BalanceEffectSpec.Owner.SHOP,
"Запас корпуса",
"Максимальное здоровье и effective HP.",
95f),
effect(
"defense.regen",
PowerAxis.REGEN,
BalanceEffectSpec.Owner.SHOP,
"Ремонтный цикл",
"Восстановление в длинном забеге.",
80f),
effect(
"defense.shield",
PowerAxis.SHIELD,
BalanceEffectSpec.Owner.ABILITY,
"Щитовой контур",
"Временное здоровье и щитовые окна.",
80f),
effect(
"defense.armor",
PowerAxis.ARMOR,
BalanceEffectSpec.Owner.SHOP,
"Броня",
"Снижение контактного и снарядного урона.",
75f),
effect(
"bonus.economy",
PowerAxis.ECONOMY,
BalanceEffectSpec.Owner.SHOP,
"Экономика",
"Доход, скидки и окупаемость покупок.",
70f),
effect(
"bonus.utility",
PowerAxis.UTILITY,
BalanceEffectSpec.Owner.BONUS,
"Утилити",
"Радиус подбора, длительность и качество бонусов.",
70f),
effect(
"ability.scaling",
PowerAxis.ABILITY,
BalanceEffectSpec.Owner.TALENT,
"Мастерство способностей",
"Cooldown и сила активных способностей.",
70f)
};
private static final EnumMap<PowerAxis, BalanceEffectSpec> BY_AXIS =
new EnumMap<>(PowerAxis.class);
static {
for (BalanceEffectSpec effect : EFFECTS) {
BY_AXIS.put(effect.axis(), effect);
}
}
private BalanceEffectRegistry() {}
public static BalanceEffectSpec[] all() {
BalanceEffectSpec[] copy = new BalanceEffectSpec[EFFECTS.length];
System.arraycopy(EFFECTS, 0, copy, 0, EFFECTS.length);
return copy;
}
public static BalanceEffectSpec forAxis(PowerAxis axis) {
BalanceEffectSpec effect = BY_AXIS.get(axis);
if (effect == null) {
throw new IllegalArgumentException("No balance effect registered for axis: " + axis);
}
return effect;
}
public static Map<PowerAxis, BalanceEffectSpec> byAxis() {
return new EnumMap<>(BY_AXIS);
}
private static BalanceEffectSpec effect(
String id,
PowerAxis axis,
BalanceEffectSpec.Owner owner,
String gameName,
String description,
float ratingCap) {
return new BalanceEffectSpec(
id, axis, owner, gameName, description, DEFAULT_RATING_FLOOR, ratingCap);
}
}
@@ -0,0 +1,71 @@
package ru.project.tower.balance;
import java.util.Objects;
public final class BalanceEffectSpec {
public enum Owner {
SHOP,
CARD,
TALENT,
BONUS,
ABILITY,
SIMULATION
}
private final String id;
private final PowerAxis axis;
private final Owner owner;
private final String gameName;
private final String description;
private final Float ratingFloor;
private final Float ratingCap;
BalanceEffectSpec(
String id,
PowerAxis axis,
Owner owner,
String gameName,
String description,
Float ratingFloor,
Float ratingCap) {
this.id = Objects.requireNonNull(id, "id");
this.axis = Objects.requireNonNull(axis, "axis");
this.owner = Objects.requireNonNull(owner, "owner");
this.gameName = Objects.requireNonNull(gameName, "gameName");
this.description = Objects.requireNonNull(description, "description");
this.ratingFloor = ratingFloor;
this.ratingCap = ratingCap;
}
public String id() {
return id;
}
public PowerAxis axis() {
return axis;
}
public Owner owner() {
return owner;
}
public String gameName() {
return gameName;
}
public String description() {
return description;
}
public Float ratingFloor() {
return ratingFloor;
}
public Float ratingCap() {
return ratingCap;
}
public boolean hasLimit() {
return ratingFloor != null || ratingCap != null;
}
}
@@ -0,0 +1,49 @@
package ru.project.tower.balance;
import java.util.List;
import java.util.Locale;
public final class BalanceReportExporter {
private BalanceReportExporter() {}
public static String export(String reportId, List<SimulationReport> reports) {
StringBuilder out = new StringBuilder();
out.append("balance-report ").append(reportId == null ? "unnamed" : reportId).append('\n');
out.append(
"strategy,wave,firstBoss,shop,moneyEarned,moneySpent,abilities,synergies,bonusSpawned,bonusCollected,damage,cooldownMs\n");
if (reports == null) {
return out.toString();
}
for (SimulationReport report : reports) {
out.append(report.strategyId())
.append(',')
.append(report.waveReached())
.append(',')
.append(format(report.firstBossKillSeconds()))
.append(',')
.append(report.shopPurchases())
.append(',')
.append(report.moneyEarned())
.append(',')
.append(report.moneySpent())
.append(',')
.append(report.abilityUses())
.append(',')
.append(report.triggeredSynergies())
.append(',')
.append(report.bonusSpawned())
.append(',')
.append(report.bonusCollected())
.append(',')
.append(format(report.effectiveDamage()))
.append(',')
.append(format(report.effectiveShotCooldownMs()))
.append('\n');
}
return out.toString();
}
private static String format(float value) {
return String.format(Locale.ROOT, "%.2f", value);
}
}
@@ -0,0 +1,197 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class BalanceRouteReport {
private final String strategyId;
private final int waveReached;
private final float firstBossKillSeconds;
private final int shopPurchases;
private final int abilityUses;
private final int selectedDamageCards;
private final int selectedFireRateCards;
private final int peakVisibleBonusBalls;
private final float effectiveDamage;
private final float effectiveShotCooldownMs;
private final EnumMap<PowerAxis, Float> effectivePowerByAxis;
private final int moneyEarned;
private final int moneySpent;
private final int economyPaybackWave;
BalanceRouteReport(
String strategyId,
int waveReached,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs) {
this(
strategyId,
waveReached,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
emptyAxes(),
0,
0,
0);
}
BalanceRouteReport(
String strategyId,
int waveReached,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
Map<PowerAxis, Float> effectivePowerByAxis) {
this(
strategyId,
waveReached,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
effectivePowerByAxis,
0,
0,
0);
}
BalanceRouteReport(
String strategyId,
int waveReached,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
Map<PowerAxis, Float> effectivePowerByAxis,
int moneyEarned,
int moneySpent,
int economyPaybackWave) {
this.strategyId = strategyId;
this.waveReached = waveReached;
this.firstBossKillSeconds = firstBossKillSeconds;
this.shopPurchases = shopPurchases;
this.abilityUses = abilityUses;
this.selectedDamageCards = selectedDamageCards;
this.selectedFireRateCards = selectedFireRateCards;
this.peakVisibleBonusBalls = peakVisibleBonusBalls;
this.effectiveDamage = effectiveDamage;
this.effectiveShotCooldownMs = effectiveShotCooldownMs;
this.effectivePowerByAxis = completeAxes(effectivePowerByAxis);
this.moneyEarned = moneyEarned;
this.moneySpent = moneySpent;
this.economyPaybackWave = economyPaybackWave;
}
public String strategyId() {
return strategyId;
}
public int waveReached() {
return waveReached;
}
public float firstBossKillSeconds() {
return firstBossKillSeconds;
}
public int shopPurchases() {
return shopPurchases;
}
public int abilityUses() {
return abilityUses;
}
public int selectedDamageCards() {
return selectedDamageCards;
}
public int selectedFireRateCards() {
return selectedFireRateCards;
}
public int peakVisibleBonusBalls() {
return peakVisibleBonusBalls;
}
public float effectiveDamage() {
return effectiveDamage;
}
public float effectiveShotCooldownMs() {
return effectiveShotCooldownMs;
}
public Map<PowerAxis, Float> effectivePowerByAxis() {
return new EnumMap<>(effectivePowerByAxis);
}
public int moneyEarned() {
return moneyEarned;
}
public int moneySpent() {
return moneySpent;
}
public int economyPaybackWave() {
return economyPaybackWave;
}
public float defensePower() {
return value(PowerAxis.HEALTH)
+ value(PowerAxis.REGEN)
+ value(PowerAxis.SHIELD)
+ value(PowerAxis.ARMOR);
}
public float utilityPower() {
return value(PowerAxis.ECONOMY) + value(PowerAxis.UTILITY) + value(PowerAxis.ABILITY);
}
private float value(PowerAxis axis) {
Float value = effectivePowerByAxis.get(axis);
return value == null ? 0f : value;
}
private static EnumMap<PowerAxis, Float> emptyAxes() {
EnumMap<PowerAxis, Float> axes = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
axes.put(axis, 0f);
}
return axes;
}
private static EnumMap<PowerAxis, Float> completeAxes(Map<PowerAxis, Float> source) {
EnumMap<PowerAxis, Float> axes = emptyAxes();
if (source != null) {
axes.putAll(source);
}
return axes;
}
}
@@ -0,0 +1,581 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.upgrades.UpgradeKind;
public final class BalanceRouteSimulator {
private static final int MAX_AUDIT_WAVE = 120;
private static final int FIRST_BOSS_WAVE = 20;
public BalanceRouteReport runDamageFireRateNoShopNoAbility(long seed) {
RouteState state = new RouteState("damage-fire-rate:no-shop:no-ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyExploitCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, false)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runFreshNoMenuTalentShopCardAbilityRoute(long seed) {
RouteState state = new RouteState("fresh:no-menu-talent:shop-card-ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyFreshNoMenuTalentCardIfDue(wave);
state.applyFreshNoMenuTalentShopIfDue(wave);
state.applyFreshNoMenuTalentAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (isFreshNoMenuTalentOverwhelmed(state, wave)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runBalancedShopAndAbilityRoute(long seed) {
RouteState state = new RouteState("balanced:shop:ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyBalancedCardIfDue(wave);
state.applyBalancedShopIfDue(wave);
state.applyAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runSustainControlShopAndAbilityRoute(long seed) {
RouteState state = new RouteState("sustain-control:shop:ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applySustainControlCardIfDue(wave);
state.applySustainControlShopIfDue(wave);
state.applyAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runMixedNoShopNoAbilityRoute(long seed) {
RouteState state = new RouteState("mixed:no-shop:no-ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, false)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runAttackSingleTargetShopRoute(long seed) {
RouteState state = new RouteState("attack:single-target-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applySingleTargetShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runAttackSwarmShopRoute(long seed) {
RouteState state = new RouteState("attack:swarm-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applySwarmShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runAttackBossShopRoute(long seed) {
RouteState state = new RouteState("attack:boss-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyBossShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runDefenseShopRoute(long seed) {
RouteState state = new RouteState("defense:shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyDefenseShopIfDue(wave);
state.applySustainControlCardIfDue(wave);
state.applyAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runUtilityEconomyShopRoute(long seed) {
RouteState state = new RouteState("utility:economy-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyUtilityShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
private static boolean isOverwhelmed(RouteState state, int wave, boolean balancedRoute) {
float routeSupport =
balancedRoute ? state.shopPurchases * 2.15f + state.abilityUses * 0.35f : 0f;
float requiredDps =
4.5f + wave * 0.85f + (float) Math.pow(wave, 1.22f) * 0.08f - routeSupport;
return wave > FIRST_BOSS_WAVE && state.effectiveDps(wave) < requiredDps;
}
private static boolean isFreshNoMenuTalentOverwhelmed(RouteState state, int wave) {
float routeSupport =
Math.min(state.shopPurchases, 5) * 0.75f + Math.min(state.abilityUses, 2) * 0.35f;
float requiredDps =
1.75f
+ wave * 0.42f
+ (float) Math.pow(wave, 1.18f) * 0.10f
+ Math.max(0, wave - 11) * 2.00f
- routeSupport;
return wave >= 3 && state.effectiveDps(wave) < requiredDps;
}
private static float bossKillSeconds(float bossHealth, RouteState state, int wave) {
return bossHealth / Math.max(0.01f, state.effectiveDps(wave));
}
private static final class RouteState {
private final String strategyId;
private int waveReached;
private float firstBossKillSeconds;
private int shopPurchases;
private int abilityUses;
private int selectedDamageCards;
private int selectedFireRateCards;
private int selectedMixedCards;
private int peakVisibleBonusBalls;
private float baseDamage = 1f;
private float runDamageBonus;
private float shotCooldownMs = PlayerCombatState.DEFAULT_SHOT_COOLDOWN_MS;
private float runCooldownMultiplier = 1f;
private float critChance = PlayerCombatState.DEFAULT_BASE_CRIT_CHANCE;
private float critMultiplier = PlayerCombatState.DEFAULT_BASE_CRIT_MULTIPLIER;
private float sustainRating;
private float pierceRating;
private float aoeRating;
private float bossingRating;
private float armorRating;
private float shieldRating;
private float economyRating;
private float utilityRating;
private int moneyEarned;
private int moneySpent;
private int economyPaybackWave;
private RouteState(String strategyId, int shopPurchases, int abilityUses) {
this.strategyId = strategyId;
this.shopPurchases = shopPurchases;
this.abilityUses = abilityUses;
}
private void applyExploitCardIfDue(int wave) {
if (wave % 3 != 0) {
return;
}
if (selectedDamageCards <= selectedFireRateCards) {
applyDamageCard();
} else {
applyFireRateCard(0.9f);
}
}
private void applyBalancedCardIfDue(int wave) {
if (wave % 3 != 0) {
return;
}
int lane = selectedMixedCards % 4;
if (lane == 0) {
applyDamageCard();
} else if (lane == 1) {
applyFireRateCard(0.92f);
} else if (lane == 2) {
critChance += 0.035f;
} else {
sustainRating += 1.6f;
}
selectedMixedCards++;
}
private void applySustainControlCardIfDue(int wave) {
if (wave % 3 != 0) {
return;
}
int lane = selectedMixedCards % 4;
if (lane == 0) {
sustainRating += 2.8f;
} else if (lane == 1) {
critChance += 0.04f;
} else if (lane == 2) {
applyFireRateCard(0.94f);
} else {
applyDamageCard();
}
selectedMixedCards++;
}
private void applyFreshNoMenuTalentCardIfDue(int wave) {
if (wave < 2 || wave % 3 != 2) {
return;
}
int lane = selectedMixedCards % 3;
if (lane == 0) {
applyDamageCard();
} else if (lane == 1) {
applyFireRateCard(0.94f);
} else {
sustainRating += 0.9f;
}
selectedMixedCards++;
}
private void applyDamageCard() {
if (selectedDamageCards >= UpgradeKind.DAMAGE.maxStacks()) {
applyFireRateCard(0.9f);
return;
}
selectedDamageCards++;
runDamageBonus +=
Math.max(
1f,
baseDamage
* EndlessBalanceSpec.upgradeCardDamagePercent(
selectedDamageCards - 1)
/ 100f);
}
private void applyFireRateCard(float ignoredMultiplier) {
if (selectedFireRateCards >= UpgradeKind.FIRE_RATE.maxStacks()) {
return;
}
float multiplier =
EndlessBalanceSpec.upgradeCardFireRateMultiplier(selectedFireRateCards);
selectedFireRateCards++;
runCooldownMultiplier *= multiplier;
}
private void applyBalancedShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) {
return;
}
int lane = shopPurchases % 5;
if (lane == 0) {
baseDamage += 1.4f;
} else if (lane == 1) {
shotCooldownMs = Math.max(260f, shotCooldownMs - 38f);
} else if (lane == 2) {
critChance += 0.04f;
} else if (lane == 3) {
critMultiplier += 0.09f;
} else {
sustainRating += 3.2f;
}
shopPurchases++;
}
private void applySustainControlShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) {
return;
}
int lane = shopPurchases % 5;
if (lane == 0) {
sustainRating += 4.2f;
} else if (lane == 1) {
shotCooldownMs = Math.max(300f, shotCooldownMs - 30f);
} else if (lane == 2) {
critChance += 0.035f;
} else if (lane == 3) {
baseDamage += 1.0f;
} else {
critMultiplier += 0.07f;
}
shopPurchases++;
}
private void applyFreshNoMenuTalentShopIfDue(int wave) {
if (wave < 2 || wave % 2 != 0) {
return;
}
int lane = shopPurchases % 4;
if (lane == 0) {
baseDamage += 0.35f;
} else if (lane == 1) {
shotCooldownMs = Math.max(420f, shotCooldownMs - 18f);
} else if (lane == 2) {
sustainRating += 0.8f;
} else {
critChance += 0.012f;
}
shopPurchases++;
}
private void applySingleTargetShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
baseDamage += 1.8f;
} else if (lane == 1) {
critChance += 0.055f;
} else if (lane == 2) {
critMultiplier += 0.12f;
} else if (lane == 3) {
bossingRating += 1.0f;
} else {
shotCooldownMs = Math.max(280f, shotCooldownMs - 24f);
}
shopPurchases++;
}
private void applySwarmShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
pierceRating += 3.4f;
} else if (lane == 1) {
aoeRating += 4.2f;
} else if (lane == 2) {
baseDamage += 0.8f;
} else if (lane == 3) {
shotCooldownMs = Math.max(310f, shotCooldownMs - 28f);
} else {
utilityRating += 0.8f;
}
shopPurchases++;
}
private void applyBossShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
bossingRating += 4.5f;
} else if (lane == 1) {
critChance += 0.045f;
} else if (lane == 2) {
critMultiplier += 0.10f;
} else if (lane == 3) {
baseDamage += 1.1f;
} else {
shotCooldownMs = Math.max(300f, shotCooldownMs - 18f);
}
shopPurchases++;
}
private void applyDefenseShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
sustainRating += 5.0f;
} else if (lane == 1) {
armorRating += 4.0f;
} else if (lane == 2) {
shieldRating += 3.5f;
} else if (lane == 3) {
aoeRating += 0.8f;
} else {
baseDamage += 0.7f;
}
shopPurchases++;
}
private void applyUtilityShopIfDue(int wave) {
moneyEarned += 8 + wave / 2;
if (wave < 4 || wave % 2 != 0) return;
int cost = Math.max(3, 7 - Math.min(3, shopPurchases / 2));
moneySpent += cost;
if (economyPaybackWave == 0 && moneyEarned > moneySpent) {
economyPaybackWave = wave;
}
int lane = shopPurchases % 5;
if (lane == 0) {
economyRating += 3.8f;
} else if (lane == 1) {
utilityRating += 4.0f;
} else if (lane == 2) {
peakVisibleBonusBalls = Math.max(peakVisibleBonusBalls, 2);
} else if (lane == 3) {
shotCooldownMs = Math.max(330f, shotCooldownMs - 18f);
} else {
baseDamage += 0.6f;
}
shopPurchases++;
}
private void applyAbilityIfDue(int wave) {
if (wave >= 12 && wave % 5 == 0) {
abilityUses++;
sustainRating += 0.55f;
}
}
private void applyFreshNoMenuTalentAbilityIfDue(int wave) {
if (wave >= 6 && wave % 4 == 2) {
abilityUses++;
sustainRating += 0.45f;
}
}
private void observeBonusDensity(int wave) {
int visibleBonusBalls = expectedVisibleBonusBalls(wave);
peakVisibleBonusBalls = Math.max(peakVisibleBonusBalls, visibleBonusBalls);
}
private float effectiveDps(int wave) {
int bonusStacks = expectedVisibleBonusBalls(wave);
float damageMultiplier =
EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.DAMAGE, bonusStacks / 3);
float cooldownMultiplier =
EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.FIRE_RATE, bonusStacks / 3);
float averageCritMultiplier =
1f
+ Math.min(EndlessBalanceSpec.CRIT_CHANCE_CAP, critChance)
* (critMultiplier - 1f);
float sustainMultiplier = 1f + Math.min(0.45f, sustainRating * 0.018f);
float shapeMultiplier =
1f
+ Math.min(0.25f, pierceRating * 0.010f + aoeRating * 0.008f)
+ Math.min(0.18f, bossingRating * 0.010f);
float damage = (baseDamage + runDamageBonus) * damageMultiplier;
float cooldown =
Math.max(90f, shotCooldownMs * runCooldownMultiplier * cooldownMultiplier);
return damage
* averageCritMultiplier
* sustainMultiplier
* shapeMultiplier
* 1000f
/ cooldown;
}
private int expectedVisibleBonusBalls(int wave) {
float chance =
Math.min(
0.2f,
EndlessBalanceSpec.bonusDropBudgetThroughWave(wave)
/ (float) Math.max(8, wave + 20));
int expectedRolls =
Math.round(EndlessBalanceSpec.waveTuning(wave).enemiesPerWave() * chance);
int budgeted = Math.min(EndlessBalanceSpec.bonusDropBudgetForWave(wave), expectedRolls);
return Math.min(EndlessBalanceSpec.maxVisibleBonusBalls(), budgeted);
}
private BalanceRouteReport report() {
float finalDamage = baseDamage + runDamageBonus;
float finalCooldown = Math.max(90f, shotCooldownMs * runCooldownMultiplier);
EnumMap<PowerAxis, Float> axes = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
axes.put(axis, 0f);
}
axes.put(PowerAxis.DAMAGE, finalDamage);
axes.put(PowerAxis.CADENCE, 1000f / Math.max(1f, finalCooldown));
axes.put(PowerAxis.CRIT, Math.max(0f, critChance * 100f + (critMultiplier - 1f) * 25f));
axes.put(PowerAxis.PIERCE, selectedMixedCards * 0.75f + pierceRating);
axes.put(
PowerAxis.AOE,
selectedMixedCards * 0.60f + peakVisibleBonusBalls * 0.35f + aoeRating);
axes.put(PowerAxis.CONTROL, abilityUses * 1.4f + sustainRating * 0.25f);
axes.put(
PowerAxis.BOSSING,
finalDamage * 0.70f + axes.get(PowerAxis.CRIT) * 0.20f + bossingRating);
axes.put(PowerAxis.HEALTH, sustainRating);
axes.put(PowerAxis.REGEN, sustainRating * 0.70f);
axes.put(PowerAxis.SHIELD, abilityUses * 1.8f + shopPurchases * 0.15f + shieldRating);
axes.put(PowerAxis.ARMOR, sustainRating * 0.45f + armorRating);
axes.put(PowerAxis.ECONOMY, shopPurchases * 0.70f + economyRating);
axes.put(
PowerAxis.UTILITY,
peakVisibleBonusBalls * 1.2f + selectedMixedCards * 0.20f + utilityRating);
axes.put(PowerAxis.ABILITY, abilityUses * 2.1f);
return new BalanceRouteReport(
strategyId,
waveReached,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
finalDamage,
finalCooldown,
axes,
moneyEarned,
moneySpent,
economyPaybackWave);
}
}
}
@@ -0,0 +1,81 @@
package ru.project.tower.balance;
public final class BalanceRouteTarget {
private final String strategyId;
private final int minWaveReached;
private final int maxWaveReached;
private final float minFirstBossKillSeconds;
private final float maxFirstBossKillSeconds;
private final boolean usesPermanentMenuTalents;
private final boolean usesInRunShop;
private final boolean usesRunCards;
private final boolean usesAbilities;
private final String designGoal;
public BalanceRouteTarget(
String strategyId,
int minWaveReached,
int maxWaveReached,
float minFirstBossKillSeconds,
float maxFirstBossKillSeconds,
boolean usesPermanentMenuTalents,
boolean usesInRunShop,
boolean usesRunCards,
boolean usesAbilities,
String designGoal) {
this.strategyId = strategyId;
this.minWaveReached = minWaveReached;
this.maxWaveReached = maxWaveReached;
this.minFirstBossKillSeconds = minFirstBossKillSeconds;
this.maxFirstBossKillSeconds = maxFirstBossKillSeconds;
this.usesPermanentMenuTalents = usesPermanentMenuTalents;
this.usesInRunShop = usesInRunShop;
this.usesRunCards = usesRunCards;
this.usesAbilities = usesAbilities;
this.designGoal = designGoal;
}
public String strategyId() {
return strategyId;
}
public int minWaveReached() {
return minWaveReached;
}
public int maxWaveReached() {
return maxWaveReached;
}
public float minFirstBossKillSeconds() {
return minFirstBossKillSeconds;
}
public float maxFirstBossKillSeconds() {
return maxFirstBossKillSeconds;
}
public boolean usesPermanentMenuTalents() {
return usesPermanentMenuTalents;
}
public boolean usesInRunShop() {
return usesInRunShop;
}
public boolean usesRunCards() {
return usesRunCards;
}
public boolean usesAbilities() {
return usesAbilities;
}
public String designGoal() {
return designGoal;
}
public boolean containsWaveReached(int waveReached) {
return waveReached >= minWaveReached && waveReached <= maxWaveReached;
}
}
@@ -0,0 +1,909 @@
package ru.project.tower.balance;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.Pools;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import ru.project.tower.abilities.AbilityHost;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.abilities.AbilitySystem;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.abilities.Turret;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.entities.circles.BasicCircleData;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.player.EffectiveCombatStats;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.player.PlayerRunCombatCalculator;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.support.SeededRandomSource;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.talents.TalentData;
import ru.project.tower.talents.TalentTree;
import ru.project.tower.upgrades.UpgradeHost;
import ru.project.tower.upgrades.UpgradeKind;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.upgrades.shop.ShopUpgradeHost;
import ru.project.tower.upgrades.shop.ShopUpgradeKind;
import ru.project.tower.upgrades.shop.ShopUpgradeSystem;
import ru.project.tower.waves.WaveTuning;
public final class BalanceRuntimeHarness {
private static final int MAX_RUNTIME_WAVE = 140;
private static final int FIRST_BOSS_WAVE = EndlessBalanceSpec.BOSS_WAVE_INTERVAL;
private static final Rectangle PLAYER_SQUARE = new Rectangle(100f, 100f, 50f, 50f);
private static final BonusBallData.BonusType[] RUNTIME_BONUS_SEQUENCE = {
BonusBallData.BonusType.DAMAGE,
BonusBallData.BonusType.FIRE_RATE,
BonusBallData.BonusType.BULLET_SPEED,
BonusBallData.BonusType.CRIT_WINDOW,
BonusBallData.BonusType.PICKUP_MAGNET,
BonusBallData.BonusType.HEAL,
BonusBallData.BonusType.SHIELD_PULSE,
BonusBallData.BonusType.ABILITY_CHARGE,
BonusBallData.BonusType.COIN_BURST
};
public List<SimulationReport> run(long seed) {
return List.of(
simulate(seed, RuntimeRoute.fresh()),
simulate(seed, RuntimeRoute.exploit()),
simulate(seed, RuntimeRoute.balanced()),
simulate(seed, RuntimeRoute.moderateMeta()),
simulate(seed, RuntimeRoute.highMeta()));
}
private SimulationReport simulate(long seed, RuntimeRoute route) {
RuntimeState state = new RuntimeState(seed, route);
float firstBossKillSeconds = 0f;
int waveReached = 0;
for (int wave = 1; wave <= MAX_RUNTIME_WAVE; wave++) {
waveReached = wave;
state.prepareWave(wave);
state.applyRouteChoices(wave);
state.spawnBonusIfBudgetAllows(wave);
state.castAbilityIfDue(wave);
if (wave == FIRST_BOSS_WAVE) {
firstBossKillSeconds = state.bossKillSeconds(wave);
}
boolean overwhelmed = state.isOverwhelmed(wave);
state.finishWave();
if (overwhelmed) {
break;
}
}
return state.report(waveReached, firstBossKillSeconds);
}
private static final class RuntimeState implements UpgradeHost, ShopUpgradeHost, AbilityHost {
private final RuntimeRoute route;
private final long seed;
private final SeededRandomSource random;
private final PlayerCombatState combatState = new PlayerCombatState();
private final Array<BaseCircleData> circles = new Array<>();
private final Array<BonusBallData> runtimeBonusBalls = new Array<>();
private final PlayerAbilities playerAbilities;
private final Array<AbilityKind> selectedAbilities = new Array<>();
private final UpgradeSystem upgradeSystem;
private final ShopUpgradeSystem shopUpgradeSystem;
private final BonusBallSystem bonusBallSystem = new BonusBallSystem(runtimeBonusBalls);
private final AbilitySystem abilitySystem;
private final TalentTree talentTree;
private final PlayerStats playerStats;
private final EnumMap<PowerAxis, Float> axes = new EnumMap<>(PowerAxis.class);
private float baseDamage = 1f;
private float runDamageBonus;
private int runPierceBonus;
private float runAoeBonus;
private int currentWave = 1;
private int selectedDamageCards;
private int selectedFireRateCards;
private int abilityUses;
private int bonusDrops;
private int bonusSpawned;
private int bonusCollected;
private int peakVisibleBonusBalls;
private int activeBonusUptimeWaves;
private int moneyEarned;
private int moneySpent;
private float damageTaken;
private RuntimeState(long seed, RuntimeRoute route) {
this.route = route;
this.seed = seed;
this.random =
new SeededRandomSource(SeededRandomSource.forkSeed(seed, route.strategyId));
this.playerAbilities = new PlayerAbilities(new RuntimeAbilityPreferences());
for (AbilityKind ability : AbilityKind.values()) {
selectedAbilities.add(ability);
}
this.upgradeSystem = new UpgradeSystem(random, this);
this.shopUpgradeSystem = new ShopUpgradeSystem(this);
this.playerStats = new PlayerStats(new RuntimeAbilityPreferences());
route.applyMeta(playerStats);
this.talentTree = new TalentTree(playerStats);
this.abilitySystem =
new AbilitySystem(
PLAYER_SQUARE,
circles,
playerAbilities,
selectedAbilities,
random,
this);
applyPermanentTalentStats();
for (PowerAxis axis : PowerAxis.values()) {
axes.put(axis, 0f);
}
}
private void applyPermanentTalentStats() {
baseDamage += talentTree.getStatBonus(TalentData.TalentType.DAMAGE);
int maxHealth =
PlayerCombatState.DEFAULT_MAX_HEALTH
+ (int) talentTree.getStatBonus(TalentData.TalentType.HEALTH);
float shotCooldown =
Math.max(
PlayerCombatState.MIN_SHOT_COOLDOWN_MS,
PlayerCombatState.DEFAULT_SHOT_COOLDOWN_MS
- talentTree.getStatBonus(TalentData.TalentType.COOLDOWN));
int regen = (int) talentTree.getStatBonus(TalentData.TalentType.REGEN);
combatState.resetForNewRun(maxHealth, shotCooldown, regen);
combatState.setMoney(20);
}
private void prepareWave(int wave) {
currentWave = wave;
int income = Math.max(1, 3 + wave / 2);
combatState.addMoney(income);
moneyEarned += income;
circles.clear();
WaveTuning tuning = EndlessBalanceSpec.waveTuning(wave);
int health = tuning.basicCircleHealth();
circles.add(
new BasicCircleData(
new Circle(125f, 125f, 10f),
new Vector2(),
health,
tuning.circleDamage()));
}
private void applyRouteChoices(int wave) {
if (route.cardsEnabled && wave % 3 == 0) {
applyCard();
}
if (route.shopEnabled && wave >= route.firstShopWave && wave % 2 == 0) {
buyShopUpgrade();
}
}
private void applyCard() {
upgradeSystem.triggerPick();
UpgradeKind[] options = upgradeSystem.getPickOptions();
if (options == null) return;
int choice;
if (route.rawDamageFireRateOnly) {
UpgradeKind kind =
selectedDamageCards <= selectedFireRateCards
? UpgradeKind.DAMAGE
: UpgradeKind.FIRE_RATE;
choice = indexOf(options, kind);
} else {
choice = chooseFreshStarterCardIfNeeded();
if (choice < 0) {
improveMixedOfferWithRerolls();
choice = chooseMixedCardOption();
}
}
if (choice < 0) choice = 0;
UpgradeKind selected = options[choice];
upgradeSystem.applyPick(choice);
if (isDamageLike(selected)) selectedDamageCards++;
if (isCadenceLike(selected)) selectedFireRateCards++;
}
private boolean isDamageLike(UpgradeKind kind) {
switch (kind) {
case DAMAGE:
case BOSS_BREAKER:
case EXECUTION_LINE:
case BLAST_PROTOCOL:
case VOLATILE_BARGAIN:
return true;
default:
return false;
}
}
private boolean isCadenceLike(UpgradeKind kind) {
switch (kind) {
case FIRE_RATE:
case BONUS_MAGNET:
return true;
default:
return false;
}
}
private int indexOf(UpgradeKind[] options, UpgradeKind kind) {
for (int i = 0; i < options.length; i++) {
if (options[i] == kind) return i;
}
return -1;
}
private int chooseFreshStarterCardIfNeeded() {
if (!route.strategyId.contains("fresh")
|| selectedDamageCards + selectedFireRateCards > 0) {
return -1;
}
int index = indexOfDamageOrCadenceLike(upgradeSystem.getPickOptions());
while (index < 0 && upgradeSystem.rerollsRemaining() > 0) {
if (!upgradeSystem.rerollPick(shopUpgradeSystem.cardRerollDiscount())) {
return -1;
}
index = indexOfDamageOrCadenceLike(upgradeSystem.getPickOptions());
}
return index;
}
private int indexOfDamageOrCadenceLike(UpgradeKind[] options) {
if (options == null) return -1;
for (int i = 0; i < options.length; i++) {
if (isDamageLike(options[i]) || isCadenceLike(options[i])) return i;
}
return -1;
}
private int chooseMixedCardOption() {
UpgradeSystem.OfferCardView[] views = upgradeSystem.activeOfferViews();
int bestIndex = 0;
int bestScore = Integer.MIN_VALUE;
for (int i = 0; i < views.length; i++) {
int score = mixedCardScore(views[i]);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
private void improveMixedOfferWithRerolls() {
while (bestMixedCardScore() < 50 && upgradeSystem.rerollsRemaining() > 0) {
if (!upgradeSystem.rerollPick(shopUpgradeSystem.cardRerollDiscount())) {
return;
}
}
}
private int bestMixedCardScore() {
UpgradeSystem.OfferCardView[] views = upgradeSystem.activeOfferViews();
int bestScore = Integer.MIN_VALUE;
for (UpgradeSystem.OfferCardView view : views) {
bestScore = Math.max(bestScore, mixedCardScore(view));
}
return bestScore;
}
private int mixedCardScore(UpgradeSystem.OfferCardView view) {
if (view.synergyReady()) return 100;
switch (view.kind()) {
case DAMAGE:
case CRIT:
return view.stacks() < 2 ? 90 - view.stacks() : 40;
case FIRE_RATE:
return view.stacks() < 2 ? 80 - view.stacks() : 35;
case PIERCE:
return view.stacks() < 2 ? 75 - view.stacks() : 50;
case AOE:
return view.stacks() < 2 ? 70 - view.stacks() : 45;
case HEAL:
case REGEN:
return view.stacks() < 2 ? 60 - view.stacks() : 30;
default:
return 0;
}
}
private void buyShopUpgrade() {
ShopUpgradeKind kind;
int lane =
shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE)
+ shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN)
+ shopUpgradeSystem.level(ShopUpgradeKind.REGEN)
+ shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE);
if (route.rawDamageFireRateOnly) {
kind = lane % 2 == 0 ? ShopUpgradeKind.DAMAGE : ShopUpgradeKind.COOLDOWN;
} else {
int family = lane % 4;
if (family == 0) {
kind = ShopUpgradeKind.DAMAGE;
} else if (family == 1) {
kind = ShopUpgradeKind.COOLDOWN;
} else if (family == 2) {
kind = ShopUpgradeKind.REGEN;
} else {
kind = ShopUpgradeKind.CRIT_CHANCE;
}
}
shopUpgradeSystem.tryBuy(kind);
}
private void spawnBonusIfBudgetAllows(int wave) {
int budget = EndlessBalanceSpec.bonusDropBudgetForWave(wave);
if (budget <= 0) return;
for (int i = 0;
i < budget
&& runtimeBonusBalls.size < EndlessBalanceSpec.maxVisibleBonusBalls();
i++) {
BonusBallData ball = Pools.obtain(BonusBallData.class);
BonusBallData.BonusType type =
RUNTIME_BONUS_SEQUENCE[bonusSpawned % RUNTIME_BONUS_SEQUENCE.length];
ball.configure(125f, 125f, 10f, 0f, 0f, type);
runtimeBonusBalls.add(ball);
bonusSpawned++;
bonusDrops = bonusSpawned;
peakVisibleBonusBalls = Math.max(peakVisibleBonusBalls, runtimeBonusBalls.size);
if (shouldCollectSpawnedBonus()) {
bonusBallSystem.collect(runtimeBonusBalls.size - 1);
bonusCollected++;
}
}
}
private boolean shouldCollectSpawnedBonus() {
boolean pickupPity =
route.bonusPickupSkill >= 0.15f
&& runtimeBonusBalls.size >= EndlessBalanceSpec.maxVisibleBonusBalls();
return random.nextFloat() < route.bonusPickupSkill || pickupPity;
}
private void finishWave() {
if (bonusBallSystem.getActiveBonusCount() > 0) {
activeBonusUptimeWaves++;
}
bonusBallSystem.endWave();
}
private void castAbilityIfDue(int wave) {
if (!route.abilitiesEnabled
|| wave < route.firstAbilityWave
|| wave % route.abilityEveryWaves != 0) {
return;
}
long now = 200_000L + wave * 30_000L;
abilitySystem.activateExplosion(0f, 0f, now);
abilityUses++;
abilitySystem.tick(now + 1L);
}
private boolean isOverwhelmed(int wave) {
WaveTuning tuning = EndlessBalanceSpec.waveTuning(wave);
float pressure =
tuning.enemiesPerWave() * tuning.basicCircleHealth() * 0.034f
+ tuning.circleDamage() * 1.65f
+ Math.max(0, wave - 11) * route.postEarlyPressure;
float dps = effectiveDps();
float sustain =
combatState.getMaxHealth() * 0.020f
+ combatState.getHealthRegenRate() * 0.75f
+ runAoeBonus * 0.025f
+ runPierceBonus * 0.30f
+ abilityUses * route.abilityValue;
damageTaken += Math.max(0f, pressure - dps - sustain) * 0.18f;
return wave >= route.minimumFailCheckWave && dps + sustain < pressure;
}
private float effectiveDps() {
EffectiveCombatStats stats = currentStats();
float crit = 1f + stats.critChance() * (stats.critMultiplier() - 1f);
return stats.damage() * crit * 1000f / Math.max(90f, stats.shotCooldownMs());
}
private float bossKillSeconds(int wave) {
return EndlessBalanceSpec.bossHealthForWave(wave) / Math.max(0.01f, effectiveDps());
}
private SimulationReport report(int waveReached, float firstBossKillSeconds) {
EffectiveCombatStats stats = currentStats();
axes.put(PowerAxis.DAMAGE, stats.damage());
axes.put(PowerAxis.CADENCE, 1000f / Math.max(1f, stats.shotCooldownMs()));
axes.put(PowerAxis.HEALTH, (float) stats.healthRegenRate() * 2f);
axes.put(PowerAxis.AOE, stats.aoeRadiusBonus());
axes.put(PowerAxis.CONTROL, stats.controlRating());
axes.put(PowerAxis.REGEN, (float) stats.healthRegenRate());
axes.put(PowerAxis.CRIT, bonusBallSystem.getCritChanceBonus());
axes.put(PowerAxis.UTILITY, bonusBallSystem.getPickupRadiusMultiplier() - 1f);
axes.put(PowerAxis.ECONOMY, (float) moneyEarned / Math.max(1f, waveReached));
axes.put(PowerAxis.ABILITY, Math.max(0f, abilityUses * 1.5f));
for (Map.Entry<PowerAxis, Integer> rating :
talentTree.getPermanentAxisRatings().entrySet()) {
axes.put(rating.getKey(), axes.get(rating.getKey()) + rating.getValue());
}
return new SimulationReport(
seed,
route.strategyId,
waveReached,
firstBossKillSeconds,
shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE)
+ shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN)
+ shopUpgradeSystem.level(ShopUpgradeKind.REGEN)
+ shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE),
0f,
waveReached >= 100 ? 0 : 1,
damageTaken,
bonusDrops,
activeBonusPower(),
Math.max(0.01f, waveReached / 100f),
axes,
"runtime-harness",
firstBossKillSeconds,
shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE)
+ shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN)
+ shopUpgradeSystem.level(ShopUpgradeKind.REGEN)
+ shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE),
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
stats.damage(),
stats.shotCooldownMs(),
bonusSpawned,
bonusCollected,
activeBonusUptimeWaves,
upgradeSystem.triggeredSynergyCount(),
moneyEarned,
moneySpent);
}
private float activeBonusPower() {
return Math.max(
Math.max(
bonusBallSystem.getDamageMultiplier(),
1f / Math.max(0.01f, bonusBallSystem.getCooldownMultiplier())),
1f
+ bonusBallSystem.getCritChanceBonus()
+ bonusBallSystem.getPickupRadiusMultiplier()
- 1f);
}
private EffectiveCombatStats currentStats() {
return EffectiveCombatStats.fromSources(
combatState,
shopUpgradeSystem,
upgradeSystem,
bonusBallSystem,
talentTree,
currentWave,
baseDamage,
runDamageBonus,
runPierceBonus,
runAoeBonus,
runPierceBonus);
}
@Override
public void addRunDamagePercentBonus(int percent) {
runDamageBonus += Math.max(1f, baseDamage * percent / 100f);
}
@Override
public void addRunPierceBonus(int bonus) {
runPierceBonus += bonus;
}
@Override
public void addRunAoeBonus(float radiusBonus) {
runAoeBonus += radiusBonus;
}
@Override
public void addMaxHealth(int amount) {
combatState.setMaxHealth(combatState.getMaxHealth() + amount);
}
@Override
public void healToFull() {
combatState.setHealth(combatState.getMaxHealth());
}
@Override
public void addHealthRegen(int perSecond) {
combatState.setHealthRegenRate(combatState.getHealthRegenRate() + perSecond);
}
@Override
public void addFloatingText(float x, float y, String text, Color color) {}
@Override
public float getPlayerCenterX() {
return PLAYER_SQUARE.x + PLAYER_SQUARE.width / 2f;
}
@Override
public float getPlayerCenterY() {
return PLAYER_SQUARE.y + PLAYER_SQUARE.height / 2f;
}
@Override
public boolean spendMoney(int amount) {
boolean spent = combatState.spendMoney(amount);
if (spent) {
moneySpent += amount;
}
return spent;
}
@Override
public void addBaseDamage(int amount) {
}
@Override
public void addBaseSpeed(float amount) {}
@Override
public void reduceShotCooldown(float amount, float minimum) {
}
@Override
public void createExplosion(float x, float y, Color color) {}
@Override
public void triggerKillShake() {}
@Override
public void triggerCritHitstop() {}
@Override
public int effectiveBulletDamage() {
return Math.round(currentStats().damage());
}
@Override
public float abilityScalingFactor() {
return PlayerRunCombatCalculator.abilityScalingFactor(currentWave);
}
@Override
public float gameScale() {
return 1f;
}
@Override
public long abilityCooldownReductionMs() {
return 0L;
}
@Override
public boolean isBoss(BaseCircleData circle) {
return false;
}
@Override
public void addMoney(int amount) {
combatState.addMoney(amount);
moneyEarned += Math.max(0, amount);
}
@Override
public void killEnemy(BaseCircleData enemy) {
addMoney(enemy.getReward());
circles.removeValue(enemy, true);
}
@Override
public void onTurretShot(Turret turret, BaseCircleData target, long now) {}
}
private static final class RuntimeRoute {
private final String strategyId;
private final boolean shopEnabled;
private final boolean cardsEnabled;
private final boolean abilitiesEnabled;
private final boolean rawDamageFireRateOnly;
private final int firstShopWave;
private final int firstAbilityWave;
private final int abilityEveryWaves;
private final int minimumFailCheckWave;
private final float bonusPickupSkill;
private final float abilityValue;
private final float postEarlyPressure;
private final int metaProfile;
private RuntimeRoute(
String strategyId,
boolean shopEnabled,
boolean cardsEnabled,
boolean abilitiesEnabled,
boolean rawDamageFireRateOnly,
int firstShopWave,
int firstAbilityWave,
int abilityEveryWaves,
int minimumFailCheckWave,
float bonusPickupSkill,
float abilityValue,
float postEarlyPressure) {
this(
strategyId,
shopEnabled,
cardsEnabled,
abilitiesEnabled,
rawDamageFireRateOnly,
firstShopWave,
firstAbilityWave,
abilityEveryWaves,
minimumFailCheckWave,
bonusPickupSkill,
abilityValue,
postEarlyPressure,
0);
}
private RuntimeRoute(
String strategyId,
boolean shopEnabled,
boolean cardsEnabled,
boolean abilitiesEnabled,
boolean rawDamageFireRateOnly,
int firstShopWave,
int firstAbilityWave,
int abilityEveryWaves,
int minimumFailCheckWave,
float bonusPickupSkill,
float abilityValue,
float postEarlyPressure,
int metaProfile) {
this.strategyId = strategyId;
this.shopEnabled = shopEnabled;
this.cardsEnabled = cardsEnabled;
this.abilitiesEnabled = abilitiesEnabled;
this.rawDamageFireRateOnly = rawDamageFireRateOnly;
this.firstShopWave = firstShopWave;
this.firstAbilityWave = firstAbilityWave;
this.abilityEveryWaves = abilityEveryWaves;
this.minimumFailCheckWave = minimumFailCheckWave;
this.bonusPickupSkill = bonusPickupSkill;
this.abilityValue = abilityValue;
this.postEarlyPressure = postEarlyPressure;
this.metaProfile = metaProfile;
}
private static RuntimeRoute fresh() {
return new RuntimeRoute(
"runtime:fresh:no-menu-talent:shop-card-ability",
true,
true,
true,
false,
2,
6,
4,
10,
0.20f,
0.65f,
2.6f);
}
private static RuntimeRoute exploit() {
return new RuntimeRoute(
"runtime:damage-fire-rate:no-shop:no-ability",
false,
true,
false,
true,
999,
999,
999,
3,
0.05f,
0f,
3.4f);
}
private static RuntimeRoute balanced() {
return new RuntimeRoute(
"runtime:balanced:shop:ability",
true,
true,
true,
false,
2,
6,
3,
24,
0.30f,
1.40f,
1.6f);
}
private static RuntimeRoute moderateMeta() {
return new RuntimeRoute(
"runtime:moderate-meta:shop-card-ability",
true,
true,
true,
false,
2,
6,
3,
28,
0.35f,
1.55f,
1.45f,
1);
}
private static RuntimeRoute highMeta() {
return new RuntimeRoute(
"runtime:high-meta:shop-card-ability",
true,
true,
true,
false,
2,
6,
3,
34,
0.40f,
1.70f,
1.35f,
2);
}
private void applyMeta(PlayerStats stats) {
if (metaProfile <= 0) return;
invest(stats, "health_1", metaProfile == 1 ? 3 : 5);
invest(stats, "damage_1", metaProfile == 1 ? 2 : 3);
invest(stats, "cooldown_1", metaProfile == 1 ? 2 : 4);
invest(stats, "speed_1", metaProfile == 1 ? 1 : 3);
invest(stats, "regen_1", metaProfile == 1 ? 2 : 4);
invest(stats, "control_1", metaProfile == 1 ? 1 : 3);
invest(stats, "economy_1", metaProfile == 1 ? 1 : 3);
invest(stats, "ability_1", metaProfile == 1 ? 1 : 3);
invest(stats, "utility_1", metaProfile == 1 ? 1 : 3);
invest(stats, "economy_reroll", metaProfile == 1 ? 1 : 2);
invest(stats, "utility_bonus", metaProfile == 1 ? 1 : 2);
if (metaProfile >= 2) {
invest(stats, "health_2", 2);
invest(stats, "cooldown_2", 2);
invest(stats, "regen_2", 2);
invest(stats, "keystone_pierce", 1);
invest(stats, "keystone_aoe", 1);
invest(stats, "attack_precision", 2);
invest(stats, "control_execute", 2);
invest(stats, "engineering_turret", 2);
invest(stats, "survival_barrier", 2);
invest(stats, "ability_blast", 2);
invest(stats, "keystone_turret", 1);
invest(stats, "keystone_control_execute", 1);
invest(stats, "keystone_shield_regen", 1);
invest(stats, "keystone_economy_reroll", 1);
}
}
private void invest(PlayerStats stats, String talentId, int levels) {
for (int i = 0; i < levels; i++) {
stats.incrementTalentNode(talentId);
}
}
}
private static final class RuntimeAbilityPreferences implements Preferences {
@Override
public boolean getBoolean(String key, boolean defValue) {
return true;
}
@Override
public Preferences putBoolean(String key, boolean val) {
return this;
}
@Override
public Preferences putInteger(String key, int val) {
return this;
}
@Override
public Preferences putLong(String key, long val) {
return this;
}
@Override
public Preferences putFloat(String key, float val) {
return this;
}
@Override
public Preferences putString(String key, String val) {
return this;
}
@Override
public Preferences put(Map<String, ?> vals) {
return this;
}
@Override
public boolean getBoolean(String key) {
return true;
}
@Override
public int getInteger(String key) {
return 0;
}
@Override
public long getLong(String key) {
return 0L;
}
@Override
public float getFloat(String key) {
return 0f;
}
@Override
public String getString(String key) {
return "";
}
@Override
public int getInteger(String key, int defValue) {
return defValue;
}
@Override
public long getLong(String key, long defValue) {
return defValue;
}
@Override
public float getFloat(String key, float defValue) {
return defValue;
}
@Override
public String getString(String key, String defValue) {
return defValue;
}
@Override
public Map<String, ?> get() {
return new HashMap<>();
}
@Override
public boolean contains(String key) {
return true;
}
@Override
public void clear() {}
@Override
public void remove(String key) {}
@Override
public void flush() {}
}
}
@@ -0,0 +1,137 @@
package ru.project.tower.balance;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.run.RunRngStreams;
public final class BalanceSimulationHarness {
private static final int TARGET_WAVES = 120;
public List<SimulationReport> runRepresentativeSuite(long[] seeds) {
BalanceRouteSimulator routeSimulator = new BalanceRouteSimulator();
List<SimulationReport> reports = new ArrayList<>();
for (long seed : seeds) {
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runFreshNoMenuTalentShopCardAbilityRoute(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runDamageFireRateNoShopNoAbility(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runBalancedShopAndAbilityRoute(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runSustainControlShopAndAbilityRoute(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runMixedNoShopNoAbilityRoute(seed)));
}
return reports;
}
public List<SimulationReport> runRuntimeBackedSuite(long[] seeds) {
BalanceRuntimeHarness runtimeHarness = new BalanceRuntimeHarness();
List<SimulationReport> reports = new ArrayList<>();
for (long seed : seeds) {
reports.addAll(runtimeHarness.run(seed));
}
return reports;
}
public SimulationReport run(long seed, StrategyProfile profile) {
RunRngStreams streams = RunRngStreams.fromSeed(seed);
EnumMap<PowerAxis, Float> ratings = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
ratings.put(axis, profile.metaRating() * profile.weight(axis) * 0.35f);
}
int purchases = 0;
int activeDamageStacks = 0;
int activeSpeedStacks = 0;
int activeFireRateStacks = 0;
float survivability = 1f;
float bossKillSeconds = 0f;
float clearSecondsTotal = 0f;
float damageTaken = 0f;
int bonusDrops = 0;
int waveReached = 0;
for (int wave = 1; wave <= TARGET_WAVES; wave++) {
waveReached = wave;
PowerAxis chosenAxis = chooseAxis(streams.upgradeDrafts().nextFloat(), profile);
ratings.put(chosenAxis, ratings.get(chosenAxis) + 2.2f * profile.weight(chosenAxis));
purchases++;
if (streams.bonusDrops().nextFloat() < budgetedDropChance(wave)) {
bonusDrops++;
int type = streams.bonusDrops().nextIntInclusive(2);
if (type == 0) activeDamageStacks++;
if (type == 1) activeSpeedStacks++;
if (type == 2) activeFireRateStacks++;
}
bossKillSeconds = EndlessBalanceSpec.expectedBossKillSeconds(wave, ratings);
float incoming =
EndlessBalanceSpec.waveTuning(wave).circleDamage()
* (1.1f - Math.min(0.55f, ratings.get(PowerAxis.CONTROL) * 0.006f));
float mitigation =
EndlessBalanceSpec.axisMultiplier(ratings.get(PowerAxis.SHIELD))
+ EndlessBalanceSpec.axisMultiplier(ratings.get(PowerAxis.REGEN)) * 0.7f
+ EndlessBalanceSpec.axisMultiplier(ratings.get(PowerAxis.HEALTH))
* 0.5f;
survivability += mitigation * 0.015f - incoming * 0.010f;
damageTaken += Math.max(0f, incoming - mitigation * 0.35f);
clearSecondsTotal +=
Math.max(3f, EndlessBalanceSpec.waveTuning(wave).enemiesPerWave() / 8f);
if (survivability <= 0.15f) {
break;
}
}
float activeBonusPower =
EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.DAMAGE, activeDamageStacks)
+ EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.BULLET_SPEED, activeSpeedStacks)
+ (1f
/ EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.FIRE_RATE, activeFireRateStacks));
return new SimulationReport(
seed,
profile.id(),
waveReached,
bossKillSeconds,
purchases,
clearSecondsTotal / Math.max(1, waveReached),
survivability <= 0.15f ? 1 : 0,
damageTaken,
bonusDrops,
activeBonusPower,
Math.max(0.01f, survivability),
ratings);
}
private static PowerAxis chooseAxis(float roll, StrategyProfile profile) {
float total = 0f;
for (PowerAxis axis : PowerAxis.values()) {
total += profile.weight(axis);
}
float target = roll * total;
float cumulative = 0f;
for (PowerAxis axis : PowerAxis.values()) {
cumulative += profile.weight(axis);
if (target <= cumulative) {
return axis;
}
}
return PowerAxis.DAMAGE;
}
private static float budgetedDropChance(int wave) {
return Math.min(
0.32f, EndlessBalanceSpec.bonusDropBudgetThroughWave(wave) / (float) (wave + 12));
}
}
@@ -0,0 +1,78 @@
package ru.project.tower.balance;
public final class BossBalanceSpec {
private static final BossBalanceSpec GENERIC =
new BossBalanceSpec("generic", 1.0f, 8f, 32f, 7f, 42f, 5f);
private static final BossBalanceSpec[] SPECS = {
new BossBalanceSpec("SwarmQueen", 1.05f, 9f, 34f, 8f, 44f, 6f),
new BossBalanceSpec("Juggernaut", 1.22f, 11f, 40f, 9f, 48f, 7f),
new BossBalanceSpec("ShadowWeaver", 0.96f, 8f, 32f, 7f, 42f, 5f),
new BossBalanceSpec("VolatileReactor", 1.08f, 9f, 35f, 8f, 45f, 5f),
new BossBalanceSpec("GlacialWarden", 1.14f, 10f, 38f, 8f, 46f, 7f),
new BossBalanceSpec("IllusionMaster", 1.00f, 8f, 33f, 7f, 43f, 6f)
};
private final String id;
private final float healthMultiplier;
private final float firstBossMinTtkSeconds;
private final float firstBossMaxTtkSeconds;
private final float lateBossMinTtkSeconds;
private final float lateBossMaxTtkSeconds;
private final float minimumMechanicExposureSeconds;
private BossBalanceSpec(
String id,
float healthMultiplier,
float firstBossMinTtkSeconds,
float firstBossMaxTtkSeconds,
float lateBossMinTtkSeconds,
float lateBossMaxTtkSeconds,
float minimumMechanicExposureSeconds) {
this.id = id;
this.healthMultiplier = healthMultiplier;
this.firstBossMinTtkSeconds = firstBossMinTtkSeconds;
this.firstBossMaxTtkSeconds = firstBossMaxTtkSeconds;
this.lateBossMinTtkSeconds = lateBossMinTtkSeconds;
this.lateBossMaxTtkSeconds = lateBossMaxTtkSeconds;
this.minimumMechanicExposureSeconds = minimumMechanicExposureSeconds;
}
public static BossBalanceSpec forBoss(String id) {
for (BossBalanceSpec spec : SPECS) {
if (spec.id.equals(id)) return spec;
}
return GENERIC;
}
public String id() {
return id;
}
public int healthFloorForWave(int wave) {
return Math.round(EndlessBalanceSpec.bossHealthForWave(wave) * healthMultiplier);
}
public float expectedKillSecondsAtWave(int wave, float effectiveDps) {
return healthFloorForWave(wave) / Math.max(0.01f, effectiveDps);
}
public float firstBossMinTtkSeconds() {
return firstBossMinTtkSeconds;
}
public float firstBossMaxTtkSeconds() {
return firstBossMaxTtkSeconds;
}
public float lateBossMinTtkSeconds() {
return lateBossMinTtkSeconds;
}
public float lateBossMaxTtkSeconds() {
return lateBossMaxTtkSeconds;
}
public float minimumMechanicExposureSeconds() {
return minimumMechanicExposureSeconds;
}
}
@@ -0,0 +1,501 @@
package ru.project.tower.balance;
import java.util.Map;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.upgrades.shop.ShopUpgradeKind;
import ru.project.tower.upgrades.shop.ShopUpgradeRegistry;
import ru.project.tower.waves.WaveTuning;
public final class EndlessBalanceSpec {
public static final int SHOP_MAX_LEVEL = 30;
public static final int TALENT_AXIS_MAX_RATING = 55;
public static final float CRIT_CHANCE_CAP = 0.65f;
public static final float ACTIVE_DAMAGE_BONUS_CAP = 2.35f;
public static final float ACTIVE_FIRE_RATE_COOLDOWN_FLOOR = 0.42f;
public static final float ACTIVE_BULLET_SPEED_CAP = 2.25f;
public static final int BONUS_DROP_BUDGET_PER_TEN_WAVES = 7;
public static final int MAX_VISIBLE_BONUS_BALLS = 3;
public static final int BOSS_WAVE_INTERVAL = 20;
public static final float MIN_EXPECTED_LATE_BOSS_KILL_SECONDS = 6.0f;
private static final String[] ARCHETYPES = {
"BASIC", "STRONG", "FAST", "SPLITTER", "HEALER", "DASHER", "SNIPER", "SPAWNER"
};
private static final WaveBandTarget[] WAVE_BAND_TARGETS = {
new WaveBandTarget("teach", 1, 20, 1.0f, "teach movement, pickups, first boss prep"),
new WaveBandTarget("build-check", 21, 60, 1.65f, "require mixed cards and early shop"),
new WaveBandTarget(
"synergy-check", 61, 100, 2.35f, "require shop, abilities, and synergies"),
new WaveBandTarget(
"endless-pressure", 101, Integer.MAX_VALUE, 3.10f, "stress capped builds")
};
private static final BalanceRouteTarget[] ROUTE_TARGETS = {
new BalanceRouteTarget(
"fresh:no-menu-talent:shop-card-ability",
10,
12,
0f,
0f,
false,
true,
true,
true,
"fresh profile learns in-run shop, cards, and abilities before meta carries"
+ " farther"),
new BalanceRouteTarget(
"challenge:no-menu-talent:no-shop:no-ability",
3,
7,
0f,
0f,
false,
false,
false,
false,
"challenge profile intentionally ignores core in-run tools"),
new BalanceRouteTarget(
"damage-fire-rate:no-shop:no-ability",
20,
99,
8f,
60f,
false,
false,
true,
false,
"regression profile must not revive the old wave-100 exploit"),
new BalanceRouteTarget(
"mixed:no-shop:no-ability",
20,
99,
7f,
60f,
false,
false,
true,
false,
"mixed cards beat raw exploit but still need shop and abilities"),
new BalanceRouteTarget(
"balanced:shop:ability",
25,
120,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
45f,
false,
true,
true,
true,
"balanced route demonstrates normal in-run progression"),
new BalanceRouteTarget(
"sustain-control:shop:ability",
25,
120,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
45f,
false,
true,
true,
true,
"defensive route remains viable without raw damage dominance"),
new BalanceRouteTarget(
"moderate-meta:shop-card-ability",
45,
90,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
40f,
true,
true,
true,
true,
"moderate meta progression pushes past early walls without endless dominance"),
new BalanceRouteTarget(
"high-meta:capped-shop-card-ability",
100,
140,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
35f,
true,
true,
true,
true,
"capped meta handles endless pressure but must still respect boss TTK")
};
private EndlessBalanceSpec() {}
public static WaveTuning waveTuning(int wave) {
int safeWave = Math.max(1, wave);
float band = band(safeWave);
int enemies = Math.min(240, Math.round(5f + (safeWave - 1) * 1.6f + band * 8f));
float strong = Math.min(0.75f, 0.10f * (1 + (safeWave - 1) * 0.1f));
float fast = safeWave >= 3 ? 0.15f * (1 + (safeWave - 3) * 0.05f) : 0f;
float splitter = 0f;
if (safeWave >= 7) {
float progressToMax = Math.min(1f, (safeWave - 7) / 10f);
splitter = 0.05f + (0.15f - 0.05f) * progressToMax;
}
int basicHealth =
Math.min(20_000, 1 + Math.round((float) Math.pow(safeWave - 1, 1.45f) * 2.2f));
int strongHealth = Math.max(3, basicHealth * 3);
int damage = Math.min(180, 1 + Math.round((float) Math.pow(safeWave - 1, 1.05f) * 0.45f));
float speed = Math.min(2.6f, 1f + (safeWave - 1) * 0.012f);
return new WaveTuning(
safeWave,
enemies,
strong,
fast,
splitter,
basicHealth,
strongHealth,
damage,
speed);
}
public static WaveBandTarget waveBandTarget(int wave) {
int safeWave = Math.max(1, wave);
for (WaveBandTarget target : WAVE_BAND_TARGETS) {
if (safeWave >= target.firstWave() && safeWave <= target.lastWave()) {
return target;
}
}
return WAVE_BAND_TARGETS[WAVE_BAND_TARGETS.length - 1];
}
public static BalanceRouteTarget routeTarget(String strategyId) {
for (BalanceRouteTarget target : ROUTE_TARGETS) {
if (target.strategyId().equals(strategyId)) {
return target;
}
}
throw new IllegalArgumentException("Unknown balance route target: " + strategyId);
}
public static EnemyComposition compositionForWave(int wave) {
int safeWave = Math.max(1, wave);
float strong = safeWave >= 2 ? Math.min(0.22f, 0.08f + safeWave * 0.0015f) : 0f;
float fast = safeWave >= 3 ? Math.min(0.16f, 0.06f + safeWave * 0.001f) : 0f;
float splitter = safeWave >= 7 ? Math.min(0.12f, 0.04f + safeWave * 0.001f) : 0f;
float healer = safeWave >= 8 ? Math.min(0.08f, 0.025f + safeWave * 0.0007f) : 0f;
float dasher = safeWave >= 6 ? Math.min(0.11f, 0.04f + safeWave * 0.0008f) : 0f;
float sniper = safeWave >= 7 ? Math.min(0.09f, 0.03f + safeWave * 0.0007f) : 0f;
float spawner =
safeWave >= 15 && (safeWave - 15) % 11 == 0
? Math.min(0.10f, 0.05f + safeWave * 0.0005f)
: 0f;
float basic =
Math.max(0.20f, 1f - strong - fast - splitter - healer - dasher - sniper - spawner);
return new EnemyComposition(
ARCHETYPES,
new float[] {basic, strong, fast, splitter, healer, dasher, sniper, spawner});
}
public static int bonusDropBudgetThroughWave(int wave) {
return Math.max(1, ((Math.max(1, wave) + 9) / 10) * BONUS_DROP_BUDGET_PER_TEN_WAVES);
}
public static int bonusDropBudgetForWave(int wave) {
int bandWave = (Math.max(1, wave) - 1) % 10;
return bandWave < BONUS_DROP_BUDGET_PER_TEN_WAVES ? 1 : 0;
}
public static int maxVisibleBonusBalls() {
return MAX_VISIBLE_BONUS_BALLS;
}
public static float activeBonusMultiplier(BonusBallData.BonusType type, int stacks) {
int safeStacks = Math.max(0, stacks);
switch (type) {
case DAMAGE:
if (safeStacks == 0) return 1f;
return Math.min(
ACTIVE_DAMAGE_BONUS_CAP,
2f + diminishing(0f, 0.18f, safeStacks - 1, 0.55f));
case BULLET_SPEED:
if (safeStacks == 0) return 1f;
if (safeStacks == 1) return 1.5f;
return Math.min(
ACTIVE_BULLET_SPEED_CAP,
2.25f + diminishing(0f, 0.08f, safeStacks - 2, 0.50f));
case FIRE_RATE:
if (safeStacks == 0) return 1f;
return Math.max(
ACTIVE_FIRE_RATE_COOLDOWN_FLOOR,
(float) (0.6f * Math.pow(0.75f, safeStacks - 1)));
default:
throw new IllegalArgumentException("Unknown bonus type: " + type);
}
}
public static int upgradeCardDamagePercent(int existingStacks) {
int safeStacks = Math.max(0, existingStacks);
return Math.max(6, Math.round(15f * (float) Math.pow(0.86f, safeStacks)));
}
public static float upgradeCardFireRateMultiplier(int existingStacks) {
int safeStacks = Math.max(0, existingStacks);
return Math.min(0.96f, 1f - 0.10f * (float) Math.pow(0.72f, safeStacks));
}
public static int shopCost(ShopUpgradeKind kind, int level) {
int safeLevel = Math.max(0, level);
double scale = ShopUpgradeRegistry.definition(kind).costScale();
return Math.max(5, (int) (5.0 * Math.pow(scale, Math.min(safeLevel, SHOP_MAX_LEVEL))));
}
public static float shopRating(ShopUpgradeKind kind, int level) {
float familyScale =
kind == ShopUpgradeKind.CRIT_CHANCE || kind == ShopUpgradeKind.CRIT_MULTIPLIER
? 0.82f
: 1f;
return familyScale * diminishing(0f, 4f, Math.min(level, SHOP_MAX_LEVEL), 0.89f);
}
public static int shopDamageBonusAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.DAMAGE, level) * 0.25f);
}
public static float shopSpeedBonusAtLevel(int level) {
return shopRating(ShopUpgradeKind.SPEED, level) * 12.5f;
}
public static float shopCooldownReductionAtLevel(int level) {
return shopRating(ShopUpgradeKind.COOLDOWN, level) * 12.5f;
}
public static int shopRegenBonusAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.REGEN, level) * 0.25f);
}
public static float shopCritChanceBonusAtLevel(int level) {
return Math.min(CRIT_CHANCE_CAP, shopRating(ShopUpgradeKind.CRIT_CHANCE, level) * 0.0065f);
}
public static float shopCritMultiplierBonusAtLevel(int level) {
return shopRating(ShopUpgradeKind.CRIT_MULTIPLIER, level) * 0.0305f;
}
public static int shopPierceBonusAtLevel(int level) {
return Math.min(6, Math.round(shopRating(ShopUpgradeKind.PIERCE, level) * 0.18f));
}
public static float shopAoeRadiusBonusAtLevel(int level) {
return Math.min(180f, shopRating(ShopUpgradeKind.SPLASH_DAMAGE, level) * 3.2f);
}
public static float shopExecuteDamageBonusAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.EXECUTE_DAMAGE, level) * 0.006f);
}
public static float shopBossDamageBonusAtLevel(int level) {
return Math.min(0.40f, shopRating(ShopUpgradeKind.BOSS_DAMAGE, level) * 0.0065f);
}
public static float shopControlledDamageBonusAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.CONTROL_DAMAGE, level) * 0.006f);
}
public static float shopContactArmorReductionAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.CONTACT_ARMOR, level) * 0.006f);
}
public static float shopProjectileArmorReductionAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.PROJECTILE_ARMOR, level) * 0.005f);
}
public static int shopShieldCapacityBonusAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.SHIELD_CAPACITY, level) * 1.8f);
}
public static float shopShieldRechargeReductionAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.SHIELD_RECHARGE, level) * 0.006f);
}
public static float shopEmergencyBarrierThresholdAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.EMERGENCY_BARRIER, level) * 0.004f);
}
public static int shopWaveRecoveryHealAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.WAVE_RECOVERY, level) * 0.65f);
}
public static float shopSafeMobilityReductionAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.SAFE_MOBILITY, level) * 0.005f);
}
public static float shopSlowAuraStrengthAtLevel(int level) {
return Math.min(0.24f, shopRating(ShopUpgradeKind.SLOW_AURA, level) * 0.004f);
}
public static float shopPickupRadiusBonusAtLevel(int level) {
return Math.min(180f, shopRating(ShopUpgradeKind.PICKUP_RADIUS, level) * 2.5f);
}
public static int shopBonusDurationBonusAtLevel(int level) {
return Math.min(3, Math.round(shopRating(ShopUpgradeKind.BONUS_DURATION, level) * 0.08f));
}
public static float shopBonusPowerBonusAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.BONUS_POWER, level) * 0.005f);
}
public static float shopBonusStabilityBonusAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.BONUS_STABILITY, level) * 0.006f);
}
public static int shopBonusPityBonusAtLevel(int level) {
return Math.min(3, Math.round(shopRating(ShopUpgradeKind.BONUS_PITY, level) * 0.08f));
}
public static float shopBonusFilterChanceAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.BONUS_FILTER, level) * 0.006f);
}
public static float shopCoinIncomeBonusAtLevel(int level) {
return Math.min(0.45f, shopRating(ShopUpgradeKind.COIN_INCOME, level) * 0.007f);
}
public static float shopDiscountAtLevel(int level) {
return Math.min(0.25f, shopRating(ShopUpgradeKind.SHOP_DISCOUNT, level) * 0.004f);
}
public static float shopCardRerollDiscountAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.CARD_REROLL, level) * 0.006f);
}
public static float shopSynergyCatalystBonusAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.SYNERGY_CATALYST, level) * 0.005f);
}
public static int permanentTalentRating(int investedLevels) {
int safeLevels = Math.max(0, investedLevels);
if (safeLevels >= 30) {
return TALENT_AXIS_MAX_RATING;
}
return Math.min(TALENT_AXIS_MAX_RATING, Math.round(diminishing(0f, 5f, safeLevels, 0.88f)));
}
public static float axisMultiplier(float rating) {
return 1f + (float) (Math.log1p(Math.max(0f, rating)) / Math.log(10.0));
}
public static float critChanceFromRating(float baseCritChance, float rating) {
return Math.min(CRIT_CHANCE_CAP, baseCritChance + rating * 0.0065f);
}
public static int bossHealthForWave(int wave) {
int safeWave = Math.max(BOSS_WAVE_INTERVAL, wave);
return Math.round(180f + safeWave * 34f + band(safeWave) * 420f);
}
public static int bossHealthForWave(String bossId, int wave) {
return BossBalanceSpec.forBoss(bossId).healthFloorForWave(wave);
}
public static float expectedBossKillSeconds(int wave, Map<PowerAxis, Float> ratings) {
float effectiveDps = 10f * offenseMultiplier(ratings);
return bossHealthForWave(wave) / effectiveDps;
}
public static float offenseMultiplier(Map<PowerAxis, Float> ratings) {
return cappedAxisMultiplier(ratings, PowerAxis.DAMAGE, 1f)
* cappedAxisMultiplier(ratings, PowerAxis.CADENCE, 1f)
* cappedAxisMultiplier(ratings, PowerAxis.CRIT, 0.6f)
* cappedAxisMultiplier(ratings, PowerAxis.PIERCE, 0.18f)
* cappedAxisMultiplier(ratings, PowerAxis.AOE, 0.24f)
* cappedAxisMultiplier(ratings, PowerAxis.CONTROL, 0.16f)
* cappedAxisMultiplier(ratings, PowerAxis.BOSSING, 0.30f);
}
public static float effectivePower(Map<PowerAxis, Float> ratings) {
float total = 0f;
for (PowerAxis axis : PowerAxis.values()) {
total += cappedAxisMultiplier(ratings, axis, 1f);
}
float synergy =
Math.min(
cappedValue(ratings, PowerAxis.DAMAGE),
cappedValue(ratings, PowerAxis.CRIT))
* 0.025f
+ Math.min(
value(ratings, PowerAxis.SHIELD),
value(ratings, PowerAxis.REGEN))
* 0.020f
+ Math.min(value(ratings, PowerAxis.AOE), value(ratings, PowerAxis.CONTROL))
* 0.018f;
return total + synergy;
}
private static float cappedAxisMultiplier(
Map<PowerAxis, Float> ratings, PowerAxis axis, float scale) {
return axisMultiplier(cappedValue(ratings, axis) * scale);
}
private static float cappedValue(Map<PowerAxis, Float> ratings, PowerAxis axis) {
float raw = value(ratings, axis);
BalanceEffectSpec spec = BalanceEffectRegistry.forAxis(axis);
float floored = spec.ratingFloor() == null ? raw : Math.max(spec.ratingFloor(), raw);
return spec.ratingCap() == null ? floored : Math.min(spec.ratingCap(), floored);
}
private static float value(Map<PowerAxis, Float> ratings, PowerAxis axis) {
if (ratings == null) return 0f;
Float value = ratings.get(axis);
return value == null ? 0f : value;
}
private static float diminishing(float base, float perStack, int stacks, float retention) {
float value = base;
float next = perStack;
for (int i = 0; i < stacks; i++) {
value += next;
next *= retention;
}
return value;
}
private static float band(int wave) {
return Math.max(0, (wave - 1) / 10);
}
public static final class WaveBandTarget {
private final String id;
private final int firstWave;
private final int lastWave;
private final float pressureMultiplier;
private final String designGoal;
private WaveBandTarget(
String id,
int firstWave,
int lastWave,
float pressureMultiplier,
String designGoal) {
this.id = id;
this.firstWave = firstWave;
this.lastWave = lastWave;
this.pressureMultiplier = pressureMultiplier;
this.designGoal = designGoal;
}
public String id() {
return id;
}
public int firstWave() {
return firstWave;
}
public int lastWave() {
return lastWave;
}
public float pressureMultiplier() {
return pressureMultiplier;
}
public String designGoal() {
return designGoal;
}
}
}
@@ -0,0 +1,76 @@
package ru.project.tower.balance;
import java.util.Arrays;
import java.util.Objects;
public final class EnemyComposition {
private final String[] archetypes;
private final float[] weights;
public EnemyComposition(String[] archetypes, float[] weights) {
if (archetypes.length != weights.length || archetypes.length == 0) {
throw new IllegalArgumentException("composition requires matching non-empty arrays");
}
this.archetypes = Arrays.copyOf(archetypes, archetypes.length);
this.weights = Arrays.copyOf(weights, weights.length);
normalize(this.weights);
}
public int size() {
return archetypes.length;
}
public String archetypeAt(int index) {
return archetypes[index];
}
public float weightAt(int index) {
return weights[index];
}
public float weightOf(String archetype) {
Objects.requireNonNull(archetype, "archetype");
for (int i = 0; i < archetypes.length; i++) {
if (archetypes[i].equals(archetype)) {
return weights[i];
}
}
return 0f;
}
public float totalWeight() {
float total = 0f;
for (float weight : weights) {
total += weight;
}
return total;
}
public String select(float roll) {
float clampedRoll = Math.max(0f, Math.min(0.999999f, roll));
float cumulative = 0f;
for (int i = 0; i < archetypes.length; i++) {
cumulative += weights[i];
if (clampedRoll < cumulative) {
return archetypes[i];
}
}
return archetypes[archetypes.length - 1];
}
private static void normalize(float[] weights) {
float total = 0f;
for (float weight : weights) {
if (weight < 0f) {
throw new IllegalArgumentException("composition weights cannot be negative");
}
total += weight;
}
if (total <= 0f) {
throw new IllegalArgumentException("composition weight total must be > 0");
}
for (int i = 0; i < weights.length; i++) {
weights[i] /= total;
}
}
}
@@ -0,0 +1,33 @@
package ru.project.tower.balance;
public final class MetaProgressionPacing {
public static final int FIRST_ABILITY_TARGET_WAVE = 12;
public static final int FIRST_BOSS_TARGET_WAVE = 20;
public static final int FIRST_KEYSTONE_TARGET_WAVE = 25;
public static final int FIRST_MAXED_AXIS_TARGET_RUNS = 8;
private MetaProgressionPacing() {}
public static int currencyEarnedForRun(int reachedWave) {
int safeWave = Math.max(0, reachedWave);
int waveClearCurrency = safeWave * 5 + (safeWave / 5) * 10;
int gameOverAward = safeWave;
return waveClearCurrency + gameOverAward;
}
public static int projectedCurrencyAfterRuns(int... reachedWaves) {
int total = 0;
for (int wave : reachedWaves) {
total += currencyEarnedForRun(wave);
}
return total;
}
public static int firstKeystoneCostTarget() {
return 700;
}
public static int firstMaxedAxisCostTarget() {
return 100 + 150 + 225 + 337 + 506;
}
}
@@ -0,0 +1,18 @@
package ru.project.tower.balance;
public enum PowerAxis {
DAMAGE,
CADENCE,
CRIT,
PIERCE,
AOE,
CONTROL,
BOSSING,
HEALTH,
REGEN,
SHIELD,
ARMOR,
ECONOMY,
UTILITY,
ABILITY
}
@@ -0,0 +1,156 @@
package ru.project.tower.balance;
import java.util.HashMap;
import java.util.Map;
import ru.project.tower.support.RandomSource;
public final class SeededUpgradeDraftDeck {
private static final int OFFER_COUNT = 3;
private static final int MAX_REROLLS = 2;
private static final int BASE_REROLL_COST = 15;
private final UpgradeCard[] registry;
private final Map<String, Integer> pickedCopies = new HashMap<>();
private UpgradeCard[] activeOffer;
private int rerollsUsed;
public SeededUpgradeDraftDeck() {
this(UpgradeCardRegistry.all());
}
public SeededUpgradeDraftDeck(UpgradeCard[] registry) {
this.registry = registry;
}
public UpgradeCard[] draft(RandomSource random) {
UpgradeCard[] pool = eligiblePool();
UpgradeCard[] offer = new UpgradeCard[Math.min(OFFER_COUNT, pool.length)];
int poolSize = pool.length;
for (int i = 0; i < offer.length; i++) {
int index = random.nextIntInclusive(poolSize - 1);
offer[i] = pool[index];
int after = poolSize - index - 1;
if (after > 0) {
System.arraycopy(pool, index + 1, pool, index, after);
}
poolSize--;
}
activeOffer = offer;
return copyOffer();
}
public void pick(int index) {
if (activeOffer == null || index < 0 || index >= activeOffer.length) {
throw new IllegalArgumentException("No active card offer at index " + index);
}
UpgradeCard card = activeOffer[index];
pickedCopies.put(card.id(), pickedCopies.getOrDefault(card.id(), 0) + 1);
activeOffer = null;
rerollsUsed = 0;
}
public UpgradeCard[] reroll(RandomSource random) {
if (activeOffer == null) {
throw new IllegalStateException("Cannot reroll before drafting an offer");
}
if (!canReroll()) {
throw new IllegalStateException("No card rerolls remaining");
}
rerollsUsed++;
return draft(random);
}
public boolean canReroll() {
return rerollsRemaining() > 0;
}
public int rerollsRemaining() {
return Math.max(0, MAX_REROLLS - rerollsUsed);
}
public int rerollCost(float discountFraction) {
float discount = Math.max(0f, Math.min(0.80f, discountFraction));
return Math.max(1, (int) Math.ceil(BASE_REROLL_COST * (1f - discount)));
}
public Snapshot snapshot() {
String[] ids = activeOffer == null ? new String[0] : new String[activeOffer.length];
for (int i = 0; i < ids.length; i++) {
ids[i] = activeOffer[i].id();
}
return new Snapshot(new HashMap<>(pickedCopies), ids, rerollsUsed);
}
public void apply(Snapshot snapshot) {
pickedCopies.clear();
pickedCopies.putAll(snapshot.pickedCopies());
rerollsUsed = snapshot.rerollsUsed();
activeOffer = new UpgradeCard[snapshot.activeOfferIds().length];
for (int i = 0; i < activeOffer.length; i++) {
activeOffer[i] = find(snapshot.activeOfferIds()[i]);
}
}
private UpgradeCard[] copyOffer() {
UpgradeCard[] copy = new UpgradeCard[activeOffer.length];
System.arraycopy(activeOffer, 0, copy, 0, activeOffer.length);
return copy;
}
private UpgradeCard[] eligiblePool() {
int count = 0;
for (UpgradeCard card : registry) {
if (pickedCopies.getOrDefault(card.id(), 0) < card.maxCopies()) {
count++;
}
}
UpgradeCard[] pool = new UpgradeCard[count];
int index = 0;
for (UpgradeCard card : registry) {
if (pickedCopies.getOrDefault(card.id(), 0) < card.maxCopies()) {
pool[index++] = card;
}
}
return pool;
}
private UpgradeCard find(String id) {
for (UpgradeCard card : registry) {
if (card.id().equals(id)) {
return card;
}
}
throw new IllegalArgumentException("Unknown upgrade card id: " + id);
}
public static final class Snapshot {
private final Map<String, Integer> pickedCopies;
private final String[] activeOfferIds;
private final int rerollsUsed;
public Snapshot(Map<String, Integer> pickedCopies, String[] activeOfferIds) {
this(pickedCopies, activeOfferIds, 0);
}
public Snapshot(
Map<String, Integer> pickedCopies, String[] activeOfferIds, int rerollsUsed) {
this.pickedCopies = new HashMap<>(pickedCopies);
this.activeOfferIds = new String[activeOfferIds.length];
System.arraycopy(activeOfferIds, 0, this.activeOfferIds, 0, activeOfferIds.length);
this.rerollsUsed = Math.max(0, rerollsUsed);
}
public Map<String, Integer> pickedCopies() {
return new HashMap<>(pickedCopies);
}
public String[] activeOfferIds() {
String[] copy = new String[activeOfferIds.length];
System.arraycopy(activeOfferIds, 0, copy, 0, activeOfferIds.length);
return copy;
}
public int rerollsUsed() {
return rerollsUsed;
}
}
}
@@ -0,0 +1,515 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class SimulationReport {
private final long seed;
private final String sourceKind;
private final String strategyId;
private final int waveReached;
private final float latestBossKillSeconds;
private final int purchases;
private final int moneyEarned;
private final int moneySpent;
private final float averageClearSeconds;
private final int deaths;
private final float damageTaken;
private final int bonusDrops;
private final int bonusSpawned;
private final int bonusCollected;
private final float activeBonusPower;
private final int activeBonusUptimeWaves;
private final int triggeredSynergies;
private final float survivability;
private final EnumMap<PowerAxis, Float> effectivePowerByAxis;
private final float firstBossKillSeconds;
private final int shopPurchases;
private final int abilityUses;
private final int selectedDamageCards;
private final int selectedFireRateCards;
private final int peakVisibleBonusBalls;
private final float effectiveDamage;
private final float effectiveShotCooldownMs;
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
"axis-simulation",
latestBossKillSeconds,
purchases,
0,
0,
0,
0,
0f,
0f);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
"axis-simulation",
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
sourceKind,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
bonusDrops,
bonusDrops,
0,
0);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
int bonusSpawned,
int bonusCollected,
int activeBonusUptimeWaves) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
sourceKind,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
bonusSpawned,
bonusCollected,
activeBonusUptimeWaves,
0,
0,
0);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
int bonusSpawned,
int bonusCollected,
int activeBonusUptimeWaves,
int triggeredSynergies) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
sourceKind,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
bonusSpawned,
bonusCollected,
activeBonusUptimeWaves,
triggeredSynergies,
0,
0);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
int bonusSpawned,
int bonusCollected,
int activeBonusUptimeWaves,
int triggeredSynergies,
int moneyEarned,
int moneySpent) {
this.seed = seed;
this.sourceKind = sourceKind;
this.strategyId = strategyId;
this.waveReached = waveReached;
this.latestBossKillSeconds = latestBossKillSeconds;
this.purchases = purchases;
this.moneyEarned = moneyEarned;
this.moneySpent = moneySpent;
this.averageClearSeconds = averageClearSeconds;
this.deaths = deaths;
this.damageTaken = damageTaken;
this.bonusDrops = bonusDrops;
this.bonusSpawned = bonusSpawned;
this.bonusCollected = bonusCollected;
this.activeBonusPower = activeBonusPower;
this.activeBonusUptimeWaves = activeBonusUptimeWaves;
this.triggeredSynergies = triggeredSynergies;
this.survivability = survivability;
this.effectivePowerByAxis = new EnumMap<>(effectivePowerByAxis);
this.firstBossKillSeconds = firstBossKillSeconds;
this.shopPurchases = shopPurchases;
this.abilityUses = abilityUses;
this.selectedDamageCards = selectedDamageCards;
this.selectedFireRateCards = selectedFireRateCards;
this.peakVisibleBonusBalls = peakVisibleBonusBalls;
this.effectiveDamage = effectiveDamage;
this.effectiveShotCooldownMs = effectiveShotCooldownMs;
}
public static SimulationReport fromRoute(long seed, BalanceRouteReport route) {
EnumMap<PowerAxis, Float> axes = new EnumMap<>(route.effectivePowerByAxis());
axes.put(PowerAxis.DAMAGE, route.effectiveDamage());
axes.put(PowerAxis.CADENCE, 1000f / Math.max(1f, route.effectiveShotCooldownMs()));
return new SimulationReport(
seed,
route.strategyId(),
route.waveReached(),
route.firstBossKillSeconds(),
route.shopPurchases(),
0f,
route.waveReached() >= 100 ? 0 : 1,
0f,
route.peakVisibleBonusBalls(),
route.peakVisibleBonusBalls(),
Math.max(0.01f, route.waveReached() / 100f),
axes,
"route-simulation",
route.firstBossKillSeconds(),
route.shopPurchases(),
route.abilityUses(),
route.selectedDamageCards(),
route.selectedFireRateCards(),
route.peakVisibleBonusBalls(),
route.effectiveDamage(),
route.effectiveShotCooldownMs());
}
public long seed() {
return seed;
}
public String sourceKind() {
return sourceKind;
}
public String strategyId() {
return strategyId;
}
public int waveReached() {
return waveReached;
}
public float latestBossKillSeconds() {
return latestBossKillSeconds;
}
public int purchases() {
return purchases;
}
public int moneyEarned() {
return moneyEarned;
}
public int moneySpent() {
return moneySpent;
}
public float averageClearSeconds() {
return averageClearSeconds;
}
public int deaths() {
return deaths;
}
public float damageTaken() {
return damageTaken;
}
public int bonusDrops() {
return bonusDrops;
}
public int bonusSpawned() {
return bonusSpawned;
}
public int bonusCollected() {
return bonusCollected;
}
public float activeBonusPower() {
return activeBonusPower;
}
public int activeBonusUptimeWaves() {
return activeBonusUptimeWaves;
}
public int triggeredSynergies() {
return triggeredSynergies;
}
public float survivability() {
return survivability;
}
public Map<PowerAxis, Float> effectivePowerByAxis() {
return new EnumMap<>(effectivePowerByAxis);
}
public float firstBossKillSeconds() {
return firstBossKillSeconds;
}
public int shopPurchases() {
return shopPurchases;
}
public int abilityUses() {
return abilityUses;
}
public int selectedDamageCards() {
return selectedDamageCards;
}
public int selectedFireRateCards() {
return selectedFireRateCards;
}
public int peakVisibleBonusBalls() {
return peakVisibleBonusBalls;
}
public float effectiveDamage() {
return effectiveDamage;
}
public float effectiveShotCooldownMs() {
return effectiveShotCooldownMs;
}
public String summaryLine() {
return "seed="
+ seed
+ " source="
+ sourceKind
+ " strategy="
+ strategyId
+ " wave="
+ waveReached
+ " firstBoss="
+ firstBossKillSeconds
+ "s shop="
+ shopPurchases
+ " moneyEarned="
+ moneyEarned
+ " moneySpent="
+ moneySpent
+ " abilities="
+ abilityUses
+ " cards="
+ selectedDamageCards
+ "/"
+ selectedFireRateCards
+ " visibleBonus="
+ peakVisibleBonusBalls
+ " bonusDrops="
+ bonusDrops
+ " bonusSpawned="
+ bonusSpawned
+ " bonusCollected="
+ bonusCollected
+ " activeBonus="
+ activeBonusPower
+ " activeBonusUptime="
+ activeBonusUptimeWaves
+ " synergies="
+ triggeredSynergies
+ " damageTaken="
+ damageTaken
+ " damage="
+ effectiveDamage
+ " cooldownMs="
+ effectiveShotCooldownMs;
}
}
@@ -0,0 +1,50 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class StrategyProfile {
private final String id;
private final Map<PowerAxis, Float> axisWeights;
private final float metaRating;
private StrategyProfile(String id, Map<PowerAxis, Float> axisWeights, float metaRating) {
this.id = id;
this.axisWeights = new EnumMap<>(axisWeights);
this.metaRating = metaRating;
}
public static StrategyProfile of(String id, PowerAxis primary, PowerAxis secondary) {
EnumMap<PowerAxis, Float> weights = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
weights.put(axis, 0.35f);
}
weights.put(primary, 1f);
weights.put(secondary, 0.75f);
return new StrategyProfile(id, weights, 8f);
}
public static StrategyProfile mixed() {
EnumMap<PowerAxis, Float> weights = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
weights.put(axis, 0.70f);
}
return new StrategyProfile("mixed", weights, 8f);
}
public StrategyProfile withMetaTier(String tierId, float metaRating) {
return new StrategyProfile(id + ":" + tierId, axisWeights, metaRating);
}
public String id() {
return id;
}
public float weight(PowerAxis axis) {
return axisWeights.get(axis);
}
public float metaRating() {
return metaRating;
}
}
@@ -0,0 +1,91 @@
package ru.project.tower.balance;
import java.util.Objects;
public final class UpgradeCard {
private final String id;
private final String family;
private final PowerAxis primaryAxis;
private final PowerAxis synergyAxis;
private final float rating;
private final int maxCopies;
private final UpgradeCardRarity rarity;
private final float offerWeight;
private final String[] tags;
public UpgradeCard(
String id,
String family,
PowerAxis primaryAxis,
PowerAxis synergyAxis,
float rating,
int maxCopies) {
this(
id,
family,
primaryAxis,
synergyAxis,
rating,
maxCopies,
UpgradeCardRarity.COMMON,
1f,
family);
}
public UpgradeCard(
String id,
String family,
PowerAxis primaryAxis,
PowerAxis synergyAxis,
float rating,
int maxCopies,
UpgradeCardRarity rarity,
float offerWeight,
String... tags) {
this.id = Objects.requireNonNull(id, "id");
this.family = Objects.requireNonNull(family, "family");
this.primaryAxis = Objects.requireNonNull(primaryAxis, "primaryAxis");
this.synergyAxis = synergyAxis;
this.rating = rating;
this.maxCopies = maxCopies;
this.rarity = Objects.requireNonNull(rarity, "rarity");
this.offerWeight = offerWeight;
this.tags = tags == null ? new String[0] : tags.clone();
}
public String id() {
return id;
}
public String family() {
return family;
}
public PowerAxis primaryAxis() {
return primaryAxis;
}
public PowerAxis synergyAxis() {
return synergyAxis;
}
public float rating() {
return rating;
}
public int maxCopies() {
return maxCopies;
}
public UpgradeCardRarity rarity() {
return rarity;
}
public float offerWeight() {
return offerWeight;
}
public String[] tags() {
return tags.clone();
}
}
@@ -0,0 +1,7 @@
package ru.project.tower.balance;
public enum UpgradeCardRarity {
COMMON,
UNCOMMON,
RARE
}
@@ -0,0 +1,248 @@
package ru.project.tower.balance;
public final class UpgradeCardRegistry {
private static final UpgradeCard[] CARDS = {
card(
"focused_damage",
"attack",
PowerAxis.DAMAGE,
PowerAxis.CRIT,
5f,
8,
UpgradeCardRarity.COMMON,
1.2f,
"pure",
"damage"),
card(
"rupture_crit",
"attack",
PowerAxis.CRIT,
PowerAxis.DAMAGE,
4f,
7,
UpgradeCardRarity.COMMON,
1.1f,
"crit",
"burst"),
card(
"boss_breaker",
"attack",
PowerAxis.BOSSING,
PowerAxis.CRIT,
3.8f,
6,
UpgradeCardRarity.UNCOMMON,
0.85f,
"boss",
"single-target"),
card(
"execution_line",
"attack",
PowerAxis.DAMAGE,
PowerAxis.BOSSING,
3.6f,
6,
UpgradeCardRarity.UNCOMMON,
0.85f,
"execute",
"single-target"),
card(
"arc_burst",
"control",
PowerAxis.AOE,
PowerAxis.CONTROL,
4.5f,
7,
UpgradeCardRarity.COMMON,
1.0f,
"aoe",
"control"),
card(
"static_field",
"control",
PowerAxis.CONTROL,
PowerAxis.AOE,
4.5f,
7,
UpgradeCardRarity.COMMON,
1.0f,
"slow",
"aoe"),
card(
"gravity_lens",
"control",
PowerAxis.CONTROL,
PowerAxis.BOSSING,
3.5f,
5,
UpgradeCardRarity.RARE,
0.55f,
"pull",
"boss"),
card(
"barrier_cycle",
"defense",
PowerAxis.SHIELD,
PowerAxis.REGEN,
4f,
6,
UpgradeCardRarity.COMMON,
1.0f,
"shield",
"regen"),
card(
"repair_loop",
"defense",
PowerAxis.REGEN,
PowerAxis.SHIELD,
4f,
6,
UpgradeCardRarity.COMMON,
1.0f,
"regen",
"shield"),
card(
"plated_core",
"defense",
PowerAxis.ARMOR,
PowerAxis.HEALTH,
3.6f,
6,
UpgradeCardRarity.UNCOMMON,
0.85f,
"armor",
"health"),
card(
"overdrive_sustain",
"defense",
PowerAxis.HEALTH,
PowerAxis.CADENCE,
3.5f,
6,
UpgradeCardRarity.UNCOMMON,
0.75f,
"health",
"tempo"),
card(
"coin_compounder",
"utility",
PowerAxis.ECONOMY,
PowerAxis.UTILITY,
3.2f,
5,
UpgradeCardRarity.COMMON,
0.9f,
"money",
"shop"),
card(
"reroll_signal",
"utility",
PowerAxis.UTILITY,
PowerAxis.ECONOMY,
3.2f,
5,
UpgradeCardRarity.UNCOMMON,
0.75f,
"reroll",
"choice"),
card(
"bonus_magnet",
"utility",
PowerAxis.UTILITY,
PowerAxis.CADENCE,
3.0f,
5,
UpgradeCardRarity.COMMON,
0.9f,
"bonus",
"pickup"),
card(
"coil_accelerator",
"attack",
PowerAxis.CADENCE,
PowerAxis.DAMAGE,
3.5f,
7,
UpgradeCardRarity.COMMON,
1.05f,
"cadence",
"tempo"),
card(
"turret_directive",
"ability",
PowerAxis.ABILITY,
PowerAxis.AOE,
4f,
6,
UpgradeCardRarity.UNCOMMON,
0.8f,
"turret",
"aoe"),
card(
"blast_protocol",
"ability",
PowerAxis.ABILITY,
PowerAxis.DAMAGE,
3.6f,
6,
UpgradeCardRarity.UNCOMMON,
0.8f,
"explosion",
"damage"),
card(
"shield_script",
"ability",
PowerAxis.ABILITY,
PowerAxis.SHIELD,
3.4f,
6,
UpgradeCardRarity.UNCOMMON,
0.8f,
"shield",
"cooldown"),
card(
"volatile_bargain",
"risk",
PowerAxis.DAMAGE,
PowerAxis.HEALTH,
5.5f,
4,
UpgradeCardRarity.RARE,
0.45f,
"risk",
"damage"),
card(
"glass_cannon",
"risk",
PowerAxis.CRIT,
PowerAxis.ARMOR,
5.0f,
4,
UpgradeCardRarity.RARE,
0.45f,
"risk",
"crit")
};
private UpgradeCardRegistry() {}
public static UpgradeCard[] all() {
UpgradeCard[] copy = new UpgradeCard[CARDS.length];
System.arraycopy(CARDS, 0, copy, 0, CARDS.length);
return copy;
}
private static UpgradeCard card(
String id,
String family,
PowerAxis primaryAxis,
PowerAxis synergyAxis,
float rating,
int maxCopies,
UpgradeCardRarity rarity,
float offerWeight,
String... tags) {
return new UpgradeCard(
id, family, primaryAxis, synergyAxis, rating, maxCopies, rarity, offerWeight, tags);
}
}
@@ -0,0 +1,145 @@
package ru.project.tower.content;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import java.util.Objects;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.entities.circles.bosses.GlacialWardenData;
import ru.project.tower.entities.circles.bosses.IllusionMasterData;
import ru.project.tower.entities.circles.bosses.JuggernautData;
import ru.project.tower.entities.circles.bosses.ShadowWeaverData;
import ru.project.tower.entities.circles.bosses.SwarmQueenData;
import ru.project.tower.entities.circles.bosses.VolatileReactorData;
import ru.project.tower.support.BossEffectsHost;
public final class BossContentRegistry {
private static final BossSpec[] BOSSES = {
boss(
"SwarmQueen",
SwarmQueenData.class,
SwarmQueenData.getStaticRadius(),
SwarmQueenData.getStaticSpeed(),
(circle, velocity, host) -> new SwarmQueenData(circle, velocity, host)),
boss(
"Juggernaut",
JuggernautData.class,
JuggernautData.getStaticRadius(),
JuggernautData.getStaticSpeed(),
(circle, velocity, host) -> new JuggernautData(circle, velocity)),
boss(
"ShadowWeaver",
ShadowWeaverData.class,
ShadowWeaverData.getStaticRadius(),
ShadowWeaverData.getStaticSpeed(),
(circle, velocity, host) -> new ShadowWeaverData(circle, velocity)),
boss(
"VolatileReactor",
VolatileReactorData.class,
VolatileReactorData.getStaticRadius(),
VolatileReactorData.getStaticSpeed(),
(circle, velocity, host) -> new VolatileReactorData(circle, velocity)),
boss(
"GlacialWarden",
GlacialWardenData.class,
GlacialWardenData.getStaticRadius(),
GlacialWardenData.getStaticSpeed(),
(circle, velocity, host) -> new GlacialWardenData(circle, velocity)),
boss(
"IllusionMaster",
IllusionMasterData.class,
IllusionMasterData.getStaticRadius(),
IllusionMasterData.getStaticSpeed(),
(circle, velocity, host) -> new IllusionMasterData(circle, velocity))
};
private BossContentRegistry() {}
public static BossSpec[] all() {
return BOSSES.clone();
}
public static String[] ids() {
String[] ids = new String[BOSSES.length];
for (int i = 0; i < BOSSES.length; i++) {
ids[i] = BOSSES[i].id();
}
return ids;
}
public static BossSpec findById(String id) {
for (BossSpec boss : BOSSES) {
if (boss.id().equals(id)) {
return boss;
}
}
return null;
}
public static boolean isBoss(BaseCircleData enemy) {
if (enemy == null) {
return false;
}
Class<?> enemyClass = enemy.getClass();
for (BossSpec boss : BOSSES) {
if (boss.type().isAssignableFrom(enemyClass)) {
return true;
}
}
return false;
}
private static BossSpec boss(
String id,
Class<? extends BaseCircleData> type,
float radius,
float speed,
BossFactory factory) {
return new BossSpec(id, type, radius, speed, factory);
}
public interface BossFactory {
BaseCircleData create(Circle circle, Vector2 velocity, BossEffectsHost host);
}
public static final class BossSpec {
private final String id;
private final Class<? extends BaseCircleData> type;
private final float radius;
private final float speed;
private final BossFactory factory;
private BossSpec(
String id,
Class<? extends BaseCircleData> type,
float radius,
float speed,
BossFactory factory) {
this.id = Objects.requireNonNull(id, "id");
this.type = Objects.requireNonNull(type, "type");
this.radius = radius;
this.speed = speed;
this.factory = Objects.requireNonNull(factory, "factory");
}
public String id() {
return id;
}
public Class<? extends BaseCircleData> type() {
return type;
}
public float radius() {
return radius;
}
public float speed() {
return speed;
}
public BaseCircleData create(Circle circle, Vector2 velocity, BossEffectsHost host) {
return factory.create(circle, velocity, host);
}
}
}
@@ -0,0 +1,142 @@
package ru.project.tower.entities.bullets;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class BulletData implements Pool.Poolable {
public Circle bullet;
public Vector2 velocity;
public int damage;
private boolean isEnemyBullet;
private boolean isCrit = false;
private Color color;
public int piercesLeft = 0;
public boolean homing = false;
public float aoeRadius = 0f;
public float trailParticleSpawnBudget = 0f;
public BulletData() {
this.bullet = new Circle(0f, 0f, 1f);
this.velocity = new Vector2(0f, 0f);
this.damage = 1;
this.isEnemyBullet = false;
this.color = null;
}
public BulletData(Circle bullet, Vector2 velocity, int damage) {
this.bullet = bullet;
this.velocity = velocity;
this.damage = damage;
this.isEnemyBullet = false;
this.color = null;
}
public BulletData(Circle bullet, Vector2 velocity, boolean isEnemyBullet) {
this.bullet = bullet;
this.velocity = velocity;
this.damage = isEnemyBullet ? 10 : 1;
this.isEnemyBullet = isEnemyBullet;
this.color = null;
}
public void configureAsPlayerBullet(
float x, float y, float radius, float vx, float vy, int damage) {
bullet.x = x;
bullet.y = y;
bullet.radius = radius;
velocity.x = vx;
velocity.y = vy;
this.damage = damage;
this.isEnemyBullet = false;
this.isCrit = false;
this.color = null;
this.piercesLeft = 0;
this.homing = false;
this.aoeRadius = 0f;
this.trailParticleSpawnBudget = 0f;
}
public void configureAsEnemyBullet(
float x, float y, float radius, float vx, float vy, int damage) {
bullet.x = x;
bullet.y = y;
bullet.radius = radius;
velocity.x = vx;
velocity.y = vy;
this.damage = damage;
this.isEnemyBullet = true;
this.isCrit = false;
this.color = null;
this.piercesLeft = 0;
this.homing = false;
this.aoeRadius = 0f;
this.trailParticleSpawnBudget = 0f;
}
@Override
public void reset() {
bullet.x = 0f;
bullet.y = 0f;
bullet.radius = 1f;
velocity.x = 0f;
velocity.y = 0f;
damage = 1;
isEnemyBullet = false;
isCrit = false;
color = null;
piercesLeft = 0;
homing = false;
aoeRadius = 0f;
trailParticleSpawnBudget = 0f;
}
public float getX() {
return bullet.x;
}
public float getY() {
return bullet.y;
}
public float getRadius() {
return bullet.radius;
}
public void move(float delta) {
bullet.x += velocity.x * delta;
bullet.y += velocity.y * delta;
}
public Circle getCircle() {
return bullet;
}
public boolean isEnemyBullet() {
return isEnemyBullet;
}
public void setColor(Color color) {
this.color = color;
}
public Color getColor() {
return color;
}
public void setCrit(boolean isCrit) {
this.isCrit = isCrit;
}
public boolean isCrit() {
return isCrit;
}
}
@@ -0,0 +1,37 @@
package ru.project.tower.entities.bullets;
import ru.project.tower.entities.circles.BaseCircleData;
public interface BulletEffectsSink {
void onEnemyHit(BaseCircleData enemy, int damage, boolean isCrit);
void onEnemyKilled(BaseCircleData enemy);
void onEnemyBulletHitPlayer(BulletData bullet);
void onCrit(BaseCircleData enemy, int damage);
}
@@ -0,0 +1,30 @@
package ru.project.tower.entities.bullets;
public final class BulletMultipliers {
public final float speedMultiplier;
public final float damageMultiplier;
public final boolean isCrit;
public final float critDamageMultiplier;
public BulletMultipliers(
float speedMultiplier,
float damageMultiplier,
boolean isCrit,
float critDamageMultiplier) {
this.speedMultiplier = speedMultiplier;
this.damageMultiplier = damageMultiplier;
this.isCrit = isCrit;
this.critDamageMultiplier = critDamageMultiplier;
}
public static BulletMultipliers defaults() {
return new BulletMultipliers(1f, 1f, false, 1f);
}
}
@@ -0,0 +1,447 @@
package ru.project.tower.entities.bullets;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.Pools;
import java.util.Objects;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.RangeChecks;
public class BulletSystem {
private static final int DEFAULT_BASE_DAMAGE = 1;
private static final float DEFAULT_BASE_SPEED = 300f;
private static final int TICK_MISS = 0;
private static final int TICK_HIT = 1;
private static final int TICK_ENEMY_INDEX_DIRTY = 2;
private int bulletDamage = DEFAULT_BASE_DAMAGE;
private float bulletSpeed = DEFAULT_BASE_SPEED;
private int runDamageBonus = 0;
private int runPierceBonus = 0;
private float runAoeBonus = 0f;
private final Array<BulletData> bullets = new Array<>();
private final BulletTalentProvider talentProvider;
private final EnemySpatialIndex enemySpatialIndex = new EnemySpatialIndex();
private int lastPrimaryCandidateChecksForTests;
private float primaryHitTime;
public BulletSystem(BulletTalentProvider talentProvider) {
this.talentProvider = Objects.requireNonNull(talentProvider, "talentProvider");
}
public Array<BulletData> getBullets() {
return bullets;
}
int getLastPrimaryCandidateChecksForTests() {
return lastPrimaryCandidateChecksForTests;
}
public int getBaseDamage() {
return bulletDamage;
}
public float getBaseSpeed() {
return bulletSpeed;
}
public int getRunDamageBonus() {
return runDamageBonus;
}
public int getRunPierceBonus() {
return runPierceBonus;
}
public float getRunAoeBonus() {
return runAoeBonus;
}
public void fireBullet(
float fromX,
float fromY,
float angleRad,
float bulletRadius,
float gameScale,
BulletMultipliers mult) {
fireBullet(
fromX,
fromY,
angleRad,
bulletRadius,
gameScale,
mult.speedMultiplier,
mult.damageMultiplier,
mult.isCrit,
mult.critDamageMultiplier);
}
public void fireBullet(
float fromX,
float fromY,
float angleRad,
float bulletRadius,
float gameScale,
float speedMultiplier,
float damageMultiplier,
boolean isCrit,
float critDamageMultiplier) {
float effectiveSpeed = bulletSpeed * speedMultiplier * gameScale;
int baseDmg = bulletDamage + runDamageBonus;
int damage = (int) (baseDmg * damageMultiplier);
if (isCrit) {
damage = (int) (damage * critDamageMultiplier);
}
float vx = (float) Math.cos(angleRad) * effectiveSpeed;
float vy = (float) Math.sin(angleRad) * effectiveSpeed;
BulletData bullet = Pools.obtain(BulletData.class);
bullet.configureAsPlayerBullet(fromX, fromY, bulletRadius, vx, vy, damage);
bullet.setCrit(isCrit);
bullet.piercesLeft = talentProvider.pierceLevel() + runPierceBonus;
bullet.aoeRadius = talentProvider.aoeLevel() * 30f + runAoeBonus;
bullets.add(bullet);
}
public void fireTurretShot(
float fromX,
float fromY,
float angleRad,
float bulletRadius,
float gameScale,
int damage) {
float speed = bulletSpeed * gameScale;
float vx = (float) Math.cos(angleRad) * speed;
float vy = (float) Math.sin(angleRad) * speed;
BulletData bullet = Pools.obtain(BulletData.class);
bullet.configureAsPlayerBullet(fromX, fromY, bulletRadius, vx, vy, damage);
bullets.add(bullet);
}
public void addEnemyBullet(BulletData bullet) {
bullets.add(bullet);
}
public void fireEnemyBullet(
float fromX, float fromY, float bulletRadius, float vx, float vy, int damage) {
BulletData bullet = Pools.obtain(BulletData.class);
bullet.configureAsEnemyBullet(fromX, fromY, bulletRadius, vx, vy, damage);
bullets.add(bullet);
}
public void tick(
float delta,
Array<BaseCircleData> enemies,
Rectangle playerSquare,
BulletEffectsSink sink) {
lastPrimaryCandidateChecksForTests = 0;
boolean enemyIndexDirty = true;
for (int i = 0; i < bullets.size; i++) {
BulletData bulletData = bullets.get(i);
float px = bulletData.bullet.x;
float py = bulletData.bullet.y;
float mx = bulletData.velocity.x * delta;
float my = bulletData.velocity.y * delta;
int tickResult;
if (bulletData.isEnemyBullet()) {
tickResult =
tickEnemyBullet(bulletData, mx, my, playerSquare, sink)
? TICK_HIT
: TICK_MISS;
} else {
if (enemyIndexDirty || enemySpatialIndex.enemyCount() != enemies.size) {
enemySpatialIndex.build(enemies);
enemyIndexDirty = false;
}
tickResult = tickPlayerBullet(bulletData, px, py, mx, my, enemies, sink);
if ((tickResult & TICK_ENEMY_INDEX_DIRTY) != 0) {
enemyIndexDirty = true;
}
}
if ((tickResult & TICK_HIT) != 0 && finishBulletHit(bulletData, i)) {
i--;
}
}
}
private boolean tickEnemyBullet(
BulletData bulletData,
float moveX,
float moveY,
Rectangle playerSquare,
BulletEffectsSink sink) {
bulletData.bullet.x += moveX;
bulletData.bullet.y += moveY;
float squareCenterX = playerSquare.x + playerSquare.width / 2f;
float squareCenterY = playerSquare.y + playerSquare.height / 2f;
float collideAt =
bulletData.bullet.radius + Math.min(playerSquare.width, playerSquare.height) / 2f;
if (RangeChecks.withinRadiusExclusive(
bulletData.bullet.x,
bulletData.bullet.y,
squareCenterX,
squareCenterY,
collideAt)) {
sink.onEnemyBulletHitPlayer(bulletData);
return true;
}
return false;
}
private int tickPlayerBullet(
BulletData bulletData,
float previousX,
float previousY,
float moveX,
float moveY,
Array<BaseCircleData> enemies,
BulletEffectsSink sink) {
int primaryIndex =
findPrimaryHitIndex(bulletData, previousX, previousY, moveX, moveY, enemies);
if (primaryIndex < 0) {
return finishPlayerBulletMiss(bulletData, previousX, previousY, moveX, moveY);
}
BaseCircleData primary = enemies.get(primaryIndex);
movePlayerBulletToPrimaryHit(bulletData, previousX, previousY, moveX, moveY);
return applyPrimaryHit(bulletData, primary, enemies, sink);
}
private int findPrimaryHitIndex(
BulletData bulletData,
float previousX,
float previousY,
float moveX,
float moveY,
Array<BaseCircleData> enemies) {
int earliestIndex = -1;
float earliestTime = Float.POSITIVE_INFINITY;
float a = moveX * moveX + moveY * moveY;
int candidateCount =
queryPrimaryHitCandidates(bulletData, previousX, previousY, moveX, moveY);
for (int candidate = 0; candidate < candidateCount; candidate++) {
int j = enemySpatialIndex.candidateAt(candidate);
BaseCircleData enemy = enemies.get(j);
lastPrimaryCandidateChecksForTests++;
float t =
primaryCollisionTime(bulletData, enemy, previousX, previousY, moveX, moveY, a);
if (t == Float.POSITIVE_INFINITY) continue;
if (isEarlierPrimaryHit(t, earliestTime, j, earliestIndex)) {
earliestTime = t;
earliestIndex = j;
}
}
primaryHitTime = earliestTime;
return earliestIndex;
}
private int queryPrimaryHitCandidates(
BulletData bulletData, float previousX, float previousY, float moveX, float moveY) {
float sweepPadding = bulletData.bullet.radius + enemySpatialIndex.maxEnemyRadius();
return enemySpatialIndex.queryRect(
Math.min(previousX, previousX + moveX) - sweepPadding,
Math.min(previousY, previousY + moveY) - sweepPadding,
Math.max(previousX, previousX + moveX) + sweepPadding,
Math.max(previousY, previousY + moveY) + sweepPadding);
}
private float primaryCollisionTime(
BulletData bulletData,
BaseCircleData enemy,
float previousX,
float previousY,
float moveX,
float moveY,
float a) {
float radiusSum = bulletData.bullet.radius + enemy.circle.radius;
float dx = previousX - enemy.circle.x;
float dy = previousY - enemy.circle.y;
float c = dx * dx + dy * dy - radiusSum * radiusSum;
if (c <= 0f) return 0f;
if (a < 1e-6f) return Float.POSITIVE_INFINITY;
float b = 2f * (dx * moveX + dy * moveY);
float disc = b * b - 4f * a * c;
if (disc < 0f) return Float.POSITIVE_INFINITY;
float t = (-b - (float) Math.sqrt(disc)) / (2f * a);
if (t < 0f || t > 1f) return Float.POSITIVE_INFINITY;
return t;
}
private boolean isEarlierPrimaryHit(float t, float earliestTime, int j, int earliestIndex) {
return t < earliestTime
|| (Float.compare(t, earliestTime) == 0
&& (earliestIndex < 0 || j < earliestIndex));
}
private int finishPlayerBulletMiss(
BulletData bulletData, float previousX, float previousY, float moveX, float moveY) {
bulletData.bullet.x = previousX + moveX;
bulletData.bullet.y = previousY + moveY;
return TICK_MISS;
}
private void movePlayerBulletToPrimaryHit(
BulletData bulletData, float previousX, float previousY, float moveX, float moveY) {
bulletData.bullet.x = previousX + primaryHitTime * moveX;
bulletData.bullet.y = previousY + primaryHitTime * moveY;
}
private int applyPrimaryHit(
BulletData bulletData,
BaseCircleData primary,
Array<BaseCircleData> enemies,
BulletEffectsSink sink) {
applyPlayerDamage(primary, bulletData.damage, bulletData.isCrit(), sink);
if (bulletData.isCrit()) {
sink.onCrit(primary, bulletData.damage);
}
int result = TICK_HIT;
if (bulletData.aoeRadius > 0f) {
result |= applyAoeDamage(bulletData, primary, enemies, sink);
}
if (primary.health <= 0) {
sink.onEnemyKilled(primary);
result |= TICK_ENEMY_INDEX_DIRTY;
}
return result;
}
private int applyAoeDamage(
BulletData bulletData,
BaseCircleData primary,
Array<BaseCircleData> enemies,
BulletEffectsSink sink) {
float bx = bulletData.bullet.x;
float by = bulletData.bullet.y;
float aoeRadius2 = RangeChecks.radiusSquared(bulletData.aoeRadius);
int aoeCandidateCount = enemySpatialIndex.queryRadius(bx, by, bulletData.aoeRadius);
enemySpatialIndex.sortCandidatesDescending(aoeCandidateCount);
int result = 0;
for (int candidate = 0; candidate < aoeCandidateCount; candidate++) {
int k = enemySpatialIndex.candidateAt(candidate);
BaseCircleData other = enemies.get(k);
if (other == primary) continue;
if (RangeChecks.distanceSquared(bx, by, other.circle.x, other.circle.y) <= aoeRadius2) {
applyPlayerDamage(other, bulletData.damage, false, sink);
if (other.health <= 0) {
sink.onEnemyKilled(other);
result |= TICK_ENEMY_INDEX_DIRTY;
}
}
}
return result;
}
private void applyPlayerDamage(
BaseCircleData enemy, int damage, boolean crit, BulletEffectsSink sink) {
enemy.health -= damage;
sink.onEnemyHit(enemy, damage, crit);
}
private boolean finishBulletHit(BulletData bulletData, int bulletIndex) {
if (bulletData.piercesLeft > 0 && !bulletData.isEnemyBullet()) {
bulletData.piercesLeft--;
return false;
}
Pools.free(bullets.removeIndex(bulletIndex));
return true;
}
public int getEffectiveDamage(float externalDamageMultiplier) {
int base = bulletDamage + runDamageBonus;
return (int) (base * externalDamageMultiplier);
}
public float getEffectiveSpeed(float externalSpeedMultiplier) {
return bulletSpeed * externalSpeedMultiplier;
}
public void increaseBaseDamage(int delta) {
bulletDamage += delta;
}
public void increaseBaseSpeed(float delta) {
bulletSpeed += delta;
}
public void addRunDamageBonus(int delta) {
runDamageBonus += delta;
}
public void addRunPierceBonus(int delta) {
runPierceBonus += delta;
}
public void addRunAoeBonus(float delta) {
runAoeBonus += delta;
}
public void updateRadius(float newRadius) {
for (int i = 0; i < bullets.size; i++) {
bullets.get(i).bullet.radius = newRadius;
}
}
public void clear() {
for (int i = 0; i < bullets.size; i++) {
Pools.free(bullets.get(i));
}
bullets.clear();
}
public void resetToBaseline() {
bulletDamage = DEFAULT_BASE_DAMAGE;
bulletSpeed = DEFAULT_BASE_SPEED;
runDamageBonus = 0;
runPierceBonus = 0;
runAoeBonus = 0f;
}
public void applyTalentBaseStats() {
bulletDamage += (int) talentProvider.damageBonus();
bulletSpeed += talentProvider.speedBonus();
}
public void startNewRun() {
clear();
resetToBaseline();
applyTalentBaseStats();
}
}
@@ -0,0 +1,67 @@
package ru.project.tower.entities.bullets;
import java.util.Objects;
import ru.project.tower.talents.TalentData;
import ru.project.tower.talents.TalentTree;
public interface BulletTalentProvider {
int pierceLevel();
int aoeLevel();
float damageBonus();
float speedBonus();
static BulletTalentProvider none() {
return new BulletTalentProvider() {
@Override
public int pierceLevel() {
return 0;
}
@Override
public int aoeLevel() {
return 0;
}
@Override
public float damageBonus() {
return 0f;
}
@Override
public float speedBonus() {
return 0f;
}
};
}
static BulletTalentProvider fromTalentTree(TalentTree talentTree) {
Objects.requireNonNull(talentTree, "talentTree");
return new BulletTalentProvider() {
@Override
public int pierceLevel() {
return talentTree.getCurrentLevel("keystone_pierce");
}
@Override
public int aoeLevel() {
return talentTree.getCurrentLevel("keystone_aoe");
}
@Override
public float damageBonus() {
return talentTree.getStatBonus(TalentData.TalentType.DAMAGE);
}
@Override
public float speedBonus() {
return talentTree.getStatBonus(TalentData.TalentType.SPEED);
}
};
}
}
@@ -0,0 +1,40 @@
package ru.project.tower.entities.bullets;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.systems.CircleSpatialGrid;
final class EnemySpatialIndex {
private final CircleSpatialGrid grid = new CircleSpatialGrid();
void build(Array<BaseCircleData> enemies) {
grid.build(enemies);
}
int enemyCount() {
return grid.itemCount();
}
float maxEnemyRadius() {
return grid.maxRadius();
}
int queryRect(float minX, float minY, float maxX, float maxY) {
return grid.queryRect(minX, minY, maxX, maxY);
}
int queryRadius(float x, float y, float radius) {
return grid.queryRadius(x, y, radius);
}
void sortCandidatesDescending(int count) {
grid.sortCandidatesDescending(count);
}
int candidateAt(int index) {
return grid.candidateAt(index);
}
}
@@ -0,0 +1,174 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
import com.badlogic.gdx.utils.Pools;
import com.badlogic.gdx.utils.ReflectionPool;
public abstract class BaseCircleData implements Pool.Poolable {
private static final int SPAWN_POOL_INITIAL_CAPACITY = 16;
private static final int SPAWN_POOL_MAX_CAPACITY = 4096;
static {
Pools.set(
BasicCircleData.class,
new ReflectionPool<>(
BasicCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
FastCircleData.class,
new ReflectionPool<>(
FastCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
StrongCircleData.class,
new ReflectionPool<>(
StrongCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
SplitterCircleData.class,
new ReflectionPool<>(
SplitterCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
SpawnerCircleData.class,
new ReflectionPool<>(
SpawnerCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
HealerCircleData.class,
new ReflectionPool<>(
HealerCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
DasherCircleData.class,
new ReflectionPool<>(
DasherCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
SniperCircleData.class,
new ReflectionPool<>(
SniperCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
}
public Circle circle;
public Vector2 velocity;
public int health;
public int damage;
private boolean frozen = false;
private boolean elite = false;
protected BaseCircleData() {
this(new Circle(0f, 0f, 0f), new Vector2(0f, 0f), 0, 0);
}
public BaseCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this.circle = circle;
this.velocity = velocity;
this.health = health;
this.damage = damage;
}
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
circle.x = x;
circle.y = y;
circle.radius = radius;
velocity.x = vx;
velocity.y = vy;
this.health = health;
this.damage = damage;
frozen = false;
elite = false;
}
@Override
public void reset() {
configure(0f, 0f, 0f, 0f, 0f, 0, 0);
}
public static void freeIfSpawnPooled(BaseCircleData circleData) {
Class<?> type = circleData.getClass();
if (type == BasicCircleData.class
|| type == FastCircleData.class
|| type == StrongCircleData.class
|| type == SplitterCircleData.class
|| type == SpawnerCircleData.class
|| type == HealerCircleData.class
|| type == DasherCircleData.class
|| type == SniperCircleData.class) {
Pools.free(circleData);
}
}
public boolean isElite() {
return elite;
}
public void makeElite() {
if (elite) return;
elite = true;
this.health *= 2;
this.damage = Math.max(1, (int) (this.damage * 1.5f));
this.circle.radius *= 1.15f;
}
protected int getBaseReward() {
return 1;
}
public abstract float[] getColor();
public float getX() {
return circle.x;
}
public float getY() {
return circle.y;
}
public float getRadius() {
return circle.radius;
}
public void move(float delta) {
if (!frozen) {
circle.x += velocity.x * delta;
circle.y += velocity.y * delta;
}
}
public boolean isFrozen() {
return frozen;
}
public void setFrozen(boolean frozen) {
this.frozen = frozen;
}
public void takeDamage(int damage) {
health -= damage;
}
public boolean isDead() {
return health <= 0;
}
public final int getReward() {
return getBaseReward() * (elite ? 3 : 1);
}
}
@@ -0,0 +1,42 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class BasicCircleData extends BaseCircleData {
private static final float[] COLOR = {1f, 0.5f, 0.5f, 1f};
private int baseReward = 1;
public BasicCircleData() {
super();
}
public BasicCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
baseReward = 1;
}
public void markSpawnerChild() {
markNoRewardSpawn();
}
public void markNoRewardSpawn() {
baseReward = 0;
}
@Override
public float[] getColor() {
return COLOR;
}
@Override
protected int getBaseReward() {
return baseReward;
}
}
@@ -0,0 +1,110 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class BonusBallData extends BaseCircleData implements Pool.Poolable {
public enum BonusType {
BULLET_SPEED,
DAMAGE,
FIRE_RATE,
SHIELD_PULSE,
HEAL,
CRIT_WINDOW,
PICKUP_MAGNET,
ABILITY_CHARGE,
COIN_BURST
}
private BonusType bonusType;
private float pulseTime = 0;
public BonusBallData() {
super(new Circle(0f, 0f, 1f), new Vector2(0f, 0f), 1, 0);
this.bonusType = null;
}
public BonusBallData(Circle circle, Vector2 velocity, BonusType bonusType) {
super(circle, velocity, 1, 0);
this.bonusType = bonusType;
}
public void configure(float x, float y, float radius, float vx, float vy, BonusType type) {
circle.x = x;
circle.y = y;
circle.radius = radius;
velocity.x = vx;
velocity.y = vy;
health = 1;
damage = 0;
setFrozen(false);
this.bonusType = type;
this.pulseTime = 0f;
}
public BonusType getBonusType() {
return bonusType;
}
public void update(float delta) {
pulseTime += delta;
move(delta);
}
private static final float[] BULLET_SPEED_COLOR = {0.30f, 0.85f, 1.00f, 1.0f};
private static final float[] DAMAGE_COLOR = {1.00f, 0.36f, 0.32f, 1.0f};
private static final float[] FIRE_RATE_COLOR = {1.00f, 0.86f, 0.25f, 1.0f};
private static final float[] SHIELD_COLOR = {0.28f, 0.55f, 1.00f, 1.0f};
private static final float[] HEAL_COLOR = {0.25f, 1.00f, 0.55f, 1.0f};
private static final float[] CRIT_COLOR = {1.00f, 0.35f, 0.95f, 1.0f};
private static final float[] MAGNET_COLOR = {0.55f, 1.00f, 0.95f, 1.0f};
private static final float[] ABILITY_COLOR = {0.72f, 0.50f, 1.00f, 1.0f};
private static final float[] COIN_COLOR = {1.00f, 0.72f, 0.18f, 1.0f};
@Override
public float[] getColor() {
if (bonusType == null) return COIN_COLOR;
switch (bonusType) {
case BULLET_SPEED:
return BULLET_SPEED_COLOR;
case DAMAGE:
return DAMAGE_COLOR;
case FIRE_RATE:
return FIRE_RATE_COLOR;
case SHIELD_PULSE:
return SHIELD_COLOR;
case HEAL:
return HEAL_COLOR;
case CRIT_WINDOW:
return CRIT_COLOR;
case PICKUP_MAGNET:
return MAGNET_COLOR;
case ABILITY_CHARGE:
return ABILITY_COLOR;
case COIN_BURST:
default:
return COIN_COLOR;
}
}
public float getGlowAmount() {
return 0.6f + 0.4f * (float) Math.sin(pulseTime * 4f);
}
@Override
public void reset() {
circle.x = 0f;
circle.y = 0f;
circle.radius = 1f;
velocity.x = 0f;
velocity.y = 0f;
health = 1;
damage = 0;
setFrozen(false);
bonusType = null;
pulseTime = 0f;
}
}
@@ -0,0 +1,122 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.RangeChecks;
public class DasherCircleData extends BaseCircleData {
private static final long WINDUP_MS = 800L;
private static final long DASH_MS = 400L;
private static final float DASH_SPEED_MULT = 3f;
private static final float CHARGE_RANGE = 280f;
private enum State {
IDLE,
WINDUP,
DASHING
}
private State state = State.IDLE;
private long stateEnter = 0L;
private final Vector2 baseVelocity;
private final GameClock clock;
public DasherCircleData() {
super();
this.baseVelocity = new Vector2();
this.clock = GameClock.SYSTEM;
}
public DasherCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this(circle, velocity, health, damage, GameClock.SYSTEM);
}
public DasherCircleData(
Circle circle, Vector2 velocity, int health, int damage, GameClock clock) {
super(circle, velocity, health, damage);
this.baseVelocity = new Vector2(velocity);
this.clock = clock;
}
protected long now() {
return clock.nowMs();
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
baseVelocity.set(vx, vy);
state = State.IDLE;
stateEnter = 0L;
}
@Override
public void reset() {
super.reset();
baseVelocity.setZero();
state = State.IDLE;
stateEnter = 0L;
}
public boolean isWindup() {
return state == State.WINDUP;
}
public boolean isDashing() {
return state == State.DASHING;
}
public void tick(float squareX, float squareY) {
long t = now();
switch (state) {
case IDLE:
if (RangeChecks.withinRadiusExclusive(
getX(), getY(), squareX, squareY, CHARGE_RANGE)) {
state = State.WINDUP;
stateEnter = t;
velocity.scl(0.3f);
}
break;
case WINDUP:
if (t - stateEnter > WINDUP_MS) {
state = State.DASHING;
stateEnter = t;
float dx = squareX - getX(), dy = squareY - getY();
float len = (float) Math.sqrt(dx * dx + dy * dy);
if (len > 0f) {
velocity.set(dx / len, dy / len).scl(baseVelocity.len() * DASH_SPEED_MULT);
}
}
break;
case DASHING:
if (t - stateEnter > DASH_MS) {
state = State.IDLE;
velocity.set(baseVelocity);
}
break;
}
}
private static final float[] COLOR_WINDUP = {1f, 0.9f, 0.3f, 1f};
private static final float[] COLOR_DASHING = {1f, 0.4f, 0.1f, 1f};
private static final float[] COLOR_IDLE = {0.9f, 0.6f, 0.2f, 1f};
@Override
public float[] getColor() {
switch (state) {
case WINDUP:
return COLOR_WINDUP;
case DASHING:
return COLOR_DASHING;
default:
return COLOR_IDLE;
}
}
@Override
protected int getBaseReward() {
return 3;
}
}
@@ -0,0 +1,21 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class FastCircleData extends BaseCircleData {
private static final float[] COLOR = {1f, 1f, 0f, 1f};
public FastCircleData() {
super();
}
public FastCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
}
@Override
public float[] getColor() {
return COLOR;
}
}
@@ -0,0 +1,81 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
public class HealerCircleData extends BaseCircleData {
private static final long HEAL_INTERVAL_MS = 1500L;
private static final float HEAL_RADIUS = 120f;
private static final int HEAL_AMOUNT = 3;
private long lastHealTime;
private final GameClock clock;
public HealerCircleData() {
super();
this.clock = GameClock.SYSTEM;
this.lastHealTime = 0L;
}
public HealerCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this(circle, velocity, health, damage, GameClock.SYSTEM);
}
public HealerCircleData(
Circle circle, Vector2 velocity, int health, int damage, GameClock clock) {
super(circle, velocity, health, damage);
this.clock = clock;
this.lastHealTime = now();
}
protected long now() {
return clock.nowMs();
}
public void resetHealTimer() {
this.lastHealTime = now();
}
public boolean shouldPulse() {
if (now() - lastHealTime > HEAL_INTERVAL_MS) {
lastHealTime = now();
return true;
}
return false;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
this.lastHealTime = now();
}
@Override
public void reset() {
super.reset();
this.lastHealTime = 0L;
}
public static float getHealRadius() {
return HEAL_RADIUS;
}
public static int getHealAmount() {
return HEAL_AMOUNT;
}
private static final float[] COLOR = {0.2f, 1.0f, 0.5f, 1f};
@Override
public float[] getColor() {
return COLOR;
}
@Override
protected int getBaseReward() {
return 4;
}
}
@@ -0,0 +1,115 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.RangeChecks;
public class SniperCircleData extends BaseCircleData {
private static final float TARGET_RANGE = 100f;
private static final long FIRE_INTERVAL_MS = 2000L;
private static final float PROJECTILE_SPEED = 200f;
private static final float PROJECTILE_RADIUS = 4f;
private long lastShotAt = 0L;
private float playerX = 0f;
private float playerY = 0f;
private final GameClock clock;
public SniperCircleData() {
super();
this.clock = GameClock.SYSTEM;
this.lastShotAt = 0L;
}
public SniperCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this(circle, velocity, health, damage, GameClock.SYSTEM);
}
public SniperCircleData(
Circle circle, Vector2 velocity, int health, int damage, GameClock clock) {
super(circle, velocity, health, damage);
this.clock = clock;
this.lastShotAt = now();
}
protected long now() {
return clock.nowMs();
}
public void setPlayerPosition(float x, float y) {
this.playerX = x;
this.playerY = y;
}
public void resetFireTimer() {
this.lastShotAt = now();
}
@Override
public void move(float delta) {
if (!RangeChecks.withinRadiusInclusive(getX(), getY(), playerX, playerY, TARGET_RANGE)) {
super.move(delta);
} else {
velocity.set(0f, 0f);
}
}
public boolean shouldShoot() {
if (now() - lastShotAt > FIRE_INTERVAL_MS) {
lastShotAt = now();
return true;
}
return false;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
lastShotAt = now();
playerX = 0f;
playerY = 0f;
}
@Override
public void reset() {
super.reset();
lastShotAt = 0L;
playerX = 0f;
playerY = 0f;
}
public boolean isHoldingPosition() {
return RangeChecks.withinRadiusInclusive(getX(), getY(), playerX, playerY, TARGET_RANGE);
}
public static float getTargetRange() {
return TARGET_RANGE;
}
public static long getFireIntervalMs() {
return FIRE_INTERVAL_MS;
}
public static float getProjectileSpeed() {
return PROJECTILE_SPEED;
}
public static float getProjectileRadius() {
return PROJECTILE_RADIUS;
}
private static final float[] COLOR = {1f, 0.2f, 0.2f, 1f};
@Override
public float[] getColor() {
return COLOR;
}
@Override
protected int getBaseReward() {
return 3;
}
}
@@ -0,0 +1,67 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
public class SpawnerCircleData extends BaseCircleData {
private long lastSpawnTime = 0;
private static final long SPAWN_INTERVAL = 2000;
private final GameClock clock;
public SpawnerCircleData() {
this(GameClock.SYSTEM);
}
private SpawnerCircleData(GameClock clock) {
super();
this.clock = clock;
}
public SpawnerCircleData(Circle circle, Vector2 velocity, int health) {
this(circle, velocity, health, GameClock.SYSTEM);
}
public SpawnerCircleData(Circle circle, Vector2 velocity, int health, GameClock clock) {
super(circle, velocity, health, 0);
this.clock = clock;
}
public void configure(float x, float y, float radius, float vx, float vy, int health) {
super.configure(x, y, radius, vx, vy, health, 0);
lastSpawnTime = 0L;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
configure(x, y, radius, vx, vy, health);
}
@Override
public void reset() {
super.reset();
lastSpawnTime = 0L;
}
private static final float[] COLOR = {0.5f, 0f, 0.2f, 1f};
@Override
public float[] getColor() {
return COLOR;
}
public boolean shouldSpawnCircle() {
long currentTime = clock.nowMs();
if (currentTime - lastSpawnTime > SPAWN_INTERVAL) {
lastSpawnTime = currentTime;
return true;
}
return false;
}
@Override
protected int getBaseReward() {
return 5;
}
}
@@ -0,0 +1,51 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class SplitterCircleData extends BaseCircleData {
private static final float[] COLOR_SHARD = {0.8f, 0.6f, 1f, 1f};
private static final float[] COLOR_FULL = {0.6f, 0f, 0.8f, 1f};
public boolean isShard = false;
public SplitterCircleData() {
super();
}
public SplitterCircleData(
Circle circle, Vector2 velocity, int health, int damage, boolean isShard) {
super(circle, velocity, health, damage);
this.isShard = isShard;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
configure(x, y, radius, vx, vy, health, damage, false);
}
public void configure(
float x,
float y,
float radius,
float vx,
float vy,
int health,
int damage,
boolean isShard) {
super.configure(x, y, radius, vx, vy, health, damage);
this.isShard = isShard;
}
@Override
public void reset() {
super.reset();
isShard = false;
}
@Override
public float[] getColor() {
return isShard ? COLOR_SHARD : COLOR_FULL;
}
}
@@ -0,0 +1,51 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class StrongCircleData extends BaseCircleData {
private int maxHealth;
private final float[] colorScratch = {0.3f, 0.3f, 0f, 1f};
public StrongCircleData() {
super();
this.maxHealth = 0;
}
public StrongCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
this.maxHealth = health;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
this.maxHealth = health;
colorScratch[0] = 0.3f;
colorScratch[1] = 0.3f;
colorScratch[2] = health == 0 ? 0f : 1f;
colorScratch[3] = 1f;
}
@Override
public void reset() {
super.reset();
this.maxHealth = 0;
colorScratch[0] = 0.3f;
colorScratch[1] = 0.3f;
colorScratch[2] = 0f;
colorScratch[3] = 1f;
}
@Override
public float[] getColor() {
colorScratch[2] = (float) health / maxHealth;
return colorScratch;
}
@Override
protected int getBaseReward() {
return 3;
}
}
@@ -0,0 +1,371 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.MathUtilsRandomSource;
import ru.project.tower.support.RandomSource;
public class GlacialWardenData extends BaseCircleData {
private static final float WARDEN_SPEED = 60f;
private static final int WARDEN_HEALTH = 200;
private static final int WARDEN_DAMAGE = 15;
private static final float WARDEN_RADIUS = 45f;
private static final float AURA_RADIUS = 250f;
private static final float ATTACK_SPEED_SLOW = 0.5f;
private static final float PROJECTILE_SPEED_SLOW = 0.6f;
private static final float FREEZE_ATTACK_SPEED_MULTIPLIER = 0.25f;
private static final float FREEZE_PROJECTILE_SPEED_MULTIPLIER = 0.25f;
private static final long SHARD_COOLDOWN = 3000;
private static final int SHARDS_PER_VOLLEY = 7;
private static final float SHARD_SPEED = 220f;
private static final float SHARD_RADIUS = 7f;
private static final int SHARD_DAMAGE = 8;
private static final long SHARD_DAMAGE_COOLDOWN_MS = 1000L;
private static final float FREEZE_CHANCE = 0.15f;
private static final long FREEZE_DURATION = 500;
private static final long PRISON_COOLDOWN = 10000;
private static final long PRISON_CAST_TIME = 2000;
private static final long PRISON_DURATION = 4000;
private static final int PRISON_DAMAGE = 5;
private static final float PRISON_RADIUS = 80f;
private long lastShardTime;
private long lastPrisonTime;
private boolean isCastingPrison;
private long prisonCastStartTime;
private boolean isPrisonActive;
private long prisonStartTime;
private Vector2 prisonChargePosition;
private Array<IceShard> activeShards;
private boolean isShielded = true;
private long lastShieldBreakTime = 0;
private long shieldStartTime = 0;
private static final long SHIELD_DURATION = 10000;
private static final long SHIELD_COOLDOWN = 5000;
private static final float FREEZE_RADIUS = 100f;
private long lastFreezeTime = 0;
private static final long FREEZE_COOLDOWN = 8000;
private final GameClock clock;
private final RandomSource random;
private boolean freezeFieldActive;
private long freezeFieldEndTime;
private boolean playerInsideAura;
private float activeAttackSpeedMultiplier = 1f;
private float activeProjectileSpeedMultiplier = 1f;
public GlacialWardenData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public GlacialWardenData(Circle circle, Vector2 velocity, GameClock clock) {
this(circle, velocity, clock, new MathUtilsRandomSource());
}
public GlacialWardenData(
Circle circle, Vector2 velocity, GameClock clock, RandomSource random) {
super(circle, velocity, WARDEN_HEALTH, WARDEN_DAMAGE);
this.clock = clock;
this.random = random;
this.isCastingPrison = false;
this.isPrisonActive = false;
this.prisonChargePosition = new Vector2();
this.activeShards = new Array<>();
shieldStartTime = now();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
if (!isCastingPrison) {
super.move(delta);
}
if (isPrisonActive && timeSince(prisonStartTime) > PRISON_DURATION) {
isPrisonActive = false;
}
if (isCastingPrison && timeSince(prisonCastStartTime) > PRISON_CAST_TIME) {
isCastingPrison = false;
isPrisonActive = true;
prisonStartTime = now();
}
for (int i = activeShards.size - 1; i >= 0; i--) {
IceShard shard = activeShards.get(i);
shard.update(delta);
if (!shard.isActive()) {
activeShards.removeIndex(i);
}
}
}
private static final float[] COLOR_SHIELDED = {0.7f, 0.9f, 1.0f, 1.0f};
private static final float[] COLOR_DEFAULT = {0.4f, 0.6f, 0.8f, 1.0f};
@Override
public float[] getColor() {
return isShielded ? COLOR_SHIELDED : COLOR_DEFAULT;
}
public boolean shouldShootShards() {
return timeSince(lastShardTime) > SHARD_COOLDOWN;
}
public void markShardsShot() {
lastShardTime = now();
}
public boolean shouldStartPrison() {
return !isCastingPrison && !isPrisonActive && timeSince(lastPrisonTime) > PRISON_COOLDOWN;
}
public void startPrisonCast(float targetX, float targetY) {
isCastingPrison = true;
prisonCastStartTime = now();
lastPrisonTime = now();
prisonChargePosition.set(targetX, targetY);
}
public void addIceShard(float x, float y, Vector2 velocity) {
IceShard shard = new IceShard(x, y, velocity, clock, random);
activeShards.add(shard);
}
public float getAuraRadius() {
return AURA_RADIUS;
}
public static float getStaticAuraRadius() {
return AURA_RADIUS;
}
public float getAttackSpeedSlow() {
return ATTACK_SPEED_SLOW;
}
public float getProjectileSpeedSlow() {
return PROJECTILE_SPEED_SLOW;
}
public static float getFreezeAttackSpeedMultiplier() {
return FREEZE_ATTACK_SPEED_MULTIPLIER;
}
public static float getFreezeProjectileSpeedMultiplier() {
return FREEZE_PROJECTILE_SPEED_MULTIPLIER;
}
public static float getShardRadius() {
return SHARD_RADIUS;
}
public static float getShardSpeed() {
return SHARD_SPEED;
}
public static int getShardDamage() {
return SHARD_DAMAGE;
}
public static long getShardDamageCooldownMs() {
return SHARD_DAMAGE_COOLDOWN_MS;
}
public static int getShardsPerVolley() {
return SHARDS_PER_VOLLEY;
}
public static float getFreezeChance() {
return FREEZE_CHANCE;
}
public static long getFreezeDuration() {
return FREEZE_DURATION;
}
public static float getPrisonRadius() {
return PRISON_RADIUS;
}
public static int getPrisonDamage() {
return PRISON_DAMAGE;
}
public boolean isCastingPrison() {
return isCastingPrison;
}
public boolean isPrisonActive() {
return isPrisonActive;
}
public float getPrisonCastProgress() {
if (!isCastingPrison) return 0f;
return Math.min(timeSince(prisonCastStartTime) / (float) PRISON_CAST_TIME, 1f);
}
public Vector2 getPrisonChargePosition() {
return prisonChargePosition;
}
public Array<IceShard> getActiveShards() {
return activeShards;
}
@Override
protected int getBaseReward() {
return 200;
}
public static class IceShard {
private static final float LIFETIME = 2000f;
public float x, y;
public Vector2 velocity;
public long creationTime;
public float rotation;
public float rotationSpeed;
private final GameClock clock;
public IceShard(float x, float y, Vector2 velocity) {
this(x, y, velocity, GameClock.SYSTEM);
}
public IceShard(float x, float y, Vector2 velocity, GameClock clock) {
this(x, y, velocity, clock, new MathUtilsRandomSource());
}
public IceShard(float x, float y, Vector2 velocity, GameClock clock, RandomSource random) {
this.x = x;
this.y = y;
this.velocity = velocity;
this.clock = clock;
this.creationTime = clock.nowMs();
this.rotation = random.angle();
this.rotationSpeed = random.range(-5f, 5f);
}
public void update(float delta) {
x += velocity.x * delta;
y += velocity.y * delta;
rotation += rotationSpeed * delta;
}
public boolean isActive() {
return clock.nowMs() - creationTime < LIFETIME;
}
public float getAlpha() {
float age = (clock.nowMs() - creationTime) / LIFETIME;
return 1f - age * age;
}
}
public static float getStaticRadius() {
return 35f;
}
public static float getStaticSpeed() {
return 45f;
}
public void update() {
long currentTime = now();
if (isShielded && currentTime - shieldStartTime > SHIELD_DURATION) {
isShielded = false;
lastShieldBreakTime = currentTime;
} else if (!isShielded && currentTime - lastShieldBreakTime > SHIELD_COOLDOWN) {
isShielded = true;
shieldStartTime = currentTime;
}
}
public boolean shouldFreeze() {
long currentTime = now();
if (currentTime - lastFreezeTime > FREEZE_COOLDOWN) {
lastFreezeTime = currentTime;
return true;
}
return false;
}
public float getFreezeRadius() {
return FREEZE_RADIUS;
}
public void activateFreezeField() {
freezeFieldActive = true;
freezeFieldEndTime = now() + FREEZE_DURATION;
updateActiveCombatMultipliers();
}
public boolean isFreezeFieldActive() {
boolean active = updateFreezeFieldState();
updateActiveCombatMultipliers();
return active;
}
private boolean updateFreezeFieldState() {
if (freezeFieldActive && now() >= freezeFieldEndTime) {
freezeFieldActive = false;
}
return freezeFieldActive;
}
public void updatePlayerAuraState(boolean insideAura) {
playerInsideAura = insideAura;
updateActiveCombatMultipliers();
}
private void updateActiveCombatMultipliers() {
boolean frozen = updateFreezeFieldState();
if (frozen) {
activeAttackSpeedMultiplier = FREEZE_ATTACK_SPEED_MULTIPLIER;
activeProjectileSpeedMultiplier = FREEZE_PROJECTILE_SPEED_MULTIPLIER;
return;
}
activeAttackSpeedMultiplier = playerInsideAura ? ATTACK_SPEED_SLOW : 1f;
activeProjectileSpeedMultiplier = playerInsideAura ? PROJECTILE_SPEED_SLOW : 1f;
}
public boolean isPlayerInsideAura() {
return playerInsideAura;
}
public float getActiveAttackSpeedMultiplier() {
updateActiveCombatMultipliers();
return activeAttackSpeedMultiplier;
}
public float getActiveProjectileSpeedMultiplier() {
updateActiveCombatMultipliers();
return activeProjectileSpeedMultiplier;
}
public boolean isShielded() {
return isShielded;
}
@Override
public void takeDamage(int damage) {
if (!isShielded) {
super.takeDamage(damage);
}
}
}
@@ -0,0 +1,284 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class IllusionMasterData extends BaseCircleData {
private static final float ILLUSION_MASTER_RADIUS = 38f;
private static final float ILLUSION_MASTER_SPEED = 90f;
private static final int ILLUSION_MASTER_HEALTH = 180;
private static final int ILLUSION_MASTER_DAMAGE = 25;
private static final long FORM_CHANGE_INTERVAL = 8000;
private static final float FAST_FORM_SPEED = 150f;
private static final float ARMORED_FORM_SPEED = 30f;
private static final float DAMAGE_MULTIPLIER_FAST = 2.0f;
private static final float DAMAGE_MULTIPLIER_ARMORED = 0.5f;
private static final long ILLUSION_ATTACK_COOLDOWN = 5000;
private static final int FAKE_PROJECTILES_COUNT = 8;
private static final int REAL_PROJECTILES_COUNT = 3;
private static final float PROJECTILE_SPEED = 180f;
private static final float PROJECTILE_RADIUS = 8f;
private static final int PROJECTILE_DAMAGE = 12;
private static final float COLOR_CHANGE_SPEED = 2f;
private static final float DISTORTION_INTENSITY = 0.3f;
private static final long DISTORTION_DURATION = 3000;
private BossForm currentForm;
private long lastFormChangeTime;
private long lastIllusionAttackTime;
private float colorPhase;
private boolean isDistortionActive;
private long distortionStartTime;
private float baseSpeed;
private Array<IllusionData> illusions;
private long lastIllusionTime = 0;
private long lastProjectileTime = 0;
private static final long ILLUSION_COOLDOWN = 10000;
private static final long ILLUSION_DURATION_MS = 8000L;
private static final int ILLUSION_CONTACT_DAMAGE = 5;
private static final long ILLUSION_DAMAGE_COOLDOWN_MS = 1000L;
private static final long PROJECTILE_COOLDOWN = 3000;
private static final int MAX_ILLUSIONS = 3;
private static final int PROJECTILES_PER_VOLLEY = 5;
private static final float ILLUSION_HEALTH_PERCENT = 0.3f;
private final GameClock clock;
public enum BossForm {
FAST,
ARMORED
}
public IllusionMasterData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public IllusionMasterData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, ILLUSION_MASTER_HEALTH, ILLUSION_MASTER_DAMAGE);
this.clock = clock;
this.currentForm = BossForm.FAST;
this.lastFormChangeTime = now();
this.lastIllusionAttackTime = now();
this.colorPhase = 0f;
this.isDistortionActive = false;
this.baseSpeed = FAST_FORM_SPEED;
this.illusions = new Array<>();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
colorPhase += delta * COLOR_CHANGE_SPEED;
if (colorPhase > MathUtils.PI2) {
colorPhase -= MathUtils.PI2;
}
if (timeSince(lastFormChangeTime) > FORM_CHANGE_INTERVAL) {
switchForm();
}
super.move(delta);
for (int i = illusions.size - 1; i >= 0; i--) {
IllusionData illusion = illusions.get(i);
if (illusion.isDead() || illusion.isExpired(now())) {
illusions.removeIndex(i);
} else {
illusion.move(delta);
}
}
}
private void switchForm() {
currentForm = (currentForm == BossForm.FAST) ? BossForm.ARMORED : BossForm.FAST;
lastFormChangeTime = now();
baseSpeed = (currentForm == BossForm.FAST) ? FAST_FORM_SPEED : ARMORED_FORM_SPEED;
velocity.nor().scl(baseSpeed);
activateDistortion();
}
public void activateDistortion() {
isDistortionActive = true;
distortionStartTime = now();
}
private final float[] colorScratch = {0f, 0f, 0f, 1f};
@Override
public float[] getColor() {
colorScratch[0] = 0.5f + 0.5f * MathUtils.sin(colorPhase);
colorScratch[1] = 0.5f + 0.5f * MathUtils.sin(colorPhase + MathUtils.PI2 / 3);
colorScratch[2] = 0.5f + 0.5f * MathUtils.sin(colorPhase + 2 * MathUtils.PI2 / 3);
return colorScratch;
}
public boolean shouldAttack() {
return timeSince(lastIllusionAttackTime) > ILLUSION_ATTACK_COOLDOWN;
}
public void markAttackUsed() {
lastIllusionAttackTime = now();
}
@Override
public void takeDamage(int damage) {
float multiplier =
(currentForm == BossForm.FAST) ? DAMAGE_MULTIPLIER_FAST : DAMAGE_MULTIPLIER_ARMORED;
super.takeDamage((int) (damage * multiplier));
}
public boolean isDistortionActive() {
if (isDistortionActive && timeSince(distortionStartTime) > DISTORTION_DURATION) {
isDistortionActive = false;
}
return isDistortionActive;
}
public float getDistortionIntensity() {
if (!isDistortionActive) return 0f;
float progress = timeSince(distortionStartTime) / (float) DISTORTION_DURATION;
return DISTORTION_INTENSITY * (1f - progress * progress);
}
public BossForm getCurrentForm() {
return currentForm;
}
public static float getProjectileRadius() {
return PROJECTILE_RADIUS;
}
public static float getProjectileSpeed() {
return PROJECTILE_SPEED;
}
public static int getProjectileDamage() {
return PROJECTILE_DAMAGE;
}
public static int getFakeProjectilesCount() {
return FAKE_PROJECTILES_COUNT;
}
public static int getRealProjectilesCount() {
return REAL_PROJECTILES_COUNT;
}
@Override
protected int getBaseReward() {
return 225;
}
public static float getStaticRadius() {
return 25f;
}
public static float getStaticSpeed() {
return 65f;
}
public boolean shouldCreateIllusions() {
long currentTime = now();
if (currentTime - lastIllusionTime > ILLUSION_COOLDOWN && illusions.size < MAX_ILLUSIONS) {
lastIllusionTime = currentTime;
return true;
}
return false;
}
public boolean shouldShootProjectiles() {
long currentTime = now();
if (currentTime - lastProjectileTime > PROJECTILE_COOLDOWN) {
lastProjectileTime = currentTime;
return true;
}
return false;
}
public int getProjectilesPerVolley() {
return PROJECTILES_PER_VOLLEY;
}
public static long getIllusionDurationMs() {
return ILLUSION_DURATION_MS;
}
public static int getIllusionContactDamage() {
return ILLUSION_CONTACT_DAMAGE;
}
public static long getIllusionDamageCooldownMs() {
return ILLUSION_DAMAGE_COOLDOWN_MS;
}
public IllusionData createIllusion(float x, float y) {
Circle illusionCircle = new Circle(x, y, circle.radius);
Vector2 illusionVelocity = velocity.cpy();
int illusionHealth = (int) (health * ILLUSION_HEALTH_PERCENT);
IllusionData illusion =
new IllusionData(
illusionCircle,
illusionVelocity,
illusionHealth,
ILLUSION_CONTACT_DAMAGE,
now());
illusions.add(illusion);
return illusion;
}
public void removeIllusion(IllusionData illusion) {
illusions.removeValue(illusion, true);
}
public Array<IllusionData> getIllusions() {
return illusions;
}
public static class IllusionData extends BaseCircleData {
private final long creationTime;
public IllusionData(
Circle circle, Vector2 velocity, int health, int damage, long creationTime) {
super(circle, velocity, health, damage);
this.creationTime = creationTime;
}
public long getCreationTime() {
return creationTime;
}
boolean isExpired(long nowMs) {
return nowMs - creationTime > ILLUSION_DURATION_MS;
}
private final float[] illusionColor = {0.8f, 0.3f, 0.8f, 0.7f};
@Override
public float[] getColor() {
return illusionColor;
}
@Override
protected int getBaseReward() {
return 0;
}
}
}
@@ -0,0 +1,185 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class JuggernautData extends BaseCircleData {
private static final float JUGGERNAUT_CHARGE_SPEED = 300f;
private static final int JUGGERNAUT_BASE_HEALTH = 300;
private static final int JUGGERNAUT_DAMAGE = 25;
private static final float JUGGERNAUT_RADIUS = 45f;
private static final int ARMOR_LAYERS = 3;
private static final long INVULNERABILITY_DURATION = 2000;
private static final long CHARGE_PREPARATION_TIME = 2000;
private static final long CHARGE_COOLDOWN = 5000;
private static final long CHARGE_DURATION = 2000;
private static final float CHARGE_SPEED_MULTIPLIER = 3.0f;
private static final long SHOCKWAVE_COOLDOWN = 10000;
private static final float SHOCKWAVE_RADIUS = 200f;
private static final float SHOCKWAVE_FORCE = 500f;
private int currentLayer;
private long lastLayerBreakTime;
private boolean isCharging;
private boolean isPreparingCharge;
private long chargeStartTime;
private long lastChargeTime;
private long lastShockwaveTime;
private Vector2 chargeDirection;
private Vector2 normalVelocity;
private Vector2 chargeVelocity;
private final GameClock clock;
public JuggernautData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public JuggernautData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, JUGGERNAUT_BASE_HEALTH, JUGGERNAUT_DAMAGE);
this.clock = clock;
this.currentLayer = ARMOR_LAYERS;
this.isCharging = false;
this.isPreparingCharge = false;
this.chargeDirection = new Vector2();
this.normalVelocity = velocity.cpy();
this.chargeVelocity = velocity.cpy().scl(CHARGE_SPEED_MULTIPLIER);
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
if (isCharging) {
circle.x += chargeDirection.x * JUGGERNAUT_CHARGE_SPEED * delta;
circle.y += chargeDirection.y * JUGGERNAUT_CHARGE_SPEED * delta;
} else {
super.move(delta);
}
}
private static final float[] COLOR = {0.8f, 0.4f, 0.0f, 1.0f};
@Override
public float[] getColor() {
return COLOR;
}
public boolean isInvulnerable() {
return timeSince(lastLayerBreakTime) < INVULNERABILITY_DURATION;
}
@Override
public void takeDamage(int damage) {
if (isInvulnerable()) return;
super.takeDamage(damage);
if (health <= 0 && currentLayer > 1) {
currentLayer--;
health = JUGGERNAUT_BASE_HEALTH / ARMOR_LAYERS;
lastLayerBreakTime = now();
lastShockwaveTime = 0;
}
}
public boolean shouldStartCharge() {
long currentTime = now();
if (!isCharging && !isPreparingCharge && currentTime - lastChargeTime > CHARGE_COOLDOWN) {
isPreparingCharge = true;
chargeStartTime = currentTime;
return true;
}
return false;
}
public boolean shouldExecuteCharge() {
if (isPreparingCharge && timeSince(chargeStartTime) > CHARGE_PREPARATION_TIME) {
isPreparingCharge = false;
isCharging = true;
lastChargeTime = now();
velocity.set(chargeVelocity);
return true;
}
return false;
}
public void setChargeDirection(float x, float y) {
float length = (float) Math.sqrt(x * x + y * y);
if (length > 0) {
chargeDirection.set(x / length, y / length);
}
}
public boolean shouldShockwave() {
return timeSince(lastShockwaveTime) > SHOCKWAVE_COOLDOWN;
}
public void performShockwave() {
lastShockwaveTime = now();
}
public void endCharge() {
isCharging = false;
isPreparingCharge = false;
velocity.set(normalVelocity);
}
public boolean isCharging() {
return isCharging;
}
public boolean isPreparingCharge() {
return isPreparingCharge;
}
public float getChargeProgress() {
if (!isPreparingCharge) return 0f;
return Math.min(timeSince(chargeStartTime) / (float) CHARGE_PREPARATION_TIME, 1f);
}
public int getCurrentLayer() {
return currentLayer;
}
public static float getShockwaveRadius() {
return SHOCKWAVE_RADIUS;
}
public static float getShockwaveForce() {
return SHOCKWAVE_FORCE;
}
public static float getStaticRadius() {
return 40f;
}
public static float getStaticSpeed() {
return 40f;
}
@Override
protected int getBaseReward() {
return 200;
}
public void update() {
long currentTime = now();
if (isCharging && currentTime - lastChargeTime > CHARGE_DURATION) {
isCharging = false;
velocity.set(normalVelocity);
}
}
}
@@ -0,0 +1,240 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class ShadowWeaverData extends BaseCircleData {
private static final float SHADOW_WEAVER_SPEED = 100f;
private static final int SHADOW_WEAVER_BASE_HEALTH = 200;
private static final int SHADOW_WEAVER_DAMAGE = 15;
private static final float SHADOW_WEAVER_RADIUS = 40f;
private static final long TELEPORT_COOLDOWN = 5000;
private static final long TELEPORT_DURATION = 1000;
private static final float TELEPORT_MAX_DISTANCE = 300f;
private static final int MAX_CLONES = 3;
private static final long CLONE_DURATION = 8000;
private static final int CLONE_HEALTH = 30;
private static final int CLONE_DAMAGE = 5;
private static final long CLONE_DAMAGE_COOLDOWN_MS = 1000L;
private static final long PROJECTILE_COOLDOWN = 2000;
private static final float PROJECTILE_SPEED = 200f;
private static final float PROJECTILE_RADIUS = 8f;
private static final int PROJECTILE_DAMAGE = 10;
private boolean isTeleporting;
private long lastTeleportTime;
private long teleportStartTime;
private Vector2 teleportTarget;
private Vector2 originalPosition;
private long lastProjectileTime;
private float teleportProgress;
private Array<ShadowCloneData> clones;
private boolean isInvisible = false;
private long lastInvisibilityTime = 0;
private long invisibilityStartTime = 0;
private static final long INVISIBILITY_COOLDOWN = 7000;
private static final long INVISIBILITY_DURATION = 3000;
private static final float INVISIBLE_SPEED_MULTIPLIER = 1.5f;
private Vector2 normalVelocity;
private Vector2 invisibleVelocity;
private final GameClock clock;
public ShadowWeaverData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public ShadowWeaverData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, SHADOW_WEAVER_BASE_HEALTH, SHADOW_WEAVER_DAMAGE);
this.clock = clock;
this.isTeleporting = false;
this.teleportTarget = new Vector2();
this.originalPosition = new Vector2();
this.clones = new Array<>();
this.normalVelocity = velocity.cpy();
this.invisibleVelocity = velocity.cpy().scl(INVISIBLE_SPEED_MULTIPLIER);
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
if (isTeleporting) {
teleportProgress =
Math.min(1f, timeSince(teleportStartTime) / (float) TELEPORT_DURATION);
circle.x =
originalPosition.x + (teleportTarget.x - originalPosition.x) * teleportProgress;
circle.y =
originalPosition.y + (teleportTarget.y - originalPosition.y) * teleportProgress;
if (teleportProgress >= 1f) {
isTeleporting = false;
lastTeleportTime = now();
}
} else {
super.move(delta);
}
for (int i = clones.size - 1; i >= 0; i--) {
ShadowCloneData clone = clones.get(i);
if (timeSince(clone.getCreationTime()) > CLONE_DURATION) {
clones.removeIndex(i);
} else {
clone.move(delta);
}
}
}
private final float[] colorScratch = {0.3f, 0.0f, 0.5f, 1.0f};
@Override
public float[] getColor() {
colorScratch[3] = isInvisible ? 0.3f : 1.0f;
return colorScratch;
}
public boolean shouldTeleport() {
return !isTeleporting && timeSince(lastTeleportTime) > TELEPORT_COOLDOWN;
}
public void startTeleport(float targetX, float targetY) {
isTeleporting = true;
teleportStartTime = now();
teleportProgress = 0f;
originalPosition.set(circle.x, circle.y);
teleportTarget.set(targetX, targetY);
}
public void createClones() {
if (clones.size < MAX_CLONES) {
for (int i = 0; i < MAX_CLONES; i++) {
float angle = (float) (i * Math.PI * 2 / MAX_CLONES);
float distance = 100f;
float cloneX = circle.x + (float) Math.cos(angle) * distance;
float cloneY = circle.y + (float) Math.sin(angle) * distance;
Circle cloneCircle = new Circle(cloneX, cloneY, SHADOW_WEAVER_RADIUS);
Vector2 cloneVelocity = new Vector2(velocity).scl(0.8f);
ShadowCloneData clone = new ShadowCloneData(cloneCircle, cloneVelocity);
clones.add(clone);
}
}
}
public boolean shouldShoot() {
return timeSince(lastProjectileTime) > PROJECTILE_COOLDOWN;
}
public void markProjectileShot() {
lastProjectileTime = now();
}
public boolean isTeleporting() {
return isTeleporting;
}
public float getTeleportProgress() {
return teleportProgress;
}
public Array<ShadowCloneData> getClones() {
return clones;
}
public static int getCloneDamage() {
return CLONE_DAMAGE;
}
public static long getCloneDamageCooldownMs() {
return CLONE_DAMAGE_COOLDOWN_MS;
}
public static float getProjectileRadius() {
return PROJECTILE_RADIUS;
}
public static float getProjectileSpeed() {
return PROJECTILE_SPEED;
}
public static int getProjectileDamage() {
return PROJECTILE_DAMAGE;
}
@Override
protected int getBaseReward() {
return 200;
}
public static float getStaticRadius() {
return 25f;
}
public static float getStaticSpeed() {
return 70f;
}
public boolean shouldBecomeInvisible() {
long currentTime = now();
if (!isInvisible && currentTime - lastInvisibilityTime > INVISIBILITY_COOLDOWN) {
isInvisible = true;
invisibilityStartTime = currentTime;
lastInvisibilityTime = currentTime;
velocity.set(invisibleVelocity);
return true;
}
return false;
}
public void update() {
long currentTime = now();
if (isInvisible && currentTime - invisibilityStartTime > INVISIBILITY_DURATION) {
isInvisible = false;
velocity.set(normalVelocity);
}
}
public boolean isInvisible() {
return isInvisible;
}
public class ShadowCloneData extends BaseCircleData {
private long creationTime;
public ShadowCloneData(Circle circle, Vector2 velocity) {
super(circle, velocity, CLONE_HEALTH, CLONE_DAMAGE);
this.creationTime = ShadowWeaverData.this.now();
}
public long getCreationTime() {
return creationTime;
}
private final float[] cloneColorScratch = {0f, 0f, 0f, 0.5f};
@Override
public float[] getColor() {
float[] bossColor = ShadowWeaverData.this.getColor();
cloneColorScratch[0] = bossColor[0];
cloneColorScratch[1] = bossColor[1];
cloneColorScratch[2] = bossColor[2];
return cloneColorScratch;
}
}
}
@@ -0,0 +1,344 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.BossEffectsHost;
import ru.project.tower.support.GameClock;
public class SwarmQueenData extends BaseCircleData {
private static final float QUEEN_RADIUS = 40f;
private static final int QUEEN_HEALTH = 200;
private static final float QUEEN_SPEED = 40f;
private static final int QUEEN_DAMAGE = 15;
private static final int MINIONS_PER_SPAWN = 6;
private static final long MINION_SPAWN_COOLDOWN = 6000;
private static final long VULNERABILITY_DURATION = 2000;
private static final int BULLETS_PER_VOLLEY = 12;
private static final long BULLET_VOLLEY_COOLDOWN = 8000;
private static final float BULLET_SPEED = 200f;
private static final float BULLET_RADIUS = 5f;
private static final int BULLET_DAMAGE = 10;
private static final int ORBITAL_EYES_COUNT = 4;
private static final float ORBITAL_RADIUS = 60f;
private static final float ORBITAL_SPEED = 2f;
private static final int ORBITAL_PARTICLES_COUNT = 12;
private static final float ORBITAL_PARTICLE_RADIUS = 3f;
private static final float ORBITAL_PARTICLE_SPEED = 3f;
private long lastMinionSpawnTime;
private long lastBulletVolleyTime;
private boolean isVulnerable;
private long vulnerabilityStartTime;
private float orbitalAngle;
private float particleAngle = 0f;
private Array<Vector2> orbitalEyePositions;
private Array<Vector2> orbitalParticles;
private static final long CHARGING_DURATION = 1000;
private long chargingStartTime = 0;
private boolean isChargingComplete = false;
private static final Color BASE_COLOR = new Color(0.5f, 0.1f, 0.5f, 1f);
private static final Color CHARGING_COLOR = new Color(0.8f, 0.2f, 0.8f, 1f);
private static final Color VULNERABLE_COLOR = new Color(0.3f, 0.3f, 0.8f, 1f);
private static final Color MINION_COLOR = new Color(0.4f, 0.1f, 0.4f, 1f);
private static final Color PROJECTILE_COLOR = new Color(0.8f, 0.2f, 0.8f, 1f);
private final Color scratchBase = new Color();
private final Color scratchMinion = new Color();
private final Color scratchProjectile = new Color();
private final float[] scratchColorArr = new float[4];
private static final float EYE_RADIUS = 8f;
private static final float EYE_GLOW_MULTIPLIER = 1.5f;
private static final float PARTICLE_SPAWN_RATE = 0.1f;
private float particleTimer = 0f;
private final BossEffectsHost host;
private final GameClock clock;
public SwarmQueenData(Circle circle, Vector2 velocity, BossEffectsHost host) {
this(circle, velocity, host, GameClock.SYSTEM);
}
public SwarmQueenData(Circle circle, Vector2 velocity, BossEffectsHost host, GameClock clock) {
super(circle, velocity, QUEEN_HEALTH, QUEEN_DAMAGE);
this.host = host;
this.clock = clock;
this.orbitalEyePositions = new Array<>(ORBITAL_EYES_COUNT);
this.orbitalParticles = new Array<>(ORBITAL_PARTICLES_COUNT);
for (int i = 0; i < ORBITAL_EYES_COUNT; i++) {
this.orbitalEyePositions.add(new Vector2());
}
for (int i = 0; i < ORBITAL_PARTICLES_COUNT; i++) {
this.orbitalParticles.add(new Vector2());
}
updateOrbitalEyes();
updateOrbitalParticles();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
super.move(delta);
orbitalAngle += ORBITAL_SPEED * delta;
updateOrbitalEyes();
particleAngle += ORBITAL_PARTICLE_SPEED * delta;
updateOrbitalParticles();
if (isVulnerable && timeSince(vulnerabilityStartTime) > VULNERABILITY_DURATION) {
isVulnerable = false;
}
}
private void updateOrbitalEyes() {
float angleStep = (float) (2 * Math.PI / ORBITAL_EYES_COUNT);
for (int i = 0; i < ORBITAL_EYES_COUNT; i++) {
float angle = orbitalAngle + i * angleStep;
float x = getX() + ORBITAL_RADIUS * (float) Math.cos(angle);
float y = getY() + ORBITAL_RADIUS * (float) Math.sin(angle);
orbitalEyePositions.get(i).set(x, y);
}
}
private void updateOrbitalParticles() {
float baseRadius = circle.radius * 1.5f;
float angleStep = (float) (2 * Math.PI / ORBITAL_PARTICLES_COUNT);
for (int i = 0; i < ORBITAL_PARTICLES_COUNT; i++) {
float angle = particleAngle + i * angleStep;
float radiusOffset = 5f * (float) Math.sin(now() / 200.0 + i * 0.5f);
float radius = baseRadius + radiusOffset;
float x = getX() + radius * (float) Math.cos(angle);
float y = getY() + radius * (float) Math.sin(angle);
orbitalParticles.get(i).set(x, y);
}
}
private Color getBaseColor() {
scratchBase.set(BASE_COLOR);
if (isCharging()) {
scratchBase.lerp(CHARGING_COLOR, getChargeProgress());
} else if (isVulnerable()) {
scratchBase.lerp(VULNERABLE_COLOR, 0.7f);
}
scratchBase.a = 0.8f + 0.2f * (float) Math.sin(now() / 200.0);
return scratchBase;
}
@Override
public float[] getColor() {
Color color = getBaseColor();
scratchColorArr[0] = color.r;
scratchColorArr[1] = color.g;
scratchColorArr[2] = color.b;
scratchColorArr[3] = color.a;
return scratchColorArr;
}
public Color getOrbitalParticleColor() {
return getBaseColor();
}
public boolean shouldSpawnMinions() {
return timeSince(lastMinionSpawnTime) > MINION_SPAWN_COOLDOWN;
}
public void onMinionSpawn() {
long currentTime = now();
lastMinionSpawnTime = currentTime;
isVulnerable = true;
vulnerabilityStartTime = currentTime;
}
public boolean isReadyToFireProjectiles() {
return timeSince(lastBulletVolleyTime) > BULLET_VOLLEY_COOLDOWN;
}
public void onProjectileAttackStart() {
lastBulletVolleyTime = now();
}
public static float getStaticRadius() {
return QUEEN_RADIUS;
}
public static float getStaticSpeed() {
return QUEEN_SPEED;
}
public static int getMinionsPerWave() {
return MINIONS_PER_SPAWN;
}
public static int getBulletsPerVolley() {
return BULLETS_PER_VOLLEY;
}
public static float getBulletSpeed() {
return BULLET_SPEED;
}
public static float getBulletRadius() {
return BULLET_RADIUS;
}
public static int getBulletDamage() {
return BULLET_DAMAGE;
}
public Array<Vector2> getOrbitalEyePositions() {
return orbitalEyePositions;
}
public Array<Vector2> getOrbitalParticles() {
return orbitalParticles;
}
public float getOrbitalParticleRadius() {
return ORBITAL_PARTICLE_RADIUS;
}
public boolean isVulnerable() {
return isVulnerable;
}
@Override
public void takeDamage(int damage) {
if (isVulnerable) {
super.takeDamage(damage * 2);
} else {
super.takeDamage(damage);
}
}
@Override
protected int getBaseReward() {
return 200;
}
public boolean isCharging() {
if (isReadyToFireProjectiles() && !isChargingComplete) {
if (chargingStartTime == 0) {
chargingStartTime = now();
}
boolean charging = timeSince(chargingStartTime) < CHARGING_DURATION;
if (!charging) {
isChargingComplete = true;
}
return charging;
}
return false;
}
public void finishCharging() {
chargingStartTime = 0;
isChargingComplete = false;
onProjectileAttackStart();
}
public float getChargeProgress() {
if (chargingStartTime == 0) {
return 0f;
}
float progress = (float) timeSince(chargingStartTime) / CHARGING_DURATION;
return Math.min(progress, 1f);
}
public Color getMinionColor() {
scratchMinion.set(MINION_COLOR);
if (isVulnerable()) {
scratchMinion.lerp(VULNERABLE_COLOR, 0.5f);
}
scratchMinion.a = 0.9f + 0.1f * (float) Math.sin(now() / 300.0);
return scratchMinion;
}
public Color getProjectileColor() {
scratchProjectile.set(PROJECTILE_COLOR);
if (isVulnerable()) {
scratchProjectile.lerp(VULNERABLE_COLOR, 0.3f);
}
scratchProjectile.a = 0.7f + 0.3f * (float) Math.sin(now() / 150.0);
return scratchProjectile;
}
public float getEyeRadius() {
float baseRadius = EYE_RADIUS;
if (isCharging()) {
baseRadius *= (1f + 0.5f * getChargeProgress());
}
return baseRadius * (1f + 0.1f * (float) Math.sin(now() / 150.0));
}
public float getEyeGlow() {
float baseGlow = 1f;
if (isCharging()) {
baseGlow = 1f + getChargeProgress() * 2f;
} else if (isVulnerable()) {
baseGlow = 0.7f;
}
return baseGlow * (1f + 0.2f * (float) Math.sin(now() / 200.0));
}
public void updateParticles(float delta) {
particleTimer += delta;
if (particleTimer >= PARTICLE_SPAWN_RATE) {
particleTimer = 0f;
if (isCharging()) {
spawnChargeParticles();
} else if (isVulnerable()) {
spawnVulnerabilityParticles();
}
}
}
private final Color scratchParticleColor = new Color();
private void spawnChargeParticles() {
if (host != null) {
scratchParticleColor.set(CHARGING_COLOR);
scratchParticleColor.a = 0.7f + 0.3f * getChargeProgress();
host.createBossParticles(getX(), getY(), circle.radius, scratchParticleColor, 1.5f, 3);
}
}
private void spawnVulnerabilityParticles() {
if (host != null) {
scratchParticleColor.set(VULNERABLE_COLOR);
scratchParticleColor.a = 0.6f;
host.createBossParticles(getX(), getY(), circle.radius, scratchParticleColor, 1.2f, 2);
}
}
}
@@ -0,0 +1,256 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class VolatileReactorData extends BaseCircleData {
private static final float REACTOR_SPEED = 80f;
private static final int REACTOR_BASE_HEALTH = 250;
private static final int REACTOR_DAMAGE = 20;
private static final float REACTOR_RADIUS = 40f;
private static final long HEAT_CYCLE_DURATION = 15000;
private static final int EXPLOSION_DAMAGE = 50;
private static final float EXPLOSION_RADIUS = 300f;
private static final long COOLDOWN_DURATION = 5000;
private static final long FIRE_ZONE_INTERVAL = 3000;
private static final long FIRE_ZONE_DURATION = 5000;
private static final float FIRE_ZONE_RADIUS = 60f;
private static final int FIRE_ZONE_DAMAGE = 5;
private static final long FIRE_ZONE_DAMAGE_COOLDOWN_MS = 1000L;
private static final long MISSILE_COOLDOWN = 7000;
private static final int MISSILES_PER_VOLLEY = 5;
private static final float MISSILE_SPEED = 250f;
private static final float MISSILE_RADIUS = 6f;
private static final int MISSILE_DAMAGE = 8;
private float heatLevel;
private long cycleStartTime;
private boolean isExploding;
private boolean isCoolingDown;
private long lastFireZoneTime;
private long lastMissileTime;
private Array<FireZone> fireZones;
private int energyLevel = 0;
private long lastEnergyGainTime = 0;
private static final long ENERGY_GAIN_INTERVAL = 1000;
private static final int MAX_ENERGY = 100;
private static final int ENERGY_GAIN_AMOUNT = 10;
private long explosionStartTime = 0;
private static final long EXPLOSION_DURATION = 500;
private final GameClock clock;
public VolatileReactorData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public VolatileReactorData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, REACTOR_BASE_HEALTH, REACTOR_DAMAGE);
this.clock = clock;
this.heatLevel = 0f;
this.cycleStartTime = now();
this.isExploding = false;
this.isCoolingDown = false;
this.fireZones = new Array<>();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
super.move(delta);
if (!isCoolingDown) {
heatLevel = Math.min(1f, timeSince(cycleStartTime) / (float) HEAT_CYCLE_DURATION);
if (heatLevel >= 1f && !isExploding) {
isExploding = true;
}
} else {
heatLevel = Math.max(0f, 1f - timeSince(cycleStartTime) / (float) COOLDOWN_DURATION);
if (heatLevel <= 0f) {
isCoolingDown = false;
cycleStartTime = now();
}
}
for (int i = fireZones.size - 1; i >= 0; i--) {
FireZone zone = fireZones.get(i);
if (timeSince(zone.creationTime) > FIRE_ZONE_DURATION) {
fireZones.removeIndex(i);
}
}
}
private final float[] colorScratch = {0f, 0f, 0.1f, 1f};
@Override
public float[] getColor() {
float pulseIntensity = 0.5f + 0.5f * (float) Math.sin(now() * 0.01f);
float heat = heatLevel + pulseIntensity * 0.2f;
colorScratch[0] = 0.8f + heat * 0.2f;
colorScratch[1] = 0.4f - heat * 0.3f;
return colorScratch;
}
public void explode() {
isExploding = false;
isCoolingDown = true;
cycleStartTime = now();
}
public boolean shouldCreateFireZone() {
return timeSince(lastFireZoneTime) > FIRE_ZONE_INTERVAL;
}
public void createFireZone() {
FireZone zone = new FireZone(circle.x, circle.y, FIRE_ZONE_RADIUS, now(), clock);
fireZones.add(zone);
lastFireZoneTime = now();
}
public boolean shouldShootMissiles() {
return timeSince(lastMissileTime) > MISSILE_COOLDOWN;
}
public void markMissileShot() {
lastMissileTime = now();
}
public float getHeatLevel() {
return heatLevel;
}
public boolean isExploding() {
return isExploding;
}
public Array<FireZone> getFireZones() {
return fireZones;
}
public static float getFireZoneRadius() {
return FIRE_ZONE_RADIUS;
}
public static int getFireZoneDamage() {
return FIRE_ZONE_DAMAGE;
}
public static long getFireZoneDamageCooldownMs() {
return FIRE_ZONE_DAMAGE_COOLDOWN_MS;
}
public static float getMissileRadius() {
return MISSILE_RADIUS;
}
public static float getMissileSpeed() {
return MISSILE_SPEED;
}
public static int getMissileDamage() {
return MISSILE_DAMAGE;
}
public static int getMissilesPerVolley() {
return MISSILES_PER_VOLLEY;
}
public static float getExplosionRadius() {
return EXPLOSION_RADIUS;
}
public static int getExplosionDamage() {
return EXPLOSION_DAMAGE;
}
@Override
protected int getBaseReward() {
return 225;
}
public static class FireZone {
public float x, y;
public float radius;
public long creationTime;
private final GameClock clock;
public FireZone(float x, float y, float radius, long creationTime) {
this(x, y, radius, creationTime, GameClock.SYSTEM);
}
public FireZone(float x, float y, float radius, long creationTime, GameClock clock) {
this.x = x;
this.y = y;
this.radius = radius;
this.creationTime = creationTime;
this.clock = clock;
}
public float getAlpha() {
float age = (clock.nowMs() - creationTime) / (float) FIRE_ZONE_DURATION;
return 1f - age;
}
private final float[] zoneColorScratch = {1f, 0.3f, 0.1f, 0f};
public float[] getColor() {
float intensity = 0.5f + 0.5f * (float) Math.sin(clock.nowMs() * 0.005f);
zoneColorScratch[3] = getAlpha() * (0.3f + intensity * 0.2f);
return zoneColorScratch;
}
}
public static float getStaticRadius() {
return REACTOR_RADIUS;
}
public static float getStaticSpeed() {
return REACTOR_SPEED;
}
public void update() {
long currentTime = now();
if (!isExploding && currentTime - lastEnergyGainTime > ENERGY_GAIN_INTERVAL) {
energyLevel = Math.min(MAX_ENERGY, energyLevel + ENERGY_GAIN_AMOUNT);
lastEnergyGainTime = currentTime;
}
if (isExploding && currentTime - explosionStartTime > EXPLOSION_DURATION) {
isExploding = false;
energyLevel = 0;
}
}
public boolean shouldExplode() {
return energyLevel >= MAX_ENERGY && !isExploding;
}
public void startExplosion() {
if (!isExploding) {
isExploding = true;
explosionStartTime = now();
}
}
public int getEnergyLevel() {
return energyLevel;
}
}
@@ -0,0 +1,74 @@
package ru.project.tower.entities.particles;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class FloatingText implements Pool.Poolable {
public final Vector2 position = new Vector2();
public final Vector2 velocity = new Vector2();
public String text;
public Color color;
public float lifetime;
public float maxLifetime;
public float scale;
public float initialScale;
public FloatingText() {}
public void configure(float x, float y, String text, Color srcColor) {
configure(x, y, text, srcColor, 1.0f, 1.0f, 50f);
}
public void configure(
float x,
float y,
String text,
Color srcColor,
float maxLifetime,
float initialScale,
float riseSpeed) {
position.set(x, y);
velocity.set(0f, riseSpeed);
this.text = text;
if (this.color == null) this.color = new Color();
this.color.set(srcColor);
this.maxLifetime = maxLifetime;
this.lifetime = maxLifetime;
this.initialScale = initialScale;
this.scale = initialScale;
}
public void update(float delta) {
position.add(velocity.x * delta, velocity.y * delta);
lifetime -= delta;
velocity.y *= 0.95f;
float life = Math.max(0f, lifetime / maxLifetime);
scale = initialScale * (0.85f + 0.15f * life);
color.a = Math.min(1f, life / 0.4f);
}
public boolean isAlive() {
return lifetime > 0;
}
@Override
public void reset() {
position.set(0f, 0f);
velocity.set(0f, 0f);
text = null;
if (color != null) color.set(0f, 0f, 0f, 0f);
lifetime = 0f;
maxLifetime = 0f;
scale = 0f;
initialScale = 0f;
}
}
@@ -0,0 +1,48 @@
package ru.project.tower.entities.particles;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class NeonParticle implements Pool.Poolable {
public final Vector2 position = new Vector2();
public final Vector2 velocity = new Vector2();
public final Color color = new Color();
public float size;
public float lifetime;
public float maxLifetime;
public NeonParticle() {}
public void configure(float x, float y, Color color) {
position.set(x, y);
velocity.set(MathUtils.random(-20f, 20f), MathUtils.random(-20f, 20f));
size = MathUtils.random(2f, 4f);
maxLifetime = MathUtils.random(0.5f, 2.0f);
lifetime = maxLifetime;
this.color.set(color);
}
public void update(float delta) {
position.add(velocity.x * delta, velocity.y * delta);
lifetime -= delta;
size = Math.max(0, size - delta);
}
@Override
public void reset() {
position.set(0f, 0f);
velocity.set(0f, 0f);
size = 0f;
lifetime = 0f;
maxLifetime = 0f;
color.set(0f, 0f, 0f, 0f);
}
}
@@ -0,0 +1,47 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Screen;
import java.util.Objects;
import ru.project.tower.GameContext;
import ru.project.tower.screens.AbilitySelectionScreen;
import ru.project.tower.screens.AbilityShopScreen;
import ru.project.tower.screens.GameScreen;
import ru.project.tower.screens.MainScreen;
import ru.project.tower.screens.TalentTreeScreen;
import ru.project.tower.screens.abilityselection.AbilitySelectionDependencies;
import ru.project.tower.screens.abilityshop.AbilityShopDependencies;
import ru.project.tower.screens.gamescreen.GameScreenDependencies;
import ru.project.tower.screens.talenttree.TalentTreeScreenDependencies;
public final class DefaultScreenFactory implements ScreenFactory {
private final GameContext ctx;
public DefaultScreenFactory(GameContext ctx) {
this.ctx = Objects.requireNonNull(ctx, "ctx");
}
@Override
public Screen createMainMenu(ScreenNavigator navigator) {
return new MainScreen(navigator);
}
@Override
public Screen createAbilitySelection(ScreenNavigator navigator) {
return new AbilitySelectionScreen(navigator, AbilitySelectionDependencies.from(ctx));
}
@Override
public Screen createAbilityShop(ScreenNavigator navigator) {
return new AbilityShopScreen(navigator, AbilityShopDependencies.from(ctx));
}
@Override
public Screen createTalentTree(ScreenNavigator navigator) {
return new TalentTreeScreen(navigator, TalentTreeScreenDependencies.from(ctx));
}
@Override
public Screen createGameScreen(ScreenNavigator navigator) {
return new GameScreen(navigator, GameScreenDependencies.from(ctx));
}
}
@@ -0,0 +1,76 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Screen;
import java.util.Objects;
public final class GameScreenNavigator implements ScreenNavigator {
private final ScreenHost host;
private final ScreenFactory factory;
public GameScreenNavigator(ScreenHost host, ScreenFactory factory) {
this.host = Objects.requireNonNull(host, "host");
this.factory = Objects.requireNonNull(factory, "factory");
}
public static GameScreenNavigator forGame(Game game, ScreenFactory factory) {
return new GameScreenNavigator(new GdxGameScreenHost(game), factory);
}
@Override
public void showMainMenu() {
transitionTo(factory.createMainMenu(this));
}
@Override
public void showAbilitySelection() {
transitionTo(factory.createAbilitySelection(this));
}
@Override
public void showAbilityShop() {
transitionTo(factory.createAbilityShop(this));
}
@Override
public void showTalentTree() {
transitionTo(factory.createTalentTree(this));
}
@Override
public void showGameScreen() {
transitionTo(factory.createGameScreen(this));
}
private void transitionTo(Screen nextScreen) {
Screen previousScreen = host.currentScreen();
host.setScreen(Objects.requireNonNull(nextScreen, "nextScreen"));
if (previousScreen != null && previousScreen != nextScreen) {
previousScreen.dispose();
}
}
public interface ScreenHost {
Screen currentScreen();
void setScreen(Screen screen);
}
private static final class GdxGameScreenHost implements ScreenHost {
private final Game game;
private GdxGameScreenHost(Game game) {
this.game = Objects.requireNonNull(game, "game");
}
@Override
public Screen currentScreen() {
return game.getScreen();
}
@Override
public void setScreen(Screen screen) {
game.setScreen(screen);
}
}
}
@@ -0,0 +1,15 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Screen;
public interface ScreenFactory {
Screen createMainMenu(ScreenNavigator navigator);
Screen createAbilitySelection(ScreenNavigator navigator);
Screen createAbilityShop(ScreenNavigator navigator);
Screen createTalentTree(ScreenNavigator navigator);
Screen createGameScreen(ScreenNavigator navigator);
}
@@ -0,0 +1,12 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Game;
import ru.project.tower.GameContext;
public final class ScreenNavigation {
private ScreenNavigation() {}
public static ScreenNavigator forGame(Game game, GameContext ctx) {
return GameScreenNavigator.forGame(game, new DefaultScreenFactory(ctx));
}
}
@@ -0,0 +1,13 @@
package ru.project.tower.navigation;
public interface ScreenNavigator {
void showMainMenu();
void showAbilitySelection();
void showAbilityShop();
void showTalentTree();
void showGameScreen();
}
@@ -0,0 +1,189 @@
package ru.project.tower.player;
import java.util.Objects;
import ru.project.tower.balance.EndlessBalanceSpec;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.talents.TalentTree;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.upgrades.shop.ShopUpgradeSystem;
public final class EffectiveCombatStats {
private static final float CRIT_KEYSTONE_CHANCE_PER_LEVEL = 0.10f;
private static final String CRIT_KEYSTONE_ID = "keystone_crit";
private final float damage;
private final float shotCooldownMs;
private final float bulletSpeedMultiplier;
private final float critChance;
private final float critMultiplier;
private final int healthRegenRate;
private final int pierceBonus;
private final float aoeRadiusBonus;
private final float controlRating;
private final float executeDamageBonus;
private final float bossDamageBonus;
private final float controlledDamageBonus;
private final float abilityScalingFactor;
private EffectiveCombatStats(
float damage,
float shotCooldownMs,
float bulletSpeedMultiplier,
float critChance,
float critMultiplier,
int healthRegenRate,
int pierceBonus,
float aoeRadiusBonus,
float controlRating,
float executeDamageBonus,
float bossDamageBonus,
float controlledDamageBonus,
float abilityScalingFactor) {
this.damage = damage;
this.shotCooldownMs = shotCooldownMs;
this.bulletSpeedMultiplier = bulletSpeedMultiplier;
this.critChance = critChance;
this.critMultiplier = critMultiplier;
this.healthRegenRate = healthRegenRate;
this.pierceBonus = pierceBonus;
this.aoeRadiusBonus = aoeRadiusBonus;
this.controlRating = controlRating;
this.executeDamageBonus = executeDamageBonus;
this.bossDamageBonus = bossDamageBonus;
this.controlledDamageBonus = controlledDamageBonus;
this.abilityScalingFactor = abilityScalingFactor;
}
public static EffectiveCombatStats fromSources(
PlayerCombatState combatState,
ShopUpgradeSystem shopUpgradeSystem,
UpgradeSystem upgradeSystem,
BonusBallSystem bonusBallSystem,
TalentTree talentTree,
int currentWave,
float baseDamage,
float runDamageBonus,
int runPierceBonus,
float runAoeRadiusBonus,
float controlRating) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
Objects.requireNonNull(talentTree, "talentTree");
float damage =
(baseDamage + shopUpgradeSystem.damageBonus() + runDamageBonus)
* bonusBallSystem.getDamageMultiplier();
return new EffectiveCombatStats(
damage,
shotCooldownMs(
combatState,
shopUpgradeSystem.cooldownReduction(),
upgradeSystem,
bonusBallSystem),
bonusBallSystem.getSpeedMultiplier(),
critChance(combatState, shopUpgradeSystem, upgradeSystem, talentTree),
critMultiplier(combatState, shopUpgradeSystem),
combatState.getHealthRegenRate() + shopUpgradeSystem.regenBonus(),
runPierceBonus + shopUpgradeSystem.pierceBonus(),
runAoeRadiusBonus + shopUpgradeSystem.aoeRadiusBonus(),
controlRating,
shopUpgradeSystem.executeDamageBonus(),
shopUpgradeSystem.bossDamageBonus(),
shopUpgradeSystem.controlledDamageBonus(),
PlayerRunCombatCalculator.abilityScalingFactor(currentWave));
}
public static float shotCooldownMs(
PlayerCombatState combatState,
float shopCooldownReduction,
UpgradeSystem upgradeSystem,
BonusBallSystem bonusBallSystem) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
float cooldown =
(combatState.getShotCooldownMs() - shopCooldownReduction)
* upgradeSystem.getRunCooldownMult()
* bonusBallSystem.getCooldownMultiplier();
return Math.max(PlayerCombatState.MIN_SHOT_COOLDOWN_MS, cooldown);
}
public static float critChance(
PlayerCombatState combatState,
ShopUpgradeSystem shopUpgradeSystem,
UpgradeSystem upgradeSystem,
TalentTree talentTree) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(talentTree, "talentTree");
int critKeystone = talentTree.getCurrentLevel(CRIT_KEYSTONE_ID);
return Math.min(
EndlessBalanceSpec.CRIT_CHANCE_CAP,
combatState.getBaseCritChance()
+ shopUpgradeSystem.critChanceBonus()
+ critKeystone * CRIT_KEYSTONE_CHANCE_PER_LEVEL
+ upgradeSystem.getRunCritBonus());
}
public static float critMultiplier(
PlayerCombatState combatState, ShopUpgradeSystem shopUpgradeSystem) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
return combatState.getBaseCritMultiplier() + shopUpgradeSystem.critMultiplierBonus();
}
public float damage() {
return damage;
}
public float shotCooldownMs() {
return shotCooldownMs;
}
public float bulletSpeedMultiplier() {
return bulletSpeedMultiplier;
}
public float critChance() {
return critChance;
}
public float critMultiplier() {
return critMultiplier;
}
public int healthRegenRate() {
return healthRegenRate;
}
public int pierceBonus() {
return pierceBonus;
}
public float aoeRadiusBonus() {
return aoeRadiusBonus;
}
public float controlRating() {
return controlRating;
}
public float executeDamageBonus() {
return executeDamageBonus;
}
public float bossDamageBonus() {
return bossDamageBonus;
}
public float controlledDamageBonus() {
return controlledDamageBonus;
}
public float abilityScalingFactor() {
return abilityScalingFactor;
}
}
@@ -0,0 +1,365 @@
package ru.project.tower.player;
import java.util.Objects;
public final class PlayerCombatState {
public static final int DEFAULT_MAX_HEALTH = 100;
public static final float DEFAULT_SHOT_COOLDOWN_MS = 800f;
public static final float MIN_SHOT_COOLDOWN_MS = 100f;
public static final long HEALTH_REGEN_INTERVAL_MS = 1_000L;
public static final float DEFAULT_BASE_CRIT_CHANCE = 0.05f;
public static final float DEFAULT_BASE_CRIT_MULTIPLIER = 1.5f;
private int health;
private int maxHealth;
private int healthRegenRate;
private int money;
private long lastDamageTimeMs;
private long lastHealthRegenTimeMs;
private float shotCooldownMs;
private float baseCritChance;
private float baseCritMultiplier;
private int gameOverCurrencyAward;
private int maxWaveReached;
private boolean gameOverCurrencyAwarded;
private int runCurrencyEarned;
public PlayerCombatState() {
resetForNewRun(DEFAULT_MAX_HEALTH, DEFAULT_SHOT_COOLDOWN_MS, 0);
baseCritChance = DEFAULT_BASE_CRIT_CHANCE;
baseCritMultiplier = DEFAULT_BASE_CRIT_MULTIPLIER;
}
public boolean damageIfVulnerable(int damage, long damageCooldownMs, long nowMs) {
return damageIfVulnerable(damage, damageCooldownMs, nowMs, DamageBlocker.NONE);
}
public boolean damageIfVulnerable(
int damage, long damageCooldownMs, long nowMs, DamageBlocker blocker) {
if (damageCooldownMs > 0L && nowMs - lastDamageTimeMs <= damageCooldownMs) {
return false;
}
DamageBlocker activeBlocker = blocker == null ? DamageBlocker.NONE : blocker;
if (!activeBlocker.blocksDamage(nowMs)) {
health -= damage;
}
lastDamageTimeMs = nowMs;
return true;
}
public boolean regenerateIfReady(long nowMs) {
if (nowMs - lastHealthRegenTimeMs <= HEALTH_REGEN_INTERVAL_MS) {
return false;
}
health = Math.min(maxHealth, health + healthRegenRate);
lastHealthRegenTimeMs = nowMs;
return true;
}
public void resetForNewRun(int maxHealth, float shotCooldownMs, int healthRegenRate) {
money = 0;
this.maxHealth = maxHealth;
health = maxHealth;
this.healthRegenRate = healthRegenRate;
this.shotCooldownMs = shotCooldownMs;
lastDamageTimeMs = 0L;
lastHealthRegenTimeMs = 0L;
gameOverCurrencyAward = 0;
gameOverCurrencyAwarded = false;
runCurrencyEarned = 0;
}
public Snapshot snapshot() {
return new Snapshot(
money,
health,
maxHealth,
healthRegenRate,
lastDamageTimeMs,
lastHealthRegenTimeMs,
shotCooldownMs,
baseCritChance,
baseCritMultiplier,
gameOverCurrencyAward,
maxWaveReached,
gameOverCurrencyAwarded,
runCurrencyEarned);
}
public void apply(Snapshot snapshot) {
Snapshot source = Objects.requireNonNull(snapshot, "snapshot");
money = source.money();
health = source.health();
maxHealth = source.maxHealth();
healthRegenRate = source.healthRegenRate();
lastDamageTimeMs = source.lastDamageTimeMs();
lastHealthRegenTimeMs = source.lastHealthRegenTimeMs();
shotCooldownMs = source.shotCooldownMs();
baseCritChance = source.baseCritChance();
baseCritMultiplier = source.baseCritMultiplier();
gameOverCurrencyAward = source.gameOverCurrencyAward();
maxWaveReached = source.maxWaveReached();
gameOverCurrencyAwarded = source.gameOverCurrencyAwarded();
runCurrencyEarned = source.runCurrencyEarned();
}
public int awardGameOverCurrencyOnce(int currentWave) {
if (gameOverCurrencyAwarded) {
return 0;
}
gameOverCurrencyAward = currentWave;
maxWaveReached = Math.max(maxWaveReached, currentWave);
gameOverCurrencyAwarded = true;
return gameOverCurrencyAward;
}
public int getHealth() {
return health;
}
public void setHealth(int health) {
this.health = health;
}
public int getMaxHealth() {
return maxHealth;
}
public void setMaxHealth(int maxHealth) {
this.maxHealth = maxHealth;
}
public int getHealthRegenRate() {
return healthRegenRate;
}
public void setHealthRegenRate(int healthRegenRate) {
this.healthRegenRate = healthRegenRate;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public void addMoney(int amount) {
if (amount > 0) {
money += amount;
}
}
public boolean spendMoney(int amount) {
if (amount <= 0) {
return true;
}
if (money < amount) {
return false;
}
money -= amount;
return true;
}
public int getRunCurrencyEarned() {
return runCurrencyEarned;
}
public void addRunCurrencyEarned(int amount) {
if (amount > 0) {
runCurrencyEarned += amount;
}
}
public long getLastDamageTimeMs() {
return lastDamageTimeMs;
}
public void setLastDamageTimeMs(long lastDamageTimeMs) {
this.lastDamageTimeMs = lastDamageTimeMs;
}
public long getLastHealthRegenTimeMs() {
return lastHealthRegenTimeMs;
}
public void setLastHealthRegenTimeMs(long lastHealthRegenTimeMs) {
this.lastHealthRegenTimeMs = lastHealthRegenTimeMs;
}
public float getShotCooldownMs() {
return shotCooldownMs;
}
public void setShotCooldownMs(float shotCooldownMs) {
this.shotCooldownMs = shotCooldownMs;
}
public void reduceShotCooldown(float amountMs) {
shotCooldownMs = Math.max(MIN_SHOT_COOLDOWN_MS, shotCooldownMs - amountMs);
}
public float getBaseCritChance() {
return baseCritChance;
}
public void setBaseCritChance(float baseCritChance) {
this.baseCritChance = baseCritChance;
}
public float getBaseCritMultiplier() {
return baseCritMultiplier;
}
public void setBaseCritMultiplier(float baseCritMultiplier) {
this.baseCritMultiplier = baseCritMultiplier;
}
public int getGameOverCurrencyAward() {
return gameOverCurrencyAward;
}
public int getMaxWaveReached() {
return maxWaveReached;
}
public boolean isGameOverCurrencyAwarded() {
return gameOverCurrencyAwarded;
}
public interface DamageBlocker {
DamageBlocker NONE = nowMs -> false;
boolean blocksDamage(long nowMs);
}
public static final class Snapshot {
private final int money;
private final int health;
private final int maxHealth;
private final int healthRegenRate;
private final long lastDamageTimeMs;
private final long lastHealthRegenTimeMs;
private final float shotCooldownMs;
private final float baseCritChance;
private final float baseCritMultiplier;
private final int gameOverCurrencyAward;
private final int maxWaveReached;
private final boolean gameOverCurrencyAwarded;
private final int runCurrencyEarned;
public Snapshot(
int money,
int health,
int maxHealth,
int healthRegenRate,
long lastDamageTimeMs,
long lastHealthRegenTimeMs,
float shotCooldownMs,
float baseCritChance,
float baseCritMultiplier,
int gameOverCurrencyAward,
int maxWaveReached,
boolean gameOverCurrencyAwarded) {
this(
money,
health,
maxHealth,
healthRegenRate,
lastDamageTimeMs,
lastHealthRegenTimeMs,
shotCooldownMs,
baseCritChance,
baseCritMultiplier,
gameOverCurrencyAward,
maxWaveReached,
gameOverCurrencyAwarded,
0);
}
public Snapshot(
int money,
int health,
int maxHealth,
int healthRegenRate,
long lastDamageTimeMs,
long lastHealthRegenTimeMs,
float shotCooldownMs,
float baseCritChance,
float baseCritMultiplier,
int gameOverCurrencyAward,
int maxWaveReached,
boolean gameOverCurrencyAwarded,
int runCurrencyEarned) {
this.money = money;
this.health = health;
this.maxHealth = maxHealth;
this.healthRegenRate = healthRegenRate;
this.lastDamageTimeMs = lastDamageTimeMs;
this.lastHealthRegenTimeMs = lastHealthRegenTimeMs;
this.shotCooldownMs = shotCooldownMs;
this.baseCritChance = baseCritChance;
this.baseCritMultiplier = baseCritMultiplier;
this.gameOverCurrencyAward = gameOverCurrencyAward;
this.maxWaveReached = maxWaveReached;
this.gameOverCurrencyAwarded = gameOverCurrencyAwarded;
this.runCurrencyEarned = runCurrencyEarned;
}
public int money() {
return money;
}
public int health() {
return health;
}
public int maxHealth() {
return maxHealth;
}
public int healthRegenRate() {
return healthRegenRate;
}
public long lastDamageTimeMs() {
return lastDamageTimeMs;
}
public long lastHealthRegenTimeMs() {
return lastHealthRegenTimeMs;
}
public float shotCooldownMs() {
return shotCooldownMs;
}
public float baseCritChance() {
return baseCritChance;
}
public float baseCritMultiplier() {
return baseCritMultiplier;
}
public int gameOverCurrencyAward() {
return gameOverCurrencyAward;
}
public int maxWaveReached() {
return maxWaveReached;
}
public boolean gameOverCurrencyAwarded() {
return gameOverCurrencyAwarded;
}
public int runCurrencyEarned() {
return runCurrencyEarned;
}
}
}
@@ -0,0 +1,100 @@
package ru.project.tower.player;
import java.util.Objects;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.talents.TalentData;
import ru.project.tower.talents.TalentTree;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.upgrades.shop.ShopUpgradeSystem;
public final class PlayerRunCombatCalculator {
private static final float ABILITY_WAVE_SCALING_BASE = 1.15f;
private static final float MIN_COMBAT_SPEED_MULTIPLIER = 0.1f;
private PlayerRunCombatCalculator() {}
public static float abilityScalingFactor(int currentWave) {
return (float) Math.pow(ABILITY_WAVE_SCALING_BASE, currentWave - 1);
}
public static void resetCombatStateForNewRun(
PlayerCombatState combatState, TalentTree talentTree, long nowMs) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(talentTree, "talentTree");
int maxHealth =
PlayerCombatState.DEFAULT_MAX_HEALTH
+ (int) talentTree.getStatBonus(TalentData.TalentType.HEALTH);
float shotCooldown =
PlayerCombatState.DEFAULT_SHOT_COOLDOWN_MS
- talentTree.getStatBonus(TalentData.TalentType.COOLDOWN);
if (shotCooldown < PlayerCombatState.MIN_SHOT_COOLDOWN_MS) {
shotCooldown = PlayerCombatState.MIN_SHOT_COOLDOWN_MS;
}
int healthRegenRate = (int) talentTree.getStatBonus(TalentData.TalentType.REGEN);
combatState.resetForNewRun(maxHealth, shotCooldown, healthRegenRate);
combatState.setLastDamageTimeMs(nowMs);
combatState.setLastHealthRegenTimeMs(nowMs);
}
public static float effectiveShotCooldown(
PlayerCombatState combatState,
UpgradeSystem upgradeSystem,
BonusBallSystem bonusBallSystem) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
return EffectiveCombatStats.shotCooldownMs(combatState, 0f, upgradeSystem, bonusBallSystem);
}
public static float effectiveShotCooldown(
PlayerCombatState combatState,
UpgradeSystem upgradeSystem,
BonusBallSystem bonusBallSystem,
float attackSpeedMultiplier) {
float baseCooldown = effectiveShotCooldown(combatState, upgradeSystem, bonusBallSystem);
return applyAttackSpeedMultiplierToCooldown(baseCooldown, attackSpeedMultiplier);
}
public static float applyAttackSpeedMultiplierToCooldown(
float baseCooldownMs, float attackSpeedMultiplier) {
float boundedMultiplier = boundedCombatSpeedMultiplier(attackSpeedMultiplier);
return Math.max(PlayerCombatState.MIN_SHOT_COOLDOWN_MS, baseCooldownMs / boundedMultiplier);
}
public static float applyProjectileSpeedMultiplier(
float baseSpeedMultiplier, float projectileSpeedMultiplier) {
return baseSpeedMultiplier * boundedCombatSpeedMultiplier(projectileSpeedMultiplier);
}
private static float boundedCombatSpeedMultiplier(float multiplier) {
if (Float.isNaN(multiplier)) {
return 1f;
}
return Math.max(MIN_COMBAT_SPEED_MULTIPLIER, multiplier);
}
public static float effectiveCritChance(
PlayerCombatState combatState,
ShopUpgradeSystem shopUpgradeSystem,
UpgradeSystem upgradeSystem,
TalentTree talentTree) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(talentTree, "talentTree");
return EffectiveCombatStats.critChance(
combatState, shopUpgradeSystem, upgradeSystem, talentTree);
}
public static float effectiveCritMultiplier(
PlayerCombatState combatState, ShopUpgradeSystem shopUpgradeSystem) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
return EffectiveCombatStats.critMultiplier(combatState, shopUpgradeSystem);
}
}
@@ -0,0 +1,226 @@
package ru.project.tower.player;
import com.badlogic.gdx.Preferences;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class PlayerStats {
private static final String CURRENCY_KEY = "player_currency";
private static final String MAX_WAVE_KEY = "max_wave_reached";
private static final String TALENT_NODE_KEY_PREFIX = "talent_node_";
private static final String[] TALENT_NODE_IDS = {
"health_1",
"damage_1",
"cooldown_1",
"speed_1",
"regen_1",
"health_2",
"damage_2",
"cooldown_2",
"speed_2",
"control_1",
"economy_1",
"ability_1",
"utility_1",
"regen_2",
"ability_2",
"attack_precision",
"control_execute",
"engineering_turret",
"survival_barrier",
"economy_reroll",
"utility_bonus",
"ability_blast",
"keystone_pierce",
"keystone_crit",
"keystone_aoe",
"keystone_splash",
"keystone_turret",
"keystone_control_execute",
"keystone_shield_regen",
"keystone_economy_reroll"
};
private static final Set<String> VALID_TALENT_NODE_IDS =
Collections.unmodifiableSet(new HashSet<>(Arrays.asList(TALENT_NODE_IDS)));
private int currency;
private int maxWaveReached;
private final Preferences preferences;
private int saveBatchDepth;
private boolean flushPending;
private final Map<String, Integer> talentLevels = new HashMap<>();
private static final String DAMAGE_TALENT_KEY = "talent_damage_level";
private static final String HEALTH_TALENT_KEY = "talent_health_level";
private static final String SPEED_TALENT_KEY = "talent_speed_level";
private static final String COOLDOWN_TALENT_KEY = "talent_cooldown_level";
private static final String REGEN_TALENT_KEY = "talent_regen_level";
public PlayerStats(Preferences preferences) {
this.preferences = preferences;
currency = preferences.getInteger(CURRENCY_KEY, 0);
maxWaveReached = preferences.getInteger(MAX_WAVE_KEY, 0);
for (String id : TALENT_NODE_IDS) {
talentLevels.put(id, preferences.getInteger(TALENT_NODE_KEY_PREFIX + id, 0));
}
seedLegacyLevel("damage_1", DAMAGE_TALENT_KEY);
seedLegacyLevel("health_1", HEALTH_TALENT_KEY);
seedLegacyLevel("speed_1", SPEED_TALENT_KEY);
seedLegacyLevel("cooldown_1", COOLDOWN_TALENT_KEY);
seedLegacyLevel("regen_1", REGEN_TALENT_KEY);
}
private void seedLegacyLevel(String nodeId, String legacyKey) {
if (talentLevels.get(nodeId) == 0) {
int legacy = preferences.getInteger(legacyKey, 0);
if (legacy > 0) {
talentLevels.put(nodeId, legacy);
}
}
}
public void withBatchedSaves(Runnable changes) {
saveBatchDepth++;
try {
changes.run();
} finally {
saveBatchDepth--;
if (saveBatchDepth == 0 && flushPending) {
flushPending = false;
preferences.flush();
}
}
}
public int getCurrency() {
return currency;
}
public void addCurrency(int amount) {
if (amount > 0) {
currency += amount;
saveData();
}
}
public boolean spendCurrency(int amount) {
if (amount <= 0) {
return true;
}
if (currency >= amount) {
currency -= amount;
saveData();
return true;
}
return false;
}
public int getMaxWaveReached() {
return maxWaveReached;
}
public void updateMaxWave(int waveNumber) {
if (waveNumber > maxWaveReached) {
maxWaveReached = waveNumber;
saveData();
}
}
public int getTalentNodeLevel(String nodeId) {
Integer v = talentLevels.get(nodeId);
return v == null ? 0 : v;
}
public void incrementTalentNode(String nodeId) {
if (!VALID_TALENT_NODE_IDS.contains(nodeId)) {
throw new IllegalArgumentException("Unknown talent node id: " + nodeId);
}
talentLevels.put(nodeId, getTalentNodeLevel(nodeId) + 1);
saveData();
}
public int getDamageTalentLevel() {
return getTalentNodeLevel("damage_1") + getTalentNodeLevel("damage_2");
}
public int getHealthTalentLevel() {
return getTalentNodeLevel("health_1") + getTalentNodeLevel("health_2");
}
public int getSpeedTalentLevel() {
return getTalentNodeLevel("speed_1") + getTalentNodeLevel("speed_2");
}
public int getCooldownTalentLevel() {
return getTalentNodeLevel("cooldown_1") + getTalentNodeLevel("cooldown_2");
}
public int getRegenTalentLevel() {
return getTalentNodeLevel("regen_1") + getTalentNodeLevel("regen_2");
}
private void saveData() {
preferences.putInteger(CURRENCY_KEY, currency);
preferences.putInteger(MAX_WAVE_KEY, maxWaveReached);
for (String id : TALENT_NODE_IDS) {
preferences.putInteger(TALENT_NODE_KEY_PREFIX + id, getTalentNodeLevel(id));
}
flushData();
}
private void flushData() {
if (saveBatchDepth > 0) {
flushPending = true;
return;
}
preferences.flush();
}
}
@@ -0,0 +1,11 @@
package ru.project.tower.run;
public enum GameplayColor {
NEON_RED,
NEON_YELLOW,
NEON_GREEN,
NEON_CYAN,
NEON_BLUE,
NEON_PURPLE,
WHITE
}
@@ -0,0 +1,314 @@
package ru.project.tower.run;
import java.util.Objects;
public abstract class GameplayEvent {
private GameplayEvent() {}
public static final class EnemyKilled extends GameplayEvent {
public final String enemyKind;
public final GameplayPosition position;
public final int reward;
public final boolean boss;
public EnemyKilled(String enemyKind, GameplayPosition position, int reward, boolean boss) {
this.enemyKind = requireText(enemyKind, "enemyKind");
this.position = Objects.requireNonNull(position, "position");
this.reward = reward;
this.boss = boss;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof EnemyKilled)) return false;
EnemyKilled that = (EnemyKilled) other;
return reward == that.reward
&& boss == that.boss
&& enemyKind.equals(that.enemyKind)
&& position.equals(that.position);
}
@Override
public int hashCode() {
return Objects.hash(enemyKind, position, reward, boss);
}
}
public static final class PlayerDamaged extends GameplayEvent {
public final int damage;
public final int healthAfterDamage;
public final GameplayPosition position;
public PlayerDamaged(int damage, int healthAfterDamage, GameplayPosition position) {
this.damage = damage;
this.healthAfterDamage = healthAfterDamage;
this.position = Objects.requireNonNull(position, "position");
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof PlayerDamaged)) return false;
PlayerDamaged that = (PlayerDamaged) other;
return damage == that.damage
&& healthAfterDamage == that.healthAfterDamage
&& position.equals(that.position);
}
@Override
public int hashCode() {
return Objects.hash(damage, healthAfterDamage, position);
}
}
public static final class WaveCleared extends GameplayEvent {
public final int clearedWave;
public final int moneyBonus;
public final int currencyReward;
public final boolean shouldShowUpgrade;
public final GameplayPosition playerPosition;
public WaveCleared(
int clearedWave,
int moneyBonus,
int currencyReward,
boolean shouldShowUpgrade,
GameplayPosition playerPosition) {
this.clearedWave = clearedWave;
this.moneyBonus = moneyBonus;
this.currencyReward = currencyReward;
this.shouldShowUpgrade = shouldShowUpgrade;
this.playerPosition = Objects.requireNonNull(playerPosition, "playerPosition");
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof WaveCleared)) return false;
WaveCleared that = (WaveCleared) other;
return clearedWave == that.clearedWave
&& moneyBonus == that.moneyBonus
&& currencyReward == that.currencyReward
&& shouldShowUpgrade == that.shouldShowUpgrade
&& playerPosition.equals(that.playerPosition);
}
@Override
public int hashCode() {
return Objects.hash(
clearedWave, moneyBonus, currencyReward, shouldShowUpgrade, playerPosition);
}
}
public static final class BonusCollected extends GameplayEvent {
public final String bonusKind;
public final GameplayPosition position;
public final String displayText;
public BonusCollected(String bonusKind, GameplayPosition position, String displayText) {
this.bonusKind = requireText(bonusKind, "bonusKind");
this.position = Objects.requireNonNull(position, "position");
this.displayText = requireText(displayText, "displayText");
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof BonusCollected)) return false;
BonusCollected that = (BonusCollected) other;
return bonusKind.equals(that.bonusKind)
&& position.equals(that.position)
&& displayText.equals(that.displayText);
}
@Override
public int hashCode() {
return Objects.hash(bonusKind, position, displayText);
}
}
public static final class GameOver extends GameplayEvent {
public final int reachedWave;
public final int currencyEarned;
public GameOver(int reachedWave, int currencyEarned) {
this.reachedWave = reachedWave;
this.currencyEarned = currencyEarned;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof GameOver)) return false;
GameOver that = (GameOver) other;
return reachedWave == that.reachedWave && currencyEarned == that.currencyEarned;
}
@Override
public int hashCode() {
return Objects.hash(reachedWave, currencyEarned);
}
}
public static final class CriticalHit extends GameplayEvent {
public final String enemyKind;
public final GameplayPosition position;
public final int damage;
public CriticalHit(String enemyKind, GameplayPosition position, int damage) {
this.enemyKind = requireText(enemyKind, "enemyKind");
this.position = Objects.requireNonNull(position, "position");
this.damage = damage;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof CriticalHit)) return false;
CriticalHit that = (CriticalHit) other;
return damage == that.damage
&& enemyKind.equals(that.enemyKind)
&& position.equals(that.position);
}
@Override
public int hashCode() {
return Objects.hash(enemyKind, position, damage);
}
}
public static final class ExplosionRequested extends GameplayEvent {
public final GameplayPosition position;
public final GameplayColor color;
public final int particleCount;
public ExplosionRequested(
GameplayPosition position, GameplayColor color, int particleCount) {
this.position = Objects.requireNonNull(position, "position");
this.color = Objects.requireNonNull(color, "color");
this.particleCount = particleCount;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof ExplosionRequested)) return false;
ExplosionRequested that = (ExplosionRequested) other;
return particleCount == that.particleCount
&& position.equals(that.position)
&& color == that.color;
}
@Override
public int hashCode() {
return Objects.hash(position, color, particleCount);
}
}
public static final class FloatingTextRequested extends GameplayEvent {
public final GameplayPosition position;
public final String text;
public final GameplayColor color;
public final float durationSeconds;
public final float scale;
public final float riseDistance;
public FloatingTextRequested(
GameplayPosition position,
String text,
GameplayColor color,
float durationSeconds,
float scale,
float riseDistance) {
this.position = Objects.requireNonNull(position, "position");
this.text = requireText(text, "text");
this.color = Objects.requireNonNull(color, "color");
this.durationSeconds = durationSeconds;
this.scale = scale;
this.riseDistance = riseDistance;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof FloatingTextRequested)) return false;
FloatingTextRequested that = (FloatingTextRequested) other;
return Float.compare(that.durationSeconds, durationSeconds) == 0
&& Float.compare(that.scale, scale) == 0
&& Float.compare(that.riseDistance, riseDistance) == 0
&& position.equals(that.position)
&& text.equals(that.text)
&& color == that.color;
}
@Override
public int hashCode() {
return Objects.hash(position, text, color, durationSeconds, scale, riseDistance);
}
}
public static final class ShakeRequested extends GameplayEvent {
public final float intensity;
public final float hitstopSeconds;
public ShakeRequested(float intensity, float hitstopSeconds) {
this.intensity = intensity;
this.hitstopSeconds = hitstopSeconds;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof ShakeRequested)) return false;
ShakeRequested that = (ShakeRequested) other;
return Float.compare(that.intensity, intensity) == 0
&& Float.compare(that.hitstopSeconds, hitstopSeconds) == 0;
}
@Override
public int hashCode() {
return Objects.hash(intensity, hitstopSeconds);
}
}
public static final class SoundRequested extends GameplayEvent {
public final GameplaySound sound;
public final float volume;
public final float pitch;
public final float pan;
public SoundRequested(GameplaySound sound, float volume, float pitch, float pan) {
this.sound = Objects.requireNonNull(sound, "sound");
this.volume = volume;
this.pitch = pitch;
this.pan = pan;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof SoundRequested)) return false;
SoundRequested that = (SoundRequested) other;
return Float.compare(that.volume, volume) == 0
&& Float.compare(that.pitch, pitch) == 0
&& Float.compare(that.pan, pan) == 0
&& sound == that.sound;
}
@Override
public int hashCode() {
return Objects.hash(sound, volume, pitch, pan);
}
}
private static String requireText(String value, String fieldName) {
if (value == null) {
throw new NullPointerException(fieldName);
}
if (value.isEmpty()) {
throw new IllegalArgumentException(fieldName + " must not be empty");
}
return value;
}
}
@@ -0,0 +1,61 @@
package ru.project.tower.run;
import java.util.Objects;
public abstract class GameplayEventDispatcher implements GameplayEventSink {
@Override
public final void emit(GameplayEvent event) {
Objects.requireNonNull(event, "event");
if (event instanceof GameplayEvent.EnemyKilled) {
onEnemyKilled((GameplayEvent.EnemyKilled) event);
} else if (event instanceof GameplayEvent.PlayerDamaged) {
onPlayerDamaged((GameplayEvent.PlayerDamaged) event);
} else if (event instanceof GameplayEvent.WaveCleared) {
onWaveCleared((GameplayEvent.WaveCleared) event);
} else if (event instanceof GameplayEvent.BonusCollected) {
onBonusCollected((GameplayEvent.BonusCollected) event);
} else if (event instanceof GameplayEvent.GameOver) {
onGameOver((GameplayEvent.GameOver) event);
} else if (event instanceof GameplayEvent.CriticalHit) {
onCriticalHit((GameplayEvent.CriticalHit) event);
} else if (event instanceof GameplayEvent.ExplosionRequested) {
onExplosionRequested((GameplayEvent.ExplosionRequested) event);
} else if (event instanceof GameplayEvent.FloatingTextRequested) {
onFloatingTextRequested((GameplayEvent.FloatingTextRequested) event);
} else if (event instanceof GameplayEvent.ShakeRequested) {
onShakeRequested((GameplayEvent.ShakeRequested) event);
} else if (event instanceof GameplayEvent.SoundRequested) {
onSoundRequested((GameplayEvent.SoundRequested) event);
} else {
onUnhandled(event);
}
}
protected void onEnemyKilled(GameplayEvent.EnemyKilled event) {}
protected void onPlayerDamaged(GameplayEvent.PlayerDamaged event) {}
protected void onWaveCleared(GameplayEvent.WaveCleared event) {}
protected void onBonusCollected(GameplayEvent.BonusCollected event) {}
protected void onGameOver(GameplayEvent.GameOver event) {}
protected void onCriticalHit(GameplayEvent.CriticalHit event) {}
protected void onExplosionRequested(GameplayEvent.ExplosionRequested event) {}
protected void onFloatingTextRequested(GameplayEvent.FloatingTextRequested event) {}
protected void onShakeRequested(GameplayEvent.ShakeRequested event) {}
protected void onSoundRequested(GameplayEvent.SoundRequested event) {}
protected void onUnhandled(GameplayEvent event) {}
}
@@ -0,0 +1,10 @@
package ru.project.tower.run;
public interface GameplayEventSink {
void emit(GameplayEvent event);
}
@@ -0,0 +1,31 @@
package ru.project.tower.run;
import java.util.Objects;
public final class GameplayEventSinks {
private static final GameplayEventSink NOOP =
new GameplayEventSink() {
@Override
public void emit(GameplayEvent event) {
Objects.requireNonNull(event, "event");
}
};
private GameplayEventSinks() {}
public static GameplayEventSink noop() {
return NOOP;
}
public static void emitAll(GameplayEventSink sink, GameplayEvent... events) {
Objects.requireNonNull(sink, "sink");
Objects.requireNonNull(events, "events");
for (GameplayEvent event : events) {
sink.emit(Objects.requireNonNull(event, "event"));
}
}
}
@@ -0,0 +1,78 @@
package ru.project.tower.run;
public final class GameplayEvents {
private GameplayEvents() {}
public static GameplayPosition position(float x, float y) {
return new GameplayPosition(x, y);
}
public static GameplayEvent.EnemyKilled enemyKilled(
String enemyKind, float x, float y, int reward, boolean boss) {
return new GameplayEvent.EnemyKilled(enemyKind, position(x, y), reward, boss);
}
public static GameplayEvent.PlayerDamaged playerDamaged(
int damage, int healthAfterDamage, float x, float y) {
return new GameplayEvent.PlayerDamaged(damage, healthAfterDamage, position(x, y));
}
public static GameplayEvent.WaveCleared waveCleared(
int clearedWave,
int moneyBonus,
int currencyReward,
boolean shouldShowUpgrade,
float playerX,
float playerY) {
return new GameplayEvent.WaveCleared(
clearedWave,
moneyBonus,
currencyReward,
shouldShowUpgrade,
position(playerX, playerY));
}
public static GameplayEvent.BonusCollected bonusCollected(
String bonusKind, float x, float y, String displayText) {
return new GameplayEvent.BonusCollected(bonusKind, position(x, y), displayText);
}
public static GameplayEvent.GameOver gameOver(int reachedWave, int currencyEarned) {
return new GameplayEvent.GameOver(reachedWave, currencyEarned);
}
public static GameplayEvent.CriticalHit criticalHit(
String enemyKind, float x, float y, int damage) {
return new GameplayEvent.CriticalHit(enemyKind, position(x, y), damage);
}
public static GameplayEvent.ExplosionRequested explosion(
float x, float y, GameplayColor color, int particleCount) {
return new GameplayEvent.ExplosionRequested(position(x, y), color, particleCount);
}
public static GameplayEvent.FloatingTextRequested floatingText(
float x,
float y,
String text,
GameplayColor color,
float durationSeconds,
float scale,
float riseDistance) {
return new GameplayEvent.FloatingTextRequested(
position(x, y), text, color, durationSeconds, scale, riseDistance);
}
public static GameplayEvent.ShakeRequested shake(float intensity, float hitstopSeconds) {
return new GameplayEvent.ShakeRequested(intensity, hitstopSeconds);
}
public static GameplayEvent.SoundRequested sound(
GameplaySound sound, float volume, float pitch, float pan) {
return new GameplayEvent.SoundRequested(sound, volume, pitch, pan);
}
}
@@ -0,0 +1,31 @@
package ru.project.tower.run;
public final class GameplayPosition {
public final float x;
public final float y;
public GameplayPosition(float x, float y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof GameplayPosition)) return false;
GameplayPosition that = (GameplayPosition) other;
return Float.compare(that.x, x) == 0 && Float.compare(that.y, y) == 0;
}
@Override
public int hashCode() {
int result = Float.floatToIntBits(x);
result = 31 * result + Float.floatToIntBits(y);
return result;
}
@Override
public String toString() {
return "GameplayPosition{" + "x=" + x + ", y=" + y + '}';
}
}
@@ -0,0 +1,6 @@
package ru.project.tower.run;
public enum GameplaySound {
SHOT,
POP
}
@@ -0,0 +1,19 @@
package ru.project.tower.run;
public final class RunBulletSnapshot {
private final float baseSpeed;
private final int baseDamage;
public RunBulletSnapshot(float baseSpeed, int baseDamage) {
this.baseSpeed = baseSpeed;
this.baseDamage = baseDamage;
}
public float baseSpeed() {
return baseSpeed;
}
public int baseDamage() {
return baseDamage;
}
}
@@ -0,0 +1,13 @@
package ru.project.tower.run;
public interface RunFrameEvents {
void regeneratePlayer(long nowMs);
void autoShootIfReady(long nowMs);
boolean damagePlayerIfVulnerable(int damage, long cooldownMs);
boolean isPlayerDead();
void gameOver();
}
@@ -0,0 +1,264 @@
package ru.project.tower.run;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import java.util.Objects;
import ru.project.tower.abilities.AbilitySystem;
import ru.project.tower.entities.bullets.BulletEffectsSink;
import ru.project.tower.entities.bullets.BulletSystem;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.systems.CollisionSystem;
import ru.project.tower.systems.EnemySimulationSystem;
import ru.project.tower.systems.SpecialCircleSystem;
import ru.project.tower.waves.SpawnController;
import ru.project.tower.waves.WaveController;
import ru.project.tower.waves.WaveRunOrchestrator;
public final class RunFrameOrchestrator {
private static final long CONTACT_DAMAGE_COOLDOWN_MS = 1_000L;
private static final float MAX_SIMULATION_DELTA_SECONDS = 1f / 15f;
private final Array<BaseCircleData> circles;
private final Rectangle square;
private final RunFrameEvents events;
private final FrameOperations operations;
public RunFrameOrchestrator(Config config) {
this(config.circles, config.square, config.events, new DefaultFrameOperations(config));
}
RunFrameOrchestrator(
Array<BaseCircleData> circles,
Rectangle square,
RunFrameEvents events,
FrameOperations operations) {
this.circles = Objects.requireNonNull(circles, "circles");
this.square = Objects.requireNonNull(square, "square");
this.events = Objects.requireNonNull(events, "events");
this.operations = Objects.requireNonNull(operations, "operations");
}
public boolean update(
float delta,
long nowMs,
float viewportWidth,
float viewportHeight,
float gameScale,
boolean upgradePickActive) {
float simulationDelta = clampSimulationDelta(delta);
operations.updateWave(nowMs, circles.size, upgradePickActive);
if (upgradePickActive) {
return false;
}
events.regeneratePlayer(nowMs);
if (operations.isWaveActive()) {
events.autoShootIfReady(nowMs);
}
operations.updateBonusBalls(simulationDelta, viewportWidth, viewportHeight);
operations.processSpawnerChildren();
operations.tickAbilities(nowMs);
moveCircles(simulationDelta);
operations.updateSpecialCircles(gameScale);
operations.tickBullets(simulationDelta);
if (resolveContactDamage()) {
return true;
}
operations.separateCircles();
steerAndRemoveOutOfBounds(viewportWidth, viewportHeight, gameScale);
return false;
}
private static float clampSimulationDelta(float delta) {
if (delta <= 0f) {
return 0f;
}
return Math.min(delta, MAX_SIMULATION_DELTA_SECONDS);
}
private void moveCircles(float delta) {
for (BaseCircleData circleData : circles) {
operations.prepareCircleForMove(circleData);
circleData.move(delta);
}
}
private boolean resolveContactDamage() {
for (BaseCircleData circleData : circles) {
if (operations.resolveCircleSquare(circleData)
&& events.damagePlayerIfVulnerable(
circleData.damage, CONTACT_DAMAGE_COOLDOWN_MS)
&& events.isPlayerDead()) {
events.gameOver();
return true;
}
}
return false;
}
private void steerAndRemoveOutOfBounds(
float viewportWidth, float viewportHeight, float gameScale) {
for (int i = 0; i < circles.size; i++) {
BaseCircleData circleData = circles.get(i);
Circle circle = circleData.circle;
operations.steerTowardPlayer(circleData, gameScale);
if (operations.shouldRemoveOutOfBounds(circle, viewportWidth, viewportHeight)) {
BaseCircleData.freeIfSpawnPooled(circles.removeIndex(i));
i--;
}
}
}
interface FrameOperations {
void updateWave(long nowMs, int activeEnemyCount, boolean upgradePickActive);
boolean isWaveActive();
void updateBonusBalls(float delta, float viewportWidth, float viewportHeight);
void processSpawnerChildren();
void tickAbilities(long nowMs);
void prepareCircleForMove(BaseCircleData circleData);
void updateSpecialCircles(float gameScale);
void tickBullets(float delta);
boolean resolveCircleSquare(BaseCircleData circleData);
void separateCircles();
void steerTowardPlayer(BaseCircleData circleData, float gameScale);
boolean shouldRemoveOutOfBounds(Circle circle, float viewportWidth, float viewportHeight);
}
public static final class Config {
private final Array<BaseCircleData> circles;
private final Rectangle square;
private final WaveController waveController;
private final WaveRunOrchestrator waveRunOrchestrator;
private final BonusBallSystem bonusBallSystem;
private final SpawnController spawnController;
private final AbilitySystem abilitySystem;
private final BulletSystem bulletSystem;
private final BulletEffectsSink bulletEffects;
private final SpecialCircleSystem specialCircleSystem;
private final SpecialCircleSystem.Host specialCircleHost;
private final RunFrameEvents events;
public Config(
Array<BaseCircleData> circles,
Rectangle square,
WaveController waveController,
WaveRunOrchestrator waveRunOrchestrator,
BonusBallSystem bonusBallSystem,
SpawnController spawnController,
AbilitySystem abilitySystem,
BulletSystem bulletSystem,
BulletEffectsSink bulletEffects,
SpecialCircleSystem specialCircleSystem,
SpecialCircleSystem.Host specialCircleHost,
RunFrameEvents events) {
this.circles = Objects.requireNonNull(circles, "circles");
this.square = Objects.requireNonNull(square, "square");
this.waveController = Objects.requireNonNull(waveController, "waveController");
this.waveRunOrchestrator =
Objects.requireNonNull(waveRunOrchestrator, "waveRunOrchestrator");
this.bonusBallSystem = Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
this.spawnController = Objects.requireNonNull(spawnController, "spawnController");
this.abilitySystem = Objects.requireNonNull(abilitySystem, "abilitySystem");
this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem");
this.bulletEffects = Objects.requireNonNull(bulletEffects, "bulletEffects");
this.specialCircleSystem =
Objects.requireNonNull(specialCircleSystem, "specialCircleSystem");
this.specialCircleHost = Objects.requireNonNull(specialCircleHost, "specialCircleHost");
this.events = Objects.requireNonNull(events, "events");
}
}
private static final class DefaultFrameOperations implements FrameOperations {
private final Config config;
private DefaultFrameOperations(Config config) {
this.config = config;
}
@Override
public void updateWave(long nowMs, int activeEnemyCount, boolean upgradePickActive) {
config.waveRunOrchestrator.update(nowMs, activeEnemyCount, upgradePickActive);
}
@Override
public boolean isWaveActive() {
return config.waveController.isWaveActive();
}
@Override
public void updateBonusBalls(float delta, float viewportWidth, float viewportHeight) {
config.bonusBallSystem.update(delta, viewportWidth, viewportHeight);
}
@Override
public void processSpawnerChildren() {
config.spawnController.processSpawnerChildren();
}
@Override
public void tickAbilities(long nowMs) {
config.abilitySystem.tick(nowMs);
}
@Override
public void prepareCircleForMove(BaseCircleData circleData) {
config.specialCircleSystem.prepareForMovement(circleData, config.square);
}
@Override
public void updateSpecialCircles(float gameScale) {
config.specialCircleSystem.update(
config.circles,
config.square,
config.bulletSystem,
config.specialCircleHost,
gameScale);
}
@Override
public void tickBullets(float delta) {
config.bulletSystem.tick(delta, config.circles, config.square, config.bulletEffects);
}
@Override
public boolean resolveCircleSquare(BaseCircleData circleData) {
return CollisionSystem.resolveCircleSquare(circleData, config.square);
}
@Override
public void separateCircles() {
CollisionSystem.separateCircles(
config.circles, EnemySimulationSystem.DEFAULT_SEPARATION_ITERATIONS);
}
@Override
public void steerTowardPlayer(BaseCircleData circleData, float gameScale) {
EnemySimulationSystem.steerTowardPlayer(circleData, config.square, gameScale);
}
@Override
public boolean shouldRemoveOutOfBounds(
Circle circle, float viewportWidth, float viewportHeight) {
return EnemySimulationSystem.shouldRemoveOutOfBounds(
circle, viewportWidth, viewportHeight);
}
}
}
@@ -0,0 +1,142 @@
package ru.project.tower.run;
import java.util.Objects;
import ru.project.tower.support.SeededRandomSource;
public final class RunRngStreams {
private static final String WAVE_COMPOSITION = "wave-composition";
private static final String BONUS_DROPS = "bonus-drops";
private static final String BOSS_ORDER = "boss-order";
private static final String UPGRADE_DRAFTS = "upgrade-drafts";
private static final String COMBAT = "combat";
private final long runSeed;
private final SeededRandomSource waveComposition;
private final SeededRandomSource bonusDrops;
private final SeededRandomSource bossOrder;
private final SeededRandomSource upgradeDrafts;
private final SeededRandomSource combat;
private RunRngStreams(
long runSeed,
SeededRandomSource waveComposition,
SeededRandomSource bonusDrops,
SeededRandomSource bossOrder,
SeededRandomSource upgradeDrafts,
SeededRandomSource combat) {
this.runSeed = runSeed;
this.waveComposition = Objects.requireNonNull(waveComposition, "waveComposition");
this.bonusDrops = Objects.requireNonNull(bonusDrops, "bonusDrops");
this.bossOrder = Objects.requireNonNull(bossOrder, "bossOrder");
this.upgradeDrafts = Objects.requireNonNull(upgradeDrafts, "upgradeDrafts");
this.combat = Objects.requireNonNull(combat, "combat");
}
public static RunRngStreams fromSeed(long runSeed) {
return new RunRngStreams(
runSeed,
stream(runSeed, WAVE_COMPOSITION),
stream(runSeed, BONUS_DROPS),
stream(runSeed, BOSS_ORDER),
stream(runSeed, UPGRADE_DRAFTS),
stream(runSeed, COMBAT));
}
public static RunRngStreams fromSnapshot(Snapshot snapshot) {
Snapshot source = Objects.requireNonNull(snapshot, "snapshot");
return new RunRngStreams(
source.runSeed(),
SeededRandomSource.fromSnapshot(source.waveComposition()),
SeededRandomSource.fromSnapshot(source.bonusDrops()),
SeededRandomSource.fromSnapshot(source.bossOrder()),
SeededRandomSource.fromSnapshot(source.upgradeDrafts()),
SeededRandomSource.fromSnapshot(source.combat()));
}
private static SeededRandomSource stream(long runSeed, String name) {
return new SeededRandomSource(SeededRandomSource.forkSeed(runSeed, name));
}
public long runSeed() {
return runSeed;
}
public SeededRandomSource waveComposition() {
return waveComposition;
}
public SeededRandomSource bonusDrops() {
return bonusDrops;
}
public SeededRandomSource bossOrder() {
return bossOrder;
}
public SeededRandomSource upgradeDrafts() {
return upgradeDrafts;
}
public SeededRandomSource combat() {
return combat;
}
public Snapshot snapshot() {
return new Snapshot(
runSeed,
waveComposition.snapshot(SeededRandomSource.forkSeed(runSeed, WAVE_COMPOSITION)),
bonusDrops.snapshot(SeededRandomSource.forkSeed(runSeed, BONUS_DROPS)),
bossOrder.snapshot(SeededRandomSource.forkSeed(runSeed, BOSS_ORDER)),
upgradeDrafts.snapshot(SeededRandomSource.forkSeed(runSeed, UPGRADE_DRAFTS)),
combat.snapshot(SeededRandomSource.forkSeed(runSeed, COMBAT)));
}
public static final class Snapshot {
private final long runSeed;
private final SeededRandomSource.Snapshot waveComposition;
private final SeededRandomSource.Snapshot bonusDrops;
private final SeededRandomSource.Snapshot bossOrder;
private final SeededRandomSource.Snapshot upgradeDrafts;
private final SeededRandomSource.Snapshot combat;
public Snapshot(
long runSeed,
SeededRandomSource.Snapshot waveComposition,
SeededRandomSource.Snapshot bonusDrops,
SeededRandomSource.Snapshot bossOrder,
SeededRandomSource.Snapshot upgradeDrafts,
SeededRandomSource.Snapshot combat) {
this.runSeed = runSeed;
this.waveComposition = Objects.requireNonNull(waveComposition, "waveComposition");
this.bonusDrops = Objects.requireNonNull(bonusDrops, "bonusDrops");
this.bossOrder = Objects.requireNonNull(bossOrder, "bossOrder");
this.upgradeDrafts = Objects.requireNonNull(upgradeDrafts, "upgradeDrafts");
this.combat = Objects.requireNonNull(combat, "combat");
}
public long runSeed() {
return runSeed;
}
public SeededRandomSource.Snapshot waveComposition() {
return waveComposition;
}
public SeededRandomSource.Snapshot bonusDrops() {
return bonusDrops;
}
public SeededRandomSource.Snapshot bossOrder() {
return bossOrder;
}
public SeededRandomSource.Snapshot upgradeDrafts() {
return upgradeDrafts;
}
public SeededRandomSource.Snapshot combat() {
return combat;
}
}
}
@@ -0,0 +1,103 @@
package ru.project.tower.run;
import com.badlogic.gdx.utils.Array;
import java.util.Objects;
import ru.project.tower.abilities.AbilitySystem;
import ru.project.tower.entities.bullets.BulletSystem;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.waves.BossSpawnController;
import ru.project.tower.waves.SpawnController;
public final class RunSession {
private final Array<BaseCircleData> circles;
private final Array<String> spawnedBosses;
private final BulletSystem bulletSystem;
private final BonusBallSystem bonusBallSystem;
private final SpawnController spawnController;
private final BossSpawnController bossSpawnController;
private final AbilitySystem abilitySystem;
private final UpgradeSystem upgradeSystem;
private final RunRngStreams rngStreams;
RunSession(
Array<BaseCircleData> circles,
Array<String> spawnedBosses,
BulletSystem bulletSystem,
BonusBallSystem bonusBallSystem,
SpawnController spawnController,
BossSpawnController bossSpawnController,
AbilitySystem abilitySystem,
UpgradeSystem upgradeSystem,
RunRngStreams rngStreams) {
this.circles = Objects.requireNonNull(circles, "circles");
this.spawnedBosses = Objects.requireNonNull(spawnedBosses, "spawnedBosses");
this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem");
this.bonusBallSystem = Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
this.spawnController = Objects.requireNonNull(spawnController, "spawnController");
this.bossSpawnController =
Objects.requireNonNull(bossSpawnController, "bossSpawnController");
this.abilitySystem = Objects.requireNonNull(abilitySystem, "abilitySystem");
this.upgradeSystem = Objects.requireNonNull(upgradeSystem, "upgradeSystem");
this.rngStreams = rngStreams;
}
public SpawnController spawnController() {
return spawnController;
}
public BossSpawnController bossSpawnController() {
return bossSpawnController;
}
public BonusBallSystem bonusBallSystem() {
return bonusBallSystem;
}
public AbilitySystem abilitySystem() {
return abilitySystem;
}
public UpgradeSystem upgradeSystem() {
return upgradeSystem;
}
public RunRngStreams rngStreams() {
return rngStreams;
}
public void setRenderScale(float gameScale, float adaptedCircleRadius) {
spawnController.setRenderScale(gameScale, adaptedCircleRadius);
bossSpawnController.setRenderScale(gameScale);
}
public void resizeActiveCircles(
float playerCenterDeltaX, float playerCenterDeltaY, float adaptedCircleRadius) {
for (BaseCircleData circleData : circles) {
circleData.circle.x += playerCenterDeltaX;
circleData.circle.y += playerCenterDeltaY;
circleData.circle.radius = adaptedCircleRadius;
}
}
public void resetForNewRun() {
clearCircles();
bonusBallSystem.clear();
bulletSystem.startNewRun();
abilitySystem.resetForNewRun();
upgradeSystem.resetForNewRun();
spawnedBosses.clear();
}
public void dispose() {
resetForNewRun();
}
private void clearCircles() {
for (int i = 0; i < circles.size; i++) {
BaseCircleData.freeIfSpawnPooled(circles.get(i));
}
circles.clear();
}
}
@@ -0,0 +1,53 @@
package ru.project.tower.run;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import java.util.Objects;
import ru.project.tower.abilities.AbilityHost;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.entities.bullets.BulletSystem;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.support.BossEffectsHost;
import ru.project.tower.support.GameClock;
import ru.project.tower.upgrades.UpgradeHost;
public final class RunSessionConfig {
final Rectangle square;
final Array<BaseCircleData> circles;
final Array<BonusBallData> bonusBalls;
final BulletSystem bulletSystem;
final PlayerAbilities playerAbilities;
final Array<AbilityKind> selectedAbilities;
final Array<String> spawnedBosses;
final GameClock clock;
final BossEffectsHost bossEffectsHost;
final AbilityHost abilityHost;
final UpgradeHost upgradeHost;
public RunSessionConfig(
Rectangle square,
Array<BaseCircleData> circles,
Array<BonusBallData> bonusBalls,
BulletSystem bulletSystem,
PlayerAbilities playerAbilities,
Array<AbilityKind> selectedAbilities,
Array<String> spawnedBosses,
GameClock clock,
BossEffectsHost bossEffectsHost,
AbilityHost abilityHost,
UpgradeHost upgradeHost) {
this.square = Objects.requireNonNull(square, "square");
this.circles = Objects.requireNonNull(circles, "circles");
this.bonusBalls = Objects.requireNonNull(bonusBalls, "bonusBalls");
this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem");
this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities");
this.selectedAbilities = Objects.requireNonNull(selectedAbilities, "selectedAbilities");
this.spawnedBosses = Objects.requireNonNull(spawnedBosses, "spawnedBosses");
this.clock = Objects.requireNonNull(clock, "clock");
this.bossEffectsHost = Objects.requireNonNull(bossEffectsHost, "bossEffectsHost");
this.abilityHost = Objects.requireNonNull(abilityHost, "abilityHost");
this.upgradeHost = Objects.requireNonNull(upgradeHost, "upgradeHost");
}
}
@@ -0,0 +1,76 @@
package ru.project.tower.run;
import java.util.Objects;
import ru.project.tower.abilities.AbilitySystem;
import ru.project.tower.support.RandomSource;
import ru.project.tower.support.ViewportSize;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.waves.BossSpawnController;
import ru.project.tower.waves.SpawnController;
public final class RunSessionFactory {
private final RandomSource random;
private final RunRngStreams rngStreams;
private final ViewportSize viewport;
public RunSessionFactory(RandomSource random, ViewportSize viewport) {
this.random = Objects.requireNonNull(random, "random");
this.rngStreams = null;
this.viewport = Objects.requireNonNull(viewport, "viewport");
}
public RunSessionFactory(RunRngStreams rngStreams, ViewportSize viewport) {
this.random = null;
this.rngStreams = Objects.requireNonNull(rngStreams, "rngStreams");
this.viewport = Objects.requireNonNull(viewport, "viewport");
}
public RunSession create(RunSessionConfig config) {
Objects.requireNonNull(config, "config");
RandomSource waveRandom = rngStreams == null ? random : rngStreams.waveComposition();
RandomSource bonusRandom = rngStreams == null ? random : rngStreams.bonusDrops();
RandomSource bossRandom = rngStreams == null ? random : rngStreams.bossOrder();
RandomSource upgradeRandom = rngStreams == null ? random : rngStreams.upgradeDrafts();
RandomSource combatRandom = rngStreams == null ? random : rngStreams.combat();
BonusBallSystem bonusBallSystem = new BonusBallSystem(config.bonusBalls);
SpawnController spawnController =
new SpawnController(
config.square,
config.circles,
config.bonusBalls,
waveRandom,
bonusRandom,
viewport);
BossSpawnController bossSpawnController =
new BossSpawnController(
config.circles,
config.spawnedBosses,
config.bulletSystem,
config.square,
bossRandom,
viewport,
config.bossEffectsHost);
AbilitySystem abilitySystem =
new AbilitySystem(
config.square,
config.circles,
config.playerAbilities,
config.selectedAbilities,
combatRandom,
config.abilityHost,
config.clock);
UpgradeSystem upgradeSystem = new UpgradeSystem(upgradeRandom, config.upgradeHost);
return new RunSession(
config.circles,
config.spawnedBosses,
config.bulletSystem,
bonusBallSystem,
spawnController,
bossSpawnController,
abilitySystem,
upgradeSystem,
rngStreams);
}
}
@@ -0,0 +1,109 @@
package ru.project.tower.run;
import com.badlogic.gdx.utils.Array;
import java.util.Objects;
import ru.project.tower.GameState;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.entities.bullets.BulletSystem;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.upgrades.shop.ShopUpgradeSystem;
import ru.project.tower.waves.WaveController;
import ru.project.tower.waves.WaveTuning;
public final class RunSnapshotCoordinator {
private final Host host;
public interface Host {
GameState gameState();
Array<AbilityKind> selectedAbilities();
Array<String> spawnedBosses();
PlayerCombatState playerCombatState();
BulletSystem bulletSystem();
ShopUpgradeSystem shopUpgradeSystem();
WaveController waveController();
void applyWaveTuning(WaveTuning tuning);
default RunRngStreams rngStreams() {
return null;
}
}
public RunSnapshotCoordinator(Host host) {
this.host = Objects.requireNonNull(host, "host");
}
public RunStartMode resolveStartMode() {
return host.gameState().isReturnedFromShop()
? RunStartMode.RESTORE_FROM_SHOP_SNAPSHOT
: RunStartMode.FRESH_RUN;
}
public GameState.Snapshot restoreRunSnapshot(RunStartMode startMode) {
if (startMode != RunStartMode.RESTORE_FROM_SHOP_SNAPSHOT) {
return null;
}
return host.gameState().restoreGameState();
}
public void applyRestoredRunSnapshot(RunStartMode startMode, GameState.Snapshot snapshot) {
if (startMode == RunStartMode.RESTORE_FROM_SHOP_SNAPSHOT) {
applyGameSnapshot(snapshot);
}
}
public void saveGameSnapshot() {
host.gameState().setSelectedAbilities(host.selectedAbilities());
host.gameState().setSpawnedBosses(host.spawnedBosses());
host.gameState().saveGameState(captureGameSnapshot());
}
GameState.Snapshot captureGameSnapshot() {
WaveController waveController = host.waveController();
return RunSnapshotMapper.toGameStateSnapshot(
new RunSnapshotState(
host.playerCombatState().snapshot(),
new RunBulletSnapshot(
host.bulletSystem().getBaseSpeed(),
host.bulletSystem().getBaseDamage()),
host.shopUpgradeSystem().snapshot(),
new GameState.WaveSnapshot(
waveController.getCurrentWave(),
waveController.isWaveActive(),
waveController.getWaveStartTime(),
waveController.getEnemiesSpawnedThisWave(),
waveController.getEnemiesPerWave()),
host.rngStreams() == null ? null : host.rngStreams().snapshot()));
}
void applyGameSnapshot(GameState.Snapshot snapshot) {
if (snapshot == null) return;
RunSnapshotState runState =
RunSnapshotMapper.toRunSnapshotState(snapshot, host.playerCombatState().snapshot());
host.playerCombatState().apply(runState.combat());
host.bulletSystem()
.increaseBaseSpeed(
runState.bullet().baseSpeed() - host.bulletSystem().getBaseSpeed());
host.bulletSystem()
.increaseBaseDamage(
runState.bullet().baseDamage() - host.bulletSystem().getBaseDamage());
host.shopUpgradeSystem().apply(runState.shopUpgrades());
WaveController waveController = host.waveController();
GameState.WaveSnapshot wave = runState.wave();
waveController.setCurrentWave(wave.getCurrentWave());
waveController.setWaveActive(wave.isWaveActive());
waveController.setWaveStartTime(wave.getWaveStartTime());
waveController.setEnemiesSpawnedThisWave(wave.getEnemiesSpawnedThisWave());
waveController.setEnemiesPerWave(wave.getEnemiesPerWave());
host.applyWaveTuning(waveController.currentWaveTuning());
}
}
@@ -0,0 +1,82 @@
package ru.project.tower.run;
import java.util.Objects;
import ru.project.tower.GameState;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.upgrades.shop.ShopUpgradeSnapshot;
public final class RunSnapshotMapper {
private RunSnapshotMapper() {}
public static GameState.Snapshot toGameStateSnapshot(RunSnapshotState state) {
RunSnapshotState source = Objects.requireNonNull(state, "state");
PlayerCombatState.Snapshot combat = source.combat();
RunBulletSnapshot bullet = source.bullet();
return new GameState.Snapshot(
combat.money(),
combat.runCurrencyEarned(),
new GameState.HealthSnapshot(
combat.health(), combat.maxHealth(), combat.healthRegenRate()),
new GameState.BulletSnapshot(
bullet.baseSpeed(), bullet.baseDamage(), combat.shotCooldownMs()),
toGameStateUpgradeSnapshot(source.shopUpgrades()),
source.wave(),
source.rng());
}
public static RunSnapshotState toRunSnapshotState(
GameState.Snapshot snapshot, PlayerCombatState.Snapshot combatDefaults) {
GameState.Snapshot source = Objects.requireNonNull(snapshot, "snapshot");
PlayerCombatState.Snapshot defaults =
Objects.requireNonNull(combatDefaults, "combatDefaults");
GameState.HealthSnapshot health = source.getHealth();
GameState.BulletSnapshot bullet = source.getBulletStats();
return new RunSnapshotState(
new PlayerCombatState.Snapshot(
source.getMoney(),
health.getSquareHealth(),
health.getMaxSquareHealth(),
health.getHealthRegenRate(),
defaults.lastDamageTimeMs(),
defaults.lastHealthRegenTimeMs(),
bullet.getShotCooldown(),
defaults.baseCritChance(),
defaults.baseCritMultiplier(),
defaults.gameOverCurrencyAward(),
defaults.maxWaveReached(),
defaults.gameOverCurrencyAwarded(),
source.getRunCurrencyEarned()),
new RunBulletSnapshot(bullet.getBulletSpeed(), bullet.getBulletDamage()),
toShopUpgradeSnapshot(source.getUpgrades()),
source.getWave(),
source.getRng());
}
public static GameState.UpgradeSnapshot toGameStateUpgradeSnapshot(
ShopUpgradeSnapshot snapshot) {
ShopUpgradeSnapshot source = Objects.requireNonNull(snapshot, "snapshot");
return new GameState.UpgradeSnapshot(
source.getHealthLevel(),
source.getDamageLevel(),
source.getSpeedLevel(),
source.getCooldownLevel(),
source.getRegenLevel(),
source.getCritChanceLevel(),
source.getCritMultiplierLevel());
}
public static ShopUpgradeSnapshot toShopUpgradeSnapshot(GameState.UpgradeSnapshot snapshot) {
GameState.UpgradeSnapshot source = Objects.requireNonNull(snapshot, "snapshot");
return ShopUpgradeSnapshot.of(
source.getHealthUpgradeLevel(),
source.getDamageUpgradeLevel(),
source.getSpeedUpgradeLevel(),
source.getCooldownUpgradeLevel(),
source.getRegenUpgradeLevel(),
source.getCritChanceUpgradeLevel(),
source.getCritMultiplierUpgradeLevel());
}
}
@@ -0,0 +1,61 @@
package ru.project.tower.run;
import java.util.Objects;
import ru.project.tower.GameState;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.upgrades.shop.ShopUpgradeSnapshot;
public final class RunSnapshotState {
private final PlayerCombatState.Snapshot combat;
private final RunBulletSnapshot bullet;
private final ShopUpgradeSnapshot shopUpgrades;
private final GameState.WaveSnapshot wave;
private final RunRngStreams.Snapshot rng;
public RunSnapshotState(
PlayerCombatState.Snapshot combat,
RunBulletSnapshot bullet,
ShopUpgradeSnapshot shopUpgrades,
GameState.WaveSnapshot wave) {
this(combat, bullet, shopUpgrades, wave, null);
}
public RunSnapshotState(
PlayerCombatState.Snapshot combat,
RunBulletSnapshot bullet,
ShopUpgradeSnapshot shopUpgrades,
GameState.WaveSnapshot wave,
RunRngStreams.Snapshot rng) {
this.combat = Objects.requireNonNull(combat, "combat");
this.bullet = Objects.requireNonNull(bullet, "bullet");
this.shopUpgrades = Objects.requireNonNull(shopUpgrades, "shopUpgrades");
this.wave = Objects.requireNonNull(wave, "wave");
this.rng = rng;
}
public PlayerCombatState.Snapshot combat() {
return combat;
}
public RunBulletSnapshot bullet() {
return bullet;
}
public ShopUpgradeSnapshot shopUpgrades() {
return shopUpgrades;
}
public GameState.WaveSnapshot wave() {
return wave;
}
public RunRngStreams.Snapshot rng() {
return rng;
}
}
@@ -0,0 +1,7 @@
package ru.project.tower.run;
public enum RunStartMode {
FRESH_RUN,
RESTART,
RESTORE_FROM_SHOP_SNAPSHOT
}
@@ -0,0 +1,649 @@
package ru.project.tower.screens;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.GlyphLayout;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.DebugLog;
import ru.project.tower.GameContext;
import ru.project.tower.GameState;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.navigation.ScreenNavigation;
import ru.project.tower.navigation.ScreenNavigator;
import ru.project.tower.screens.abilityselection.AbilitySelectionDependencies;
import ru.project.tower.screens.abilityselection.AbilitySelectionLayout;
import ru.project.tower.screens.abilityselection.AbilitySelectionScaleMetrics;
import ru.project.tower.screens.common.NeonMenuParticleSystem;
import ru.project.tower.support.RobotoFontFactory;
import ru.project.tower.support.ScreenPalette;
public class AbilitySelectionScreen implements Screen {
private static final int MAX_ABILITY_BUTTONS = 7;
private SpriteBatch batch;
private ShapeRenderer shapeRenderer;
private BitmapFont titleFont;
private BitmapFont regularFont;
private final ScreenNavigator navigator;
private final PlayerAbilities playerAbilities;
private final GameState gameState;
private final Color neonBlue = ScreenPalette.BLUE.create();
private final Color neonPink = ScreenPalette.PINK.create();
private final Color neonCyan = ScreenPalette.CYAN.create();
private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create();
private final Color backgroundColor = ScreenPalette.SCREEN.create();
private final Color neonRed = ScreenPalette.RED.create();
private final Color neonGreen = ScreenPalette.GREEN.create();
private final Color neonYellow = ScreenPalette.CYAN.create();
private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create();
private final Color scratchColor = new Color();
private final Color scratchColor2 = new Color();
private final Color scratchColor3 = new Color();
private final GlyphLayout titleLayout = new GlyphLayout();
private final GlyphLayout abilityButtonTextLayout = new GlyphLayout();
private final GlyphLayout startButtonTextLayout = new GlyphLayout();
private final GlyphLayout backButtonTextLayout = new GlyphLayout();
private float uiScale;
private int adaptedFontSize;
private float time = 0;
private final NeonMenuParticleSystem particles = new NeonMenuParticleSystem();
private final Array<Rectangle> abilityButtons = new Array<>(MAX_ABILITY_BUTTONS);
private final Rectangle[] abilityButtonPool = {
new Rectangle(),
new Rectangle(),
new Rectangle(),
new Rectangle(),
new Rectangle(),
new Rectangle(),
new Rectangle()
};
private final Array<AbilityKind> availableAbilities = new Array<>();
private final Array<AbilityKind> selectedAbilities = new Array<>();
private final Rectangle startGameButton = new Rectangle();
private final Rectangle backButton = new Rectangle();
public AbilitySelectionScreen(Game game, GameContext ctx) {
this(ScreenNavigation.forGame(game, ctx), AbilitySelectionDependencies.from(ctx));
}
public AbilitySelectionScreen(
ScreenNavigator navigator, AbilitySelectionDependencies dependencies) {
this.navigator = navigator;
this.playerAbilities = dependencies.playerAbilities();
this.gameState = dependencies.gameState();
try {
DebugLog.log("AbilitySelectionScreen", "Constructing AbilitySelectionScreen");
DebugLog.log("AbilitySelectionScreen", "Navigator set");
} catch (Exception e) {
Gdx.app.error("AbilitySelectionScreen", "Error in constructor: ", e);
throw e;
}
}
@Override
public void show() {
try {
DebugLog.log("AbilitySelectionScreen", "Starting show() method");
initializeRenderResources();
calculateUIScale();
DebugLog.log("AbilitySelectionScreen", "Calculated UI scale");
loadFonts();
initializeParticles();
populateAvailableAbilities();
updateButtonLayout(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
installInputProcessor();
} catch (Exception e) {
Gdx.app.error("AbilitySelectionScreen", "Error in show method: ", e);
throw e;
}
}
private void initializeRenderResources() {
batch = new SpriteBatch();
DebugLog.log("AbilitySelectionScreen", "Created SpriteBatch");
shapeRenderer = new ShapeRenderer();
DebugLog.log("AbilitySelectionScreen", "Created ShapeRenderer");
}
private void loadFonts() {
try {
if (!RobotoFontFactory.exists()) {
Gdx.app.error("AbilitySelectionScreen", "Font file roboto.ttf not found!");
throw new RuntimeException("Font file not found");
}
logFontFile();
RobotoFontFactory.FontSpec spec =
RobotoFontFactory.spec(
(int) (adaptedFontSize * 1.3f),
1.5f,
neonPurple,
Color.WHITE,
1,
1,
fontShadowColor);
titleFont = RobotoFontFactory.generate(spec);
DebugLog.log("AbilitySelectionScreen", "Generated title font");
regularFont =
RobotoFontFactory.generate(
spec.withSize((int) (adaptedFontSize * 0.9f)).withBorderWidth(1.0f));
DebugLog.log("AbilitySelectionScreen", "Generated regular font");
} catch (Exception e) {
Gdx.app.error("AbilitySelectionScreen", "Error loading fonts: ", e);
throw e;
}
}
private void initializeParticles() {
particles.init(
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight(),
uiScale,
neonBlue,
neonPink,
neonCyan,
neonPurple);
}
private void populateAvailableAbilities() {
Array<AbilityKind> purchasedAbilities = playerAbilities.getPurchasedAbilities();
logAbilities("Available purchased abilities: ", "Available ability: ", purchasedAbilities);
availableAbilities.clear();
selectedAbilities.clear();
availableAbilities.addAll(purchasedAbilities);
logAvailableAbilitiesPopulated();
}
private void updateButtonLayout(int width, int height) {
AbilitySelectionLayout layout =
AbilitySelectionLayout.calculate(width, height, uiScale, availableAbilities.size);
createAbilityButtons(layout);
setRect(startGameButton, layout.startGameButton());
setRect(backButton, layout.backButton());
}
private void installInputProcessor() {
Gdx.input.setInputProcessor(
new InputAdapter() {
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
float touchX = screenX;
float touchY = Gdx.graphics.getHeight() - screenY;
for (int i = 0; i < abilityButtons.size; i++) {
if (i < availableAbilities.size
&& abilityButtons.get(i).contains(touchX, touchY)) {
toggleAbilitySelection(availableAbilities.get(i));
return true;
}
}
if (startGameButton.contains(touchX, touchY)) {
startGame();
return true;
}
if (backButton.contains(touchX, touchY)) {
navigator.showMainMenu();
return true;
}
return false;
}
});
}
private void logFontFile() {
if (DebugLog.DEBUG) {
DebugLog.log("AbilitySelectionScreen", "Font file exists: roboto.ttf");
}
}
private void logAvailableAbilitiesPopulated() {
if (DebugLog.DEBUG) {
DebugLog.log(
"AbilitySelectionScreen",
"Populated availableAbilities, total: " + availableAbilities.size);
}
}
private void createAbilityButtons(AbilitySelectionLayout layout) {
abilityButtons.clear();
for (int i = 0; i < layout.abilityButtonCount(); i++) {
Rectangle abilityButton = abilityButtonPool[i];
setRect(abilityButton, layout.abilityButton(i));
abilityButtons.add(abilityButton);
}
}
private void setRect(Rectangle target, AbilitySelectionLayout.Rect source) {
target.set(source.x(), source.y(), source.width(), source.height());
}
private void calculateUIScale() {
AbilitySelectionScaleMetrics metrics =
AbilitySelectionScaleMetrics.calculate(
Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
uiScale = metrics.uiScale();
adaptedFontSize = metrics.adaptedFontSize();
}
private void toggleAbilitySelection(AbilityKind ability) {
if (DebugLog.DEBUG) {
DebugLog.log("AbilitySelectionScreen", "Toggling ability: " + ability.persistedId());
}
if (selectedAbilities.contains(ability, true)) {
selectedAbilities.removeValue(ability, true);
if (DebugLog.DEBUG) {
DebugLog.log(
"AbilitySelectionScreen",
"Removed ability: "
+ ability.persistedId()
+ ", total: "
+ selectedAbilities.size);
}
} else {
if (selectedAbilities.size < 3) {
selectedAbilities.add(ability);
if (DebugLog.DEBUG) {
DebugLog.log(
"AbilitySelectionScreen",
"Added ability: "
+ ability.persistedId()
+ ", total: "
+ selectedAbilities.size);
}
}
}
}
private void startGame() {
try {
DebugLog.log("AbilitySelectionScreen", "Starting game...");
if (navigator == null) {
Gdx.app.error("AbilitySelectionScreen", "Screen navigator is null!");
throw new RuntimeException("Screen navigator is null");
}
if (gameState == null) {
Gdx.app.error("AbilitySelectionScreen", "GameState instance is null!");
throw new RuntimeException("GameState instance is null");
}
DebugLog.log("AbilitySelectionScreen", "Got GameState instance");
gameState.resetGameState();
DebugLog.log("AbilitySelectionScreen", "Reset game state");
logAbilities(
"Selected abilities before setting: ",
"Selected ability before setting: ",
selectedAbilities);
gameState.setSelectedAbilities(selectedAbilities);
DebugLog.log("AbilitySelectionScreen", "Set selected abilities to GameState");
Array<AbilityKind> checkAbilities = gameState.getSelectedAbilities();
logAbilities(
"Checking set abilities count: ", "Checking set ability: ", checkAbilities);
try {
DebugLog.log("AbilitySelectionScreen", "Navigating to GameScreen");
navigator.showGameScreen();
DebugLog.log("AbilitySelectionScreen", "Game started successfully");
} catch (Exception e) {
Gdx.app.error(
"AbilitySelectionScreen", "Error creating or setting GameScreen: ", e);
throw e;
}
} catch (Exception e) {
Gdx.app.error("AbilitySelectionScreen", "Error in startGame: ", e);
throw e;
}
}
private void logAbilities(
String countPrefix, String abilityPrefix, Array<AbilityKind> abilities) {
if (DebugLog.DEBUG) {
DebugLog.log("AbilitySelectionScreen", countPrefix + abilities.size);
for (AbilityKind ability : abilities) {
DebugLog.log("AbilitySelectionScreen", abilityPrefix + ability.persistedId());
}
}
}
private void renderNeonButton(Rectangle button, Color color, boolean isSelected) {
float glow = (float) (0.5f + 0.5f * Math.sin(time * 2.0f));
float mouseX = Gdx.input.getX();
float mouseY = Gdx.graphics.getHeight() - Gdx.input.getY();
boolean isHovered = button.contains(mouseX, mouseY);
boolean isPressed = isHovered && Gdx.input.isTouched();
Color borderColor = buttonBorderColor(color, isHovered, isPressed);
renderButtonFillAndGlow(button, borderColor, isSelected, isHovered, glow);
renderButtonBorder(button, borderColor, isSelected, isHovered, glow);
}
private Color buttonBorderColor(Color color, boolean isHovered, boolean isPressed) {
if (isPressed) return neonPink;
if (isHovered) return neonCyan;
return color;
}
private void renderButtonFillAndGlow(
Rectangle button,
Color borderColor,
boolean isSelected,
boolean isHovered,
float glow) {
shapeRenderer.set(ShapeType.Filled);
if (isSelected) {
shapeRenderer.setColor(0.025f, 0.09f, 0.14f, 0.96f);
} else {
shapeRenderer.setColor(0.035f, 0.043f, 0.071f, 0.96f);
}
shapeRenderer.rect(button.x, button.y, button.width, button.height);
if (isSelected || isHovered) {
scratchColor.set(borderColor);
scratchColor.a = isSelected ? 0.18f : 0.08f;
shapeRenderer.setColor(scratchColor);
shapeRenderer.rect(button.x, button.y, button.width, button.height);
}
if (isSelected) {
scratchColor2.set(borderColor);
scratchColor2.a = 0.16f * glow;
shapeRenderer.setColor(scratchColor2);
shapeRenderer.rect(
button.x, button.y + button.height - 5f * uiScale, button.width, 3f * uiScale);
}
}
private void renderButtonBorder(
Rectangle button,
Color borderColor,
boolean isSelected,
boolean isHovered,
float glow) {
shapeRenderer.end();
shapeRenderer.begin(ShapeType.Line);
scratchColor3.set(borderColor);
scratchColor3.a = isSelected ? 0.95f : isHovered ? 0.85f : 0.7f;
shapeRenderer.setColor(scratchColor3);
int thickness = isSelected ? 2 : 1;
for (int i = 0; i < thickness; i++) {
shapeRenderer.rect(
button.x + i, button.y + i, button.width - i * 2, button.height - i * 2);
}
shapeRenderer.end();
shapeRenderer.begin(ShapeType.Filled);
}
private void renderNeonGrid() {
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
float alpha = 0.18f + 0.06f * (float) Math.sin(time * 0.5f);
shapeRenderer.setColor(ScreenPalette.BLUE.set(scratchColor, alpha));
float spacing = 50f * uiScale;
float offset = (time * 10f) % spacing;
for (float y = offset; y < Gdx.graphics.getHeight(); y += spacing) {
shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y);
}
shapeRenderer.setColor(ScreenPalette.BLUE.set(scratchColor, alpha));
for (float x = offset; x < Gdx.graphics.getWidth(); x += spacing) {
shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight());
}
shapeRenderer.end();
}
@Override
public void render(float delta) {
try {
updateFrameState(delta);
clearBackground();
renderBackgroundEffects();
renderButtonShapes();
renderButtonText();
} catch (Exception e) {
Gdx.app.error("AbilitySelectionScreen", "Error in render method: ", e);
throw e;
}
}
private void updateFrameState(float delta) {
time += delta;
particles.update(
delta,
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight(),
uiScale,
neonBlue,
neonPink,
neonCyan,
neonPurple);
}
private void clearBackground() {
Gdx.gl.glClearColor(
backgroundColor.r, backgroundColor.g, backgroundColor.b, backgroundColor.a);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
}
private void renderBackgroundEffects() {
renderNeonGrid();
particles.render(shapeRenderer);
}
private void renderButtonShapes() {
shapeRenderer.begin(ShapeType.Filled);
for (int i = 0; i < abilityButtons.size; i++) {
if (i < availableAbilities.size) {
AbilityKind ability = availableAbilities.get(i);
boolean isSelected = selectedAbilities.contains(ability, true);
Color buttonColor = getAbilityColor(ability);
renderNeonButton(abilityButtons.get(i), buttonColor, isSelected);
}
}
renderNeonButton(startGameButton, neonGreen, false);
renderNeonButton(backButton, neonRed, false);
shapeRenderer.end();
}
private void renderButtonText() {
batch.begin();
try {
renderTitleText();
renderAbilityButtonText();
renderStartButtonText();
renderBackButtonText();
batch.end();
} catch (Exception e) {
Gdx.app.error("AbilitySelectionScreen", "Error rendering text: ", e);
} finally {
if (batch.isDrawing()) batch.end();
}
}
private void renderTitleText() {
String titleText = "Выберите способности";
titleLayout.setText(titleFont, titleText);
float titleX = (Gdx.graphics.getWidth() - titleLayout.width) / 2;
float titleY = Gdx.graphics.getHeight() - 80 * uiScale;
titleFont.draw(batch, titleText, titleX, titleY);
}
private void renderAbilityButtonText() {
for (int i = 0; i < abilityButtons.size; i++) {
if (i < availableAbilities.size) {
Rectangle button = abilityButtons.get(i);
AbilityKind ability = availableAbilities.get(i);
String buttonText = getAbilityName(ability);
abilityButtonTextLayout.setText(regularFont, buttonText);
float textX = button.x + (button.width - abilityButtonTextLayout.width) / 2;
float textY = button.y + (button.height + abilityButtonTextLayout.height) / 2;
regularFont.draw(batch, buttonText, textX, textY);
}
}
}
private String startButtonText(boolean wrap) {
if (selectedAbilities.size == 1) {
return wrap ? "Начать\nс 1 способностью" : "Начать с 1 способностью";
}
if (selectedAbilities.size == 0) {
return wrap ? "Начать\nбез способностей" : "Начать без способностей";
}
return "Начать игру";
}
private void renderStartButtonText() {
String startButtonText = startButtonText(false);
startButtonTextLayout.setText(regularFont, startButtonText);
float horizontalPadding = 20f * uiScale;
if (startButtonTextLayout.width > startGameButton.width - horizontalPadding * 2f) {
startButtonText = startButtonText(true);
startButtonTextLayout.setText(regularFont, startButtonText);
}
float startTextX =
startGameButton.x + (startGameButton.width - startButtonTextLayout.width) / 2;
float startTextY =
startGameButton.y + (startGameButton.height + startButtonTextLayout.height) / 2;
regularFont.draw(batch, startButtonText, startTextX, startTextY);
}
private void renderBackButtonText() {
String backText = "<";
backButtonTextLayout.setText(regularFont, backText);
float backTextX = backButton.x + (backButton.width - backButtonTextLayout.width) / 2;
float backTextY = backButton.y + (backButton.height + backButtonTextLayout.height) / 2;
regularFont.draw(batch, backText, backTextX, backTextY);
}
private Color getAbilityColor(AbilityKind ability) {
switch (ability) {
case FREEZE:
return neonCyan;
case EXPLOSION:
return neonRed;
case SHIELD:
return neonBlue;
case IMPULSE:
return neonPurple;
case DASH:
return neonCyan;
case PULL:
return neonPurple;
case TURRET:
return neonGreen;
default:
return neonYellow;
}
}
private String getAbilityName(AbilityKind ability) {
return ability.selectionName();
}
@Override
public void resize(int width, int height) {
calculateUIScale();
updateButtonLayout(width, height);
}
@Override
public void pause() {}
@Override
public void resume() {}
@Override
public void hide() {
Gdx.input.setInputProcessor(null);
}
@Override
public void dispose() {
if (batch != null) {
batch.dispose();
batch = null;
}
if (shapeRenderer != null) {
shapeRenderer.dispose();
shapeRenderer = null;
}
if (titleFont != null) {
titleFont.dispose();
titleFont = null;
}
if (regularFont != null) {
regularFont.dispose();
regularFont = null;
}
}
}
@@ -0,0 +1,488 @@
package ru.project.tower.screens;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable;
import com.badlogic.gdx.utils.Align;
import com.badlogic.gdx.utils.viewport.ScreenViewport;
import ru.project.tower.DebugLog;
import ru.project.tower.GameContext;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.navigation.ScreenNavigation;
import ru.project.tower.navigation.ScreenNavigator;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.screens.abilityshop.AbilityShopCardView;
import ru.project.tower.screens.abilityshop.AbilityShopDependencies;
import ru.project.tower.screens.abilityshop.AbilityShopPurchaseService;
import ru.project.tower.screens.abilityshop.AbilityShopResources;
import ru.project.tower.screens.abilityshop.AbilityShopScaleMetrics;
import ru.project.tower.support.RobotoFontFactory;
import ru.project.tower.support.ScreenPalette;
public class AbilityShopScreen implements Screen {
private final ScreenNavigator navigator;
private SpriteBatch batch;
private ShapeRenderer shapeRenderer;
private Stage stage;
private final AbilityShopResources resources = new AbilityShopResources();
private final PlayerStats playerStats;
private final PlayerAbilities playerAbilities;
private float uiScale;
private int adaptedFontSize;
private static final AbilityKind[] ABILITY_TYPES = AbilityKind.values();
private float time;
private final Color neonPink = ScreenPalette.PINK.create();
private final Color gridBlue = ScreenPalette.BLUE.create();
private final Color neonCyan = ScreenPalette.CYAN.create();
private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create();
private final Color neonGreen = ScreenPalette.GREEN.create();
private final Color neonRed = ScreenPalette.RED.create();
private final Color buttonDisabled = ScreenPalette.BUTTON_DISABLED.create();
private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create();
private Color[] abilityColors = {
neonCyan, neonRed, neonGreen, neonPurple, neonCyan, neonPurple, neonGreen
};
public AbilityShopScreen(Game game, GameContext ctx) {
this(ScreenNavigation.forGame(game, ctx), AbilityShopDependencies.from(ctx));
}
public AbilityShopScreen(ScreenNavigator navigator, AbilityShopDependencies dependencies) {
this.navigator = navigator;
this.playerStats = dependencies.playerStats();
this.playerAbilities = dependencies.playerAbilities();
}
private void rebuildScreenAssets() {
resources.rebuild();
createFont();
}
private void createFont() {
BitmapFont generatedFont = null;
BitmapFont generatedButtonFont = null;
try {
if (RobotoFontFactory.exists()) {
DebugLog.log("AbilityShopScreen", "Найден файл шрифта roboto.ttf");
RobotoFontFactory.FontSpec spec = createFontSpec();
generatedFont = RobotoFontFactory.generate(spec);
generatedButtonFont = generateButtonFont(spec);
installGeneratedFonts(generatedFont, generatedButtonFont);
generatedFont = null;
generatedButtonFont = null;
} else {
logMissingRobotoFont();
installFallbackFonts();
}
} catch (Exception e) {
installFallbackFonts();
Gdx.app.error("AbilityShopScreen", "Ошибка при создании шрифта: " + e.getMessage());
} finally {
disposeGeneratedFont(generatedFont);
disposeGeneratedFont(generatedButtonFont);
}
}
private RobotoFontFactory.FontSpec createFontSpec() {
return RobotoFontFactory.spec(
adaptedFontSize,
1f * (uiScale < 1 ? 1 : uiScale),
neonPurple,
Color.WHITE,
1,
1,
fontShadowColor);
}
private BitmapFont generateButtonFont(RobotoFontFactory.FontSpec spec) {
return RobotoFontFactory.generate(spec.withSize((int) (adaptedFontSize * 0.85f)));
}
private void installGeneratedFonts(BitmapFont generatedFont, BitmapFont generatedButtonFont) {
resources.installFonts(generatedFont, generatedButtonFont);
}
private void logMissingRobotoFont() {
Gdx.app.error(
"AbilityShopScreen",
"Файл шрифта roboto.ttf не найден, используем стандартный шрифт");
}
private void installFallbackFonts() {
resources.installFonts(RobotoFontFactory.fallback(), RobotoFontFactory.fallback(0.85f));
}
private void disposeGeneratedFont(BitmapFont generatedFont) {
if (generatedFont != null) generatedFont.dispose();
}
private void createUI() {
DebugLog.log("AbilityShopScreen", "Создание UI");
createButtonStyle();
createLabelStyles();
Table table = createAbilityShopTable();
Label balanceLabel = addAbilityShopHeader(table);
addAbilityCards(table, balanceLabel);
addBackButton(table);
Gdx.input.setInputProcessor(stage);
DebugLog.log("AbilityShopScreen", "UI создан успешно");
}
private Table createAbilityShopTable() {
Table table = new Table();
table.setFillParent(true);
stage.addActor(table);
return table;
}
private Label addAbilityShopHeader(Table table) {
Label titleLabel = new Label("МАГАЗИН", resources.skin());
table.add(titleLabel).padTop(14 * uiScale).padBottom(8 * uiScale);
table.row();
Label balanceLabel = new Label("Баланс: " + playerStats.getCurrency(), resources.skin());
table.add(balanceLabel).padBottom(8 * uiScale);
table.row();
return balanceLabel;
}
private void addAbilityCards(Table table, Label balanceLabel) {
float screenWidth = Gdx.graphics.getWidth();
Table abilitiesContainer = createAbilityCardsContainer(balanceLabel, screenWidth);
ScrollPane scrollPane = createAbilityScrollPane(abilitiesContainer);
table.add(scrollPane).padLeft(10 * uiScale).padRight(10 * uiScale).expand().fill();
table.row();
}
private Table createAbilityCardsContainer(Label balanceLabel, float screenWidth) {
float cardWidth = Math.min(screenWidth - 20f * uiScale, 420f * uiScale);
Table abilitiesContainer = new Table();
for (int i = 0; i < ABILITY_TYPES.length; i++) {
Table abilityCard = createAbilityCard(i, balanceLabel, cardWidth);
abilitiesContainer.add(abilityCard).pad(4 * uiScale).width(cardWidth).expandX();
abilitiesContainer.row();
}
return abilitiesContainer;
}
private ScrollPane createAbilityScrollPane(Table abilitiesContainer) {
ScrollPane scrollPane = new ScrollPane(abilitiesContainer, resources.skin());
scrollPane.setFadeScrollBars(false);
scrollPane.setScrollingDisabled(true, false);
return scrollPane;
}
private void addBackButton(Table table) {
TextButton backButton = new TextButton("НАЗАД", resources.skin());
backButton.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
handleBackClick();
}
});
table.add(backButton).pad(10 * uiScale).size(150 * uiScale, 50 * uiScale);
}
private void handleBackClick() {
try {
navigator.showMainMenu();
} catch (Exception e) {
Gdx.app.error(
"AbilityShopScreen", "Ошибка при возврате в главное меню: " + e.getMessage());
}
}
private Table createAbilityCard(int index, Label balanceLabel, float cardWidth) {
AbilityKind ability = ABILITY_TYPES[index];
AbilityShopCardView cardView =
AbilityShopCardView.forAbility(
ability,
playerAbilities.hasAbility(ability),
playerStats.getMaxWaveReached());
Table abilityCard = new Table();
abilityCard.setBackground(createCardBackground(abilityColors[index]));
abilityCard.pad(8 * uiScale);
Label nameLabel = new Label(cardView.name(), resources.skin());
nameLabel.setAlignment(Align.left);
nameLabel.setWrap(true);
Label detailsLabel = new Label(cardView.detailsText(), resources.skin());
detailsLabel.setAlignment(Align.left);
detailsLabel.setWrap(true);
final TextButton buyButton = new TextButton(cardView.buttonText(), resources.skin());
if (cardView.buttonDisabled()) {
buyButton.setDisabled(true);
} else {
buyButton.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
tryPurchaseAbility(index, buyButton, balanceLabel);
}
});
}
float buttonWidth = Math.min(94f * uiScale, cardWidth * 0.32f);
float contentWidth = cardWidth - buttonWidth - 34f * uiScale;
abilityCard.add(nameLabel).padRight(8 * uiScale).width(contentWidth).expandX().left();
abilityCard.add(buyButton).size(buttonWidth, 36 * uiScale).right();
abilityCard.row();
abilityCard
.add(detailsLabel)
.padTop(4 * uiScale)
.colspan(2)
.width(cardWidth - 18f * uiScale)
.left();
return abilityCard;
}
private void createButtonStyle() {
TextButton.TextButtonStyle buttonStyle = createShopButtonStyle();
Color[] uniqueCardColors = {neonCyan, neonRed, neonGreen, neonPurple};
installButtonDrawables(buttonStyle);
cacheCardBackgrounds(uniqueCardColors);
resources.skin().add("default", buttonStyle);
}
private TextButton.TextButtonStyle createShopButtonStyle() {
TextButton.TextButtonStyle buttonStyle = new TextButton.TextButtonStyle();
buttonStyle.font =
resources.buttonFont() != null ? resources.buttonFont() : resources.font();
buttonStyle.fontColor = Color.WHITE;
buttonStyle.overFontColor = neonCyan;
buttonStyle.downFontColor = neonPink;
return buttonStyle;
}
private void installButtonDrawables(TextButton.TextButtonStyle buttonStyle) {
buttonStyle.up = createOwnedDrawable(buildBorderPixmap(neonPurple, 2), 3);
buttonStyle.over = createOwnedDrawable(buildBorderPixmap(neonCyan, 3), 3);
buttonStyle.down = createOwnedDrawable(buildBorderPixmap(neonPink, 3), 3);
buttonStyle.disabled = createOwnedDrawable(buildBorderPixmap(buttonDisabled, 2), 3);
}
private NinePatchDrawable createOwnedDrawable(Pixmap pixmap, int split) {
return resources.screenAssets().ownNinePatch(pixmap, split);
}
private void cacheCardBackgrounds(Color[] uniqueCardColors) {
int cardSplit = (int) (3 * uiScale);
for (Color c : uniqueCardColors) {
resources.putCardBackground(c, createOwnedDrawable(buildCardPixmap(c), cardSplit));
}
}
private void createLabelStyles() {
Label.LabelStyle labelStyle = new Label.LabelStyle(resources.font(), Color.WHITE);
resources.skin().add("default", labelStyle);
ScrollPane.ScrollPaneStyle scrollPaneStyle = new ScrollPane.ScrollPaneStyle();
resources.skin().add("default", scrollPaneStyle);
}
private static Pixmap buildBorderPixmap(Color borderColor, int borderThickness) {
Pixmap px = new Pixmap(20, 20, Pixmap.Format.RGBA8888);
px.setColor(0.05f, 0.07f, 0.12f, 0.92f);
px.fill();
px.setColor(borderColor);
for (int i = 0; i < borderThickness; i++) {
px.drawRectangle(i, i, 20 - i * 2, 20 - i * 2);
}
return px;
}
private static Pixmap buildCardPixmap(Color borderColor) {
Pixmap px = new Pixmap(20, 20, Pixmap.Format.RGBA8888);
px.setColor(0.06f, 0.08f, 0.13f, 0.9f);
px.fill();
px.setColor(borderColor);
for (int i = 0; i < 2; i++) {
px.drawRectangle(i, i, 20 - i * 2, 20 - i * 2);
}
return px;
}
private NinePatchDrawable createCardBackground(Color color) {
NinePatchDrawable cached = resources.cardBackground(color);
if (cached != null) return cached;
Gdx.app.error("AbilityShopScreen", "Unknown card color — no atlas region pre-packed");
return resources.hasCardBackgrounds() ? resources.firstCardBackground() : null;
}
private void calculateUIScale() {
AbilityShopScaleMetrics metrics =
AbilityShopScaleMetrics.calculate(
Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
uiScale = metrics.uiScale();
adaptedFontSize = metrics.adaptedFontSize();
}
private void tryPurchaseAbility(int index, TextButton buyButton, Label balanceLabel) {
try {
AbilityKind abilityType = ABILITY_TYPES[index];
AbilityShopPurchaseService.Result result =
AbilityShopPurchaseService.tryPurchase(
abilityType, playerStats, playerAbilities);
if (result == AbilityShopPurchaseService.Result.PURCHASED) {
buyButton.setText("КУПЛЕНО");
buyButton.setDisabled(true);
balanceLabel.setText("Баланс: " + playerStats.getCurrency());
logPurchasedAbility(abilityType);
} else if (result == AbilityShopPurchaseService.Result.INSUFFICIENT_FUNDS) {
DebugLog.log("AbilityShopScreen", "Недостаточно средств для покупки способности");
} else if (result == AbilityShopPurchaseService.Result.LOCKED) {
DebugLog.log("AbilityShopScreen", "Способность еще не разблокирована прогрессом");
} else {
DebugLog.log("AbilityShopScreen", "Способность уже куплена");
}
} catch (Exception e) {
Gdx.app.error("AbilityShopScreen", "Ошибка при покупке способности: " + e.getMessage());
}
}
private void logPurchasedAbility(AbilityKind abilityType) {
if (DebugLog.DEBUG) {
DebugLog.log(
"AbilityShopScreen", "Куплена способность: " + abilityType.selectionName());
}
}
@Override
public void render(float delta) {
time += delta;
Gdx.gl.glClearColor(0.035f, 0.043f, 0.071f, 1f);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
renderBackdrop();
stage.act(delta);
stage.draw();
}
private void renderBackdrop() {
if (shapeRenderer == null) return;
shapeRenderer.begin(ShapeType.Line);
float spacing = 54f * uiScale;
float offset = (time * 6f) % spacing;
shapeRenderer.setColor(ScreenPalette.BLUE.set(gridBlue, 0.38f));
for (float x = offset; x < Gdx.graphics.getWidth(); x += spacing) {
shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight());
}
for (float y = offset; y < Gdx.graphics.getHeight(); y += spacing) {
shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y);
}
shapeRenderer.end();
}
@Override
public void show() {
dispose();
DebugLog.log("AbilityShopScreen", "Создание экрана магазина способностей");
calculateUIScale();
batch = new SpriteBatch();
shapeRenderer = new ShapeRenderer();
stage = new Stage(new ScreenViewport());
rebuildScreenAssets();
createUI();
}
@Override
public void resize(int width, int height) {
logResize(width, height);
calculateUIScale();
stage.getViewport().update(width, height, true);
stage.clear();
disposeScreenAssets();
rebuildScreenAssets();
createUI();
}
private void logResize(int width, int height) {
if (DebugLog.DEBUG) {
DebugLog.log("AbilityShopScreen", "Изменение размера экрана: " + width + "x" + height);
}
}
@Override
public void pause() {}
@Override
public void resume() {}
@Override
public void hide() {
Gdx.input.setInputProcessor(null);
}
@Override
public void dispose() {
try {
if (stage != null) {
stage.dispose();
stage = null;
}
if (batch != null) {
batch.dispose();
batch = null;
}
if (shapeRenderer != null) {
shapeRenderer.dispose();
shapeRenderer = null;
}
resources.dispose();
} catch (Exception e) {
Gdx.app.error("AbilityShopScreen", "Ошибка в методе dispose(): " + e.getMessage());
}
}
private void disposeScreenAssets() {
resources.dispose();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
package ru.project.tower.screens;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.GlyphLayout;
import com.badlogic.gdx.graphics.g2d.NinePatch;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable;
import com.badlogic.gdx.utils.viewport.ScreenViewport;
import ru.project.tower.DebugLog;
import ru.project.tower.GameContext;
import ru.project.tower.navigation.ScreenNavigation;
import ru.project.tower.navigation.ScreenNavigator;
import ru.project.tower.screens.common.NeonMenuParticleSystem;
import ru.project.tower.screens.main.MainScreenScaleMetrics;
import ru.project.tower.support.RobotoFontFactory;
import ru.project.tower.support.ScreenAssets;
import ru.project.tower.support.ScreenPalette;
import ru.project.tower.support.UiAtlasBuilder;
public class MainScreen implements Screen {
private Stage stage;
private Skin skin;
private TextureAtlas uiAtlas;
private ScreenAssets screenAssets;
private SpriteBatch batch;
private final ScreenNavigator navigator;
private BitmapFont font;
private BitmapFont titleFont;
private ShapeRenderer shapeRenderer;
private final Color neonBlue = ScreenPalette.BLUE.create();
private final Color neonPink = ScreenPalette.PINK.create();
private final Color neonCyan = ScreenPalette.CYAN.create();
private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create();
private final Color backgroundColor = ScreenPalette.SCREEN.create();
private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create();
private final Color scratchColor = new Color();
private final GlyphLayout titleLayout = new GlyphLayout();
private float uiScale;
private int adaptedFontSize;
private final NeonMenuParticleSystem particles = new NeonMenuParticleSystem();
private float time = 0;
private float pulseSpeed = 2.0f;
private static final String TITLE_TOP = "Square";
private static final String TITLE_BOTTOM = "Defense";
private static final float TITLE_FONT_SCALE = 1.32f;
private static final float TITLE_LINE_GAP_SCALE = 0.92f;
public MainScreen(Game game, GameContext ctx) {
this(ScreenNavigation.forGame(game, ctx));
}
public MainScreen(ScreenNavigator navigator) {
this.navigator = navigator;
}
@Override
public void show() {
dispose();
initializeRenderResources();
calculateUIScale();
initializeStage();
rebuildGeneratedMenuResources();
createMenuTable();
initializeParticles();
}
private void rebuildGeneratedMenuResources() {
disposeGeneratedMenuResources();
createFont();
screenAssets = new ScreenAssets();
skin = screenAssets.skin();
screenAssets.font("default", font);
createButtonStyle();
}
private void initializeRenderResources() {
batch = new SpriteBatch();
shapeRenderer = new ShapeRenderer();
}
private void initializeStage() {
stage = new Stage(new ScreenViewport());
Gdx.input.setInputProcessor(stage);
}
private void createFont() {
font =
RobotoFontFactory.generate(
RobotoFontFactory.spec(
adaptedFontSize,
2f,
neonPurple,
Color.WHITE,
1,
1,
fontShadowColor));
titleFont = RobotoFontFactory.generate(createTitleFontSpec());
}
private RobotoFontFactory.FontSpec createTitleFontSpec() {
int titleSize = Math.round(adaptedFontSize * TITLE_FONT_SCALE * uiScale);
int titleShadowOffset = Math.max(1, Math.round(TITLE_FONT_SCALE * uiScale));
return RobotoFontFactory.spec(
titleSize,
2f * TITLE_FONT_SCALE * uiScale,
neonPurple,
Color.WHITE,
titleShadowOffset,
titleShadowOffset,
fontShadowColor);
}
private TextButton.TextButtonStyle createButtonStyle() {
TextButton.TextButtonStyle buttonStyle = new TextButton.TextButtonStyle();
buttonStyle.font = font;
buttonStyle.fontColor = Color.WHITE;
buttonStyle.overFontColor = neonCyan;
buttonStyle.downFontColor = neonPink;
try (UiAtlasBuilder atlasBuilder = new UiAtlasBuilder(128, 128)) {
atlasBuilder.add("btn_up", buildBorderPixmap(neonCyan, 2));
atlasBuilder.add("btn_over", buildBorderPixmap(neonCyan, 3));
atlasBuilder.add("btn_down", buildBorderPixmap(neonPink, 3));
uiAtlas = screenAssets.setAtlas(atlasBuilder.build());
}
buttonStyle.up =
new NinePatchDrawable(new NinePatch(uiAtlas.findRegion("btn_up"), 3, 3, 3, 3));
buttonStyle.over =
new NinePatchDrawable(new NinePatch(uiAtlas.findRegion("btn_over"), 3, 3, 3, 3));
buttonStyle.down =
new NinePatchDrawable(new NinePatch(uiAtlas.findRegion("btn_down"), 3, 3, 3, 3));
skin.add("default", buttonStyle);
return buttonStyle;
}
private void createMenuTable() {
Table table = createMainMenuTable();
MenuButtonLayout layout = calculateMenuButtonLayout();
TextButton gameButton = addMenuButton(table, "ИГРАТЬ", layout);
TextButton talentButton = addMenuButton(table, "ТАЛАНТЫ", layout);
TextButton abilityShopButton = addMenuButton(table, "МАГАЗИН", layout);
installMenuButtonListeners(gameButton, talentButton, abilityShopButton);
}
private Table createMainMenuTable() {
Table table = new Table();
table.setFillParent(true);
table.padTop(Gdx.graphics.getHeight() * 0.22f);
stage.addActor(table);
return table;
}
private MenuButtonLayout calculateMenuButtonLayout() {
float minButtonWidth = 180 * uiScale;
float maxButtonWidth = Gdx.graphics.getWidth() * 0.7f;
float buttonWidth = Math.min(maxButtonWidth, Math.max(minButtonWidth, 300 * uiScale));
float minButtonHeight = 50 * uiScale;
float maxButtonHeight = Gdx.graphics.getHeight() * 0.12f;
float buttonHeight = Math.min(maxButtonHeight, Math.max(minButtonHeight, 60 * uiScale));
return new MenuButtonLayout(buttonWidth, buttonHeight, Math.max(10, 20 * uiScale));
}
private TextButton addMenuButton(Table table, String text, MenuButtonLayout layout) {
TextButton button = new TextButton(text, skin);
table.add(button).size(layout.buttonWidth, layout.buttonHeight).pad(layout.buttonPadding);
table.row();
return button;
}
private void installMenuButtonListeners(
TextButton gameButton, TextButton talentButton, TextButton abilityShopButton) {
gameButton.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
navigator.showAbilitySelection();
}
});
talentButton.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
handleTalentClick();
}
});
abilityShopButton.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
handleAbilityShopClick();
}
});
}
private void handleTalentClick() {
try {
DebugLog.log("MainScreen", "Attempting to open TalentTreeScreen...");
navigator.showTalentTree();
} catch (RuntimeException e) {
Gdx.app.error("MainScreen", "Error opening talents", e);
}
}
private void handleAbilityShopClick() {
try {
navigator.showAbilityShop();
} catch (Exception e) {
Gdx.app.error("MainScreen", "Error opening ability shop", e);
}
}
private static final class MenuButtonLayout {
private final float buttonWidth;
private final float buttonHeight;
private final float buttonPadding;
private MenuButtonLayout(float buttonWidth, float buttonHeight, float buttonPadding) {
this.buttonWidth = buttonWidth;
this.buttonHeight = buttonHeight;
this.buttonPadding = buttonPadding;
}
}
private void initializeParticles() {
particles.init(
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight(),
uiScale,
neonBlue,
neonPink,
neonCyan,
neonPurple);
}
private void renderNeonTitleText() {
time += Gdx.graphics.getDeltaTime();
float glow = 0.5f + 0.5f * (float) Math.sin(time * pulseSpeed);
float scale = 1.0f + 0.1f * glow;
batch.begin();
titleFont.getData().setScale(scale);
float titleY = Gdx.graphics.getHeight() * 0.86f;
float lineGap = titleFont.getLineHeight() * TITLE_LINE_GAP_SCALE * scale;
drawCenteredTitleLine(TITLE_TOP, titleY, glow);
drawCenteredTitleLine(TITLE_BOTTOM, titleY - lineGap, glow);
titleFont.getData().setScale(1.0f);
batch.end();
}
private void drawCenteredTitleLine(String text, float y, float glow) {
titleLayout.setText(titleFont, text);
float x = (Gdx.graphics.getWidth() - titleLayout.width) / 2f;
drawTitleLine(text, x, y, glow);
}
private void drawTitleLine(String text, float x, float y, float glow) {
drawGlowLayer(batch, text, x, y, ScreenPalette.PINK, 0.15f + 0.18f * glow, 3f);
drawGlowLayer(batch, text, x, y, ScreenPalette.BLUE, 0.26f + 0.22f * glow, 2f);
drawGlowLayer(batch, text, x, y, ScreenPalette.CYAN, 0.45f + 0.26f * glow, 1f);
titleFont.setColor(Color.WHITE);
titleFont.draw(batch, text, x, y);
}
private void drawGlowLayer(
SpriteBatch batch,
String text,
float x,
float y,
ScreenPalette palette,
float alpha,
float offset) {
titleFont.setColor(palette.set(scratchColor, alpha));
titleFont.draw(batch, text, x + offset, y + offset);
titleFont.draw(batch, text, x - offset, y - offset);
}
private void calculateUIScale() {
float screenWidth = Gdx.graphics.getWidth();
float screenHeight = Gdx.graphics.getHeight();
MainScreenScaleMetrics metrics =
MainScreenScaleMetrics.calculate(screenWidth, screenHeight);
uiScale = metrics.uiScale();
adaptedFontSize = metrics.adaptedFontSize();
if (DebugLog.DEBUG) {
logScaleMetrics(screenWidth, screenHeight, metrics);
}
}
private void logScaleMetrics(
float screenWidth, float screenHeight, MainScreenScaleMetrics metrics) {
DebugLog.log(
"MainScreen",
"UI Scale: "
+ uiScale
+ ", Font Size: "
+ adaptedFontSize
+ ", Portrait: "
+ metrics.isPortrait()
+ ", Screen: "
+ screenWidth
+ "x"
+ screenHeight);
}
private void renderNeonGrid() {
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
float alpha = 0.2f + 0.1f * (float) Math.sin(time * 0.5f);
shapeRenderer.setColor(ScreenPalette.BLUE.set(scratchColor, alpha));
float spacing = 50f * uiScale;
float offset = (time * 10f) % spacing;
for (float y = offset; y < Gdx.graphics.getHeight(); y += spacing) {
shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y);
}
for (float x = offset; x < Gdx.graphics.getWidth(); x += spacing) {
shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight());
}
shapeRenderer.end();
}
@Override
public void render(float delta) {
time += delta;
particles.update(
delta,
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight(),
uiScale,
neonBlue,
neonPink,
neonCyan,
neonPurple);
Gdx.gl.glClearColor(
backgroundColor.r, backgroundColor.g, backgroundColor.b, backgroundColor.a);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
renderNeonGrid();
particles.render(shapeRenderer);
renderNeonTitleText();
stage.act(delta);
stage.draw();
}
@Override
public void resize(int width, int height) {
calculateUIScale();
stage.getViewport().update(width, height, true);
stage.clear();
disposeGeneratedMenuResources();
rebuildGeneratedMenuResources();
createMenuTable();
initializeParticles();
}
@Override
public void pause() {}
@Override
public void resume() {}
@Override
public void hide() {
Gdx.input.setInputProcessor(null);
}
@Override
public void dispose() {
if (stage != null) {
stage.dispose();
stage = null;
}
disposeGeneratedMenuResources();
if (batch != null) {
batch.dispose();
batch = null;
}
if (shapeRenderer != null) {
shapeRenderer.dispose();
shapeRenderer = null;
}
}
private void disposeGeneratedMenuResources() {
if (screenAssets != null) {
screenAssets.dispose();
}
screenAssets = null;
skin = null;
font = null;
if (titleFont != null) {
titleFont.dispose();
}
titleFont = null;
uiAtlas = null;
}
private static Pixmap buildBorderPixmap(Color borderColor, int borderThickness) {
Pixmap px = new Pixmap(20, 20, Pixmap.Format.RGBA8888);
px.setColor(0.05f, 0.07f, 0.12f, 0.92f);
px.fill();
px.setColor(borderColor);
for (int i = 0; i < borderThickness; i++) {
px.drawRectangle(i, i, 20 - i * 2, 20 - i * 2);
}
return px;
}
}
@@ -0,0 +1,796 @@
package ru.project.tower.screens;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputMultiplexer;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.GlyphLayout;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable;
import com.badlogic.gdx.utils.viewport.ScreenViewport;
import java.util.List;
import ru.project.tower.DebugLog;
import ru.project.tower.GameContext;
import ru.project.tower.navigation.ScreenNavigation;
import ru.project.tower.navigation.ScreenNavigator;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.screens.common.NeonMenuParticleSystem;
import ru.project.tower.screens.talenttree.TalentTreeScaleMetrics;
import ru.project.tower.screens.talenttree.TalentTreeScreenDependencies;
import ru.project.tower.support.RobotoFontFactory;
import ru.project.tower.support.ScreenAssets;
import ru.project.tower.support.ScreenPalette;
import ru.project.tower.support.UiAtlasBuilder;
import ru.project.tower.talents.TalentData;
import ru.project.tower.talents.TalentTree;
public class TalentTreeScreen implements Screen {
private static final String SMOKE_STATE_PROPERTY = "tower.smoke.state";
private static final String SMOKE_TALENT_POPUP_STATE = "talentPopup";
private final ScreenNavigator navigator;
private Stage uiStage;
private Stage worldStage;
private ShapeRenderer shapeRenderer;
private final TalentTree talentTree;
private final PlayerStats playerStats;
private Skin skin;
private TextureAtlas uiAtlas;
private ScreenAssets screenAssets;
private BitmapFont font;
private BitmapFont smallFont;
private Table infoPopup;
private Label nameLabel, descLabel, costLabel, balanceLabel;
private TextButton upgradeButton;
private TalentNodeActor selectedNode;
private Vector3 lastTouch = new Vector3();
private boolean isDragging = false;
private float time = 0;
private final Color neonBlue = ScreenPalette.BLUE.create();
private final Color neonCyan = ScreenPalette.CYAN.create();
private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create();
private final Color lockedGray = ScreenPalette.LOCKED_GRAY.create();
private final Color purchasedColor = ScreenPalette.GREEN.create();
private final Color backgroundColor = ScreenPalette.SCREEN.create();
private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create();
private final Color scratchColor = new Color();
private final Color scratchColor2 = new Color();
private final Vector2 treeCenter = new Vector2();
private float uiScale;
private int adaptedFontSize;
private final NeonMenuParticleSystem particles =
new NeonMenuParticleSystem(NeonMenuParticleSystem.Profile.DRIFTING);
public TalentTreeScreen(Game game, GameContext ctx) {
this(ScreenNavigation.forGame(game, ctx), TalentTreeScreenDependencies.from(ctx));
}
public TalentTreeScreen(ScreenNavigator navigator, TalentTreeScreenDependencies dependencies) {
this.navigator = navigator;
this.talentTree = dependencies.talentTree();
this.playerStats = dependencies.playerStats();
}
@Override
public void show() {
DebugLog.log("TalentTreeScreen", "Initializing...");
try {
this.shapeRenderer = new ShapeRenderer();
calculateUIScale();
this.worldStage = new Stage(new ScreenViewport());
this.uiStage = new Stage(new ScreenViewport());
rebuildScreenAssets();
initTreeWorld();
initUI();
centerCameraOnTree();
applySmokeStateIfRequested();
particles.init(
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight(),
uiScale,
neonBlue,
neonPurple,
neonBlue,
neonPurple);
InputMultiplexer plexer = new InputMultiplexer(uiStage, worldStage);
Gdx.input.setInputProcessor(plexer);
DebugLog.log("TalentTreeScreen", "Initialized successfully.");
} catch (RuntimeException t) {
Gdx.app.error("TalentTreeScreen", "FATAL CRASH DURING SHOW", t);
dispose();
throw new IllegalStateException("TalentTreeScreen show failed", t);
}
}
private void rebuildScreenAssets() {
screenAssets = new ScreenAssets();
initFonts();
initSkin();
}
private void initFonts() {
BitmapFont generatedFont = null;
BitmapFont generatedSmallFont = null;
if (RobotoFontFactory.exists()) {
try {
RobotoFontFactory.FontSpec spec =
RobotoFontFactory.spec(
adaptedFontSize,
1.5f * (uiScale < 1 ? 1 : uiScale),
neonPurple,
Color.WHITE,
1,
1,
fontShadowColor)
.withCharacters(RobotoFontFactory.FONT_CHARACTERS_WITH_DEFAULTS);
generatedFont = RobotoFontFactory.generate(spec);
generatedSmallFont =
RobotoFontFactory.generate(
spec.withSize((int) (adaptedFontSize * 0.7f))
.withBorderWidth(1.0f * (uiScale < 1 ? 1 : uiScale)));
font = screenAssets.font("default", generatedFont);
generatedFont = null;
smallFont = screenAssets.font("small", generatedSmallFont);
generatedSmallFont = null;
} finally {
if (generatedFont != null) generatedFont.dispose();
if (generatedSmallFont != null) generatedSmallFont.dispose();
}
} else {
font = screenAssets.font("default", RobotoFontFactory.fallback());
smallFont = screenAssets.font("small", RobotoFontFactory.fallback(0.8f));
}
}
private void initSkin() {
skin = screenAssets.skin();
int btnW = (int) (100 * uiScale);
int btnH = (int) (40 * uiScale);
Pixmap pUp = new Pixmap(btnW, btnH, Pixmap.Format.RGBA8888);
pUp.setColor(0, 0, 0, 0.6f);
pUp.fill();
pUp.setColor(neonCyan);
pUp.drawRectangle(0, 0, btnW, btnH);
pUp.drawRectangle(1, 1, btnW - 2, btnH - 2);
Pixmap pDown = new Pixmap(btnW, btnH, Pixmap.Format.RGBA8888);
pDown.setColor(0.1f, 0.3f, 0.5f, 0.8f);
pDown.fill();
pDown.setColor(neonCyan);
pDown.drawRectangle(0, 0, btnW, btnH);
try (UiAtlasBuilder atlasBuilder = new UiAtlasBuilder(512, 256)) {
atlasBuilder.add("btn_up", pUp);
atlasBuilder.add("btn_down", pDown);
uiAtlas = screenAssets.setAtlas(atlasBuilder.build());
}
TextButton.TextButtonStyle style = new TextButton.TextButtonStyle();
style.font = font;
style.up = new TextureRegionDrawable(uiAtlas.findRegion("btn_up"));
style.down = new TextureRegionDrawable(uiAtlas.findRegion("btn_down"));
style.overFontColor = neonCyan;
skin.add("default", style);
skin.add("default", new Label.LabelStyle(font, Color.WHITE));
skin.add("small", new Label.LabelStyle(smallFont, Color.WHITE));
Label.LabelStyle titleStyle = new Label.LabelStyle(font, neonCyan);
skin.add("title", titleStyle);
}
public static Vector2 computeTreeCenter(TalentTree tree) {
return computeTreeCenterInto(tree, new Vector2());
}
public static Vector2 computeTreeCenterInto(TalentTree tree, Vector2 out) {
if (out == null) {
throw new IllegalArgumentException("out must not be null");
}
if (tree == null) return out.set(0f, 0f);
List<TalentData> all = tree.getAllTalents();
if (all == null || all.isEmpty()) return out.set(0f, 0f);
float minX = Float.POSITIVE_INFINITY, maxX = Float.NEGATIVE_INFINITY;
float minY = Float.POSITIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY;
for (TalentData td : all) {
if (td == null) continue;
float x = td.getX();
float y = td.getY();
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (minX == Float.POSITIVE_INFINITY) return out.set(0f, 0f);
return out.set((minX + maxX) * 0.5f, (minY + maxY) * 0.5f);
}
private void centerCameraOnTree() {
if (worldStage == null) return;
computeTreeCenterInto(talentTree, treeCenter);
worldStage.getCamera().position.set(treeCenter.x * uiScale, treeCenter.y * uiScale, 0f);
worldStage.getCamera().update();
}
private void applySmokeStateIfRequested() {
if (SMOKE_TALENT_POPUP_STATE.equals(System.getProperty(SMOKE_STATE_PROPERTY, ""))) {
showTalentPopupForSmoke("keystone_splash");
}
}
private void showTalentPopupForSmoke(String talentId) {
if (worldStage == null || infoPopup == null) return;
com.badlogic.gdx.utils.Array<Actor> actors = worldStage.getActors();
for (int i = 0, n = actors.size; i < n; i++) {
if (!(actors.get(i) instanceof TalentNodeActor)) continue;
TalentNodeActor node = (TalentNodeActor) actors.get(i);
if (!node.talent.getId().equals(talentId)) continue;
selectedNode = node;
updatePopup(node.talent);
infoPopup.setPosition(
computePanelX(uiStage.getViewport().getWorldWidth(), infoPopup.getWidth()),
computePanelY(uiStage.getViewport().getWorldHeight(), infoPopup.getHeight()));
infoPopup.setVisible(true);
return;
}
}
private void initTreeWorld() {
List<TalentData> talents = talentTree.getAllTalents();
if (talents == null) return;
for (TalentData td : talents) {
if (td != null) worldStage.addActor(new TalentNodeActor(td));
}
}
private void initUI() {
createInfoPopup();
addTopBar();
}
private void createInfoPopup() {
infoPopup = new Table();
float popupW = 300 * uiScale;
float popupH = 240 * uiScale;
infoPopup.setBackground(getNeonWindowBg((int) popupW, (int) popupH));
infoPopup.setVisible(false);
createInfoPopupLabels();
upgradeButton = createUpgradeButton();
TextButton close = createInfoPopupCloseButton();
populateInfoPopup(close, popupW, popupH);
}
private void createInfoPopupLabels() {
nameLabel = new Label("", skin, "title");
descLabel = new Label("", skin, "small");
descLabel.setWrap(true);
costLabel = new Label("", skin);
}
private TextButton createUpgradeButton() {
TextButton button = new TextButton("УЛУЧШИТЬ", skin);
button.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent event, float x, float y) {
if (selectedNode != null && !upgradeButton.isDisabled())
purchase(selectedNode.talent);
}
});
return button;
}
private TextButton createInfoPopupCloseButton() {
TextButton close = new TextButton("X", skin);
close.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent e, float x, float y) {
infoPopup.setVisible(false);
selectedNode = null;
}
});
return close;
}
private void populateInfoPopup(TextButton close, float popupW, float popupH) {
Table inner = new Table();
inner.add(nameLabel).pad(5 * uiScale).row();
inner.add(descLabel).width(240 * uiScale).pad(10 * uiScale).row();
inner.add(costLabel).pad(5 * uiScale).row();
inner.add(upgradeButton).size(160 * uiScale, 45 * uiScale).pad(10 * uiScale);
infoPopup.add(close).right().size(40 * uiScale, 40 * uiScale).pad(5 * uiScale).row();
infoPopup.add(inner).expand().center();
infoPopup.setSize(popupW, popupH);
infoPopup.setPosition(
computePanelX(uiStage.getViewport().getWorldWidth(), popupW),
computePanelY(uiStage.getViewport().getWorldHeight(), popupH));
uiStage.addActor(infoPopup);
}
private void addTopBar() {
Table top = new Table();
top.setFillParent(true);
top.top();
balanceLabel = new Label("Баланс: " + playerStats.getCurrency(), skin);
TextButton back = new TextButton("НАЗАД", skin);
back.addListener(
new ClickListener() {
@Override
public void clicked(InputEvent e, float x, float y) {
navigator.showMainMenu();
}
});
top.add(balanceLabel).expandX().left().pad(15 * uiScale);
top.add(back).right().pad(10 * uiScale).size(120 * uiScale, 40 * uiScale);
uiStage.addActor(top);
}
private TextureRegionDrawable getNeonWindowBg(int w, int h) {
Pixmap pix = new Pixmap(w, h, Pixmap.Format.RGBA8888);
pix.setColor(0.05f, 0.05f, 0.1f, 0.9f);
pix.fill();
pix.setColor(neonBlue);
pix.drawRectangle(0, 0, w, h);
pix.drawRectangle(1, 1, w - 2, h - 2);
pix.setColor(neonCyan);
pix.fillRectangle(0, 0, 10, 2);
pix.fillRectangle(0, 0, 2, 10);
pix.fillRectangle(w - 10, 0, 10, 2);
pix.fillRectangle(w - 2, 0, 2, 10);
pix.fillRectangle(0, h - 2, 10, 2);
pix.fillRectangle(0, h - 10, 2, 10);
pix.fillRectangle(w - 10, h - 2, 10, 2);
pix.fillRectangle(w - 2, h - 10, 2, 10);
return screenAssets.own(pix);
}
public static float computePanelX(float viewportW, float panelW) {
return Math.max(0f, (viewportW - panelW) / 2f);
}
public static float computePanelY(float viewportH, float panelH) {
return Math.max(0f, (viewportH - panelH) / 2f);
}
private void purchase(TalentData t) {
int lvl = talentTree.getCurrentLevel(t.getId());
if (talentTree.canUpgradeTalent(t.getId()) && playerStats.spendCurrency(t.getCost(lvl))) {
talentTree.upgradeTalent(t.getId());
updatePopup(t);
balanceLabel.setText("Баланс: " + playerStats.getCurrency());
}
}
private void updatePopup(TalentData t) {
int lvl = talentTree.getCurrentLevel(t.getId());
nameLabel.setText(t.getName() + " (" + lvl + "/" + t.getMaxLevel() + ")");
descLabel.setText(t.getDescription() + "\n" + talentTree.boundedRatingText(t.getId()));
if (lvl >= t.getMaxLevel()) {
costLabel.setText("МАКС.");
upgradeButton.setDisabled(true);
} else {
int c = t.getCost(lvl);
costLabel.setText("Цена: " + c);
upgradeButton.setDisabled(
!talentTree.canUpgradeTalent(t.getId()) || playerStats.getCurrency() < c);
}
}
@Override
public void render(float delta) {
Gdx.gl.glClearColor(backgroundColor.r, backgroundColor.g, backgroundColor.b, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
time += delta;
handleIn();
particles.update(
delta,
Gdx.graphics.getWidth(),
Gdx.graphics.getHeight(),
uiScale,
neonBlue,
neonPurple,
neonBlue,
neonPurple);
renderBackgroundGrid();
shapeRenderer.setProjectionMatrix(uiStage.getCamera().combined);
particles.render(shapeRenderer);
worldStage.act(delta);
worldStage.getCamera().update();
shapeRenderer.setProjectionMatrix(worldStage.getCamera().combined);
renderTalentConnections();
renderTalentNodes();
worldStage.draw();
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
uiStage.act(delta);
uiStage.draw();
}
private void renderTalentConnections() {
Gdx.gl.glEnable(GL20.GL_BLEND);
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE);
renderTalentConnectionGlowPass();
renderTalentConnectionCorePass();
Gdx.gl.glLineWidth(1);
}
private void renderTalentConnectionGlowPass() {
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
Gdx.gl.glLineWidth(5 * uiScale);
for (TalentData t : talentTree.getAllTalents()) {
for (String dep : t.getDependencyIds()) {
TalentData p = talentTree.getTalent(dep);
if (p != null) {
boolean active = talentConnectionActive(t, p);
Color baseCol = talentConnectionColor(active);
scratchColor.set(baseCol);
scratchColor.a =
(0.2f + 0.1f * MathUtils.sin(time * 2f)) * (active ? 1f : 0.5f);
shapeRenderer.setColor(scratchColor);
shapeRenderer.line(
t.getX() * uiScale,
t.getY() * uiScale,
p.getX() * uiScale,
p.getY() * uiScale);
}
}
}
shapeRenderer.end();
}
private void renderTalentConnectionCorePass() {
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
Gdx.gl.glLineWidth(1 * uiScale);
for (TalentData t : talentTree.getAllTalents()) {
for (String dep : t.getDependencyIds()) {
TalentData p = talentTree.getTalent(dep);
if (p != null) {
shapeRenderer.setColor(talentConnectionColor(talentConnectionActive(t, p)));
shapeRenderer.line(
t.getX() * uiScale,
t.getY() * uiScale,
p.getX() * uiScale,
p.getY() * uiScale);
}
}
}
shapeRenderer.end();
}
private boolean talentConnectionActive(TalentData child, TalentData parent) {
return talentTree.getCurrentLevel(child.getId()) > 0
|| talentTree.getCurrentLevel(parent.getId()) > 0;
}
private Color talentConnectionColor(boolean active) {
return active ? neonBlue : lockedGray;
}
private void renderTalentNodes() {
Gdx.gl.glEnable(GL20.GL_BLEND);
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE);
shapeRenderer.setProjectionMatrix(worldStage.getCamera().combined);
renderTalentNodeGlowPass();
renderTalentNodeFillPass();
renderTalentNodeBorderPass();
Gdx.gl.glLineWidth(1);
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
}
private void renderTalentNodeGlowPass() {
float glowScale = 1.0f + 0.1f * MathUtils.sin(time * 3f);
float alpha = 0.4f * (0.8f + 0.2f * MathUtils.sin(time * 5f));
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
Gdx.gl.glLineWidth(3 * uiScale);
com.badlogic.gdx.utils.Array<Actor> actors = worldStage.getActors();
for (int i = 0, n = actors.size; i < n; i++) {
if (actors.get(i) instanceof TalentNodeActor) {
TalentNodeActor node = (TalentNodeActor) actors.get(i);
scratchColor.set(talentNodeColor(node));
scratchColor.a = alpha;
shapeRenderer.setColor(scratchColor);
node.drawHex(node.getX() + node.r, node.getY() + node.r, node.r * glowScale, false);
}
}
shapeRenderer.end();
}
private void renderTalentNodeFillPass() {
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
com.badlogic.gdx.utils.Array<Actor> actors = worldStage.getActors();
for (int i = 0, n = actors.size; i < n; i++) {
if (actors.get(i) instanceof TalentNodeActor) {
TalentNodeActor node = (TalentNodeActor) actors.get(i);
int level = talentTree.getCurrentLevel(node.talent.getId());
boolean unlocked = talentTree.isUnlocked(node.talent.getId());
scratchColor2.set(talentNodeColor(node));
scratchColor2.a = (level > 0 || unlocked) ? 0.3f : 0.1f;
shapeRenderer.setColor(scratchColor2);
node.drawHex(node.getX() + node.r, node.getY() + node.r, node.r * 0.85f, true);
}
}
shapeRenderer.end();
}
private void renderTalentNodeBorderPass() {
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
Gdx.gl.glLineWidth(2 * uiScale);
com.badlogic.gdx.utils.Array<Actor> actors = worldStage.getActors();
for (int i = 0, n = actors.size; i < n; i++) {
if (actors.get(i) instanceof TalentNodeActor) {
TalentNodeActor node = (TalentNodeActor) actors.get(i);
shapeRenderer.setColor(talentNodeColor(node));
node.drawHex(node.getX() + node.r, node.getY() + node.r, node.r * 0.85f, false);
}
}
shapeRenderer.end();
}
private Color talentNodeColor(TalentNodeActor node) {
int level = talentTree.getCurrentLevel(node.talent.getId());
if (level > 0) return purchasedColor;
if (talentTree.isUnlocked(node.talent.getId())) return neonBlue;
return lockedGray;
}
private void renderBackgroundGrid() {
shapeRenderer.setProjectionMatrix(uiStage.getCamera().combined);
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
float alpha = 0.1f + 0.05f * MathUtils.sin(time * 0.5f);
shapeRenderer.setColor(scratchColor.set(0.1f, 0.2f, 0.4f, alpha));
float cellSize = 60 * uiScale;
float offsetX = (time * 10f * uiScale) % cellSize;
float offsetY = (time * 5f * uiScale) % cellSize;
for (float x = -offsetX; x < Gdx.graphics.getWidth(); x += cellSize) {
shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight());
}
for (float y = -offsetY; y < Gdx.graphics.getHeight(); y += cellSize) {
shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y);
}
shapeRenderer.end();
}
private void calculateUIScale() {
TalentTreeScaleMetrics metrics =
TalentTreeScaleMetrics.calculate(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
uiScale = metrics.uiScale();
adaptedFontSize = metrics.adaptedFontSize();
}
private void handleIn() {
if (Gdx.input.isTouched()) {
if (!isDragging) {
isDragging = true;
lastTouch.set(Gdx.input.getX(), Gdx.input.getY(), 0);
} else {
float dx = lastTouch.x - Gdx.input.getX();
float dy = Gdx.input.getY() - lastTouch.y;
worldStage.getCamera().translate(dx, dy, 0);
lastTouch.set(Gdx.input.getX(), Gdx.input.getY(), 0);
}
} else isDragging = false;
}
@Override
public void resize(int w, int h) {
calculateUIScale();
worldStage.getViewport().update(w, h, false);
uiStage.getViewport().update(w, h, true);
centerCameraOnTree();
updateTalentNodeBounds();
resizeInfoPopup(w, h);
uiStage.clear();
disposeScreenAssets();
rebuildScreenAssets();
initUI();
applySmokeStateIfRequested();
}
private void updateTalentNodeBounds() {
if (worldStage == null) {
return;
}
com.badlogic.gdx.utils.Array<Actor> actors = worldStage.getActors();
for (int i = 0, n = actors.size; i < n; i++) {
if (actors.get(i) instanceof TalentNodeActor) {
((TalentNodeActor) actors.get(i)).updateBounds();
}
}
}
private void resizeInfoPopup(int viewportWidth, int viewportHeight) {
if (infoPopup == null) {
return;
}
infoPopup.setSize(300 * uiScale, 240 * uiScale);
infoPopup.setPosition(
computePanelX(viewportWidth, infoPopup.getWidth()),
computePanelY(viewportHeight, infoPopup.getHeight()));
}
@Override
public void pause() {}
@Override
public void resume() {}
@Override
public void hide() {
Gdx.input.setInputProcessor(null);
}
@Override
public void dispose() {
if (worldStage != null) {
worldStage.dispose();
worldStage = null;
}
if (uiStage != null) {
uiStage.dispose();
uiStage = null;
}
if (shapeRenderer != null) {
shapeRenderer.dispose();
shapeRenderer = null;
}
disposeScreenAssets();
}
private void disposeScreenAssets() {
if (screenAssets != null) screenAssets.dispose();
screenAssets = null;
skin = null;
uiAtlas = null;
font = null;
smallFont = null;
}
private class TalentNodeActor extends Actor {
final TalentData talent;
float r = 40;
final GlyphLayout layout = new GlyphLayout();
final String typeInitial;
final StringBuilder levelText = new StringBuilder(5);
private final float[] hexVertices = new float[12];
TalentNodeActor(TalentData t) {
this.talent = t;
this.typeInitial = t.getType().toString().substring(0, 1);
updateBounds();
addListener(
new ClickListener() {
@Override
public void clicked(InputEvent e, float x, float y) {
selectedNode = TalentNodeActor.this;
updatePopup(talent);
infoPopup.setPosition(
computePanelX(
uiStage.getViewport().getWorldWidth(),
infoPopup.getWidth()),
computePanelY(
uiStage.getViewport().getWorldHeight(),
infoPopup.getHeight()));
infoPopup.setVisible(true);
}
});
}
void updateBounds() {
r = 40 * uiScale;
float tx = talent.getX() * uiScale;
float ty = talent.getY() * uiScale;
setBounds(tx - r, ty - r, r * 2, r * 2);
}
@Override
public void draw(Batch batch, float alpha) {
int lvl = talentTree.getCurrentLevel(talent.getId());
font.setColor(Color.WHITE);
layout.setText(font, typeInitial);
font.draw(
batch,
typeInitial,
getX() + r - layout.width / 2,
getY() + r + layout.height / 2);
levelText.setLength(0);
levelText.append(lvl).append('/').append(talent.getMaxLevel());
smallFont.setColor(neonCyan);
layout.setText(smallFont, levelText);
smallFont.draw(batch, levelText, getX() + r - layout.width / 2, getY() - 5 * uiScale);
}
private void drawHex(float x, float y, float radius, boolean filled) {
for (int i = 0; i < 6; i++) {
hexVertices[i * 2] = x + radius * MathUtils.cosDeg(i * 60 + 30);
hexVertices[i * 2 + 1] = y + radius * MathUtils.sinDeg(i * 60 + 30);
}
if (filled) {
for (int i = 0; i < 6; i++) {
shapeRenderer.triangle(
x,
y,
hexVertices[i * 2],
hexVertices[i * 2 + 1],
hexVertices[((i + 1) % 6) * 2],
hexVertices[((i + 1) % 6) * 2 + 1]);
}
} else {
for (int i = 0; i < 6; i++) {
shapeRenderer.line(
hexVertices[i * 2],
hexVertices[i * 2 + 1],
hexVertices[((i + 1) % 6) * 2],
hexVertices[((i + 1) % 6) * 2 + 1]);
}
}
}
}
}
@@ -0,0 +1,30 @@
package ru.project.tower.screens.abilityselection;
import java.util.Objects;
import ru.project.tower.GameContext;
import ru.project.tower.GameState;
import ru.project.tower.abilities.PlayerAbilities;
public final class AbilitySelectionDependencies {
private final PlayerAbilities playerAbilities;
private final GameState gameState;
public AbilitySelectionDependencies(PlayerAbilities playerAbilities, GameState gameState) {
this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities");
this.gameState = Objects.requireNonNull(gameState, "gameState");
}
public static AbilitySelectionDependencies from(GameContext ctx) {
Objects.requireNonNull(ctx, "ctx");
return new AbilitySelectionDependencies(ctx.playerAbilities, ctx.gameState);
}
public PlayerAbilities playerAbilities() {
return playerAbilities;
}
public GameState gameState() {
return gameState;
}
}
@@ -0,0 +1,145 @@
package ru.project.tower.screens.abilityselection;
public final class AbilitySelectionLayout {
private static final int MAX_ABILITY_BUTTONS = 7;
private final float buttonWidth;
private final float buttonHeight;
private final float buttonPadding;
private final Rect[] abilityButtons;
private final Rect startGameButton;
private final Rect backButton;
private AbilitySelectionLayout(
float buttonWidth,
float buttonHeight,
float buttonPadding,
Rect[] abilityButtons,
Rect startGameButton,
Rect backButton) {
this.buttonWidth = buttonWidth;
this.buttonHeight = buttonHeight;
this.buttonPadding = buttonPadding;
this.abilityButtons = abilityButtons;
this.startGameButton = startGameButton;
this.backButton = backButton;
}
public static AbilitySelectionLayout calculate(
int width, int height, float uiScale, int availableAbilityCount) {
float buttonWidth = Math.min(300 * uiScale, width * 0.6f);
float buttonHeight = Math.min(80 * uiScale, height * 0.12f);
float buttonPadding = 20 * uiScale;
int totalButtons = Math.min(MAX_ABILITY_BUTTONS, Math.max(0, availableAbilityCount));
Rect[] abilityButtons = new Rect[totalButtons];
Rect startGameButton =
new Rect(width / 2f - buttonWidth / 2f, buttonPadding, buttonWidth, buttonHeight);
float abilityTop = height - Math.min(150 * uiScale, height * 0.22f);
float abilityBottom = startGameButton.y + startGameButton.height + buttonPadding;
float abilityStackSpace = Math.max(0f, abilityTop - abilityBottom);
float abilityStackHeight =
totalButtons == 0
? 0f
: totalButtons * buttonHeight + (totalButtons - 1) * buttonPadding;
if (totalButtons > 0 && abilityStackHeight > abilityStackSpace) {
float compressedButtonHeight =
abilityStackSpace / (totalButtons + (totalButtons - 1) * 0.25f);
buttonHeight = Math.max(1f, compressedButtonHeight);
buttonPadding = Math.max(1f, buttonHeight * 0.25f);
abilityStackHeight = totalButtons * buttonHeight + (totalButtons - 1) * buttonPadding;
}
float idealFirstAbilityY = height / 2f + abilityStackHeight / 2f - buttonHeight;
float minFirstAbilityY =
totalButtons == 0
? idealFirstAbilityY
: abilityBottom + (totalButtons - 1) * (buttonHeight + buttonPadding);
float maxFirstAbilityY = abilityTop - buttonHeight;
float firstAbilityY = clamp(idealFirstAbilityY, minFirstAbilityY, maxFirstAbilityY);
for (int i = 0; i < totalButtons; i++) {
abilityButtons[i] =
new Rect(
width / 2f - buttonWidth / 2f,
firstAbilityY - i * (buttonHeight + buttonPadding),
buttonWidth,
buttonHeight);
}
float backSize = 50 * uiScale;
float backPadding = 30 * uiScale;
Rect backButton =
new Rect(backPadding, height - backSize - backPadding, backSize, backSize);
return new AbilitySelectionLayout(
buttonWidth,
buttonHeight,
buttonPadding,
abilityButtons,
startGameButton,
backButton);
}
private static float clamp(float value, float min, float max) {
return Math.min(Math.max(value, min), max);
}
public float buttonWidth() {
return buttonWidth;
}
public float buttonHeight() {
return buttonHeight;
}
public float buttonPadding() {
return buttonPadding;
}
public int abilityButtonCount() {
return abilityButtons.length;
}
public Rect abilityButton(int index) {
return abilityButtons[index];
}
public Rect startGameButton() {
return startGameButton;
}
public Rect backButton() {
return backButton;
}
public static final class Rect {
private final float x;
private final float y;
private final float width;
private final float height;
private Rect(float x, float y, float width, float height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public float x() {
return x;
}
public float y() {
return y;
}
public float width() {
return width;
}
public float height() {
return height;
}
}
}
@@ -0,0 +1,45 @@
package ru.project.tower.screens.abilityselection;
public final class AbilitySelectionScaleMetrics {
private static final float BASE_WIDTH = 1280f;
private static final float BASE_HEIGHT = 720f;
private final float uiScale;
private final int adaptedFontSize;
private AbilitySelectionScaleMetrics(float uiScale, int adaptedFontSize) {
this.uiScale = uiScale;
this.adaptedFontSize = adaptedFontSize;
}
public static AbilitySelectionScaleMetrics calculate(
float viewportWidth, float viewportHeight) {
float scaleX = viewportWidth / BASE_WIDTH;
float scaleY = viewportHeight / BASE_HEIGHT;
boolean isPortrait = viewportHeight > viewportWidth;
float uiScale;
int adaptedFontSize;
if (isPortrait) {
uiScale = clamp(scaleY * 1.2f, 1.0f, 2.2f);
adaptedFontSize = (int) Math.min(32, Math.max(18, 22 * uiScale));
} else {
uiScale = clamp(Math.min(scaleX, scaleY) * 1.2f, 0.8f, 1.8f);
adaptedFontSize = (int) Math.min(28, Math.max(16, 20 * uiScale));
}
return new AbilitySelectionScaleMetrics(uiScale, adaptedFontSize);
}
private static float clamp(float value, float min, float max) {
return Math.min(Math.max(min, value), max);
}
public float uiScale() {
return uiScale;
}
public int adaptedFontSize() {
return adaptedFontSize;
}
}
@@ -0,0 +1,70 @@
package ru.project.tower.screens.abilityshop;
import ru.project.tower.abilities.AbilityKind;
public final class AbilityShopCardView {
private final String name;
private final String priceText;
private final String unlockText;
private final String detailsText;
private final String buttonText;
private final boolean buttonDisabled;
private AbilityShopCardView(
String name,
String priceText,
String unlockText,
String detailsText,
String buttonText,
boolean buttonDisabled) {
this.name = name;
this.priceText = priceText;
this.unlockText = unlockText;
this.detailsText = detailsText;
this.buttonText = buttonText;
this.buttonDisabled = buttonDisabled;
}
public static AbilityShopCardView forAbility(AbilityKind ability, boolean owned) {
return forAbility(ability, owned, ability.unlockWaveRequirement());
}
public static AbilityShopCardView forAbility(
AbilityKind ability, boolean owned, int maxWaveReached) {
boolean locked = !owned && maxWaveReached < ability.unlockWaveRequirement();
String priceText = ability.shopPrice() + " монет";
return new AbilityShopCardView(
ability.selectionName(),
priceText,
ability.unlockWaveRequirement() == 0
? "Доступно сразу"
: "Откроется: волна " + ability.unlockWaveRequirement(),
locked ? priceText + " / волна " + ability.unlockWaveRequirement() : priceText,
owned ? "КУПЛЕНО" : locked ? "ЗАКРЫТО" : "КУПИТЬ",
owned || locked);
}
public String name() {
return name;
}
public String priceText() {
return priceText;
}
public String unlockText() {
return unlockText;
}
public String detailsText() {
return detailsText;
}
public String buttonText() {
return buttonText;
}
public boolean buttonDisabled() {
return buttonDisabled;
}
}
@@ -0,0 +1,30 @@
package ru.project.tower.screens.abilityshop;
import java.util.Objects;
import ru.project.tower.GameContext;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.player.PlayerStats;
public final class AbilityShopDependencies {
private final PlayerStats playerStats;
private final PlayerAbilities playerAbilities;
public AbilityShopDependencies(PlayerStats playerStats, PlayerAbilities playerAbilities) {
this.playerStats = Objects.requireNonNull(playerStats, "playerStats");
this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities");
}
public static AbilityShopDependencies from(GameContext ctx) {
Objects.requireNonNull(ctx, "ctx");
return new AbilityShopDependencies(ctx.playerStats, ctx.playerAbilities);
}
public PlayerStats playerStats() {
return playerStats;
}
public PlayerAbilities playerAbilities() {
return playerAbilities;
}
}
@@ -0,0 +1,31 @@
package ru.project.tower.screens.abilityshop;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.player.PlayerStats;
public final class AbilityShopPurchaseService {
private AbilityShopPurchaseService() {}
public static Result tryPurchase(
AbilityKind ability, PlayerStats playerStats, PlayerAbilities playerAbilities) {
if (playerAbilities.hasAbility(ability)) {
return Result.ALREADY_OWNED;
}
if (!playerAbilities.canUnlock(ability, playerStats.getMaxWaveReached())) {
return Result.LOCKED;
}
if (!playerStats.spendCurrency(ability.shopPrice())) {
return Result.INSUFFICIENT_FUNDS;
}
playerAbilities.purchaseAbility(ability);
return Result.PURCHASED;
}
public enum Result {
PURCHASED,
ALREADY_OWNED,
LOCKED,
INSUFFICIENT_FUNDS
}
}
@@ -0,0 +1,73 @@
package ru.project.tower.screens.abilityshop;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable;
import com.badlogic.gdx.utils.Disposable;
import java.util.IdentityHashMap;
import ru.project.tower.support.ScreenAssets;
public final class AbilityShopResources implements Disposable {
private final IdentityHashMap<Color, NinePatchDrawable> cardBackgrounds =
new IdentityHashMap<>();
private ScreenAssets screenAssets;
private Skin skin;
private BitmapFont font;
private BitmapFont buttonFont;
public void rebuild() {
dispose();
screenAssets = new ScreenAssets();
skin = screenAssets.skin();
}
public ScreenAssets screenAssets() {
return screenAssets;
}
public Skin skin() {
return skin;
}
public BitmapFont font() {
return font;
}
public BitmapFont buttonFont() {
return buttonFont;
}
public void installFonts(BitmapFont font, BitmapFont buttonFont) {
this.font = screenAssets.font("default", font);
this.buttonFont = screenAssets.font("button", buttonFont);
}
public void putCardBackground(Color color, NinePatchDrawable drawable) {
cardBackgrounds.put(color, drawable);
}
public NinePatchDrawable cardBackground(Color color) {
return cardBackgrounds.get(color);
}
public boolean hasCardBackgrounds() {
return !cardBackgrounds.isEmpty();
}
public NinePatchDrawable firstCardBackground() {
return cardBackgrounds.values().iterator().next();
}
@Override
public void dispose() {
if (screenAssets != null) screenAssets.dispose();
screenAssets = null;
skin = null;
font = null;
buttonFont = null;
cardBackgrounds.clear();
}
}
@@ -0,0 +1,41 @@
package ru.project.tower.screens.abilityshop;
public final class AbilityShopScaleMetrics {
private static final float BASE_WIDTH = 1280f;
private static final float BASE_HEIGHT = 720f;
private final float uiScale;
private final int adaptedFontSize;
private AbilityShopScaleMetrics(float uiScale, int adaptedFontSize) {
this.uiScale = uiScale;
this.adaptedFontSize = adaptedFontSize;
}
public static AbilityShopScaleMetrics calculate(float viewportWidth, float viewportHeight) {
boolean isPortrait = viewportHeight > viewportWidth;
float scaleX = viewportWidth / BASE_WIDTH;
float scaleY = viewportHeight / BASE_HEIGHT;
if (isPortrait) {
float phoneScale = Math.min(viewportWidth / 360f, viewportHeight / 640f);
float uiScale = clamp(phoneScale, 0.88f, 1.25f);
return new AbilityShopScaleMetrics(uiScale, (int) clamp(18 * uiScale, 16, 22));
}
float uiScale = clamp(Math.min(scaleX, scaleY), 0.7f, 1.5f);
return new AbilityShopScaleMetrics(uiScale, (int) clamp(20 * uiScale, 18, 28));
}
public float uiScale() {
return uiScale;
}
public int adaptedFontSize() {
return adaptedFontSize;
}
private static float clamp(float value, float min, float max) {
return Math.min(max, Math.max(min, value));
}
}
@@ -0,0 +1,171 @@
package ru.project.tower.screens.common;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
public final class NeonMenuParticleSystem {
public enum Profile {
RISING(-30f, 30f, -10f, 50f, 1f, 4f, 1f, 3f, 1f, false, 0.1f),
DRIFTING(-20f, 20f, -20f, 20f, 1f, 3f, 2f, 5f, 0.5f, true, 0f);
final float minVelocityX;
final float maxVelocityX;
final float minVelocityY;
final float maxVelocityY;
final float minSize;
final float maxSize;
final float minLifetime;
final float maxLifetime;
final float alphaMultiplier;
final boolean replaceExpired;
final float spawnChance;
Profile(
float minVelocityX,
float maxVelocityX,
float minVelocityY,
float maxVelocityY,
float minSize,
float maxSize,
float minLifetime,
float maxLifetime,
float alphaMultiplier,
boolean replaceExpired,
float spawnChance) {
this.minVelocityX = minVelocityX;
this.maxVelocityX = maxVelocityX;
this.minVelocityY = minVelocityY;
this.maxVelocityY = maxVelocityY;
this.minSize = minSize;
this.maxSize = maxSize;
this.minLifetime = minLifetime;
this.maxLifetime = maxLifetime;
this.alphaMultiplier = alphaMultiplier;
this.replaceExpired = replaceExpired;
this.spawnChance = spawnChance;
}
}
private final Profile profile;
private final Array<NeonParticle> particles = new Array<>();
public NeonMenuParticleSystem() {
this(Profile.RISING);
}
public NeonMenuParticleSystem(Profile profile) {
this.profile = profile;
}
public void init(
float width,
float height,
float uiScale,
Color colorA,
Color colorB,
Color colorC,
Color colorD) {
particles.clear();
int particleCount = (int) (50 * uiScale);
for (int i = 0; i < particleCount; i++) {
spawn(
MathUtils.random(0, width),
MathUtils.random(0, height),
uiScale,
randomColor(colorA, colorB, colorC, colorD));
}
}
public void update(
float delta,
float width,
float height,
float uiScale,
Color colorA,
Color colorB,
Color colorC,
Color colorD) {
for (int i = particles.size - 1; i >= 0; i--) {
NeonParticle particle = particles.get(i);
particle.update(delta);
if (particle.lifetime <= 0f) {
particles.removeIndex(i);
if (profile.replaceExpired) {
spawn(
MathUtils.random(0, width),
MathUtils.random(0, height),
uiScale,
randomColor(colorA, colorB, colorC, colorD));
}
}
}
if (profile.spawnChance > 0f && MathUtils.random() < profile.spawnChance) {
spawn(
MathUtils.random(0, width),
0f,
uiScale,
randomColor(colorA, colorB, colorC, colorD));
}
}
public void render(ShapeRenderer shapeRenderer) {
Gdx.gl.glEnable(GL20.GL_BLEND);
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE);
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
for (NeonParticle particle : particles) {
shapeRenderer.setColor(particle.color);
shapeRenderer.circle(particle.position.x, particle.position.y, particle.size);
}
shapeRenderer.end();
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
}
private void spawn(float x, float y, float uiScale, Color color) {
particles.add(new NeonParticle(x, y, uiScale, color, profile));
}
private static Color randomColor(Color colorA, Color colorB, Color colorC, Color colorD) {
float choice = MathUtils.random();
if (choice < 0.25f) return colorA;
if (choice < 0.5f) return colorB;
if (choice < 0.75f) return colorC;
return colorD;
}
private static final class NeonParticle {
final Vector2 position;
final Vector2 velocity;
final Color color;
final float size;
final float maxLifetime;
final float alphaMultiplier;
float lifetime;
private NeonParticle(float x, float y, float uiScale, Color color, Profile profile) {
position = new Vector2(x, y);
velocity =
new Vector2(
MathUtils.random(profile.minVelocityX, profile.maxVelocityX),
MathUtils.random(profile.minVelocityY, profile.maxVelocityY));
size = MathUtils.random(profile.minSize, profile.maxSize) * uiScale;
maxLifetime = MathUtils.random(profile.minLifetime, profile.maxLifetime);
lifetime = maxLifetime;
alphaMultiplier = profile.alphaMultiplier;
this.color = new Color(color);
}
private void update(float delta) {
position.add(velocity.x * delta, velocity.y * delta);
lifetime -= delta;
color.a = (lifetime / maxLifetime) * alphaMultiplier;
}
}
}
@@ -0,0 +1,32 @@
package ru.project.tower.screens.gamescreen;
import java.util.Objects;
import ru.project.tower.entities.bullets.BulletSystem;
import ru.project.tower.entities.bullets.BulletTalentProvider;
import ru.project.tower.run.RunSessionFactory;
import ru.project.tower.support.MathUtilsRandomSource;
import ru.project.tower.support.RandomSource;
import ru.project.tower.support.ViewportSize;
public final class GameScreenComposition {
private final GameScreenDependencies dependencies;
private final RandomSource random;
public GameScreenComposition(GameScreenDependencies dependencies) {
this(dependencies, new MathUtilsRandomSource());
}
GameScreenComposition(GameScreenDependencies dependencies, RandomSource random) {
this.dependencies = Objects.requireNonNull(dependencies, "dependencies");
this.random = Objects.requireNonNull(random, "random");
}
public BulletSystem createBulletSystem() {
return new BulletSystem(BulletTalentProvider.fromTalentTree(dependencies.talentTree()));
}
public RunSessionFactory createRunSessionFactory(ViewportSize viewportSize) {
return new RunSessionFactory(random, Objects.requireNonNull(viewportSize, "viewportSize"));
}
}
@@ -0,0 +1,71 @@
package ru.project.tower.screens.gamescreen;
import java.util.Objects;
import ru.project.tower.GameContext;
import ru.project.tower.GameState;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider;
import ru.project.tower.support.GameAssetService;
import ru.project.tower.talents.TalentTree;
public final class GameScreenDependencies {
private final PlayerStats playerStats;
private final PlayerAbilities playerAbilities;
private final GameState gameState;
private final TalentTree talentTree;
private final GameAssetService assetService;
private final SafeAreaInsetsProvider safeAreaInsetsProvider;
public GameScreenDependencies(
PlayerStats playerStats,
PlayerAbilities playerAbilities,
GameState gameState,
TalentTree talentTree,
GameAssetService assetService,
SafeAreaInsetsProvider safeAreaInsetsProvider) {
this.playerStats = Objects.requireNonNull(playerStats, "playerStats");
this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities");
this.gameState = Objects.requireNonNull(gameState, "gameState");
this.talentTree = Objects.requireNonNull(talentTree, "talentTree");
this.assetService = Objects.requireNonNull(assetService, "assetService");
this.safeAreaInsetsProvider =
Objects.requireNonNull(safeAreaInsetsProvider, "safeAreaInsetsProvider");
}
public static GameScreenDependencies from(GameContext ctx) {
Objects.requireNonNull(ctx, "ctx");
return new GameScreenDependencies(
ctx.playerStats,
ctx.playerAbilities,
ctx.gameState,
ctx.talentTree,
ctx.assetService,
ctx.safeAreaInsetsProvider);
}
public PlayerStats playerStats() {
return playerStats;
}
public PlayerAbilities playerAbilities() {
return playerAbilities;
}
public GameState gameState() {
return gameState;
}
public TalentTree talentTree() {
return talentTree;
}
public GameAssetService assetService() {
return assetService;
}
public SafeAreaInsetsProvider safeAreaInsetsProvider() {
return safeAreaInsetsProvider;
}
}
@@ -0,0 +1,122 @@
package ru.project.tower.screens.gamescreen.abilities;
import com.badlogic.gdx.graphics.Color;
import java.util.Objects;
import ru.project.tower.abilities.AbilityHost;
import ru.project.tower.abilities.Turret;
import ru.project.tower.entities.bullets.BulletSystem;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.run.GameplayEventSink;
import ru.project.tower.systems.EnemyClassifier;
import ru.project.tower.systems.PlayerShootingSystem;
public final class GameScreenAbilityHost implements AbilityHost {
private final Host host;
private final BulletSystem bulletSystem;
private final PlayerShootingSystem playerShootingSystem;
private final GameplayEventSink gameplayEventSink;
public GameScreenAbilityHost(
Host host,
BulletSystem bulletSystem,
PlayerShootingSystem playerShootingSystem,
GameplayEventSink gameplayEventSink) {
this.host = Objects.requireNonNull(host, "host");
this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem");
this.playerShootingSystem =
Objects.requireNonNull(playerShootingSystem, "playerShootingSystem");
this.gameplayEventSink = Objects.requireNonNull(gameplayEventSink, "gameplayEventSink");
}
@Override
public void addFloatingText(float x, float y, String text, Color color) {
host.addFloatingText(x, y, text, color);
}
@Override
public void createExplosion(float x, float y, Color color) {
host.createExplosion(x, y, color);
}
@Override
public void triggerKillShake() {
host.triggerKillShake();
}
@Override
public void triggerCritHitstop() {
host.triggerCritHitstop();
}
@Override
public int effectiveBulletDamage() {
return bulletSystem.getEffectiveDamage(host.activeBonusDamageMultiplier());
}
@Override
public float abilityScalingFactor() {
return host.abilityScalingFactor();
}
@Override
public float gameScale() {
return host.gameScale();
}
@Override
public long abilityCooldownReductionMs() {
return host.abilityCooldownReductionMs();
}
@Override
public boolean isBoss(BaseCircleData circle) {
return EnemyClassifier.isBoss(circle);
}
@Override
public void addMoney(int amount) {
host.addMoney(amount);
}
@Override
public void killEnemy(BaseCircleData enemy) {
host.killEnemy(enemy);
}
@Override
public void onTurretShot(Turret turret, BaseCircleData target, long now) {
playerShootingSystem.shootTurretAtTarget(
turret,
target,
bulletSystem,
gameplayEventSink,
host.adaptedBulletRadius(),
host.gameScale() * host.activeBonusSpeedMultiplier());
}
public interface Host {
void addFloatingText(float x, float y, String text, Color color);
void createExplosion(float x, float y, Color color);
void triggerKillShake();
void triggerCritHitstop();
float activeBonusDamageMultiplier();
float abilityScalingFactor();
float gameScale();
long abilityCooldownReductionMs();
void addMoney(int amount);
void killEnemy(BaseCircleData enemy);
float activeBonusSpeedMultiplier();
float adaptedBulletRadius();
}
}

Some files were not shown because too many files have changed in this diff Show More