Initial commit
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+145
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
@@ -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
Reference in New Issue
Block a user