Initial commit

This commit is contained in:
2026-06-16 10:57:01 +03:00
commit 1cd5c8b03c
277 changed files with 42921 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# https://editorconfig.org
root = true
[*]
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{java,scala,groovy,kt,kts}]
indent_size = 4
[*.gradle]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+20
View File
@@ -0,0 +1,20 @@
# Gradle / build
.gradle/
build/
*/build/
out/
# Android
local.properties
*.apk
*.aab
.cxx/
captures/
# IntelliJ / Android Studio
.idea/
*.iml
# OS
.DS_Store
Thumbs.db
+35
View File
@@ -0,0 +1,35 @@
# Tower
A [libGDX](https://libgdx.com/) project generated with [gdx-liftoff](https://github.com/libgdx/gdx-liftoff).
This project was generated with a template including simple application launchers and an `ApplicationAdapter` extension that draws libGDX logo.
## Platforms
- `core`: Main module with the application logic shared by all platforms.
- `lwjgl3`: Primary desktop platform using LWJGL3; was called 'desktop' in older docs.
- `android`: Android mobile platform. Needs Android SDK.
## Gradle
This project uses [Gradle](https://gradle.org/) to manage dependencies.
The Gradle wrapper was included, so you can run Gradle tasks using `gradlew.bat` or `./gradlew` commands.
Useful Gradle tasks and flags:
- `--continue`: when using this flag, errors will not stop the tasks from running.
- `--daemon`: thanks to this flag, Gradle daemon will be used to run chosen tasks.
- `--offline`: when using this flag, cached dependency archives will be used.
- `--refresh-dependencies`: this flag forces validation of all dependencies. Useful for snapshot versions.
- `android:lint`: performs Android project validation.
- `build`: builds sources and archives of every project.
- `cleanEclipse`: removes Eclipse project data.
- `cleanIdea`: removes IntelliJ project data.
- `clean`: removes `build` folders, which store compiled classes and built archives.
- `eclipse`: generates Eclipse project data.
- `idea`: generates IntelliJ project data.
- `lwjgl3:jar`: builds application's runnable jar, which can be found at `lwjgl3/build/libs`.
- `lwjgl3:run`: starts the application.
- `test`: runs unit tests (if any).
Note that most tasks that are not specific to a single project can be run with `name:` prefix, where the `name` should be replaced with the ID of a specific project.
For example, `core:clean` removes `build` folder only from the `core` project.
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:glEsVersion="0x00020000" android:required="true"/>
<application
android:allowBackup="true"
android:fullBackupContent="true"
android:icon="@drawable/ic_launcher"
android:isGame="true"
android:appCategory="game"
android:label="@string/app_name"
tools:ignore="UnusedAttribute"
android:theme="@style/GdxTheme">
<activity
android:name="ru.project.tower.android.AndroidLauncher"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
+130
View File
@@ -0,0 +1,130 @@
buildscript {
repositories {
mavenCentral()
google()
}
}
apply plugin: 'com.android.application'
android {
namespace "ru.project.tower"
compileSdk 35
sourceSets {
main {
manifest.srcFile 'AndroidManifest.xml'
java.setSrcDirs(['src/main/java'])
aidl.setSrcDirs(['src/main/java'])
renderscript.setSrcDirs(['src/main/java'])
res.setSrcDirs(['res'])
assets.setSrcDirs(['../assets'])
jniLibs.setSrcDirs(['libs'])
}
}
packagingOptions {
resources {
excludes += ['META-INF/robovm/ios/robovm.xml', 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES',
'META-INF/dependencies.txt', '**/*.gwt.xml']
pickFirsts += ['META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/license.txt', 'META-INF/LGPL2.1',
'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/notice.txt']
}
}
defaultConfig {
applicationId 'ru.project.tower'
minSdkVersion 19
targetSdkVersion 35
versionCode 1
versionName "1.0"
multiDexEnabled true
}
compileOptions {
sourceCompatibility "8"
targetCompatibility "8"
coreLibraryDesugaringEnabled true
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
// needed for AAPT2, may be needed for other tools
google()
}
configurations { natives }
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation "com.badlogicgames.gdx:gdx-backend-android:$gdxVersion"
implementation project(':core')
natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-arm64-v8a"
natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-armeabi-v7a"
natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86"
natives "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86_64"
natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a"
natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a"
natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86"
natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86_64"
}
// Called every time gradle gets executed, takes the native dependencies of
// the natives configuration, and extracts them to the proper libs/ folders
// so they get packed with the APK.
tasks.register('copyAndroidNatives') {
doFirst {
file("libs/armeabi-v7a/").mkdirs()
file("libs/arm64-v8a/").mkdirs()
file("libs/x86_64/").mkdirs()
file("libs/x86/").mkdirs()
configurations.natives.copy().files.each { jar ->
def outputDir = null
if(jar.name.endsWith("natives-armeabi-v7a.jar")) outputDir = file("libs/armeabi-v7a")
if(jar.name.endsWith("natives-arm64-v8a.jar")) outputDir = file("libs/arm64-v8a")
if(jar.name.endsWith("natives-x86_64.jar")) outputDir = file("libs/x86_64")
if(jar.name.endsWith("natives-x86.jar")) outputDir = file("libs/x86")
if(outputDir != null) {
copy {
from zipTree(jar)
into outputDir
include "*.so"
}
}
}
}
}
tasks.matching { it.name.contains("merge") && it.name.contains("JniLibFolders") }.configureEach { packageTask ->
packageTask.dependsOn 'copyAndroidNatives'
}
tasks.register('run', Exec) {
def path
def localProperties = project.file("../local.properties")
if (localProperties.exists()) {
Properties properties = new Properties()
localProperties.withInputStream { instr ->
properties.load(instr)
}
def sdkDir = properties.getProperty('sdk.dir')
if (sdkDir) {
path = sdkDir
} else {
path = "$System.env.ANDROID_SDK_ROOT"
}
} else {
path = "$System.env.ANDROID_SDK_ROOT"
}
def adb = path + "/platform-tools/adb"
commandLine "$adb", 'shell', 'am', 'start', '-n', 'ru.project.tower/ru.project.tower.android.AndroidLauncher'
}
eclipse.project.name = appName + "-android"
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
# To enable ProGuard in your project, edit project.properties
# to define the proguard.config property as described in that file.
#
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in ${sdk.dir}/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the ProGuard
# include property in project.properties.
#
# For more details, see
# https://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
-verbose
-dontwarn android.support.**
-dontwarn com.badlogic.gdx.backends.android.AndroidFragmentApplication
# Needed by the gdx-controllers official extension.
-keep class com.badlogic.gdx.controllers.android.AndroidControllers
# Needed by the Box2D official extension.
-keepclassmembers class com.badlogic.gdx.physics.box2d.World {
boolean contactFilter(long, long);
void beginContact(long);
void endContact(long);
void preSolve(long, long);
void postSolve(long, long);
boolean reportFixture(long);
float reportRayFixture(long, float, float, float, float, float);
}
# You will need the next three lines if you use scene2d for UI or gameplay.
# If you don't use scene2d at all, you can remove or comment out the next line:
-keep public class com.badlogic.gdx.scenes.scene2d.** { *; }
# You will need the next two lines if you use BitmapFont or any scene2d.ui text:
-keep public class com.badlogic.gdx.graphics.g2d.BitmapFont { *; }
# You will probably need this line in most cases:
-keep public class com.badlogic.gdx.graphics.Color { *; }
# These two lines are used with mapping files; see https://developer.android.com/build/shrink-code#retracing
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically generated by Android Tools.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file must be checked in Version Control Systems.
#
# To customize properties used by the Ant build system edit
# "ant.properties", and override values to adapt the script to your
# project structure.
#
# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home):
#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-rules.pro
# Project target.
target=android-19
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_background_color"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M457.78,372.5l-201.78,116.5l-201.78,-116.5l0,-233l201.78,-116.5l201.78,116.5z"
android:strokeWidth="23"
android:fillColor="#4a4a4a"
android:strokeColor="#000000"/>
<group>
<clip-path
android:pathData="M449.71,363.84l-193.71,111.84l-193.71,-111.84l0,-223.68l193.71,-111.84l193.71,111.84z"/>
<path
android:pathData="M256,67.33c-0.36,0.13 -1.07,0.4 -2,0.99 -1.67,1.07 -3.94,2.89 -6.4,5.26 -4.93,4.75 -10.72,11.66 -16.1,19.34a142.77,142.77 0,0 0,-2.95 4.41c9.43,-1.86 18.44,-2.8 27.45,-2.8 9.01,0 18.03,0.94 27.45,2.8a142.77,142.77 135,0 0,-2.95 -4.41c-5.38,-7.68 -11.17,-14.6 -16.1,-19.34 -2.46,-2.37 -4.73,-4.2 -6.4,-5.26a9.93,9.93 135,0 0,-2 -0.99zM256,108.66c-11.75,0 -23.5,1.9 -36.98,5.69C214.74,123.36 212,131.66 212,136.8l0,127.71c4.08,-13.81 7.7,-30.59 11.2,-51.52L223.2,200.8c0,-8.8 3.65,-18.74 9.03,-27.7 2.69,-4.48 5.85,-8.63 9.66,-11.94C245.69,157.86 250.4,155.2 256,155.2s10.31,2.66 14.12,5.96c3.81,3.3 6.97,7.45 9.66,11.94C285.15,182.06 288.8,192 288.8,200.8l0,12.19c3.5,20.94 7.12,37.71 11.2,51.52L300,136.8c0,-5.14 -2.74,-13.44 -7.02,-22.45 -13.47,-3.79 -25.23,-5.69 -36.98,-5.69zM185.6,150.71c-5.21,9.21 -12,23.48 -12,37.29l0,31.2l24,0l0,-31.2c0,-13.81 -6.79,-28.08 -12,-37.29zM326.4,150.71c-5.21,9.21 -12,23.48 -12,37.29l0,31.2l24,0l0,-31.2c0,-13.81 -6.79,-28.08 -12,-37.29zM256,169.6c-0.8,0 -2.49,0.54 -4.68,2.44 -2.19,1.9 -4.63,4.95 -6.74,8.46C240.35,187.54 237.6,196.8 237.6,200.8l0,131.02l-2.16,2.12c-8.49,8.33 -10.26,28.91 -10.52,42.22 5.34,2.83 14.26,5.13 23.88,5.94L248.8,341.6l14.4,0l0,40.49c9.62,-0.8 18.54,-3.11 23.88,-5.94 -0.25,-13.27 -1.97,-33.66 -10.57,-42.26L274.4,331.78L274.4,200.8c0,-4 -2.75,-13.26 -6.97,-20.3 -2.11,-3.52 -4.55,-6.57 -6.74,-8.46C258.49,170.14 256.8,169.6 256,169.6zM243.2,193.6l25.6,0l0,14.4l-25.6,0l0,-14.4zM173.6,233.6l0,93.05c9.94,-8.52 17.6,-17.06 24,-27.75L197.6,233.6l-24,0zM314.4,233.6l0,65.3c6.4,10.69 14.06,19.24 24,27.75L338.4,233.6l-24,0zM223.2,276.78c-2.81,8.32 -5.87,15.8 -9.36,22.54 -7.44,14.41 -16.73,25.6 -28.37,36.03l30.94,1.55c1.73,-3.88 3.94,-7.57 6.78,-10.87l0,-49.24zM288.8,276.78l0,49.28c2.81,3.3 5,6.98 6.72,10.84l31,-1.55c-11.64,-10.42 -20.92,-21.62 -28.37,-36.03 -3.48,-6.74 -6.55,-14.22 -9.36,-22.54zM446.4,289.61c-10.84,4.52 -15.22,15.65 -21.56,33.46 0,0 -36.65,0.34 -42.7,14.05 -6.46,14.65 19.67,43.83 19.67,43.83s-21.71,-6.98 -30.34,-1.69c-9.59,5.88 -13.61,30.87 -13.61,30.87 -2.83,-0.68 -5.52,-1.24 -8.11,-1.71L341.33,383.2l8.53,0l-4.79,-8.77 -42.81,2.14 -0.65,1.2c-0,0.73 -0.01,1.56 -0.01,2.22l0,3.2l5.6,0l-8.53,25.6 17.07,-12.8 4,12.01c-11.83,2.51 -20.44,8.55 -28.72,16.43L281.6,405.6l12.8,0l-10.24,-12.49C275.61,395.79 265.83,396.8 256,396.8c-9.85,0 -19.66,-1.02 -28.22,-3.7L217.6,405.6l12.8,0l-10.31,20.62c-9.71,-4.02 -20.02,-8.02 -30.08,-11.44L196.27,396l17.07,12.8L204.8,383.2l5.6,0l0,-3.2c0,-0.65 -0.01,-1.47 -0.01,-2.18l-0.68,-1.24 -42.82,-2.14 -4.76,8.77l8.53,0l-7.96,23.89c-3.19,-0.65 -6.24,-1.16 -9.1,-1.49 -25.25,-2.9 -31.78,3.71 -46.35,9.93 0,0 13.33,-77.26 -13.35,-94.7 -3.3,-2.16 -7.43,-3.03 -11.99,-2.98 -5.06,0.04 -10.67,1.22 -16.31,2.99L65.6,442.4l189.6,0l0.8,1.6 12.8,-25.6 18.79,9.4c-4.45,4.5 -8.93,9.46 -13.88,14.6L446.4,442.4L446.4,289.61zM168.82,348.93c-4.32,3.9 -5.52,7.7 -5.45,9.24 0.04,0.88 0.19,1 0.41,1.19 0.22,0.19 0.81,0.63 2.63,0.63l0.18,0l44.16,2.21c0.25,-3.58 0.68,-7.33 1.39,-11.11l-43.31,-2.17zM343.18,348.93l-43.35,2.17c0.72,3.78 1.15,7.53 1.41,11.11l44.18,-2.21L345.6,360c1.82,0 2.42,-0.44 2.63,-0.63 0.22,-0.19 0.37,-0.32 0.41,-1.19 0.06,-1.54 -1.14,-5.34 -5.45,-9.24zM179.2,396l5.7,17.09c-7.29,-2.35 -14.35,-4.32 -20.86,-5.72L179.2,396zM332.8,396l16.44,12.33c-7.58,-1.34 -14.17,-1.78 -20.05,-1.5L332.8,396zM243.2,418.4l11.64,23.27c-7.9,-3.74 -18.63,-8.66 -30.68,-13.75L243.2,418.4z"
android:fillColor="#ffda54"/>
</group>
<path
android:pathData="M135.76,190.05L112.46,190.05v-15.61q0,-6.81 -0.77,-8.47 -0.72,-1.72 -3.26,-1.72 -2.88,0 -3.65,2.05 -0.77,2.05 -0.77,8.85v41.61q0,6.53 0.77,8.52 0.77,1.99 3.49,1.99 2.6,0 3.38,-1.99 0.83,-1.99 0.83,-9.35v-11.23h23.3v3.49q0,13.89 -1.99,19.7 -1.94,5.81 -8.69,10.18 -6.7,4.37 -16.55,4.37 -10.24,0 -16.88,-3.71 -6.64,-3.71 -8.8,-10.24 -2.16,-6.59 -2.16,-19.76L80.7,182.58q0,-9.68 0.66,-14.5 0.66,-4.87 3.93,-9.35 3.32,-4.48 9.13,-7.03 5.87,-2.6 13.45,-2.6 10.29,0 16.99,3.98 6.7,3.98 8.8,9.96 2.1,5.92 2.1,18.48zM197.68,150.98v89.59h-23.3v-37.63h-6.97v37.63h-23.3v-89.59h23.3v32.04h6.97v-32.04zM247.27,150.98 L260.6,240.57L236.75,240.57l-1.16,-16.1h-8.36l-1.38,16.1h-24.13l11.84,-89.59zM234.93,208.59q-1.77,-15.22 -3.54,-37.57 -3.54,25.68 -4.43,37.57zM316.6,150.98v89.59h-20.42l-12.12,-40.73v40.73h-19.48v-89.59h19.48l13.06,40.34v-40.34zM379.41,183.96h-23.3v-8.13q0,-7.69 -0.66,-9.63 -0.66,-1.94 -3.15,-1.94 -2.16,0 -2.93,1.66 -0.77,1.66 -0.77,8.52v43q0,6.03 0.77,7.97 0.77,1.88 3.1,1.88 2.55,0 3.43,-2.16 0.94,-2.16 0.94,-8.41v-10.62h-4.7v-13.61h27.28v48.09h-14.66l-2.16,-6.42q-2.38,4.15 -6.03,6.25 -3.6,2.05 -8.52,2.05 -5.87,0 -11.01,-2.82 -5.09,-2.88 -7.75,-7.08 -2.66,-4.21 -3.32,-8.8 -0.66,-4.65 -0.66,-13.89v-26.62q0,-12.84 1.38,-18.65 1.38,-5.81 7.91,-10.62 6.59,-4.87 16.99,-4.87 10.24,0 16.99,4.21 6.75,4.21 8.8,10.02 2.05,5.76 2.05,16.77zM388.43,150.98h38.85v17.93h-15.55v16.99h14.55v17.04h-14.55v19.7h17.1v17.93L388.43,240.57Z"
android:strokeWidth="6.1"
android:fillColor="#f30d0d"
android:strokeColor="#000000"/>
<path
android:pathData="m202.17,286.98v17.93h-13.83v71.66h-23.3v-71.66h-13.78v-17.93zM261.05,286.98v89.59h-23.3v-37.63h-6.97v37.63L207.48,376.57v-89.59h23.3v32.04h6.97v-32.04zM293.69,286.98v89.59h-23.3v-89.59zM352.41,314.09L330.77,314.09v-6.64q0,-4.65 -0.83,-5.92 -0.83,-1.27 -2.77,-1.27 -2.1,0 -3.21,1.72 -1.05,1.72 -1.05,5.2 0,4.48 1.22,6.75 1.16,2.27 6.59,5.48 15.55,9.24 19.59,15.16 4.04,5.92 4.04,19.09 0,9.57 -2.27,14.11 -2.21,4.54 -8.63,7.64 -6.42,3.04 -14.94,3.04 -9.35,0 -15.99,-3.54 -6.59,-3.54 -8.63,-9.02 -2.05,-5.48 -2.05,-15.55v-5.87h21.64v10.9q0,5.04 0.89,6.47 0.94,1.44 3.26,1.44 2.32,0 3.43,-1.83 1.16,-1.83 1.16,-5.42 0,-7.91 -2.16,-10.35 -2.21,-2.43 -10.9,-8.13 -8.69,-5.76 -11.51,-8.36 -2.82,-2.6 -4.7,-7.19 -1.83,-4.59 -1.83,-11.73 0,-10.29 2.6,-15.05 2.66,-4.76 8.52,-7.42 5.87,-2.71 14.17,-2.71 9.08,0 15.44,2.93 6.42,2.93 8.47,7.42 2.1,4.43 2.1,15.11z"
android:strokeWidth="6.1"
android:fillColor="#f30d0d"
android:strokeColor="#000000"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_background_color">#F5A623FF</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tower</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<resources>
<style name="GdxTheme" parent="android:Theme">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowAnimationStyle">@android:style/Animation</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
@@ -0,0 +1,18 @@
package ru.project.tower.android;
import android.os.Bundle;
import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;
import ru.project.tower.Main;
/** Launches the Android application. */
public class AndroidLauncher extends AndroidApplication {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AndroidApplicationConfiguration configuration = new AndroidApplicationConfiguration();
configuration.useImmersiveMode = true; // Recommended, but not required.
initialize(new Main(), configuration);
}
}
+3
View File
@@ -0,0 +1,3 @@
This file can be deleted if there are any other files present in this folder.
It is only here to ensure this folder is added to Git, instead of being
ignored because it is empty.
+9
View File
@@ -0,0 +1,9 @@
.gitkeep
hs_err_pid15412.log
libgdx.png
pop.mp3
pop.wav
roboto.ttf
shot.mp3
shot.wav
ui.ttf
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
buildscript {
repositories {
mavenCentral()
maven { url 'https://s01.oss.sonatype.org' }
gradlePluginPortal()
mavenLocal()
google()
maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
}
dependencies {
classpath "com.android.tools.build:gradle:8.6.1"
}
}
allprojects {
apply plugin: 'eclipse'
apply plugin: 'idea'
// This allows you to "Build and run using IntelliJ IDEA", an option in IDEA's Settings.
idea {
module {
outputDir file('build/classes/java/main')
testOutputDir file('build/classes/java/test')
}
}
}
configure(subprojects - project(':android')) {
apply plugin: 'java-library'
sourceCompatibility = 8
// From https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/
// The article can be helpful when using assets.txt in your project.
tasks.register('generateAssetList') {
inputs.dir("${project.rootDir}/assets/")
// projectFolder/assets
File assetsFolder = new File("${project.rootDir}/assets/")
// projectFolder/assets/assets.txt
File assetsFile = new File(assetsFolder, "assets.txt")
// delete that file in case we've already created it
assetsFile.delete()
// iterate through all files inside that folder
// convert it to a relative path
// and append it to the file assets.txt
fileTree(assetsFolder).collect { assetsFolder.relativePath(it) }.sort().each {
assetsFile.append(it + "\n")
}
}
processResources.dependsOn 'generateAssetList'
compileJava {
options.incremental = true
}
}
subprojects {
version = "$projectVersion"
ext.appName = 'Tower'
repositories {
mavenCentral()
maven { url 'https://s01.oss.sonatype.org' }
// You may want to remove the following line if you have errors downloading dependencies.
mavenLocal()
maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
maven { url 'https://jitpack.io' }
}
}
eclipse.project.name = 'Tower' + '-parent'
+18
View File
@@ -0,0 +1,18 @@
[compileJava, compileTestJava]*.options*.encoding = 'UTF-8'
eclipse.project.name = appName + '-core'
dependencies {
api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion"
api "com.badlogicgames.gdx:gdx:$gdxVersion"
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.assertj:assertj-core:3.26.3'
if(enableGraalNative == 'true') {
implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion"
}
}
test {
useJUnitPlatform()
}
@@ -0,0 +1,17 @@
package ru.project.tower;
import com.badlogic.gdx.Gdx;
public final class DebugLog {
public static final boolean DEBUG = false;
private DebugLog() {}
public static void log(String tag, String message) {
if (DEBUG) Gdx.app.log(tag, message);
}
public static void log(String tag, String message, Throwable t) {
if (DEBUG) Gdx.app.log(tag, message, t);
}
}
@@ -0,0 +1,61 @@
package ru.project.tower;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.utils.Disposable;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider;
import ru.project.tower.support.GameAssetService;
import ru.project.tower.support.LibGdxAssetService;
import ru.project.tower.talents.TalentTree;
public class GameContext implements Disposable {
public final Preferences playerDataPrefs;
public final Preferences abilitiesPrefs;
public final GameAssetService assetService;
public final SafeAreaInsetsProvider safeAreaInsetsProvider;
public final PlayerStats playerStats;
public final PlayerAbilities playerAbilities;
public final GameState gameState;
public final TalentTree talentTree;
public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) {
this(
playerDataPrefs,
abilitiesPrefs,
new LibGdxAssetService(),
SafeAreaInsetsProvider.none());
}
public GameContext(
Preferences playerDataPrefs,
Preferences abilitiesPrefs,
GameAssetService assetService) {
this(playerDataPrefs, abilitiesPrefs, assetService, SafeAreaInsetsProvider.none());
}
public GameContext(
Preferences playerDataPrefs,
Preferences abilitiesPrefs,
GameAssetService assetService,
SafeAreaInsetsProvider safeAreaInsetsProvider) {
this.playerDataPrefs = playerDataPrefs;
this.abilitiesPrefs = abilitiesPrefs;
this.assetService = assetService;
this.safeAreaInsetsProvider = safeAreaInsetsProvider;
this.playerStats = new PlayerStats(playerDataPrefs);
this.playerAbilities = new PlayerAbilities(abilitiesPrefs);
this.gameState = new GameState();
this.talentTree = new TalentTree(this.playerStats);
}
@Override
public void dispose() {
assetService.dispose();
}
}
@@ -0,0 +1,367 @@
package ru.project.tower;
import com.badlogic.gdx.utils.Array;
import java.util.Objects;
import java.util.Optional;
import ru.project.tower.abilities.AbilityKind;
public class GameState {
private boolean returnedFromShop = false;
private Array<AbilityKind> selectedAbilities = new Array<>();
private RunSnapshot savedRunSnapshot;
private Array<String> spawnedBosses = new Array<>();
public GameState() {}
public void saveGameState(Snapshot snapshot) {
Snapshot nextSnapshot = Objects.requireNonNull(snapshot, "snapshot");
returnedFromShop = true;
savedRunSnapshot = new RunSnapshot(nextSnapshot, selectedAbilities, spawnedBosses);
}
public Snapshot restoreGameState() {
if (!returnedFromShop) {
return null;
}
if (savedRunSnapshot == null) {
return null;
}
selectedAbilities.clear();
selectedAbilities.addAll(savedRunSnapshot.selectedAbilities);
spawnedBosses.clear();
spawnedBosses.addAll(savedRunSnapshot.spawnedBosses);
return savedRunSnapshot.snapshot;
}
public void resetGameState() {
DebugLog.log("GameState", "Resetting game state");
returnedFromShop = false;
selectedAbilities.clear();
spawnedBosses.clear();
savedRunSnapshot = null;
DebugLog.log("GameState", "Cleared selected abilities");
}
public boolean isReturnedFromShop() {
return returnedFromShop;
}
public void setSelectedAbilities(Array<AbilityKind> abilities) {
DebugLog.log("GameState", "Setting selected abilities, count: " + abilities.size);
selectedAbilities.clear();
selectedAbilities.addAll(abilities);
for (AbilityKind ability : selectedAbilities) {
DebugLog.log("GameState", "Added ability: " + ability.persistedId());
}
}
public void setSelectedAbilityIds(Array<String> abilityIds) {
DebugLog.log("GameState", "Setting selected ability ids, count: " + abilityIds.size);
selectedAbilities.clear();
for (String abilityId : abilityIds) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(abilityId);
if (kind.isPresent()) {
selectedAbilities.add(kind.get());
DebugLog.log("GameState", "Added ability: " + abilityId);
} else {
DebugLog.log("GameState", "Ignoring unknown ability id: " + abilityId);
}
}
}
public Array<AbilityKind> getSelectedAbilities() {
DebugLog.log("GameState", "Getting selected abilities, count: " + selectedAbilities.size);
return copyOf(selectedAbilities);
}
public Array<String> getSelectedAbilityIds() {
Array<String> abilityIds = new Array<>();
for (AbilityKind kind : selectedAbilities) {
abilityIds.add(kind.persistedId());
}
return abilityIds;
}
public Array<String> getSpawnedBosses() {
return copyOf(spawnedBosses);
}
public void setSpawnedBosses(Array<String> spawnedBosses) {
this.spawnedBosses.clear();
this.spawnedBosses.addAll(spawnedBosses);
}
private static <T> Array<T> copyOf(Array<T> source) {
Array<T> copy = new Array<>();
copy.addAll(source);
return copy;
}
private static final class RunSnapshot {
private final Snapshot snapshot;
private final Array<AbilityKind> selectedAbilities;
private final Array<String> spawnedBosses;
private RunSnapshot(
Snapshot snapshot,
Array<AbilityKind> selectedAbilities,
Array<String> spawnedBosses) {
this.snapshot = Objects.requireNonNull(snapshot, "snapshot");
this.selectedAbilities = copyOf(selectedAbilities);
this.spawnedBosses = copyOf(spawnedBosses);
}
}
public static final class Snapshot {
private final int money;
private final int runCurrencyEarned;
private final HealthSnapshot health;
private final BulletSnapshot bulletStats;
private final UpgradeSnapshot upgrades;
private final WaveSnapshot wave;
private final ru.project.tower.run.RunRngStreams.Snapshot rng;
public Snapshot(
int money,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave) {
this(money, health, bulletStats, upgrades, wave, null);
}
public Snapshot(
int money,
int runCurrencyEarned,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave) {
this(money, runCurrencyEarned, health, bulletStats, upgrades, wave, null);
}
public Snapshot(
int money,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave,
ru.project.tower.run.RunRngStreams.Snapshot rng) {
this(money, 0, health, bulletStats, upgrades, wave, rng);
}
public Snapshot(
int money,
int runCurrencyEarned,
HealthSnapshot health,
BulletSnapshot bulletStats,
UpgradeSnapshot upgrades,
WaveSnapshot wave,
ru.project.tower.run.RunRngStreams.Snapshot rng) {
this.money = money;
this.runCurrencyEarned = Math.max(0, runCurrencyEarned);
this.health = Objects.requireNonNull(health, "health");
this.bulletStats = Objects.requireNonNull(bulletStats, "bulletStats");
this.upgrades = Objects.requireNonNull(upgrades, "upgrades");
this.wave = Objects.requireNonNull(wave, "wave");
this.rng = rng;
}
public int getMoney() {
return money;
}
public int getRunCurrencyEarned() {
return runCurrencyEarned;
}
public HealthSnapshot getHealth() {
return health;
}
public BulletSnapshot getBulletStats() {
return bulletStats;
}
public UpgradeSnapshot getUpgrades() {
return upgrades;
}
public WaveSnapshot getWave() {
return wave;
}
public ru.project.tower.run.RunRngStreams.Snapshot getRng() {
return rng;
}
}
public static final class HealthSnapshot {
private final int squareHealth;
private final int maxSquareHealth;
private final int healthRegenRate;
public HealthSnapshot(int squareHealth, int maxSquareHealth, int healthRegenRate) {
this.squareHealth = squareHealth;
this.maxSquareHealth = maxSquareHealth;
this.healthRegenRate = healthRegenRate;
}
public int getSquareHealth() {
return squareHealth;
}
public int getMaxSquareHealth() {
return maxSquareHealth;
}
public int getHealthRegenRate() {
return healthRegenRate;
}
}
public static final class BulletSnapshot {
private final float bulletSpeed;
private final int bulletDamage;
private final float shotCooldown;
public BulletSnapshot(float bulletSpeed, int bulletDamage, float shotCooldown) {
this.bulletSpeed = bulletSpeed;
this.bulletDamage = bulletDamage;
this.shotCooldown = shotCooldown;
}
public float getBulletSpeed() {
return bulletSpeed;
}
public int getBulletDamage() {
return bulletDamage;
}
public float getShotCooldown() {
return shotCooldown;
}
}
public static final class UpgradeSnapshot {
private final int healthUpgradeLevel;
private final int damageUpgradeLevel;
private final int speedUpgradeLevel;
private final int cooldownUpgradeLevel;
private final int regenUpgradeLevel;
private final int critChanceUpgradeLevel;
private final int critMultiplierUpgradeLevel;
public UpgradeSnapshot(
int healthUpgradeLevel,
int damageUpgradeLevel,
int speedUpgradeLevel,
int cooldownUpgradeLevel,
int regenUpgradeLevel,
int critChanceUpgradeLevel,
int critMultiplierUpgradeLevel) {
this.healthUpgradeLevel = healthUpgradeLevel;
this.damageUpgradeLevel = damageUpgradeLevel;
this.speedUpgradeLevel = speedUpgradeLevel;
this.cooldownUpgradeLevel = cooldownUpgradeLevel;
this.regenUpgradeLevel = regenUpgradeLevel;
this.critChanceUpgradeLevel = critChanceUpgradeLevel;
this.critMultiplierUpgradeLevel = critMultiplierUpgradeLevel;
}
public int getHealthUpgradeLevel() {
return healthUpgradeLevel;
}
public int getDamageUpgradeLevel() {
return damageUpgradeLevel;
}
public int getSpeedUpgradeLevel() {
return speedUpgradeLevel;
}
public int getCooldownUpgradeLevel() {
return cooldownUpgradeLevel;
}
public int getRegenUpgradeLevel() {
return regenUpgradeLevel;
}
public int getCritChanceUpgradeLevel() {
return critChanceUpgradeLevel;
}
public int getCritMultiplierUpgradeLevel() {
return critMultiplierUpgradeLevel;
}
}
public static final class WaveSnapshot {
private final int currentWave;
private final boolean waveActive;
private final long waveStartTime;
private final long enemiesSpawnedThisWave;
private final long enemiesPerWave;
public WaveSnapshot(
int currentWave,
boolean waveActive,
long waveStartTime,
long enemiesSpawnedThisWave,
long enemiesPerWave) {
this.currentWave = currentWave;
this.waveActive = waveActive;
this.waveStartTime = waveStartTime;
this.enemiesSpawnedThisWave = enemiesSpawnedThisWave;
this.enemiesPerWave = enemiesPerWave;
}
public int getCurrentWave() {
return currentWave;
}
public boolean isWaveActive() {
return waveActive;
}
public long getWaveStartTime() {
return waveStartTime;
}
public long getEnemiesSpawnedThisWave() {
return enemiesSpawnedThisWave;
}
public long getEnemiesPerWave() {
return enemiesPerWave;
}
}
}
@@ -0,0 +1,65 @@
package ru.project.tower;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import java.util.Objects;
import ru.project.tower.navigation.ScreenNavigation;
import ru.project.tower.navigation.ScreenNavigator;
import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider;
public class Main extends Game {
private static final String PLAYER_DATA_PREFS_NAME = "tower_attack_player_data";
private static final String ABILITIES_PREFS_NAME = "tower_attack_player_abilities";
private GameContext ctx;
private ScreenNavigator screenNavigator;
private final SafeAreaInsetsProvider safeAreaInsetsProvider;
public Main() {
this(SafeAreaInsetsProvider.none());
}
public Main(SafeAreaInsetsProvider safeAreaInsetsProvider) {
this.safeAreaInsetsProvider =
Objects.requireNonNull(safeAreaInsetsProvider, "safeAreaInsetsProvider");
}
protected final ScreenNavigator screenNavigator() {
return screenNavigator;
}
protected final GameContext gameContext() {
return ctx;
}
@Override
public void create() {
ctx =
new GameContext(
Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME),
Gdx.app.getPreferences(ABILITIES_PREFS_NAME),
new ru.project.tower.support.LibGdxAssetService(),
safeAreaInsetsProvider);
screenNavigator = ScreenNavigation.forGame(this, ctx);
screenNavigator.showMainMenu();
}
@Override
public void dispose() {
if (getScreen() != null) {
getScreen().dispose();
}
if (ctx != null) {
ctx.dispose();
}
super.dispose();
}
}
@@ -0,0 +1,75 @@
package ru.project.tower.abilities;
import ru.project.tower.support.GameClock;
public class AbilityEffect {
public float x, y;
public float radius;
public float duration;
public long startTime;
public String type;
private final GameClock clock;
public AbilityEffect(float x, float y, float radius, float duration, int typeId) {
this(x, y, radius, duration, typeId, GameClock.SYSTEM.nowMs(), GameClock.SYSTEM);
}
public AbilityEffect(
float x,
float y,
float radius,
float duration,
int typeId,
long startTime,
GameClock clock) {
this.x = x;
this.y = y;
this.radius = radius;
this.duration = duration;
this.startTime = startTime;
this.clock = clock;
switch (typeId) {
case 0:
this.type = "freeze";
break;
case 1:
this.type = "explosion";
break;
case 2:
this.type = "shield";
break;
case 3:
this.type = "impulse";
break;
default:
this.type = "unknown";
}
}
public boolean isExpired() {
return isExpired(clock.nowMs());
}
public boolean isExpired(long now) {
return now - startTime > duration;
}
public float getAlpha() {
return getAlpha(clock.nowMs());
}
public float getAlpha(long now) {
float progress = (now - startTime) / duration;
return 1f - progress;
}
public float getRemainingPercentage() {
return getRemainingPercentage(clock.nowMs());
}
public float getRemainingPercentage(long now) {
float elapsed = now - startTime;
return Math.max(0f, 1f - (elapsed / duration));
}
}
@@ -0,0 +1,41 @@
package ru.project.tower.abilities;
import com.badlogic.gdx.graphics.Color;
import ru.project.tower.entities.circles.BaseCircleData;
public interface AbilityHost {
void addFloatingText(float x, float y, String text, Color color);
void createExplosion(float x, float y, Color color);
void triggerKillShake();
void triggerCritHitstop();
int effectiveBulletDamage();
float abilityScalingFactor();
float gameScale();
long abilityCooldownReductionMs();
boolean isBoss(BaseCircleData circle);
void addMoney(int amount);
void killEnemy(BaseCircleData enemy);
void onTurretShot(Turret turret, BaseCircleData target, long now);
}
@@ -0,0 +1,83 @@
package ru.project.tower.abilities;
import java.util.Optional;
public enum AbilityKind {
FREEZE("freeze", "has_freeze_ability", true, 8_000L, "Заморозка", "ЗАМОРОЗКА", 50, 0),
EXPLOSION("explosion", "has_explosion_ability", true, 12_000L, "Взрыв", "ВЗРЫВ", 75, 0),
SHIELD("shield", "has_shield_ability", false, 20_000L, "Щит", "ЩИТ", 60, 12),
IMPULSE("impulse", "has_impulse_ability", false, 15_000L, "Импульс", "ИМПУЛЬС", 65, 16),
DASH("dash", "has_dash_ability", false, 5_000L, "Рывок", "РЫВОК", 80, 20),
PULL("pull", "has_pull_ability", false, 10_000L, "Притяжение", "ТЯГА", 90, 24),
TURRET("turret", "has_turret_ability", false, 25_000L, "Турель", "ТУРЕЛЬ", 120, 28);
private static final AbilityKind[] ABILITY_KINDS = values();
private final String persistedId;
private final String preferenceKey;
private final boolean defaultUnlocked;
private final long baseCooldownMs;
private final String selectionName;
private final String hudLabel;
private final int shopPrice;
private final int unlockWaveRequirement;
AbilityKind(
String persistedId,
String preferenceKey,
boolean defaultUnlocked,
long baseCooldownMs,
String selectionName,
String hudLabel,
int shopPrice,
int unlockWaveRequirement) {
this.persistedId = persistedId;
this.preferenceKey = preferenceKey;
this.defaultUnlocked = defaultUnlocked;
this.baseCooldownMs = baseCooldownMs;
this.selectionName = selectionName;
this.hudLabel = hudLabel;
this.shopPrice = shopPrice;
this.unlockWaveRequirement = unlockWaveRequirement;
}
public String persistedId() {
return persistedId;
}
public String preferenceKey() {
return preferenceKey;
}
public boolean defaultUnlocked() {
return defaultUnlocked;
}
public long baseCooldownMs() {
return baseCooldownMs;
}
public String selectionName() {
return selectionName;
}
public String hudLabel() {
return hudLabel;
}
public int shopPrice() {
return shopPrice;
}
public int unlockWaveRequirement() {
return unlockWaveRequirement;
}
public static Optional<AbilityKind> fromPersistedId(String persistedId) {
if (persistedId == null) return Optional.empty();
for (AbilityKind kind : ABILITY_KINDS) {
if (kind.persistedId.equals(persistedId)) return Optional.of(kind);
}
return Optional.empty();
}
}
@@ -0,0 +1,460 @@
package ru.project.tower.abilities;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import java.util.EnumMap;
import java.util.Optional;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.RandomSource;
import ru.project.tower.support.RangeChecks;
import ru.project.tower.systems.CircleSpatialIndex;
public final class AbilitySystem {
public static final long FREEZE_COOLDOWN = AbilityKind.FREEZE.baseCooldownMs();
public static final long EXPLOSION_COOLDOWN = AbilityKind.EXPLOSION.baseCooldownMs();
public static final long SHIELD_COOLDOWN = AbilityKind.SHIELD.baseCooldownMs();
public static final long IMPULSE_COOLDOWN = AbilityKind.IMPULSE.baseCooldownMs();
public static final long DASH_COOLDOWN = AbilityKind.DASH.baseCooldownMs();
public static final long PULL_COOLDOWN = AbilityKind.PULL.baseCooldownMs();
public static final long TURRET_COOLDOWN = AbilityKind.TURRET.baseCooldownMs();
public static final long ABILITY_COOLDOWN_MIN = 1_500L;
public static final float FREEZE_RADIUS = 150f;
public static final int FREEZE_DURATION = 3_000;
public static final int EXPLOSION_DAMAGE = 15;
public static final float EXPLOSION_RADIUS = 150f;
public static final int SHIELD_DURATION = 5_000;
public static final float IMPULSE_RADIUS = 200f;
public static final float IMPULSE_FORCE = 10_000f;
public static final int IMPULSE_DURATION = 500;
public static final int DASH_IFRAME_DURATION = 600;
public static final float DASH_REPULSE_RADIUS = 180f;
public static final float DASH_REPULSE_FORCE = 3_000f;
public static final float PULL_RADIUS = 260f;
public static final float PULL_FORCE = 6_000f;
public static final int PULL_DURATION = 700;
public static final int TURRET_LIFETIME = 12_000;
public static final long TURRET_SHOT_INTERVAL = 450L;
public static final float TURRET_RANGE = 450f;
private static final float FORCE_FALLOFF_MIN = 0.15f;
public static final float TURRET_DEPLOY_DISTANCE = 100f;
private static final Color NEON_CYAN = new Color(0.2f, 1f, 1f, 1f);
private static final Color NEON_GREEN = new Color(0.2f, 1f, 0.2f, 1f);
private static final AbilityKind[] ABILITY_KINDS = AbilityKind.values();
private final Rectangle playerSquare;
private final Array<BaseCircleData> circles;
private final PlayerAbilities playerAbilities;
private final Array<AbilityKind> selectedAbilities;
private final RandomSource random;
private final AbilityHost host;
private final GameClock clock;
private final CircleSpatialIndex turretTargetIndex = new CircleSpatialIndex();
private int lastTurretTargetCandidateChecks;
private final EnumMap<AbilityKind, Long> lastActivationTimes = new EnumMap<>(AbilityKind.class);
private boolean shieldActive = false;
private float activeShieldDuration = 0f;
private long dashIFramesUntil = 0L;
private long lastFreezeCastTime = 0L;
private float activeFreezeDuration = 0f;
private final Array<AbilityEffect> activeEffects = new Array<>();
private final Array<Turret> activeTurrets = new Array<>();
public AbilitySystem(
Rectangle playerSquare,
Array<BaseCircleData> circles,
PlayerAbilities playerAbilities,
Array<AbilityKind> selectedAbilities,
RandomSource random,
AbilityHost host) {
this(
playerSquare,
circles,
playerAbilities,
selectedAbilities,
random,
host,
GameClock.SYSTEM);
}
public AbilitySystem(
Rectangle playerSquare,
Array<BaseCircleData> circles,
PlayerAbilities playerAbilities,
Array<AbilityKind> selectedAbilities,
RandomSource random,
AbilityHost host,
GameClock clock) {
this.playerSquare = playerSquare;
this.circles = circles;
this.playerAbilities = playerAbilities;
this.selectedAbilities = selectedAbilities;
this.random = random;
this.host = host;
this.clock = clock;
for (AbilityKind kind : ABILITY_KINDS) {
lastActivationTimes.put(kind, 0L);
}
}
public void activateFreeze(float x, float y, long now) {
if (!canActivate(AbilityKind.FREEZE, now)) return;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
float scaling = host.abilityScalingFactor();
float radius = FREEZE_RADIUS * (1 + (scaling - 1) * 0.3f);
float duration = FREEZE_DURATION * scaling;
activeEffects.add(new AbilityEffect(centerX, centerY, radius, duration, 0, now, clock));
setLastActivationTime(AbilityKind.FREEZE, now);
float radius2 = RangeChecks.radiusSquared(radius);
for (BaseCircleData c : circles) {
if (RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y) <= radius2) {
c.velocity.set(0f, 0f);
c.setFrozen(true);
}
}
lastFreezeCastTime = now;
activeFreezeDuration = duration;
}
private long getCooldown(AbilityKind ability) {
if (ability == null) return 0L;
return Math.max(
ABILITY_COOLDOWN_MIN, ability.baseCooldownMs() - host.abilityCooldownReductionMs());
}
private boolean canActivate(AbilityKind ability, long now) {
return playerAbilities.hasAbility(ability)
&& selectedAbilities.contains(ability, true)
&& now - lastActivationTime(ability) >= getCooldown(ability);
}
private long lastActivationTime(AbilityKind ability) {
Long last = lastActivationTimes.get(ability);
return last == null ? 0L : last;
}
private void setLastActivationTime(AbilityKind ability, long now) {
lastActivationTimes.put(ability, now);
}
public void activateExplosion(float x, float y, long now) {
if (!canActivate(AbilityKind.EXPLOSION, now)) return;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
float scaling = host.abilityScalingFactor();
float radius = EXPLOSION_RADIUS * (1 + (scaling - 1) * 0.5f);
int damage = Math.round(EXPLOSION_DAMAGE * scaling);
activeEffects.add(new AbilityEffect(centerX, centerY, radius, 500, 1, now, clock));
setLastActivationTime(AbilityKind.EXPLOSION, now);
float radius2 = RangeChecks.radiusSquared(radius);
for (int i = circles.size - 1; i >= 0; i--) {
BaseCircleData c = circles.get(i);
if (RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y) <= radius2) {
int actual = c.isFrozen() ? damage * 2 : damage;
c.health -= actual;
if (c.isFrozen()) {
host.addFloatingText(c.getX(), c.getY(), "SHATTER!", NEON_CYAN);
}
if (c.health <= 0) {
host.killEnemy(c);
}
}
}
host.triggerKillShake();
}
public void activateShield(long now) {
if (!canActivate(AbilityKind.SHIELD, now)) return;
shieldActive = true;
float scaling = host.abilityScalingFactor();
float duration = SHIELD_DURATION * scaling;
activeShieldDuration = duration;
activeEffects.add(
new AbilityEffect(
playerSquare.x + playerSquare.width / 2f,
playerSquare.y + playerSquare.height / 2f,
playerSquare.width * 1.5f,
duration,
2,
now,
clock));
setLastActivationTime(AbilityKind.SHIELD, now);
}
public void grantShieldPulse(long now, int durationMs) {
if (durationMs <= 0) return;
shieldActive = true;
activeShieldDuration = Math.max(activeShieldDuration, durationMs);
setLastActivationTime(AbilityKind.SHIELD, now);
activeEffects.add(
new AbilityEffect(
playerSquare.x + playerSquare.width / 2f,
playerSquare.y + playerSquare.height / 2f,
playerSquare.width,
durationMs,
2,
now,
clock));
}
public void reduceAllCooldowns(long amountMs) {
if (amountMs <= 0) return;
for (AbilityKind kind : ABILITY_KINDS) {
lastActivationTimes.put(kind, lastActivationTime(kind) - amountMs);
}
}
public void activateImpulse(long now) {
if (!canActivate(AbilityKind.IMPULSE, now)) return;
float scaling = host.abilityScalingFactor();
float radius = IMPULSE_RADIUS * (1 + (scaling - 1) * 0.4f);
float force = IMPULSE_FORCE * scaling;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
activeEffects.add(
new AbilityEffect(centerX, centerY, radius, IMPULSE_DURATION, 3, now, clock));
setLastActivationTime(AbilityKind.IMPULSE, now);
boolean shatteredAny = false;
float radius2 = RangeChecks.radiusSquared(radius);
for (int i = circles.size - 1; i >= 0; i--) {
BaseCircleData c = circles.get(i);
float dx = c.circle.x - centerX;
float dy = c.circle.y - centerY;
float d2 = RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y);
if (d2 <= radius2) {
if (c.isFrozen() && c.health <= 50 && !host.isBoss(c)) {
host.addFloatingText(c.getX(), c.getY(), "SHATTER!", NEON_CYAN);
host.killEnemy(c);
shatteredAny = true;
continue;
}
float d = (float) Math.sqrt(d2);
float dirX = dx / Math.max(0.1f, d);
float dirY = dy / Math.max(0.1f, d);
float forceFactor = radialForceFactor(d, radius);
float actualForce = force * forceFactor;
c.velocity.set(dirX * actualForce, dirY * actualForce);
}
}
if (shatteredAny) host.triggerCritHitstop();
}
public void activateDash(long now) {
if (!canActivate(AbilityKind.DASH, now)) return;
setLastActivationTime(AbilityKind.DASH, now);
dashIFramesUntil = now + DASH_IFRAME_DURATION;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
activeEffects.add(
new AbilityEffect(
centerX,
centerY,
DASH_REPULSE_RADIUS,
DASH_IFRAME_DURATION,
2,
now,
clock));
float radius2 = RangeChecks.radiusSquared(DASH_REPULSE_RADIUS);
for (BaseCircleData c : circles) {
float dx = c.circle.x - centerX;
float dy = c.circle.y - centerY;
float d2 = RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y);
if (d2 <= radius2) {
float d = (float) Math.sqrt(d2);
float dirX = dx / Math.max(0.1f, d);
float dirY = dy / Math.max(0.1f, d);
float f = DASH_REPULSE_FORCE * radialForceFactor(d, DASH_REPULSE_RADIUS);
c.velocity.set(dirX * f, dirY * f);
}
}
host.addFloatingText(centerX, centerY + 30, "DASH!", NEON_CYAN);
host.triggerKillShake();
}
public void activatePull(long now) {
if (!canActivate(AbilityKind.PULL, now)) return;
setLastActivationTime(AbilityKind.PULL, now);
float scaling = host.abilityScalingFactor();
float radius = PULL_RADIUS * (1f + (scaling - 1f) * 0.3f);
float force = PULL_FORCE * scaling;
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
activeEffects.add(
new AbilityEffect(centerX, centerY, radius, PULL_DURATION, 3, now, clock));
float radius2 = RangeChecks.radiusSquared(radius);
for (BaseCircleData c : circles) {
if (host.isBoss(c)) continue;
float d2 = RangeChecks.distanceSquared(centerX, centerY, c.circle.x, c.circle.y);
if (d2 <= radius2 && d2 > 1f) {
float d = (float) Math.sqrt(d2);
float dirX = (centerX - c.circle.x) / d;
float dirY = (centerY - c.circle.y) / d;
float forceFactor = radialForceFactor(d, radius);
c.velocity.set(dirX * force * forceFactor, dirY * force * forceFactor);
}
}
}
private static float radialForceFactor(float distance, float radius) {
return Math.max(FORCE_FALLOFF_MIN, 1f - distance / radius);
}
public void activateTurret(long now) {
if (!canActivate(AbilityKind.TURRET, now)) return;
setLastActivationTime(AbilityKind.TURRET, now);
float centerX = playerSquare.x + playerSquare.width / 2f;
float centerY = playerSquare.y + playerSquare.height / 2f;
float angle = random.angle();
float dist = TURRET_DEPLOY_DISTANCE * host.gameScale();
float tx = centerX + MathUtils.cos(angle) * dist;
float ty = centerY + MathUtils.sin(angle) * dist;
int dmg = Math.max(1, host.effectiveBulletDamage());
activeTurrets.add(new Turret(tx, ty, now, dmg));
host.addFloatingText(tx, ty + 20, "TURRET!", NEON_GREEN);
host.triggerKillShake();
}
public void tick(long now) {
lastTurretTargetCandidateChecks = 0;
if (shieldActive && now - lastActivationTime(AbilityKind.SHIELD) > activeShieldDuration)
shieldActive = false;
if (activeFreezeDuration > 0f && now - lastFreezeCastTime > activeFreezeDuration) {
for (BaseCircleData c : circles) c.setFrozen(false);
activeFreezeDuration = 0f;
}
for (int i = activeEffects.size - 1; i >= 0; i--) {
if (activeEffects.get(i).isExpired(now)) activeEffects.removeIndex(i);
}
if (activeTurrets.size > 0) {
turretTargetIndex.build(circles);
}
for (int i = activeTurrets.size - 1; i >= 0; i--) {
Turret t = activeTurrets.get(i);
if (now - t.spawnedAt > TURRET_LIFETIME) {
activeTurrets.removeIndex(i);
continue;
}
if (now - t.lastShot < TURRET_SHOT_INTERVAL) continue;
BaseCircleData target = null;
float best = Float.POSITIVE_INFINITY;
float range2 = RangeChecks.radiusSquared(TURRET_RANGE);
int candidateCount = turretTargetIndex.queryRadius(t.x, t.y, TURRET_RANGE);
for (int candidate = 0; candidate < candidateCount; candidate++) {
BaseCircleData c = circles.get(turretTargetIndex.candidateAt(candidate));
lastTurretTargetCandidateChecks++;
float d2 = RangeChecks.distanceSquared(t.x, t.y, c.circle.x, c.circle.y);
if (d2 < range2 && d2 < best) {
target = c;
best = d2;
}
}
if (target == null) continue;
t.lastShot = now;
host.onTurretShot(t, target, now);
}
}
int lastTurretTargetCandidateChecksForTests() {
return lastTurretTargetCandidateChecks;
}
public void resetForNewRun() {
shieldActive = false;
activeShieldDuration = 0f;
dashIFramesUntil = 0L;
lastFreezeCastTime = 0L;
activeFreezeDuration = 0f;
activeEffects.clear();
activeTurrets.clear();
for (AbilityKind kind : ABILITY_KINDS) {
lastActivationTimes.put(kind, 0L);
}
}
public long remainingCooldownMs(String ability, long now) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(ability);
return kind.isPresent() ? remainingCooldownMs(kind.get(), now) : 0L;
}
public long remainingCooldownMs(AbilityKind ability, long now) {
if (ability == null) return 0L;
return Math.max(0L, getCooldown(ability) - (now - lastActivationTime(ability)));
}
public boolean isShieldActive(long now) {
return shieldActive;
}
public boolean isDashInvulnerable(long now) {
return now < dashIFramesUntil;
}
public boolean isFreezeZoneActive(long now) {
return activeFreezeDuration > 0f;
}
public Array<AbilityEffect> getActiveEffects() {
return activeEffects;
}
public Array<Turret> getActiveTurrets() {
return activeTurrets;
}
}
@@ -0,0 +1,170 @@
package ru.project.tower.abilities;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.utils.Array;
import java.util.EnumMap;
import java.util.Optional;
import ru.project.tower.DebugLog;
public class PlayerAbilities {
private static final AbilityKind[] ABILITY_KINDS = AbilityKind.values();
private final EnumMap<AbilityKind, Boolean> ownedAbilities = new EnumMap<>(AbilityKind.class);
private final Preferences preferences;
public PlayerAbilities(Preferences preferences) {
try {
DebugLog.log("PlayerAbilities", "Initializing PlayerAbilities");
this.preferences = preferences;
DebugLog.log("PlayerAbilities", "Got preferences");
for (AbilityKind kind : ABILITY_KINDS) {
ownedAbilities.put(
kind, preferences.getBoolean(kind.preferenceKey(), kind.defaultUnlocked()));
}
logLoadedAbilities();
} catch (Exception e) {
Gdx.app.error("PlayerAbilities", "Error in constructor: ", e);
throw e;
}
}
private void logLoadedAbilities() {
if (DebugLog.DEBUG) {
DebugLog.log(
"PlayerAbilities",
"Loaded abilities - Freeze: "
+ hasAbility(AbilityKind.FREEZE)
+ ", Explosion: "
+ hasAbility(AbilityKind.EXPLOSION)
+ ", Shield: "
+ hasAbility(AbilityKind.SHIELD)
+ ", Impulse: "
+ hasAbility(AbilityKind.IMPULSE)
+ ", Dash: "
+ hasAbility(AbilityKind.DASH)
+ ", Pull: "
+ hasAbility(AbilityKind.PULL)
+ ", Turret: "
+ hasAbility(AbilityKind.TURRET));
}
}
public Array<AbilityKind> getPurchasedAbilities() {
try {
Array<AbilityKind> abilities = new Array<>();
for (AbilityKind kind : ABILITY_KINDS) {
if (hasAbility(kind)) abilities.add(kind);
}
if (DebugLog.DEBUG) {
DebugLog.log(
"PlayerAbilities", "Getting purchased abilities, count: " + abilities.size);
for (AbilityKind ability : abilities) {
DebugLog.log("PlayerAbilities", "Available ability: " + ability.persistedId());
}
}
return abilities;
} catch (Exception e) {
Gdx.app.error("PlayerAbilities", "Error in getPurchasedAbilities: ", e);
throw e;
}
}
public int purchasedAbilityCount() {
int count = 0;
for (AbilityKind kind : ABILITY_KINDS) {
if (hasAbility(kind)) count++;
}
return count;
}
public Array<String> getPurchasedAbilityIds() {
Array<String> abilityIds = new Array<>();
for (AbilityKind kind : ABILITY_KINDS) {
if (hasAbility(kind)) abilityIds.add(kind.persistedId());
}
return abilityIds;
}
public boolean hasAbility(AbilityKind abilityType) {
return abilityType != null && Boolean.TRUE.equals(ownedAbilities.get(abilityType));
}
public boolean hasAbility(String abilityType) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(abilityType);
return kind.isPresent() && hasAbility(kind.get());
}
public void purchaseAbility(AbilityKind abilityType) {
if (abilityType == null) return;
ownedAbilities.put(abilityType, true);
saveData();
}
public boolean canUnlock(AbilityKind abilityType, int maxWaveReached) {
if (abilityType == null) return false;
return maxWaveReached >= abilityType.unlockWaveRequirement();
}
public boolean purchaseAbilityIfUnlocked(AbilityKind abilityType, int maxWaveReached) {
if (!canUnlock(abilityType, maxWaveReached)) {
return false;
}
purchaseAbility(abilityType);
return true;
}
public void purchaseAbility(String abilityType) {
Optional<AbilityKind> kind = AbilityKind.fromPersistedId(abilityType);
if (kind.isPresent()) {
purchaseAbility(kind.get());
}
}
private void saveData() {
for (AbilityKind kind : ABILITY_KINDS) {
preferences.putBoolean(kind.preferenceKey(), hasAbility(kind));
}
preferences.flush();
}
public void clearAll() {
for (AbilityKind kind : ABILITY_KINDS) {
ownedAbilities.put(kind, false);
}
saveData();
}
}
@@ -0,0 +1,23 @@
package ru.project.tower.abilities;
public final class Turret {
public float x, y;
public long spawnedAt;
public long lastShot;
public int damage;
public Turret(float x, float y, long now, int damage) {
this.x = x;
this.y = y;
this.spawnedAt = now;
this.lastShot = now;
this.damage = damage;
}
}
@@ -0,0 +1,147 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class BalanceEffectRegistry {
private static final float DEFAULT_RATING_FLOOR = 0f;
private static final BalanceEffectSpec[] EFFECTS = {
effect(
"attack.core_damage",
PowerAxis.DAMAGE,
BalanceEffectSpec.Owner.SHOP,
"Урон ядра",
"Базовая сила попадания.",
95f),
effect(
"attack.cadence",
PowerAxis.CADENCE,
BalanceEffectSpec.Owner.SHOP,
"Скорострельность",
"Темп стрельбы с общим floor по cooldown.",
90f),
effect(
"attack.crit",
PowerAxis.CRIT,
BalanceEffectSpec.Owner.SHOP,
"Критический контур",
"Шанс и сила критических попаданий.",
75f),
effect(
"attack.pierce",
PowerAxis.PIERCE,
BalanceEffectSpec.Owner.CARD,
"Пробитие",
"Зачистка плотных волн без прямого boss-DPS.",
70f),
effect(
"attack.aoe",
PowerAxis.AOE,
BalanceEffectSpec.Owner.CARD,
"Осколочный урон",
"Площадной урон по скоплениям.",
70f),
effect(
"attack.controlled",
PowerAxis.CONTROL,
BalanceEffectSpec.Owner.ABILITY,
"Эксплуатация контроля",
"Сила по замедленным, стянутым и замороженным целям.",
70f),
effect(
"attack.bossing",
PowerAxis.BOSSING,
BalanceEffectSpec.Owner.SHOP,
"Анти-босс модуль",
"Отдельная ось урона по элитам и боссам.",
65f),
effect(
"defense.health",
PowerAxis.HEALTH,
BalanceEffectSpec.Owner.SHOP,
"Запас корпуса",
"Максимальное здоровье и effective HP.",
95f),
effect(
"defense.regen",
PowerAxis.REGEN,
BalanceEffectSpec.Owner.SHOP,
"Ремонтный цикл",
"Восстановление в длинном забеге.",
80f),
effect(
"defense.shield",
PowerAxis.SHIELD,
BalanceEffectSpec.Owner.ABILITY,
"Щитовой контур",
"Временное здоровье и щитовые окна.",
80f),
effect(
"defense.armor",
PowerAxis.ARMOR,
BalanceEffectSpec.Owner.SHOP,
"Броня",
"Снижение контактного и снарядного урона.",
75f),
effect(
"bonus.economy",
PowerAxis.ECONOMY,
BalanceEffectSpec.Owner.SHOP,
"Экономика",
"Доход, скидки и окупаемость покупок.",
70f),
effect(
"bonus.utility",
PowerAxis.UTILITY,
BalanceEffectSpec.Owner.BONUS,
"Утилити",
"Радиус подбора, длительность и качество бонусов.",
70f),
effect(
"ability.scaling",
PowerAxis.ABILITY,
BalanceEffectSpec.Owner.TALENT,
"Мастерство способностей",
"Cooldown и сила активных способностей.",
70f)
};
private static final EnumMap<PowerAxis, BalanceEffectSpec> BY_AXIS =
new EnumMap<>(PowerAxis.class);
static {
for (BalanceEffectSpec effect : EFFECTS) {
BY_AXIS.put(effect.axis(), effect);
}
}
private BalanceEffectRegistry() {}
public static BalanceEffectSpec[] all() {
BalanceEffectSpec[] copy = new BalanceEffectSpec[EFFECTS.length];
System.arraycopy(EFFECTS, 0, copy, 0, EFFECTS.length);
return copy;
}
public static BalanceEffectSpec forAxis(PowerAxis axis) {
BalanceEffectSpec effect = BY_AXIS.get(axis);
if (effect == null) {
throw new IllegalArgumentException("No balance effect registered for axis: " + axis);
}
return effect;
}
public static Map<PowerAxis, BalanceEffectSpec> byAxis() {
return new EnumMap<>(BY_AXIS);
}
private static BalanceEffectSpec effect(
String id,
PowerAxis axis,
BalanceEffectSpec.Owner owner,
String gameName,
String description,
float ratingCap) {
return new BalanceEffectSpec(
id, axis, owner, gameName, description, DEFAULT_RATING_FLOOR, ratingCap);
}
}
@@ -0,0 +1,71 @@
package ru.project.tower.balance;
import java.util.Objects;
public final class BalanceEffectSpec {
public enum Owner {
SHOP,
CARD,
TALENT,
BONUS,
ABILITY,
SIMULATION
}
private final String id;
private final PowerAxis axis;
private final Owner owner;
private final String gameName;
private final String description;
private final Float ratingFloor;
private final Float ratingCap;
BalanceEffectSpec(
String id,
PowerAxis axis,
Owner owner,
String gameName,
String description,
Float ratingFloor,
Float ratingCap) {
this.id = Objects.requireNonNull(id, "id");
this.axis = Objects.requireNonNull(axis, "axis");
this.owner = Objects.requireNonNull(owner, "owner");
this.gameName = Objects.requireNonNull(gameName, "gameName");
this.description = Objects.requireNonNull(description, "description");
this.ratingFloor = ratingFloor;
this.ratingCap = ratingCap;
}
public String id() {
return id;
}
public PowerAxis axis() {
return axis;
}
public Owner owner() {
return owner;
}
public String gameName() {
return gameName;
}
public String description() {
return description;
}
public Float ratingFloor() {
return ratingFloor;
}
public Float ratingCap() {
return ratingCap;
}
public boolean hasLimit() {
return ratingFloor != null || ratingCap != null;
}
}
@@ -0,0 +1,49 @@
package ru.project.tower.balance;
import java.util.List;
import java.util.Locale;
public final class BalanceReportExporter {
private BalanceReportExporter() {}
public static String export(String reportId, List<SimulationReport> reports) {
StringBuilder out = new StringBuilder();
out.append("balance-report ").append(reportId == null ? "unnamed" : reportId).append('\n');
out.append(
"strategy,wave,firstBoss,shop,moneyEarned,moneySpent,abilities,synergies,bonusSpawned,bonusCollected,damage,cooldownMs\n");
if (reports == null) {
return out.toString();
}
for (SimulationReport report : reports) {
out.append(report.strategyId())
.append(',')
.append(report.waveReached())
.append(',')
.append(format(report.firstBossKillSeconds()))
.append(',')
.append(report.shopPurchases())
.append(',')
.append(report.moneyEarned())
.append(',')
.append(report.moneySpent())
.append(',')
.append(report.abilityUses())
.append(',')
.append(report.triggeredSynergies())
.append(',')
.append(report.bonusSpawned())
.append(',')
.append(report.bonusCollected())
.append(',')
.append(format(report.effectiveDamage()))
.append(',')
.append(format(report.effectiveShotCooldownMs()))
.append('\n');
}
return out.toString();
}
private static String format(float value) {
return String.format(Locale.ROOT, "%.2f", value);
}
}
@@ -0,0 +1,197 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class BalanceRouteReport {
private final String strategyId;
private final int waveReached;
private final float firstBossKillSeconds;
private final int shopPurchases;
private final int abilityUses;
private final int selectedDamageCards;
private final int selectedFireRateCards;
private final int peakVisibleBonusBalls;
private final float effectiveDamage;
private final float effectiveShotCooldownMs;
private final EnumMap<PowerAxis, Float> effectivePowerByAxis;
private final int moneyEarned;
private final int moneySpent;
private final int economyPaybackWave;
BalanceRouteReport(
String strategyId,
int waveReached,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs) {
this(
strategyId,
waveReached,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
emptyAxes(),
0,
0,
0);
}
BalanceRouteReport(
String strategyId,
int waveReached,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
Map<PowerAxis, Float> effectivePowerByAxis) {
this(
strategyId,
waveReached,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
effectivePowerByAxis,
0,
0,
0);
}
BalanceRouteReport(
String strategyId,
int waveReached,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
Map<PowerAxis, Float> effectivePowerByAxis,
int moneyEarned,
int moneySpent,
int economyPaybackWave) {
this.strategyId = strategyId;
this.waveReached = waveReached;
this.firstBossKillSeconds = firstBossKillSeconds;
this.shopPurchases = shopPurchases;
this.abilityUses = abilityUses;
this.selectedDamageCards = selectedDamageCards;
this.selectedFireRateCards = selectedFireRateCards;
this.peakVisibleBonusBalls = peakVisibleBonusBalls;
this.effectiveDamage = effectiveDamage;
this.effectiveShotCooldownMs = effectiveShotCooldownMs;
this.effectivePowerByAxis = completeAxes(effectivePowerByAxis);
this.moneyEarned = moneyEarned;
this.moneySpent = moneySpent;
this.economyPaybackWave = economyPaybackWave;
}
public String strategyId() {
return strategyId;
}
public int waveReached() {
return waveReached;
}
public float firstBossKillSeconds() {
return firstBossKillSeconds;
}
public int shopPurchases() {
return shopPurchases;
}
public int abilityUses() {
return abilityUses;
}
public int selectedDamageCards() {
return selectedDamageCards;
}
public int selectedFireRateCards() {
return selectedFireRateCards;
}
public int peakVisibleBonusBalls() {
return peakVisibleBonusBalls;
}
public float effectiveDamage() {
return effectiveDamage;
}
public float effectiveShotCooldownMs() {
return effectiveShotCooldownMs;
}
public Map<PowerAxis, Float> effectivePowerByAxis() {
return new EnumMap<>(effectivePowerByAxis);
}
public int moneyEarned() {
return moneyEarned;
}
public int moneySpent() {
return moneySpent;
}
public int economyPaybackWave() {
return economyPaybackWave;
}
public float defensePower() {
return value(PowerAxis.HEALTH)
+ value(PowerAxis.REGEN)
+ value(PowerAxis.SHIELD)
+ value(PowerAxis.ARMOR);
}
public float utilityPower() {
return value(PowerAxis.ECONOMY) + value(PowerAxis.UTILITY) + value(PowerAxis.ABILITY);
}
private float value(PowerAxis axis) {
Float value = effectivePowerByAxis.get(axis);
return value == null ? 0f : value;
}
private static EnumMap<PowerAxis, Float> emptyAxes() {
EnumMap<PowerAxis, Float> axes = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
axes.put(axis, 0f);
}
return axes;
}
private static EnumMap<PowerAxis, Float> completeAxes(Map<PowerAxis, Float> source) {
EnumMap<PowerAxis, Float> axes = emptyAxes();
if (source != null) {
axes.putAll(source);
}
return axes;
}
}
@@ -0,0 +1,581 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.upgrades.UpgradeKind;
public final class BalanceRouteSimulator {
private static final int MAX_AUDIT_WAVE = 120;
private static final int FIRST_BOSS_WAVE = 20;
public BalanceRouteReport runDamageFireRateNoShopNoAbility(long seed) {
RouteState state = new RouteState("damage-fire-rate:no-shop:no-ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyExploitCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, false)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runFreshNoMenuTalentShopCardAbilityRoute(long seed) {
RouteState state = new RouteState("fresh:no-menu-talent:shop-card-ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyFreshNoMenuTalentCardIfDue(wave);
state.applyFreshNoMenuTalentShopIfDue(wave);
state.applyFreshNoMenuTalentAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (isFreshNoMenuTalentOverwhelmed(state, wave)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runBalancedShopAndAbilityRoute(long seed) {
RouteState state = new RouteState("balanced:shop:ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyBalancedCardIfDue(wave);
state.applyBalancedShopIfDue(wave);
state.applyAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runSustainControlShopAndAbilityRoute(long seed) {
RouteState state = new RouteState("sustain-control:shop:ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applySustainControlCardIfDue(wave);
state.applySustainControlShopIfDue(wave);
state.applyAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runMixedNoShopNoAbilityRoute(long seed) {
RouteState state = new RouteState("mixed:no-shop:no-ability", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, false)) {
break;
}
}
return state.report();
}
public BalanceRouteReport runAttackSingleTargetShopRoute(long seed) {
RouteState state = new RouteState("attack:single-target-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applySingleTargetShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runAttackSwarmShopRoute(long seed) {
RouteState state = new RouteState("attack:swarm-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applySwarmShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runAttackBossShopRoute(long seed) {
RouteState state = new RouteState("attack:boss-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyBossShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runDefenseShopRoute(long seed) {
RouteState state = new RouteState("defense:shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyDefenseShopIfDue(wave);
state.applySustainControlCardIfDue(wave);
state.applyAbilityIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
public BalanceRouteReport runUtilityEconomyShopRoute(long seed) {
RouteState state = new RouteState("utility:economy-shop", 0, 0);
for (int wave = 1; wave <= MAX_AUDIT_WAVE; wave++) {
state.waveReached = wave;
state.applyUtilityShopIfDue(wave);
state.applyBalancedCardIfDue(wave);
state.observeBonusDensity(wave);
if (wave == FIRST_BOSS_WAVE) {
state.firstBossKillSeconds =
bossKillSeconds(EndlessBalanceSpec.bossHealthForWave(wave), state, wave);
}
if (isOverwhelmed(state, wave, true)) break;
}
return state.report();
}
private static boolean isOverwhelmed(RouteState state, int wave, boolean balancedRoute) {
float routeSupport =
balancedRoute ? state.shopPurchases * 2.15f + state.abilityUses * 0.35f : 0f;
float requiredDps =
4.5f + wave * 0.85f + (float) Math.pow(wave, 1.22f) * 0.08f - routeSupport;
return wave > FIRST_BOSS_WAVE && state.effectiveDps(wave) < requiredDps;
}
private static boolean isFreshNoMenuTalentOverwhelmed(RouteState state, int wave) {
float routeSupport =
Math.min(state.shopPurchases, 5) * 0.75f + Math.min(state.abilityUses, 2) * 0.35f;
float requiredDps =
1.75f
+ wave * 0.42f
+ (float) Math.pow(wave, 1.18f) * 0.10f
+ Math.max(0, wave - 11) * 2.00f
- routeSupport;
return wave >= 3 && state.effectiveDps(wave) < requiredDps;
}
private static float bossKillSeconds(float bossHealth, RouteState state, int wave) {
return bossHealth / Math.max(0.01f, state.effectiveDps(wave));
}
private static final class RouteState {
private final String strategyId;
private int waveReached;
private float firstBossKillSeconds;
private int shopPurchases;
private int abilityUses;
private int selectedDamageCards;
private int selectedFireRateCards;
private int selectedMixedCards;
private int peakVisibleBonusBalls;
private float baseDamage = 1f;
private float runDamageBonus;
private float shotCooldownMs = PlayerCombatState.DEFAULT_SHOT_COOLDOWN_MS;
private float runCooldownMultiplier = 1f;
private float critChance = PlayerCombatState.DEFAULT_BASE_CRIT_CHANCE;
private float critMultiplier = PlayerCombatState.DEFAULT_BASE_CRIT_MULTIPLIER;
private float sustainRating;
private float pierceRating;
private float aoeRating;
private float bossingRating;
private float armorRating;
private float shieldRating;
private float economyRating;
private float utilityRating;
private int moneyEarned;
private int moneySpent;
private int economyPaybackWave;
private RouteState(String strategyId, int shopPurchases, int abilityUses) {
this.strategyId = strategyId;
this.shopPurchases = shopPurchases;
this.abilityUses = abilityUses;
}
private void applyExploitCardIfDue(int wave) {
if (wave % 3 != 0) {
return;
}
if (selectedDamageCards <= selectedFireRateCards) {
applyDamageCard();
} else {
applyFireRateCard(0.9f);
}
}
private void applyBalancedCardIfDue(int wave) {
if (wave % 3 != 0) {
return;
}
int lane = selectedMixedCards % 4;
if (lane == 0) {
applyDamageCard();
} else if (lane == 1) {
applyFireRateCard(0.92f);
} else if (lane == 2) {
critChance += 0.035f;
} else {
sustainRating += 1.6f;
}
selectedMixedCards++;
}
private void applySustainControlCardIfDue(int wave) {
if (wave % 3 != 0) {
return;
}
int lane = selectedMixedCards % 4;
if (lane == 0) {
sustainRating += 2.8f;
} else if (lane == 1) {
critChance += 0.04f;
} else if (lane == 2) {
applyFireRateCard(0.94f);
} else {
applyDamageCard();
}
selectedMixedCards++;
}
private void applyFreshNoMenuTalentCardIfDue(int wave) {
if (wave < 2 || wave % 3 != 2) {
return;
}
int lane = selectedMixedCards % 3;
if (lane == 0) {
applyDamageCard();
} else if (lane == 1) {
applyFireRateCard(0.94f);
} else {
sustainRating += 0.9f;
}
selectedMixedCards++;
}
private void applyDamageCard() {
if (selectedDamageCards >= UpgradeKind.DAMAGE.maxStacks()) {
applyFireRateCard(0.9f);
return;
}
selectedDamageCards++;
runDamageBonus +=
Math.max(
1f,
baseDamage
* EndlessBalanceSpec.upgradeCardDamagePercent(
selectedDamageCards - 1)
/ 100f);
}
private void applyFireRateCard(float ignoredMultiplier) {
if (selectedFireRateCards >= UpgradeKind.FIRE_RATE.maxStacks()) {
return;
}
float multiplier =
EndlessBalanceSpec.upgradeCardFireRateMultiplier(selectedFireRateCards);
selectedFireRateCards++;
runCooldownMultiplier *= multiplier;
}
private void applyBalancedShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) {
return;
}
int lane = shopPurchases % 5;
if (lane == 0) {
baseDamage += 1.4f;
} else if (lane == 1) {
shotCooldownMs = Math.max(260f, shotCooldownMs - 38f);
} else if (lane == 2) {
critChance += 0.04f;
} else if (lane == 3) {
critMultiplier += 0.09f;
} else {
sustainRating += 3.2f;
}
shopPurchases++;
}
private void applySustainControlShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) {
return;
}
int lane = shopPurchases % 5;
if (lane == 0) {
sustainRating += 4.2f;
} else if (lane == 1) {
shotCooldownMs = Math.max(300f, shotCooldownMs - 30f);
} else if (lane == 2) {
critChance += 0.035f;
} else if (lane == 3) {
baseDamage += 1.0f;
} else {
critMultiplier += 0.07f;
}
shopPurchases++;
}
private void applyFreshNoMenuTalentShopIfDue(int wave) {
if (wave < 2 || wave % 2 != 0) {
return;
}
int lane = shopPurchases % 4;
if (lane == 0) {
baseDamage += 0.35f;
} else if (lane == 1) {
shotCooldownMs = Math.max(420f, shotCooldownMs - 18f);
} else if (lane == 2) {
sustainRating += 0.8f;
} else {
critChance += 0.012f;
}
shopPurchases++;
}
private void applySingleTargetShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
baseDamage += 1.8f;
} else if (lane == 1) {
critChance += 0.055f;
} else if (lane == 2) {
critMultiplier += 0.12f;
} else if (lane == 3) {
bossingRating += 1.0f;
} else {
shotCooldownMs = Math.max(280f, shotCooldownMs - 24f);
}
shopPurchases++;
}
private void applySwarmShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
pierceRating += 3.4f;
} else if (lane == 1) {
aoeRating += 4.2f;
} else if (lane == 2) {
baseDamage += 0.8f;
} else if (lane == 3) {
shotCooldownMs = Math.max(310f, shotCooldownMs - 28f);
} else {
utilityRating += 0.8f;
}
shopPurchases++;
}
private void applyBossShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
bossingRating += 4.5f;
} else if (lane == 1) {
critChance += 0.045f;
} else if (lane == 2) {
critMultiplier += 0.10f;
} else if (lane == 3) {
baseDamage += 1.1f;
} else {
shotCooldownMs = Math.max(300f, shotCooldownMs - 18f);
}
shopPurchases++;
}
private void applyDefenseShopIfDue(int wave) {
if (wave < 4 || wave % 2 != 0) return;
int lane = shopPurchases % 5;
if (lane == 0) {
sustainRating += 5.0f;
} else if (lane == 1) {
armorRating += 4.0f;
} else if (lane == 2) {
shieldRating += 3.5f;
} else if (lane == 3) {
aoeRating += 0.8f;
} else {
baseDamage += 0.7f;
}
shopPurchases++;
}
private void applyUtilityShopIfDue(int wave) {
moneyEarned += 8 + wave / 2;
if (wave < 4 || wave % 2 != 0) return;
int cost = Math.max(3, 7 - Math.min(3, shopPurchases / 2));
moneySpent += cost;
if (economyPaybackWave == 0 && moneyEarned > moneySpent) {
economyPaybackWave = wave;
}
int lane = shopPurchases % 5;
if (lane == 0) {
economyRating += 3.8f;
} else if (lane == 1) {
utilityRating += 4.0f;
} else if (lane == 2) {
peakVisibleBonusBalls = Math.max(peakVisibleBonusBalls, 2);
} else if (lane == 3) {
shotCooldownMs = Math.max(330f, shotCooldownMs - 18f);
} else {
baseDamage += 0.6f;
}
shopPurchases++;
}
private void applyAbilityIfDue(int wave) {
if (wave >= 12 && wave % 5 == 0) {
abilityUses++;
sustainRating += 0.55f;
}
}
private void applyFreshNoMenuTalentAbilityIfDue(int wave) {
if (wave >= 6 && wave % 4 == 2) {
abilityUses++;
sustainRating += 0.45f;
}
}
private void observeBonusDensity(int wave) {
int visibleBonusBalls = expectedVisibleBonusBalls(wave);
peakVisibleBonusBalls = Math.max(peakVisibleBonusBalls, visibleBonusBalls);
}
private float effectiveDps(int wave) {
int bonusStacks = expectedVisibleBonusBalls(wave);
float damageMultiplier =
EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.DAMAGE, bonusStacks / 3);
float cooldownMultiplier =
EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.FIRE_RATE, bonusStacks / 3);
float averageCritMultiplier =
1f
+ Math.min(EndlessBalanceSpec.CRIT_CHANCE_CAP, critChance)
* (critMultiplier - 1f);
float sustainMultiplier = 1f + Math.min(0.45f, sustainRating * 0.018f);
float shapeMultiplier =
1f
+ Math.min(0.25f, pierceRating * 0.010f + aoeRating * 0.008f)
+ Math.min(0.18f, bossingRating * 0.010f);
float damage = (baseDamage + runDamageBonus) * damageMultiplier;
float cooldown =
Math.max(90f, shotCooldownMs * runCooldownMultiplier * cooldownMultiplier);
return damage
* averageCritMultiplier
* sustainMultiplier
* shapeMultiplier
* 1000f
/ cooldown;
}
private int expectedVisibleBonusBalls(int wave) {
float chance =
Math.min(
0.2f,
EndlessBalanceSpec.bonusDropBudgetThroughWave(wave)
/ (float) Math.max(8, wave + 20));
int expectedRolls =
Math.round(EndlessBalanceSpec.waveTuning(wave).enemiesPerWave() * chance);
int budgeted = Math.min(EndlessBalanceSpec.bonusDropBudgetForWave(wave), expectedRolls);
return Math.min(EndlessBalanceSpec.maxVisibleBonusBalls(), budgeted);
}
private BalanceRouteReport report() {
float finalDamage = baseDamage + runDamageBonus;
float finalCooldown = Math.max(90f, shotCooldownMs * runCooldownMultiplier);
EnumMap<PowerAxis, Float> axes = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
axes.put(axis, 0f);
}
axes.put(PowerAxis.DAMAGE, finalDamage);
axes.put(PowerAxis.CADENCE, 1000f / Math.max(1f, finalCooldown));
axes.put(PowerAxis.CRIT, Math.max(0f, critChance * 100f + (critMultiplier - 1f) * 25f));
axes.put(PowerAxis.PIERCE, selectedMixedCards * 0.75f + pierceRating);
axes.put(
PowerAxis.AOE,
selectedMixedCards * 0.60f + peakVisibleBonusBalls * 0.35f + aoeRating);
axes.put(PowerAxis.CONTROL, abilityUses * 1.4f + sustainRating * 0.25f);
axes.put(
PowerAxis.BOSSING,
finalDamage * 0.70f + axes.get(PowerAxis.CRIT) * 0.20f + bossingRating);
axes.put(PowerAxis.HEALTH, sustainRating);
axes.put(PowerAxis.REGEN, sustainRating * 0.70f);
axes.put(PowerAxis.SHIELD, abilityUses * 1.8f + shopPurchases * 0.15f + shieldRating);
axes.put(PowerAxis.ARMOR, sustainRating * 0.45f + armorRating);
axes.put(PowerAxis.ECONOMY, shopPurchases * 0.70f + economyRating);
axes.put(
PowerAxis.UTILITY,
peakVisibleBonusBalls * 1.2f + selectedMixedCards * 0.20f + utilityRating);
axes.put(PowerAxis.ABILITY, abilityUses * 2.1f);
return new BalanceRouteReport(
strategyId,
waveReached,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
finalDamage,
finalCooldown,
axes,
moneyEarned,
moneySpent,
economyPaybackWave);
}
}
}
@@ -0,0 +1,81 @@
package ru.project.tower.balance;
public final class BalanceRouteTarget {
private final String strategyId;
private final int minWaveReached;
private final int maxWaveReached;
private final float minFirstBossKillSeconds;
private final float maxFirstBossKillSeconds;
private final boolean usesPermanentMenuTalents;
private final boolean usesInRunShop;
private final boolean usesRunCards;
private final boolean usesAbilities;
private final String designGoal;
public BalanceRouteTarget(
String strategyId,
int minWaveReached,
int maxWaveReached,
float minFirstBossKillSeconds,
float maxFirstBossKillSeconds,
boolean usesPermanentMenuTalents,
boolean usesInRunShop,
boolean usesRunCards,
boolean usesAbilities,
String designGoal) {
this.strategyId = strategyId;
this.minWaveReached = minWaveReached;
this.maxWaveReached = maxWaveReached;
this.minFirstBossKillSeconds = minFirstBossKillSeconds;
this.maxFirstBossKillSeconds = maxFirstBossKillSeconds;
this.usesPermanentMenuTalents = usesPermanentMenuTalents;
this.usesInRunShop = usesInRunShop;
this.usesRunCards = usesRunCards;
this.usesAbilities = usesAbilities;
this.designGoal = designGoal;
}
public String strategyId() {
return strategyId;
}
public int minWaveReached() {
return minWaveReached;
}
public int maxWaveReached() {
return maxWaveReached;
}
public float minFirstBossKillSeconds() {
return minFirstBossKillSeconds;
}
public float maxFirstBossKillSeconds() {
return maxFirstBossKillSeconds;
}
public boolean usesPermanentMenuTalents() {
return usesPermanentMenuTalents;
}
public boolean usesInRunShop() {
return usesInRunShop;
}
public boolean usesRunCards() {
return usesRunCards;
}
public boolean usesAbilities() {
return usesAbilities;
}
public String designGoal() {
return designGoal;
}
public boolean containsWaveReached(int waveReached) {
return waveReached >= minWaveReached && waveReached <= maxWaveReached;
}
}
@@ -0,0 +1,909 @@
package ru.project.tower.balance;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.Pools;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import ru.project.tower.abilities.AbilityHost;
import ru.project.tower.abilities.AbilityKind;
import ru.project.tower.abilities.AbilitySystem;
import ru.project.tower.abilities.PlayerAbilities;
import ru.project.tower.abilities.Turret;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.entities.circles.BasicCircleData;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.player.EffectiveCombatStats;
import ru.project.tower.player.PlayerCombatState;
import ru.project.tower.player.PlayerRunCombatCalculator;
import ru.project.tower.player.PlayerStats;
import ru.project.tower.support.SeededRandomSource;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.talents.TalentData;
import ru.project.tower.talents.TalentTree;
import ru.project.tower.upgrades.UpgradeHost;
import ru.project.tower.upgrades.UpgradeKind;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.upgrades.shop.ShopUpgradeHost;
import ru.project.tower.upgrades.shop.ShopUpgradeKind;
import ru.project.tower.upgrades.shop.ShopUpgradeSystem;
import ru.project.tower.waves.WaveTuning;
public final class BalanceRuntimeHarness {
private static final int MAX_RUNTIME_WAVE = 140;
private static final int FIRST_BOSS_WAVE = EndlessBalanceSpec.BOSS_WAVE_INTERVAL;
private static final Rectangle PLAYER_SQUARE = new Rectangle(100f, 100f, 50f, 50f);
private static final BonusBallData.BonusType[] RUNTIME_BONUS_SEQUENCE = {
BonusBallData.BonusType.DAMAGE,
BonusBallData.BonusType.FIRE_RATE,
BonusBallData.BonusType.BULLET_SPEED,
BonusBallData.BonusType.CRIT_WINDOW,
BonusBallData.BonusType.PICKUP_MAGNET,
BonusBallData.BonusType.HEAL,
BonusBallData.BonusType.SHIELD_PULSE,
BonusBallData.BonusType.ABILITY_CHARGE,
BonusBallData.BonusType.COIN_BURST
};
public List<SimulationReport> run(long seed) {
return List.of(
simulate(seed, RuntimeRoute.fresh()),
simulate(seed, RuntimeRoute.exploit()),
simulate(seed, RuntimeRoute.balanced()),
simulate(seed, RuntimeRoute.moderateMeta()),
simulate(seed, RuntimeRoute.highMeta()));
}
private SimulationReport simulate(long seed, RuntimeRoute route) {
RuntimeState state = new RuntimeState(seed, route);
float firstBossKillSeconds = 0f;
int waveReached = 0;
for (int wave = 1; wave <= MAX_RUNTIME_WAVE; wave++) {
waveReached = wave;
state.prepareWave(wave);
state.applyRouteChoices(wave);
state.spawnBonusIfBudgetAllows(wave);
state.castAbilityIfDue(wave);
if (wave == FIRST_BOSS_WAVE) {
firstBossKillSeconds = state.bossKillSeconds(wave);
}
boolean overwhelmed = state.isOverwhelmed(wave);
state.finishWave();
if (overwhelmed) {
break;
}
}
return state.report(waveReached, firstBossKillSeconds);
}
private static final class RuntimeState implements UpgradeHost, ShopUpgradeHost, AbilityHost {
private final RuntimeRoute route;
private final long seed;
private final SeededRandomSource random;
private final PlayerCombatState combatState = new PlayerCombatState();
private final Array<BaseCircleData> circles = new Array<>();
private final Array<BonusBallData> runtimeBonusBalls = new Array<>();
private final PlayerAbilities playerAbilities;
private final Array<AbilityKind> selectedAbilities = new Array<>();
private final UpgradeSystem upgradeSystem;
private final ShopUpgradeSystem shopUpgradeSystem;
private final BonusBallSystem bonusBallSystem = new BonusBallSystem(runtimeBonusBalls);
private final AbilitySystem abilitySystem;
private final TalentTree talentTree;
private final PlayerStats playerStats;
private final EnumMap<PowerAxis, Float> axes = new EnumMap<>(PowerAxis.class);
private float baseDamage = 1f;
private float runDamageBonus;
private int runPierceBonus;
private float runAoeBonus;
private int currentWave = 1;
private int selectedDamageCards;
private int selectedFireRateCards;
private int abilityUses;
private int bonusDrops;
private int bonusSpawned;
private int bonusCollected;
private int peakVisibleBonusBalls;
private int activeBonusUptimeWaves;
private int moneyEarned;
private int moneySpent;
private float damageTaken;
private RuntimeState(long seed, RuntimeRoute route) {
this.route = route;
this.seed = seed;
this.random =
new SeededRandomSource(SeededRandomSource.forkSeed(seed, route.strategyId));
this.playerAbilities = new PlayerAbilities(new RuntimeAbilityPreferences());
for (AbilityKind ability : AbilityKind.values()) {
selectedAbilities.add(ability);
}
this.upgradeSystem = new UpgradeSystem(random, this);
this.shopUpgradeSystem = new ShopUpgradeSystem(this);
this.playerStats = new PlayerStats(new RuntimeAbilityPreferences());
route.applyMeta(playerStats);
this.talentTree = new TalentTree(playerStats);
this.abilitySystem =
new AbilitySystem(
PLAYER_SQUARE,
circles,
playerAbilities,
selectedAbilities,
random,
this);
applyPermanentTalentStats();
for (PowerAxis axis : PowerAxis.values()) {
axes.put(axis, 0f);
}
}
private void applyPermanentTalentStats() {
baseDamage += talentTree.getStatBonus(TalentData.TalentType.DAMAGE);
int maxHealth =
PlayerCombatState.DEFAULT_MAX_HEALTH
+ (int) talentTree.getStatBonus(TalentData.TalentType.HEALTH);
float shotCooldown =
Math.max(
PlayerCombatState.MIN_SHOT_COOLDOWN_MS,
PlayerCombatState.DEFAULT_SHOT_COOLDOWN_MS
- talentTree.getStatBonus(TalentData.TalentType.COOLDOWN));
int regen = (int) talentTree.getStatBonus(TalentData.TalentType.REGEN);
combatState.resetForNewRun(maxHealth, shotCooldown, regen);
combatState.setMoney(20);
}
private void prepareWave(int wave) {
currentWave = wave;
int income = Math.max(1, 3 + wave / 2);
combatState.addMoney(income);
moneyEarned += income;
circles.clear();
WaveTuning tuning = EndlessBalanceSpec.waveTuning(wave);
int health = tuning.basicCircleHealth();
circles.add(
new BasicCircleData(
new Circle(125f, 125f, 10f),
new Vector2(),
health,
tuning.circleDamage()));
}
private void applyRouteChoices(int wave) {
if (route.cardsEnabled && wave % 3 == 0) {
applyCard();
}
if (route.shopEnabled && wave >= route.firstShopWave && wave % 2 == 0) {
buyShopUpgrade();
}
}
private void applyCard() {
upgradeSystem.triggerPick();
UpgradeKind[] options = upgradeSystem.getPickOptions();
if (options == null) return;
int choice;
if (route.rawDamageFireRateOnly) {
UpgradeKind kind =
selectedDamageCards <= selectedFireRateCards
? UpgradeKind.DAMAGE
: UpgradeKind.FIRE_RATE;
choice = indexOf(options, kind);
} else {
choice = chooseFreshStarterCardIfNeeded();
if (choice < 0) {
improveMixedOfferWithRerolls();
choice = chooseMixedCardOption();
}
}
if (choice < 0) choice = 0;
UpgradeKind selected = options[choice];
upgradeSystem.applyPick(choice);
if (isDamageLike(selected)) selectedDamageCards++;
if (isCadenceLike(selected)) selectedFireRateCards++;
}
private boolean isDamageLike(UpgradeKind kind) {
switch (kind) {
case DAMAGE:
case BOSS_BREAKER:
case EXECUTION_LINE:
case BLAST_PROTOCOL:
case VOLATILE_BARGAIN:
return true;
default:
return false;
}
}
private boolean isCadenceLike(UpgradeKind kind) {
switch (kind) {
case FIRE_RATE:
case BONUS_MAGNET:
return true;
default:
return false;
}
}
private int indexOf(UpgradeKind[] options, UpgradeKind kind) {
for (int i = 0; i < options.length; i++) {
if (options[i] == kind) return i;
}
return -1;
}
private int chooseFreshStarterCardIfNeeded() {
if (!route.strategyId.contains("fresh")
|| selectedDamageCards + selectedFireRateCards > 0) {
return -1;
}
int index = indexOfDamageOrCadenceLike(upgradeSystem.getPickOptions());
while (index < 0 && upgradeSystem.rerollsRemaining() > 0) {
if (!upgradeSystem.rerollPick(shopUpgradeSystem.cardRerollDiscount())) {
return -1;
}
index = indexOfDamageOrCadenceLike(upgradeSystem.getPickOptions());
}
return index;
}
private int indexOfDamageOrCadenceLike(UpgradeKind[] options) {
if (options == null) return -1;
for (int i = 0; i < options.length; i++) {
if (isDamageLike(options[i]) || isCadenceLike(options[i])) return i;
}
return -1;
}
private int chooseMixedCardOption() {
UpgradeSystem.OfferCardView[] views = upgradeSystem.activeOfferViews();
int bestIndex = 0;
int bestScore = Integer.MIN_VALUE;
for (int i = 0; i < views.length; i++) {
int score = mixedCardScore(views[i]);
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
private void improveMixedOfferWithRerolls() {
while (bestMixedCardScore() < 50 && upgradeSystem.rerollsRemaining() > 0) {
if (!upgradeSystem.rerollPick(shopUpgradeSystem.cardRerollDiscount())) {
return;
}
}
}
private int bestMixedCardScore() {
UpgradeSystem.OfferCardView[] views = upgradeSystem.activeOfferViews();
int bestScore = Integer.MIN_VALUE;
for (UpgradeSystem.OfferCardView view : views) {
bestScore = Math.max(bestScore, mixedCardScore(view));
}
return bestScore;
}
private int mixedCardScore(UpgradeSystem.OfferCardView view) {
if (view.synergyReady()) return 100;
switch (view.kind()) {
case DAMAGE:
case CRIT:
return view.stacks() < 2 ? 90 - view.stacks() : 40;
case FIRE_RATE:
return view.stacks() < 2 ? 80 - view.stacks() : 35;
case PIERCE:
return view.stacks() < 2 ? 75 - view.stacks() : 50;
case AOE:
return view.stacks() < 2 ? 70 - view.stacks() : 45;
case HEAL:
case REGEN:
return view.stacks() < 2 ? 60 - view.stacks() : 30;
default:
return 0;
}
}
private void buyShopUpgrade() {
ShopUpgradeKind kind;
int lane =
shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE)
+ shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN)
+ shopUpgradeSystem.level(ShopUpgradeKind.REGEN)
+ shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE);
if (route.rawDamageFireRateOnly) {
kind = lane % 2 == 0 ? ShopUpgradeKind.DAMAGE : ShopUpgradeKind.COOLDOWN;
} else {
int family = lane % 4;
if (family == 0) {
kind = ShopUpgradeKind.DAMAGE;
} else if (family == 1) {
kind = ShopUpgradeKind.COOLDOWN;
} else if (family == 2) {
kind = ShopUpgradeKind.REGEN;
} else {
kind = ShopUpgradeKind.CRIT_CHANCE;
}
}
shopUpgradeSystem.tryBuy(kind);
}
private void spawnBonusIfBudgetAllows(int wave) {
int budget = EndlessBalanceSpec.bonusDropBudgetForWave(wave);
if (budget <= 0) return;
for (int i = 0;
i < budget
&& runtimeBonusBalls.size < EndlessBalanceSpec.maxVisibleBonusBalls();
i++) {
BonusBallData ball = Pools.obtain(BonusBallData.class);
BonusBallData.BonusType type =
RUNTIME_BONUS_SEQUENCE[bonusSpawned % RUNTIME_BONUS_SEQUENCE.length];
ball.configure(125f, 125f, 10f, 0f, 0f, type);
runtimeBonusBalls.add(ball);
bonusSpawned++;
bonusDrops = bonusSpawned;
peakVisibleBonusBalls = Math.max(peakVisibleBonusBalls, runtimeBonusBalls.size);
if (shouldCollectSpawnedBonus()) {
bonusBallSystem.collect(runtimeBonusBalls.size - 1);
bonusCollected++;
}
}
}
private boolean shouldCollectSpawnedBonus() {
boolean pickupPity =
route.bonusPickupSkill >= 0.15f
&& runtimeBonusBalls.size >= EndlessBalanceSpec.maxVisibleBonusBalls();
return random.nextFloat() < route.bonusPickupSkill || pickupPity;
}
private void finishWave() {
if (bonusBallSystem.getActiveBonusCount() > 0) {
activeBonusUptimeWaves++;
}
bonusBallSystem.endWave();
}
private void castAbilityIfDue(int wave) {
if (!route.abilitiesEnabled
|| wave < route.firstAbilityWave
|| wave % route.abilityEveryWaves != 0) {
return;
}
long now = 200_000L + wave * 30_000L;
abilitySystem.activateExplosion(0f, 0f, now);
abilityUses++;
abilitySystem.tick(now + 1L);
}
private boolean isOverwhelmed(int wave) {
WaveTuning tuning = EndlessBalanceSpec.waveTuning(wave);
float pressure =
tuning.enemiesPerWave() * tuning.basicCircleHealth() * 0.034f
+ tuning.circleDamage() * 1.65f
+ Math.max(0, wave - 11) * route.postEarlyPressure;
float dps = effectiveDps();
float sustain =
combatState.getMaxHealth() * 0.020f
+ combatState.getHealthRegenRate() * 0.75f
+ runAoeBonus * 0.025f
+ runPierceBonus * 0.30f
+ abilityUses * route.abilityValue;
damageTaken += Math.max(0f, pressure - dps - sustain) * 0.18f;
return wave >= route.minimumFailCheckWave && dps + sustain < pressure;
}
private float effectiveDps() {
EffectiveCombatStats stats = currentStats();
float crit = 1f + stats.critChance() * (stats.critMultiplier() - 1f);
return stats.damage() * crit * 1000f / Math.max(90f, stats.shotCooldownMs());
}
private float bossKillSeconds(int wave) {
return EndlessBalanceSpec.bossHealthForWave(wave) / Math.max(0.01f, effectiveDps());
}
private SimulationReport report(int waveReached, float firstBossKillSeconds) {
EffectiveCombatStats stats = currentStats();
axes.put(PowerAxis.DAMAGE, stats.damage());
axes.put(PowerAxis.CADENCE, 1000f / Math.max(1f, stats.shotCooldownMs()));
axes.put(PowerAxis.HEALTH, (float) stats.healthRegenRate() * 2f);
axes.put(PowerAxis.AOE, stats.aoeRadiusBonus());
axes.put(PowerAxis.CONTROL, stats.controlRating());
axes.put(PowerAxis.REGEN, (float) stats.healthRegenRate());
axes.put(PowerAxis.CRIT, bonusBallSystem.getCritChanceBonus());
axes.put(PowerAxis.UTILITY, bonusBallSystem.getPickupRadiusMultiplier() - 1f);
axes.put(PowerAxis.ECONOMY, (float) moneyEarned / Math.max(1f, waveReached));
axes.put(PowerAxis.ABILITY, Math.max(0f, abilityUses * 1.5f));
for (Map.Entry<PowerAxis, Integer> rating :
talentTree.getPermanentAxisRatings().entrySet()) {
axes.put(rating.getKey(), axes.get(rating.getKey()) + rating.getValue());
}
return new SimulationReport(
seed,
route.strategyId,
waveReached,
firstBossKillSeconds,
shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE)
+ shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN)
+ shopUpgradeSystem.level(ShopUpgradeKind.REGEN)
+ shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE),
0f,
waveReached >= 100 ? 0 : 1,
damageTaken,
bonusDrops,
activeBonusPower(),
Math.max(0.01f, waveReached / 100f),
axes,
"runtime-harness",
firstBossKillSeconds,
shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE)
+ shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN)
+ shopUpgradeSystem.level(ShopUpgradeKind.REGEN)
+ shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE),
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
stats.damage(),
stats.shotCooldownMs(),
bonusSpawned,
bonusCollected,
activeBonusUptimeWaves,
upgradeSystem.triggeredSynergyCount(),
moneyEarned,
moneySpent);
}
private float activeBonusPower() {
return Math.max(
Math.max(
bonusBallSystem.getDamageMultiplier(),
1f / Math.max(0.01f, bonusBallSystem.getCooldownMultiplier())),
1f
+ bonusBallSystem.getCritChanceBonus()
+ bonusBallSystem.getPickupRadiusMultiplier()
- 1f);
}
private EffectiveCombatStats currentStats() {
return EffectiveCombatStats.fromSources(
combatState,
shopUpgradeSystem,
upgradeSystem,
bonusBallSystem,
talentTree,
currentWave,
baseDamage,
runDamageBonus,
runPierceBonus,
runAoeBonus,
runPierceBonus);
}
@Override
public void addRunDamagePercentBonus(int percent) {
runDamageBonus += Math.max(1f, baseDamage * percent / 100f);
}
@Override
public void addRunPierceBonus(int bonus) {
runPierceBonus += bonus;
}
@Override
public void addRunAoeBonus(float radiusBonus) {
runAoeBonus += radiusBonus;
}
@Override
public void addMaxHealth(int amount) {
combatState.setMaxHealth(combatState.getMaxHealth() + amount);
}
@Override
public void healToFull() {
combatState.setHealth(combatState.getMaxHealth());
}
@Override
public void addHealthRegen(int perSecond) {
combatState.setHealthRegenRate(combatState.getHealthRegenRate() + perSecond);
}
@Override
public void addFloatingText(float x, float y, String text, Color color) {}
@Override
public float getPlayerCenterX() {
return PLAYER_SQUARE.x + PLAYER_SQUARE.width / 2f;
}
@Override
public float getPlayerCenterY() {
return PLAYER_SQUARE.y + PLAYER_SQUARE.height / 2f;
}
@Override
public boolean spendMoney(int amount) {
boolean spent = combatState.spendMoney(amount);
if (spent) {
moneySpent += amount;
}
return spent;
}
@Override
public void addBaseDamage(int amount) {
}
@Override
public void addBaseSpeed(float amount) {}
@Override
public void reduceShotCooldown(float amount, float minimum) {
}
@Override
public void createExplosion(float x, float y, Color color) {}
@Override
public void triggerKillShake() {}
@Override
public void triggerCritHitstop() {}
@Override
public int effectiveBulletDamage() {
return Math.round(currentStats().damage());
}
@Override
public float abilityScalingFactor() {
return PlayerRunCombatCalculator.abilityScalingFactor(currentWave);
}
@Override
public float gameScale() {
return 1f;
}
@Override
public long abilityCooldownReductionMs() {
return 0L;
}
@Override
public boolean isBoss(BaseCircleData circle) {
return false;
}
@Override
public void addMoney(int amount) {
combatState.addMoney(amount);
moneyEarned += Math.max(0, amount);
}
@Override
public void killEnemy(BaseCircleData enemy) {
addMoney(enemy.getReward());
circles.removeValue(enemy, true);
}
@Override
public void onTurretShot(Turret turret, BaseCircleData target, long now) {}
}
private static final class RuntimeRoute {
private final String strategyId;
private final boolean shopEnabled;
private final boolean cardsEnabled;
private final boolean abilitiesEnabled;
private final boolean rawDamageFireRateOnly;
private final int firstShopWave;
private final int firstAbilityWave;
private final int abilityEveryWaves;
private final int minimumFailCheckWave;
private final float bonusPickupSkill;
private final float abilityValue;
private final float postEarlyPressure;
private final int metaProfile;
private RuntimeRoute(
String strategyId,
boolean shopEnabled,
boolean cardsEnabled,
boolean abilitiesEnabled,
boolean rawDamageFireRateOnly,
int firstShopWave,
int firstAbilityWave,
int abilityEveryWaves,
int minimumFailCheckWave,
float bonusPickupSkill,
float abilityValue,
float postEarlyPressure) {
this(
strategyId,
shopEnabled,
cardsEnabled,
abilitiesEnabled,
rawDamageFireRateOnly,
firstShopWave,
firstAbilityWave,
abilityEveryWaves,
minimumFailCheckWave,
bonusPickupSkill,
abilityValue,
postEarlyPressure,
0);
}
private RuntimeRoute(
String strategyId,
boolean shopEnabled,
boolean cardsEnabled,
boolean abilitiesEnabled,
boolean rawDamageFireRateOnly,
int firstShopWave,
int firstAbilityWave,
int abilityEveryWaves,
int minimumFailCheckWave,
float bonusPickupSkill,
float abilityValue,
float postEarlyPressure,
int metaProfile) {
this.strategyId = strategyId;
this.shopEnabled = shopEnabled;
this.cardsEnabled = cardsEnabled;
this.abilitiesEnabled = abilitiesEnabled;
this.rawDamageFireRateOnly = rawDamageFireRateOnly;
this.firstShopWave = firstShopWave;
this.firstAbilityWave = firstAbilityWave;
this.abilityEveryWaves = abilityEveryWaves;
this.minimumFailCheckWave = minimumFailCheckWave;
this.bonusPickupSkill = bonusPickupSkill;
this.abilityValue = abilityValue;
this.postEarlyPressure = postEarlyPressure;
this.metaProfile = metaProfile;
}
private static RuntimeRoute fresh() {
return new RuntimeRoute(
"runtime:fresh:no-menu-talent:shop-card-ability",
true,
true,
true,
false,
2,
6,
4,
10,
0.20f,
0.65f,
2.6f);
}
private static RuntimeRoute exploit() {
return new RuntimeRoute(
"runtime:damage-fire-rate:no-shop:no-ability",
false,
true,
false,
true,
999,
999,
999,
3,
0.05f,
0f,
3.4f);
}
private static RuntimeRoute balanced() {
return new RuntimeRoute(
"runtime:balanced:shop:ability",
true,
true,
true,
false,
2,
6,
3,
24,
0.30f,
1.40f,
1.6f);
}
private static RuntimeRoute moderateMeta() {
return new RuntimeRoute(
"runtime:moderate-meta:shop-card-ability",
true,
true,
true,
false,
2,
6,
3,
28,
0.35f,
1.55f,
1.45f,
1);
}
private static RuntimeRoute highMeta() {
return new RuntimeRoute(
"runtime:high-meta:shop-card-ability",
true,
true,
true,
false,
2,
6,
3,
34,
0.40f,
1.70f,
1.35f,
2);
}
private void applyMeta(PlayerStats stats) {
if (metaProfile <= 0) return;
invest(stats, "health_1", metaProfile == 1 ? 3 : 5);
invest(stats, "damage_1", metaProfile == 1 ? 2 : 3);
invest(stats, "cooldown_1", metaProfile == 1 ? 2 : 4);
invest(stats, "speed_1", metaProfile == 1 ? 1 : 3);
invest(stats, "regen_1", metaProfile == 1 ? 2 : 4);
invest(stats, "control_1", metaProfile == 1 ? 1 : 3);
invest(stats, "economy_1", metaProfile == 1 ? 1 : 3);
invest(stats, "ability_1", metaProfile == 1 ? 1 : 3);
invest(stats, "utility_1", metaProfile == 1 ? 1 : 3);
invest(stats, "economy_reroll", metaProfile == 1 ? 1 : 2);
invest(stats, "utility_bonus", metaProfile == 1 ? 1 : 2);
if (metaProfile >= 2) {
invest(stats, "health_2", 2);
invest(stats, "cooldown_2", 2);
invest(stats, "regen_2", 2);
invest(stats, "keystone_pierce", 1);
invest(stats, "keystone_aoe", 1);
invest(stats, "attack_precision", 2);
invest(stats, "control_execute", 2);
invest(stats, "engineering_turret", 2);
invest(stats, "survival_barrier", 2);
invest(stats, "ability_blast", 2);
invest(stats, "keystone_turret", 1);
invest(stats, "keystone_control_execute", 1);
invest(stats, "keystone_shield_regen", 1);
invest(stats, "keystone_economy_reroll", 1);
}
}
private void invest(PlayerStats stats, String talentId, int levels) {
for (int i = 0; i < levels; i++) {
stats.incrementTalentNode(talentId);
}
}
}
private static final class RuntimeAbilityPreferences implements Preferences {
@Override
public boolean getBoolean(String key, boolean defValue) {
return true;
}
@Override
public Preferences putBoolean(String key, boolean val) {
return this;
}
@Override
public Preferences putInteger(String key, int val) {
return this;
}
@Override
public Preferences putLong(String key, long val) {
return this;
}
@Override
public Preferences putFloat(String key, float val) {
return this;
}
@Override
public Preferences putString(String key, String val) {
return this;
}
@Override
public Preferences put(Map<String, ?> vals) {
return this;
}
@Override
public boolean getBoolean(String key) {
return true;
}
@Override
public int getInteger(String key) {
return 0;
}
@Override
public long getLong(String key) {
return 0L;
}
@Override
public float getFloat(String key) {
return 0f;
}
@Override
public String getString(String key) {
return "";
}
@Override
public int getInteger(String key, int defValue) {
return defValue;
}
@Override
public long getLong(String key, long defValue) {
return defValue;
}
@Override
public float getFloat(String key, float defValue) {
return defValue;
}
@Override
public String getString(String key, String defValue) {
return defValue;
}
@Override
public Map<String, ?> get() {
return new HashMap<>();
}
@Override
public boolean contains(String key) {
return true;
}
@Override
public void clear() {}
@Override
public void remove(String key) {}
@Override
public void flush() {}
}
}
@@ -0,0 +1,137 @@
package ru.project.tower.balance;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.run.RunRngStreams;
public final class BalanceSimulationHarness {
private static final int TARGET_WAVES = 120;
public List<SimulationReport> runRepresentativeSuite(long[] seeds) {
BalanceRouteSimulator routeSimulator = new BalanceRouteSimulator();
List<SimulationReport> reports = new ArrayList<>();
for (long seed : seeds) {
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runFreshNoMenuTalentShopCardAbilityRoute(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runDamageFireRateNoShopNoAbility(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runBalancedShopAndAbilityRoute(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runSustainControlShopAndAbilityRoute(seed)));
reports.add(
SimulationReport.fromRoute(
seed, routeSimulator.runMixedNoShopNoAbilityRoute(seed)));
}
return reports;
}
public List<SimulationReport> runRuntimeBackedSuite(long[] seeds) {
BalanceRuntimeHarness runtimeHarness = new BalanceRuntimeHarness();
List<SimulationReport> reports = new ArrayList<>();
for (long seed : seeds) {
reports.addAll(runtimeHarness.run(seed));
}
return reports;
}
public SimulationReport run(long seed, StrategyProfile profile) {
RunRngStreams streams = RunRngStreams.fromSeed(seed);
EnumMap<PowerAxis, Float> ratings = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
ratings.put(axis, profile.metaRating() * profile.weight(axis) * 0.35f);
}
int purchases = 0;
int activeDamageStacks = 0;
int activeSpeedStacks = 0;
int activeFireRateStacks = 0;
float survivability = 1f;
float bossKillSeconds = 0f;
float clearSecondsTotal = 0f;
float damageTaken = 0f;
int bonusDrops = 0;
int waveReached = 0;
for (int wave = 1; wave <= TARGET_WAVES; wave++) {
waveReached = wave;
PowerAxis chosenAxis = chooseAxis(streams.upgradeDrafts().nextFloat(), profile);
ratings.put(chosenAxis, ratings.get(chosenAxis) + 2.2f * profile.weight(chosenAxis));
purchases++;
if (streams.bonusDrops().nextFloat() < budgetedDropChance(wave)) {
bonusDrops++;
int type = streams.bonusDrops().nextIntInclusive(2);
if (type == 0) activeDamageStacks++;
if (type == 1) activeSpeedStacks++;
if (type == 2) activeFireRateStacks++;
}
bossKillSeconds = EndlessBalanceSpec.expectedBossKillSeconds(wave, ratings);
float incoming =
EndlessBalanceSpec.waveTuning(wave).circleDamage()
* (1.1f - Math.min(0.55f, ratings.get(PowerAxis.CONTROL) * 0.006f));
float mitigation =
EndlessBalanceSpec.axisMultiplier(ratings.get(PowerAxis.SHIELD))
+ EndlessBalanceSpec.axisMultiplier(ratings.get(PowerAxis.REGEN)) * 0.7f
+ EndlessBalanceSpec.axisMultiplier(ratings.get(PowerAxis.HEALTH))
* 0.5f;
survivability += mitigation * 0.015f - incoming * 0.010f;
damageTaken += Math.max(0f, incoming - mitigation * 0.35f);
clearSecondsTotal +=
Math.max(3f, EndlessBalanceSpec.waveTuning(wave).enemiesPerWave() / 8f);
if (survivability <= 0.15f) {
break;
}
}
float activeBonusPower =
EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.DAMAGE, activeDamageStacks)
+ EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.BULLET_SPEED, activeSpeedStacks)
+ (1f
/ EndlessBalanceSpec.activeBonusMultiplier(
BonusBallData.BonusType.FIRE_RATE, activeFireRateStacks));
return new SimulationReport(
seed,
profile.id(),
waveReached,
bossKillSeconds,
purchases,
clearSecondsTotal / Math.max(1, waveReached),
survivability <= 0.15f ? 1 : 0,
damageTaken,
bonusDrops,
activeBonusPower,
Math.max(0.01f, survivability),
ratings);
}
private static PowerAxis chooseAxis(float roll, StrategyProfile profile) {
float total = 0f;
for (PowerAxis axis : PowerAxis.values()) {
total += profile.weight(axis);
}
float target = roll * total;
float cumulative = 0f;
for (PowerAxis axis : PowerAxis.values()) {
cumulative += profile.weight(axis);
if (target <= cumulative) {
return axis;
}
}
return PowerAxis.DAMAGE;
}
private static float budgetedDropChance(int wave) {
return Math.min(
0.32f, EndlessBalanceSpec.bonusDropBudgetThroughWave(wave) / (float) (wave + 12));
}
}
@@ -0,0 +1,78 @@
package ru.project.tower.balance;
public final class BossBalanceSpec {
private static final BossBalanceSpec GENERIC =
new BossBalanceSpec("generic", 1.0f, 8f, 32f, 7f, 42f, 5f);
private static final BossBalanceSpec[] SPECS = {
new BossBalanceSpec("SwarmQueen", 1.05f, 9f, 34f, 8f, 44f, 6f),
new BossBalanceSpec("Juggernaut", 1.22f, 11f, 40f, 9f, 48f, 7f),
new BossBalanceSpec("ShadowWeaver", 0.96f, 8f, 32f, 7f, 42f, 5f),
new BossBalanceSpec("VolatileReactor", 1.08f, 9f, 35f, 8f, 45f, 5f),
new BossBalanceSpec("GlacialWarden", 1.14f, 10f, 38f, 8f, 46f, 7f),
new BossBalanceSpec("IllusionMaster", 1.00f, 8f, 33f, 7f, 43f, 6f)
};
private final String id;
private final float healthMultiplier;
private final float firstBossMinTtkSeconds;
private final float firstBossMaxTtkSeconds;
private final float lateBossMinTtkSeconds;
private final float lateBossMaxTtkSeconds;
private final float minimumMechanicExposureSeconds;
private BossBalanceSpec(
String id,
float healthMultiplier,
float firstBossMinTtkSeconds,
float firstBossMaxTtkSeconds,
float lateBossMinTtkSeconds,
float lateBossMaxTtkSeconds,
float minimumMechanicExposureSeconds) {
this.id = id;
this.healthMultiplier = healthMultiplier;
this.firstBossMinTtkSeconds = firstBossMinTtkSeconds;
this.firstBossMaxTtkSeconds = firstBossMaxTtkSeconds;
this.lateBossMinTtkSeconds = lateBossMinTtkSeconds;
this.lateBossMaxTtkSeconds = lateBossMaxTtkSeconds;
this.minimumMechanicExposureSeconds = minimumMechanicExposureSeconds;
}
public static BossBalanceSpec forBoss(String id) {
for (BossBalanceSpec spec : SPECS) {
if (spec.id.equals(id)) return spec;
}
return GENERIC;
}
public String id() {
return id;
}
public int healthFloorForWave(int wave) {
return Math.round(EndlessBalanceSpec.bossHealthForWave(wave) * healthMultiplier);
}
public float expectedKillSecondsAtWave(int wave, float effectiveDps) {
return healthFloorForWave(wave) / Math.max(0.01f, effectiveDps);
}
public float firstBossMinTtkSeconds() {
return firstBossMinTtkSeconds;
}
public float firstBossMaxTtkSeconds() {
return firstBossMaxTtkSeconds;
}
public float lateBossMinTtkSeconds() {
return lateBossMinTtkSeconds;
}
public float lateBossMaxTtkSeconds() {
return lateBossMaxTtkSeconds;
}
public float minimumMechanicExposureSeconds() {
return minimumMechanicExposureSeconds;
}
}
@@ -0,0 +1,501 @@
package ru.project.tower.balance;
import java.util.Map;
import ru.project.tower.entities.circles.BonusBallData;
import ru.project.tower.upgrades.shop.ShopUpgradeKind;
import ru.project.tower.upgrades.shop.ShopUpgradeRegistry;
import ru.project.tower.waves.WaveTuning;
public final class EndlessBalanceSpec {
public static final int SHOP_MAX_LEVEL = 30;
public static final int TALENT_AXIS_MAX_RATING = 55;
public static final float CRIT_CHANCE_CAP = 0.65f;
public static final float ACTIVE_DAMAGE_BONUS_CAP = 2.35f;
public static final float ACTIVE_FIRE_RATE_COOLDOWN_FLOOR = 0.42f;
public static final float ACTIVE_BULLET_SPEED_CAP = 2.25f;
public static final int BONUS_DROP_BUDGET_PER_TEN_WAVES = 7;
public static final int MAX_VISIBLE_BONUS_BALLS = 3;
public static final int BOSS_WAVE_INTERVAL = 20;
public static final float MIN_EXPECTED_LATE_BOSS_KILL_SECONDS = 6.0f;
private static final String[] ARCHETYPES = {
"BASIC", "STRONG", "FAST", "SPLITTER", "HEALER", "DASHER", "SNIPER", "SPAWNER"
};
private static final WaveBandTarget[] WAVE_BAND_TARGETS = {
new WaveBandTarget("teach", 1, 20, 1.0f, "teach movement, pickups, first boss prep"),
new WaveBandTarget("build-check", 21, 60, 1.65f, "require mixed cards and early shop"),
new WaveBandTarget(
"synergy-check", 61, 100, 2.35f, "require shop, abilities, and synergies"),
new WaveBandTarget(
"endless-pressure", 101, Integer.MAX_VALUE, 3.10f, "stress capped builds")
};
private static final BalanceRouteTarget[] ROUTE_TARGETS = {
new BalanceRouteTarget(
"fresh:no-menu-talent:shop-card-ability",
10,
12,
0f,
0f,
false,
true,
true,
true,
"fresh profile learns in-run shop, cards, and abilities before meta carries"
+ " farther"),
new BalanceRouteTarget(
"challenge:no-menu-talent:no-shop:no-ability",
3,
7,
0f,
0f,
false,
false,
false,
false,
"challenge profile intentionally ignores core in-run tools"),
new BalanceRouteTarget(
"damage-fire-rate:no-shop:no-ability",
20,
99,
8f,
60f,
false,
false,
true,
false,
"regression profile must not revive the old wave-100 exploit"),
new BalanceRouteTarget(
"mixed:no-shop:no-ability",
20,
99,
7f,
60f,
false,
false,
true,
false,
"mixed cards beat raw exploit but still need shop and abilities"),
new BalanceRouteTarget(
"balanced:shop:ability",
25,
120,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
45f,
false,
true,
true,
true,
"balanced route demonstrates normal in-run progression"),
new BalanceRouteTarget(
"sustain-control:shop:ability",
25,
120,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
45f,
false,
true,
true,
true,
"defensive route remains viable without raw damage dominance"),
new BalanceRouteTarget(
"moderate-meta:shop-card-ability",
45,
90,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
40f,
true,
true,
true,
true,
"moderate meta progression pushes past early walls without endless dominance"),
new BalanceRouteTarget(
"high-meta:capped-shop-card-ability",
100,
140,
MIN_EXPECTED_LATE_BOSS_KILL_SECONDS,
35f,
true,
true,
true,
true,
"capped meta handles endless pressure but must still respect boss TTK")
};
private EndlessBalanceSpec() {}
public static WaveTuning waveTuning(int wave) {
int safeWave = Math.max(1, wave);
float band = band(safeWave);
int enemies = Math.min(240, Math.round(5f + (safeWave - 1) * 1.6f + band * 8f));
float strong = Math.min(0.75f, 0.10f * (1 + (safeWave - 1) * 0.1f));
float fast = safeWave >= 3 ? 0.15f * (1 + (safeWave - 3) * 0.05f) : 0f;
float splitter = 0f;
if (safeWave >= 7) {
float progressToMax = Math.min(1f, (safeWave - 7) / 10f);
splitter = 0.05f + (0.15f - 0.05f) * progressToMax;
}
int basicHealth =
Math.min(20_000, 1 + Math.round((float) Math.pow(safeWave - 1, 1.45f) * 2.2f));
int strongHealth = Math.max(3, basicHealth * 3);
int damage = Math.min(180, 1 + Math.round((float) Math.pow(safeWave - 1, 1.05f) * 0.45f));
float speed = Math.min(2.6f, 1f + (safeWave - 1) * 0.012f);
return new WaveTuning(
safeWave,
enemies,
strong,
fast,
splitter,
basicHealth,
strongHealth,
damage,
speed);
}
public static WaveBandTarget waveBandTarget(int wave) {
int safeWave = Math.max(1, wave);
for (WaveBandTarget target : WAVE_BAND_TARGETS) {
if (safeWave >= target.firstWave() && safeWave <= target.lastWave()) {
return target;
}
}
return WAVE_BAND_TARGETS[WAVE_BAND_TARGETS.length - 1];
}
public static BalanceRouteTarget routeTarget(String strategyId) {
for (BalanceRouteTarget target : ROUTE_TARGETS) {
if (target.strategyId().equals(strategyId)) {
return target;
}
}
throw new IllegalArgumentException("Unknown balance route target: " + strategyId);
}
public static EnemyComposition compositionForWave(int wave) {
int safeWave = Math.max(1, wave);
float strong = safeWave >= 2 ? Math.min(0.22f, 0.08f + safeWave * 0.0015f) : 0f;
float fast = safeWave >= 3 ? Math.min(0.16f, 0.06f + safeWave * 0.001f) : 0f;
float splitter = safeWave >= 7 ? Math.min(0.12f, 0.04f + safeWave * 0.001f) : 0f;
float healer = safeWave >= 8 ? Math.min(0.08f, 0.025f + safeWave * 0.0007f) : 0f;
float dasher = safeWave >= 6 ? Math.min(0.11f, 0.04f + safeWave * 0.0008f) : 0f;
float sniper = safeWave >= 7 ? Math.min(0.09f, 0.03f + safeWave * 0.0007f) : 0f;
float spawner =
safeWave >= 15 && (safeWave - 15) % 11 == 0
? Math.min(0.10f, 0.05f + safeWave * 0.0005f)
: 0f;
float basic =
Math.max(0.20f, 1f - strong - fast - splitter - healer - dasher - sniper - spawner);
return new EnemyComposition(
ARCHETYPES,
new float[] {basic, strong, fast, splitter, healer, dasher, sniper, spawner});
}
public static int bonusDropBudgetThroughWave(int wave) {
return Math.max(1, ((Math.max(1, wave) + 9) / 10) * BONUS_DROP_BUDGET_PER_TEN_WAVES);
}
public static int bonusDropBudgetForWave(int wave) {
int bandWave = (Math.max(1, wave) - 1) % 10;
return bandWave < BONUS_DROP_BUDGET_PER_TEN_WAVES ? 1 : 0;
}
public static int maxVisibleBonusBalls() {
return MAX_VISIBLE_BONUS_BALLS;
}
public static float activeBonusMultiplier(BonusBallData.BonusType type, int stacks) {
int safeStacks = Math.max(0, stacks);
switch (type) {
case DAMAGE:
if (safeStacks == 0) return 1f;
return Math.min(
ACTIVE_DAMAGE_BONUS_CAP,
2f + diminishing(0f, 0.18f, safeStacks - 1, 0.55f));
case BULLET_SPEED:
if (safeStacks == 0) return 1f;
if (safeStacks == 1) return 1.5f;
return Math.min(
ACTIVE_BULLET_SPEED_CAP,
2.25f + diminishing(0f, 0.08f, safeStacks - 2, 0.50f));
case FIRE_RATE:
if (safeStacks == 0) return 1f;
return Math.max(
ACTIVE_FIRE_RATE_COOLDOWN_FLOOR,
(float) (0.6f * Math.pow(0.75f, safeStacks - 1)));
default:
throw new IllegalArgumentException("Unknown bonus type: " + type);
}
}
public static int upgradeCardDamagePercent(int existingStacks) {
int safeStacks = Math.max(0, existingStacks);
return Math.max(6, Math.round(15f * (float) Math.pow(0.86f, safeStacks)));
}
public static float upgradeCardFireRateMultiplier(int existingStacks) {
int safeStacks = Math.max(0, existingStacks);
return Math.min(0.96f, 1f - 0.10f * (float) Math.pow(0.72f, safeStacks));
}
public static int shopCost(ShopUpgradeKind kind, int level) {
int safeLevel = Math.max(0, level);
double scale = ShopUpgradeRegistry.definition(kind).costScale();
return Math.max(5, (int) (5.0 * Math.pow(scale, Math.min(safeLevel, SHOP_MAX_LEVEL))));
}
public static float shopRating(ShopUpgradeKind kind, int level) {
float familyScale =
kind == ShopUpgradeKind.CRIT_CHANCE || kind == ShopUpgradeKind.CRIT_MULTIPLIER
? 0.82f
: 1f;
return familyScale * diminishing(0f, 4f, Math.min(level, SHOP_MAX_LEVEL), 0.89f);
}
public static int shopDamageBonusAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.DAMAGE, level) * 0.25f);
}
public static float shopSpeedBonusAtLevel(int level) {
return shopRating(ShopUpgradeKind.SPEED, level) * 12.5f;
}
public static float shopCooldownReductionAtLevel(int level) {
return shopRating(ShopUpgradeKind.COOLDOWN, level) * 12.5f;
}
public static int shopRegenBonusAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.REGEN, level) * 0.25f);
}
public static float shopCritChanceBonusAtLevel(int level) {
return Math.min(CRIT_CHANCE_CAP, shopRating(ShopUpgradeKind.CRIT_CHANCE, level) * 0.0065f);
}
public static float shopCritMultiplierBonusAtLevel(int level) {
return shopRating(ShopUpgradeKind.CRIT_MULTIPLIER, level) * 0.0305f;
}
public static int shopPierceBonusAtLevel(int level) {
return Math.min(6, Math.round(shopRating(ShopUpgradeKind.PIERCE, level) * 0.18f));
}
public static float shopAoeRadiusBonusAtLevel(int level) {
return Math.min(180f, shopRating(ShopUpgradeKind.SPLASH_DAMAGE, level) * 3.2f);
}
public static float shopExecuteDamageBonusAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.EXECUTE_DAMAGE, level) * 0.006f);
}
public static float shopBossDamageBonusAtLevel(int level) {
return Math.min(0.40f, shopRating(ShopUpgradeKind.BOSS_DAMAGE, level) * 0.0065f);
}
public static float shopControlledDamageBonusAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.CONTROL_DAMAGE, level) * 0.006f);
}
public static float shopContactArmorReductionAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.CONTACT_ARMOR, level) * 0.006f);
}
public static float shopProjectileArmorReductionAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.PROJECTILE_ARMOR, level) * 0.005f);
}
public static int shopShieldCapacityBonusAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.SHIELD_CAPACITY, level) * 1.8f);
}
public static float shopShieldRechargeReductionAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.SHIELD_RECHARGE, level) * 0.006f);
}
public static float shopEmergencyBarrierThresholdAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.EMERGENCY_BARRIER, level) * 0.004f);
}
public static int shopWaveRecoveryHealAtLevel(int level) {
return Math.round(shopRating(ShopUpgradeKind.WAVE_RECOVERY, level) * 0.65f);
}
public static float shopSafeMobilityReductionAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.SAFE_MOBILITY, level) * 0.005f);
}
public static float shopSlowAuraStrengthAtLevel(int level) {
return Math.min(0.24f, shopRating(ShopUpgradeKind.SLOW_AURA, level) * 0.004f);
}
public static float shopPickupRadiusBonusAtLevel(int level) {
return Math.min(180f, shopRating(ShopUpgradeKind.PICKUP_RADIUS, level) * 2.5f);
}
public static int shopBonusDurationBonusAtLevel(int level) {
return Math.min(3, Math.round(shopRating(ShopUpgradeKind.BONUS_DURATION, level) * 0.08f));
}
public static float shopBonusPowerBonusAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.BONUS_POWER, level) * 0.005f);
}
public static float shopBonusStabilityBonusAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.BONUS_STABILITY, level) * 0.006f);
}
public static int shopBonusPityBonusAtLevel(int level) {
return Math.min(3, Math.round(shopRating(ShopUpgradeKind.BONUS_PITY, level) * 0.08f));
}
public static float shopBonusFilterChanceAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.BONUS_FILTER, level) * 0.006f);
}
public static float shopCoinIncomeBonusAtLevel(int level) {
return Math.min(0.45f, shopRating(ShopUpgradeKind.COIN_INCOME, level) * 0.007f);
}
public static float shopDiscountAtLevel(int level) {
return Math.min(0.25f, shopRating(ShopUpgradeKind.SHOP_DISCOUNT, level) * 0.004f);
}
public static float shopCardRerollDiscountAtLevel(int level) {
return Math.min(0.35f, shopRating(ShopUpgradeKind.CARD_REROLL, level) * 0.006f);
}
public static float shopSynergyCatalystBonusAtLevel(int level) {
return Math.min(0.30f, shopRating(ShopUpgradeKind.SYNERGY_CATALYST, level) * 0.005f);
}
public static int permanentTalentRating(int investedLevels) {
int safeLevels = Math.max(0, investedLevels);
if (safeLevels >= 30) {
return TALENT_AXIS_MAX_RATING;
}
return Math.min(TALENT_AXIS_MAX_RATING, Math.round(diminishing(0f, 5f, safeLevels, 0.88f)));
}
public static float axisMultiplier(float rating) {
return 1f + (float) (Math.log1p(Math.max(0f, rating)) / Math.log(10.0));
}
public static float critChanceFromRating(float baseCritChance, float rating) {
return Math.min(CRIT_CHANCE_CAP, baseCritChance + rating * 0.0065f);
}
public static int bossHealthForWave(int wave) {
int safeWave = Math.max(BOSS_WAVE_INTERVAL, wave);
return Math.round(180f + safeWave * 34f + band(safeWave) * 420f);
}
public static int bossHealthForWave(String bossId, int wave) {
return BossBalanceSpec.forBoss(bossId).healthFloorForWave(wave);
}
public static float expectedBossKillSeconds(int wave, Map<PowerAxis, Float> ratings) {
float effectiveDps = 10f * offenseMultiplier(ratings);
return bossHealthForWave(wave) / effectiveDps;
}
public static float offenseMultiplier(Map<PowerAxis, Float> ratings) {
return cappedAxisMultiplier(ratings, PowerAxis.DAMAGE, 1f)
* cappedAxisMultiplier(ratings, PowerAxis.CADENCE, 1f)
* cappedAxisMultiplier(ratings, PowerAxis.CRIT, 0.6f)
* cappedAxisMultiplier(ratings, PowerAxis.PIERCE, 0.18f)
* cappedAxisMultiplier(ratings, PowerAxis.AOE, 0.24f)
* cappedAxisMultiplier(ratings, PowerAxis.CONTROL, 0.16f)
* cappedAxisMultiplier(ratings, PowerAxis.BOSSING, 0.30f);
}
public static float effectivePower(Map<PowerAxis, Float> ratings) {
float total = 0f;
for (PowerAxis axis : PowerAxis.values()) {
total += cappedAxisMultiplier(ratings, axis, 1f);
}
float synergy =
Math.min(
cappedValue(ratings, PowerAxis.DAMAGE),
cappedValue(ratings, PowerAxis.CRIT))
* 0.025f
+ Math.min(
value(ratings, PowerAxis.SHIELD),
value(ratings, PowerAxis.REGEN))
* 0.020f
+ Math.min(value(ratings, PowerAxis.AOE), value(ratings, PowerAxis.CONTROL))
* 0.018f;
return total + synergy;
}
private static float cappedAxisMultiplier(
Map<PowerAxis, Float> ratings, PowerAxis axis, float scale) {
return axisMultiplier(cappedValue(ratings, axis) * scale);
}
private static float cappedValue(Map<PowerAxis, Float> ratings, PowerAxis axis) {
float raw = value(ratings, axis);
BalanceEffectSpec spec = BalanceEffectRegistry.forAxis(axis);
float floored = spec.ratingFloor() == null ? raw : Math.max(spec.ratingFloor(), raw);
return spec.ratingCap() == null ? floored : Math.min(spec.ratingCap(), floored);
}
private static float value(Map<PowerAxis, Float> ratings, PowerAxis axis) {
if (ratings == null) return 0f;
Float value = ratings.get(axis);
return value == null ? 0f : value;
}
private static float diminishing(float base, float perStack, int stacks, float retention) {
float value = base;
float next = perStack;
for (int i = 0; i < stacks; i++) {
value += next;
next *= retention;
}
return value;
}
private static float band(int wave) {
return Math.max(0, (wave - 1) / 10);
}
public static final class WaveBandTarget {
private final String id;
private final int firstWave;
private final int lastWave;
private final float pressureMultiplier;
private final String designGoal;
private WaveBandTarget(
String id,
int firstWave,
int lastWave,
float pressureMultiplier,
String designGoal) {
this.id = id;
this.firstWave = firstWave;
this.lastWave = lastWave;
this.pressureMultiplier = pressureMultiplier;
this.designGoal = designGoal;
}
public String id() {
return id;
}
public int firstWave() {
return firstWave;
}
public int lastWave() {
return lastWave;
}
public float pressureMultiplier() {
return pressureMultiplier;
}
public String designGoal() {
return designGoal;
}
}
}
@@ -0,0 +1,76 @@
package ru.project.tower.balance;
import java.util.Arrays;
import java.util.Objects;
public final class EnemyComposition {
private final String[] archetypes;
private final float[] weights;
public EnemyComposition(String[] archetypes, float[] weights) {
if (archetypes.length != weights.length || archetypes.length == 0) {
throw new IllegalArgumentException("composition requires matching non-empty arrays");
}
this.archetypes = Arrays.copyOf(archetypes, archetypes.length);
this.weights = Arrays.copyOf(weights, weights.length);
normalize(this.weights);
}
public int size() {
return archetypes.length;
}
public String archetypeAt(int index) {
return archetypes[index];
}
public float weightAt(int index) {
return weights[index];
}
public float weightOf(String archetype) {
Objects.requireNonNull(archetype, "archetype");
for (int i = 0; i < archetypes.length; i++) {
if (archetypes[i].equals(archetype)) {
return weights[i];
}
}
return 0f;
}
public float totalWeight() {
float total = 0f;
for (float weight : weights) {
total += weight;
}
return total;
}
public String select(float roll) {
float clampedRoll = Math.max(0f, Math.min(0.999999f, roll));
float cumulative = 0f;
for (int i = 0; i < archetypes.length; i++) {
cumulative += weights[i];
if (clampedRoll < cumulative) {
return archetypes[i];
}
}
return archetypes[archetypes.length - 1];
}
private static void normalize(float[] weights) {
float total = 0f;
for (float weight : weights) {
if (weight < 0f) {
throw new IllegalArgumentException("composition weights cannot be negative");
}
total += weight;
}
if (total <= 0f) {
throw new IllegalArgumentException("composition weight total must be > 0");
}
for (int i = 0; i < weights.length; i++) {
weights[i] /= total;
}
}
}
@@ -0,0 +1,33 @@
package ru.project.tower.balance;
public final class MetaProgressionPacing {
public static final int FIRST_ABILITY_TARGET_WAVE = 12;
public static final int FIRST_BOSS_TARGET_WAVE = 20;
public static final int FIRST_KEYSTONE_TARGET_WAVE = 25;
public static final int FIRST_MAXED_AXIS_TARGET_RUNS = 8;
private MetaProgressionPacing() {}
public static int currencyEarnedForRun(int reachedWave) {
int safeWave = Math.max(0, reachedWave);
int waveClearCurrency = safeWave * 5 + (safeWave / 5) * 10;
int gameOverAward = safeWave;
return waveClearCurrency + gameOverAward;
}
public static int projectedCurrencyAfterRuns(int... reachedWaves) {
int total = 0;
for (int wave : reachedWaves) {
total += currencyEarnedForRun(wave);
}
return total;
}
public static int firstKeystoneCostTarget() {
return 700;
}
public static int firstMaxedAxisCostTarget() {
return 100 + 150 + 225 + 337 + 506;
}
}
@@ -0,0 +1,18 @@
package ru.project.tower.balance;
public enum PowerAxis {
DAMAGE,
CADENCE,
CRIT,
PIERCE,
AOE,
CONTROL,
BOSSING,
HEALTH,
REGEN,
SHIELD,
ARMOR,
ECONOMY,
UTILITY,
ABILITY
}
@@ -0,0 +1,156 @@
package ru.project.tower.balance;
import java.util.HashMap;
import java.util.Map;
import ru.project.tower.support.RandomSource;
public final class SeededUpgradeDraftDeck {
private static final int OFFER_COUNT = 3;
private static final int MAX_REROLLS = 2;
private static final int BASE_REROLL_COST = 15;
private final UpgradeCard[] registry;
private final Map<String, Integer> pickedCopies = new HashMap<>();
private UpgradeCard[] activeOffer;
private int rerollsUsed;
public SeededUpgradeDraftDeck() {
this(UpgradeCardRegistry.all());
}
public SeededUpgradeDraftDeck(UpgradeCard[] registry) {
this.registry = registry;
}
public UpgradeCard[] draft(RandomSource random) {
UpgradeCard[] pool = eligiblePool();
UpgradeCard[] offer = new UpgradeCard[Math.min(OFFER_COUNT, pool.length)];
int poolSize = pool.length;
for (int i = 0; i < offer.length; i++) {
int index = random.nextIntInclusive(poolSize - 1);
offer[i] = pool[index];
int after = poolSize - index - 1;
if (after > 0) {
System.arraycopy(pool, index + 1, pool, index, after);
}
poolSize--;
}
activeOffer = offer;
return copyOffer();
}
public void pick(int index) {
if (activeOffer == null || index < 0 || index >= activeOffer.length) {
throw new IllegalArgumentException("No active card offer at index " + index);
}
UpgradeCard card = activeOffer[index];
pickedCopies.put(card.id(), pickedCopies.getOrDefault(card.id(), 0) + 1);
activeOffer = null;
rerollsUsed = 0;
}
public UpgradeCard[] reroll(RandomSource random) {
if (activeOffer == null) {
throw new IllegalStateException("Cannot reroll before drafting an offer");
}
if (!canReroll()) {
throw new IllegalStateException("No card rerolls remaining");
}
rerollsUsed++;
return draft(random);
}
public boolean canReroll() {
return rerollsRemaining() > 0;
}
public int rerollsRemaining() {
return Math.max(0, MAX_REROLLS - rerollsUsed);
}
public int rerollCost(float discountFraction) {
float discount = Math.max(0f, Math.min(0.80f, discountFraction));
return Math.max(1, (int) Math.ceil(BASE_REROLL_COST * (1f - discount)));
}
public Snapshot snapshot() {
String[] ids = activeOffer == null ? new String[0] : new String[activeOffer.length];
for (int i = 0; i < ids.length; i++) {
ids[i] = activeOffer[i].id();
}
return new Snapshot(new HashMap<>(pickedCopies), ids, rerollsUsed);
}
public void apply(Snapshot snapshot) {
pickedCopies.clear();
pickedCopies.putAll(snapshot.pickedCopies());
rerollsUsed = snapshot.rerollsUsed();
activeOffer = new UpgradeCard[snapshot.activeOfferIds().length];
for (int i = 0; i < activeOffer.length; i++) {
activeOffer[i] = find(snapshot.activeOfferIds()[i]);
}
}
private UpgradeCard[] copyOffer() {
UpgradeCard[] copy = new UpgradeCard[activeOffer.length];
System.arraycopy(activeOffer, 0, copy, 0, activeOffer.length);
return copy;
}
private UpgradeCard[] eligiblePool() {
int count = 0;
for (UpgradeCard card : registry) {
if (pickedCopies.getOrDefault(card.id(), 0) < card.maxCopies()) {
count++;
}
}
UpgradeCard[] pool = new UpgradeCard[count];
int index = 0;
for (UpgradeCard card : registry) {
if (pickedCopies.getOrDefault(card.id(), 0) < card.maxCopies()) {
pool[index++] = card;
}
}
return pool;
}
private UpgradeCard find(String id) {
for (UpgradeCard card : registry) {
if (card.id().equals(id)) {
return card;
}
}
throw new IllegalArgumentException("Unknown upgrade card id: " + id);
}
public static final class Snapshot {
private final Map<String, Integer> pickedCopies;
private final String[] activeOfferIds;
private final int rerollsUsed;
public Snapshot(Map<String, Integer> pickedCopies, String[] activeOfferIds) {
this(pickedCopies, activeOfferIds, 0);
}
public Snapshot(
Map<String, Integer> pickedCopies, String[] activeOfferIds, int rerollsUsed) {
this.pickedCopies = new HashMap<>(pickedCopies);
this.activeOfferIds = new String[activeOfferIds.length];
System.arraycopy(activeOfferIds, 0, this.activeOfferIds, 0, activeOfferIds.length);
this.rerollsUsed = Math.max(0, rerollsUsed);
}
public Map<String, Integer> pickedCopies() {
return new HashMap<>(pickedCopies);
}
public String[] activeOfferIds() {
String[] copy = new String[activeOfferIds.length];
System.arraycopy(activeOfferIds, 0, copy, 0, activeOfferIds.length);
return copy;
}
public int rerollsUsed() {
return rerollsUsed;
}
}
}
@@ -0,0 +1,515 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class SimulationReport {
private final long seed;
private final String sourceKind;
private final String strategyId;
private final int waveReached;
private final float latestBossKillSeconds;
private final int purchases;
private final int moneyEarned;
private final int moneySpent;
private final float averageClearSeconds;
private final int deaths;
private final float damageTaken;
private final int bonusDrops;
private final int bonusSpawned;
private final int bonusCollected;
private final float activeBonusPower;
private final int activeBonusUptimeWaves;
private final int triggeredSynergies;
private final float survivability;
private final EnumMap<PowerAxis, Float> effectivePowerByAxis;
private final float firstBossKillSeconds;
private final int shopPurchases;
private final int abilityUses;
private final int selectedDamageCards;
private final int selectedFireRateCards;
private final int peakVisibleBonusBalls;
private final float effectiveDamage;
private final float effectiveShotCooldownMs;
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
"axis-simulation",
latestBossKillSeconds,
purchases,
0,
0,
0,
0,
0f,
0f);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
"axis-simulation",
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
sourceKind,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
bonusDrops,
bonusDrops,
0,
0);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
int bonusSpawned,
int bonusCollected,
int activeBonusUptimeWaves) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
sourceKind,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
bonusSpawned,
bonusCollected,
activeBonusUptimeWaves,
0,
0,
0);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
int bonusSpawned,
int bonusCollected,
int activeBonusUptimeWaves,
int triggeredSynergies) {
this(
seed,
strategyId,
waveReached,
latestBossKillSeconds,
purchases,
averageClearSeconds,
deaths,
damageTaken,
bonusDrops,
activeBonusPower,
survivability,
effectivePowerByAxis,
sourceKind,
firstBossKillSeconds,
shopPurchases,
abilityUses,
selectedDamageCards,
selectedFireRateCards,
peakVisibleBonusBalls,
effectiveDamage,
effectiveShotCooldownMs,
bonusSpawned,
bonusCollected,
activeBonusUptimeWaves,
triggeredSynergies,
0,
0);
}
public SimulationReport(
long seed,
String strategyId,
int waveReached,
float latestBossKillSeconds,
int purchases,
float averageClearSeconds,
int deaths,
float damageTaken,
int bonusDrops,
float activeBonusPower,
float survivability,
Map<PowerAxis, Float> effectivePowerByAxis,
String sourceKind,
float firstBossKillSeconds,
int shopPurchases,
int abilityUses,
int selectedDamageCards,
int selectedFireRateCards,
int peakVisibleBonusBalls,
float effectiveDamage,
float effectiveShotCooldownMs,
int bonusSpawned,
int bonusCollected,
int activeBonusUptimeWaves,
int triggeredSynergies,
int moneyEarned,
int moneySpent) {
this.seed = seed;
this.sourceKind = sourceKind;
this.strategyId = strategyId;
this.waveReached = waveReached;
this.latestBossKillSeconds = latestBossKillSeconds;
this.purchases = purchases;
this.moneyEarned = moneyEarned;
this.moneySpent = moneySpent;
this.averageClearSeconds = averageClearSeconds;
this.deaths = deaths;
this.damageTaken = damageTaken;
this.bonusDrops = bonusDrops;
this.bonusSpawned = bonusSpawned;
this.bonusCollected = bonusCollected;
this.activeBonusPower = activeBonusPower;
this.activeBonusUptimeWaves = activeBonusUptimeWaves;
this.triggeredSynergies = triggeredSynergies;
this.survivability = survivability;
this.effectivePowerByAxis = new EnumMap<>(effectivePowerByAxis);
this.firstBossKillSeconds = firstBossKillSeconds;
this.shopPurchases = shopPurchases;
this.abilityUses = abilityUses;
this.selectedDamageCards = selectedDamageCards;
this.selectedFireRateCards = selectedFireRateCards;
this.peakVisibleBonusBalls = peakVisibleBonusBalls;
this.effectiveDamage = effectiveDamage;
this.effectiveShotCooldownMs = effectiveShotCooldownMs;
}
public static SimulationReport fromRoute(long seed, BalanceRouteReport route) {
EnumMap<PowerAxis, Float> axes = new EnumMap<>(route.effectivePowerByAxis());
axes.put(PowerAxis.DAMAGE, route.effectiveDamage());
axes.put(PowerAxis.CADENCE, 1000f / Math.max(1f, route.effectiveShotCooldownMs()));
return new SimulationReport(
seed,
route.strategyId(),
route.waveReached(),
route.firstBossKillSeconds(),
route.shopPurchases(),
0f,
route.waveReached() >= 100 ? 0 : 1,
0f,
route.peakVisibleBonusBalls(),
route.peakVisibleBonusBalls(),
Math.max(0.01f, route.waveReached() / 100f),
axes,
"route-simulation",
route.firstBossKillSeconds(),
route.shopPurchases(),
route.abilityUses(),
route.selectedDamageCards(),
route.selectedFireRateCards(),
route.peakVisibleBonusBalls(),
route.effectiveDamage(),
route.effectiveShotCooldownMs());
}
public long seed() {
return seed;
}
public String sourceKind() {
return sourceKind;
}
public String strategyId() {
return strategyId;
}
public int waveReached() {
return waveReached;
}
public float latestBossKillSeconds() {
return latestBossKillSeconds;
}
public int purchases() {
return purchases;
}
public int moneyEarned() {
return moneyEarned;
}
public int moneySpent() {
return moneySpent;
}
public float averageClearSeconds() {
return averageClearSeconds;
}
public int deaths() {
return deaths;
}
public float damageTaken() {
return damageTaken;
}
public int bonusDrops() {
return bonusDrops;
}
public int bonusSpawned() {
return bonusSpawned;
}
public int bonusCollected() {
return bonusCollected;
}
public float activeBonusPower() {
return activeBonusPower;
}
public int activeBonusUptimeWaves() {
return activeBonusUptimeWaves;
}
public int triggeredSynergies() {
return triggeredSynergies;
}
public float survivability() {
return survivability;
}
public Map<PowerAxis, Float> effectivePowerByAxis() {
return new EnumMap<>(effectivePowerByAxis);
}
public float firstBossKillSeconds() {
return firstBossKillSeconds;
}
public int shopPurchases() {
return shopPurchases;
}
public int abilityUses() {
return abilityUses;
}
public int selectedDamageCards() {
return selectedDamageCards;
}
public int selectedFireRateCards() {
return selectedFireRateCards;
}
public int peakVisibleBonusBalls() {
return peakVisibleBonusBalls;
}
public float effectiveDamage() {
return effectiveDamage;
}
public float effectiveShotCooldownMs() {
return effectiveShotCooldownMs;
}
public String summaryLine() {
return "seed="
+ seed
+ " source="
+ sourceKind
+ " strategy="
+ strategyId
+ " wave="
+ waveReached
+ " firstBoss="
+ firstBossKillSeconds
+ "s shop="
+ shopPurchases
+ " moneyEarned="
+ moneyEarned
+ " moneySpent="
+ moneySpent
+ " abilities="
+ abilityUses
+ " cards="
+ selectedDamageCards
+ "/"
+ selectedFireRateCards
+ " visibleBonus="
+ peakVisibleBonusBalls
+ " bonusDrops="
+ bonusDrops
+ " bonusSpawned="
+ bonusSpawned
+ " bonusCollected="
+ bonusCollected
+ " activeBonus="
+ activeBonusPower
+ " activeBonusUptime="
+ activeBonusUptimeWaves
+ " synergies="
+ triggeredSynergies
+ " damageTaken="
+ damageTaken
+ " damage="
+ effectiveDamage
+ " cooldownMs="
+ effectiveShotCooldownMs;
}
}
@@ -0,0 +1,50 @@
package ru.project.tower.balance;
import java.util.EnumMap;
import java.util.Map;
public final class StrategyProfile {
private final String id;
private final Map<PowerAxis, Float> axisWeights;
private final float metaRating;
private StrategyProfile(String id, Map<PowerAxis, Float> axisWeights, float metaRating) {
this.id = id;
this.axisWeights = new EnumMap<>(axisWeights);
this.metaRating = metaRating;
}
public static StrategyProfile of(String id, PowerAxis primary, PowerAxis secondary) {
EnumMap<PowerAxis, Float> weights = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
weights.put(axis, 0.35f);
}
weights.put(primary, 1f);
weights.put(secondary, 0.75f);
return new StrategyProfile(id, weights, 8f);
}
public static StrategyProfile mixed() {
EnumMap<PowerAxis, Float> weights = new EnumMap<>(PowerAxis.class);
for (PowerAxis axis : PowerAxis.values()) {
weights.put(axis, 0.70f);
}
return new StrategyProfile("mixed", weights, 8f);
}
public StrategyProfile withMetaTier(String tierId, float metaRating) {
return new StrategyProfile(id + ":" + tierId, axisWeights, metaRating);
}
public String id() {
return id;
}
public float weight(PowerAxis axis) {
return axisWeights.get(axis);
}
public float metaRating() {
return metaRating;
}
}
@@ -0,0 +1,91 @@
package ru.project.tower.balance;
import java.util.Objects;
public final class UpgradeCard {
private final String id;
private final String family;
private final PowerAxis primaryAxis;
private final PowerAxis synergyAxis;
private final float rating;
private final int maxCopies;
private final UpgradeCardRarity rarity;
private final float offerWeight;
private final String[] tags;
public UpgradeCard(
String id,
String family,
PowerAxis primaryAxis,
PowerAxis synergyAxis,
float rating,
int maxCopies) {
this(
id,
family,
primaryAxis,
synergyAxis,
rating,
maxCopies,
UpgradeCardRarity.COMMON,
1f,
family);
}
public UpgradeCard(
String id,
String family,
PowerAxis primaryAxis,
PowerAxis synergyAxis,
float rating,
int maxCopies,
UpgradeCardRarity rarity,
float offerWeight,
String... tags) {
this.id = Objects.requireNonNull(id, "id");
this.family = Objects.requireNonNull(family, "family");
this.primaryAxis = Objects.requireNonNull(primaryAxis, "primaryAxis");
this.synergyAxis = synergyAxis;
this.rating = rating;
this.maxCopies = maxCopies;
this.rarity = Objects.requireNonNull(rarity, "rarity");
this.offerWeight = offerWeight;
this.tags = tags == null ? new String[0] : tags.clone();
}
public String id() {
return id;
}
public String family() {
return family;
}
public PowerAxis primaryAxis() {
return primaryAxis;
}
public PowerAxis synergyAxis() {
return synergyAxis;
}
public float rating() {
return rating;
}
public int maxCopies() {
return maxCopies;
}
public UpgradeCardRarity rarity() {
return rarity;
}
public float offerWeight() {
return offerWeight;
}
public String[] tags() {
return tags.clone();
}
}
@@ -0,0 +1,7 @@
package ru.project.tower.balance;
public enum UpgradeCardRarity {
COMMON,
UNCOMMON,
RARE
}
@@ -0,0 +1,248 @@
package ru.project.tower.balance;
public final class UpgradeCardRegistry {
private static final UpgradeCard[] CARDS = {
card(
"focused_damage",
"attack",
PowerAxis.DAMAGE,
PowerAxis.CRIT,
5f,
8,
UpgradeCardRarity.COMMON,
1.2f,
"pure",
"damage"),
card(
"rupture_crit",
"attack",
PowerAxis.CRIT,
PowerAxis.DAMAGE,
4f,
7,
UpgradeCardRarity.COMMON,
1.1f,
"crit",
"burst"),
card(
"boss_breaker",
"attack",
PowerAxis.BOSSING,
PowerAxis.CRIT,
3.8f,
6,
UpgradeCardRarity.UNCOMMON,
0.85f,
"boss",
"single-target"),
card(
"execution_line",
"attack",
PowerAxis.DAMAGE,
PowerAxis.BOSSING,
3.6f,
6,
UpgradeCardRarity.UNCOMMON,
0.85f,
"execute",
"single-target"),
card(
"arc_burst",
"control",
PowerAxis.AOE,
PowerAxis.CONTROL,
4.5f,
7,
UpgradeCardRarity.COMMON,
1.0f,
"aoe",
"control"),
card(
"static_field",
"control",
PowerAxis.CONTROL,
PowerAxis.AOE,
4.5f,
7,
UpgradeCardRarity.COMMON,
1.0f,
"slow",
"aoe"),
card(
"gravity_lens",
"control",
PowerAxis.CONTROL,
PowerAxis.BOSSING,
3.5f,
5,
UpgradeCardRarity.RARE,
0.55f,
"pull",
"boss"),
card(
"barrier_cycle",
"defense",
PowerAxis.SHIELD,
PowerAxis.REGEN,
4f,
6,
UpgradeCardRarity.COMMON,
1.0f,
"shield",
"regen"),
card(
"repair_loop",
"defense",
PowerAxis.REGEN,
PowerAxis.SHIELD,
4f,
6,
UpgradeCardRarity.COMMON,
1.0f,
"regen",
"shield"),
card(
"plated_core",
"defense",
PowerAxis.ARMOR,
PowerAxis.HEALTH,
3.6f,
6,
UpgradeCardRarity.UNCOMMON,
0.85f,
"armor",
"health"),
card(
"overdrive_sustain",
"defense",
PowerAxis.HEALTH,
PowerAxis.CADENCE,
3.5f,
6,
UpgradeCardRarity.UNCOMMON,
0.75f,
"health",
"tempo"),
card(
"coin_compounder",
"utility",
PowerAxis.ECONOMY,
PowerAxis.UTILITY,
3.2f,
5,
UpgradeCardRarity.COMMON,
0.9f,
"money",
"shop"),
card(
"reroll_signal",
"utility",
PowerAxis.UTILITY,
PowerAxis.ECONOMY,
3.2f,
5,
UpgradeCardRarity.UNCOMMON,
0.75f,
"reroll",
"choice"),
card(
"bonus_magnet",
"utility",
PowerAxis.UTILITY,
PowerAxis.CADENCE,
3.0f,
5,
UpgradeCardRarity.COMMON,
0.9f,
"bonus",
"pickup"),
card(
"coil_accelerator",
"attack",
PowerAxis.CADENCE,
PowerAxis.DAMAGE,
3.5f,
7,
UpgradeCardRarity.COMMON,
1.05f,
"cadence",
"tempo"),
card(
"turret_directive",
"ability",
PowerAxis.ABILITY,
PowerAxis.AOE,
4f,
6,
UpgradeCardRarity.UNCOMMON,
0.8f,
"turret",
"aoe"),
card(
"blast_protocol",
"ability",
PowerAxis.ABILITY,
PowerAxis.DAMAGE,
3.6f,
6,
UpgradeCardRarity.UNCOMMON,
0.8f,
"explosion",
"damage"),
card(
"shield_script",
"ability",
PowerAxis.ABILITY,
PowerAxis.SHIELD,
3.4f,
6,
UpgradeCardRarity.UNCOMMON,
0.8f,
"shield",
"cooldown"),
card(
"volatile_bargain",
"risk",
PowerAxis.DAMAGE,
PowerAxis.HEALTH,
5.5f,
4,
UpgradeCardRarity.RARE,
0.45f,
"risk",
"damage"),
card(
"glass_cannon",
"risk",
PowerAxis.CRIT,
PowerAxis.ARMOR,
5.0f,
4,
UpgradeCardRarity.RARE,
0.45f,
"risk",
"crit")
};
private UpgradeCardRegistry() {}
public static UpgradeCard[] all() {
UpgradeCard[] copy = new UpgradeCard[CARDS.length];
System.arraycopy(CARDS, 0, copy, 0, CARDS.length);
return copy;
}
private static UpgradeCard card(
String id,
String family,
PowerAxis primaryAxis,
PowerAxis synergyAxis,
float rating,
int maxCopies,
UpgradeCardRarity rarity,
float offerWeight,
String... tags) {
return new UpgradeCard(
id, family, primaryAxis, synergyAxis, rating, maxCopies, rarity, offerWeight, tags);
}
}
@@ -0,0 +1,145 @@
package ru.project.tower.content;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import java.util.Objects;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.entities.circles.bosses.GlacialWardenData;
import ru.project.tower.entities.circles.bosses.IllusionMasterData;
import ru.project.tower.entities.circles.bosses.JuggernautData;
import ru.project.tower.entities.circles.bosses.ShadowWeaverData;
import ru.project.tower.entities.circles.bosses.SwarmQueenData;
import ru.project.tower.entities.circles.bosses.VolatileReactorData;
import ru.project.tower.support.BossEffectsHost;
public final class BossContentRegistry {
private static final BossSpec[] BOSSES = {
boss(
"SwarmQueen",
SwarmQueenData.class,
SwarmQueenData.getStaticRadius(),
SwarmQueenData.getStaticSpeed(),
(circle, velocity, host) -> new SwarmQueenData(circle, velocity, host)),
boss(
"Juggernaut",
JuggernautData.class,
JuggernautData.getStaticRadius(),
JuggernautData.getStaticSpeed(),
(circle, velocity, host) -> new JuggernautData(circle, velocity)),
boss(
"ShadowWeaver",
ShadowWeaverData.class,
ShadowWeaverData.getStaticRadius(),
ShadowWeaverData.getStaticSpeed(),
(circle, velocity, host) -> new ShadowWeaverData(circle, velocity)),
boss(
"VolatileReactor",
VolatileReactorData.class,
VolatileReactorData.getStaticRadius(),
VolatileReactorData.getStaticSpeed(),
(circle, velocity, host) -> new VolatileReactorData(circle, velocity)),
boss(
"GlacialWarden",
GlacialWardenData.class,
GlacialWardenData.getStaticRadius(),
GlacialWardenData.getStaticSpeed(),
(circle, velocity, host) -> new GlacialWardenData(circle, velocity)),
boss(
"IllusionMaster",
IllusionMasterData.class,
IllusionMasterData.getStaticRadius(),
IllusionMasterData.getStaticSpeed(),
(circle, velocity, host) -> new IllusionMasterData(circle, velocity))
};
private BossContentRegistry() {}
public static BossSpec[] all() {
return BOSSES.clone();
}
public static String[] ids() {
String[] ids = new String[BOSSES.length];
for (int i = 0; i < BOSSES.length; i++) {
ids[i] = BOSSES[i].id();
}
return ids;
}
public static BossSpec findById(String id) {
for (BossSpec boss : BOSSES) {
if (boss.id().equals(id)) {
return boss;
}
}
return null;
}
public static boolean isBoss(BaseCircleData enemy) {
if (enemy == null) {
return false;
}
Class<?> enemyClass = enemy.getClass();
for (BossSpec boss : BOSSES) {
if (boss.type().isAssignableFrom(enemyClass)) {
return true;
}
}
return false;
}
private static BossSpec boss(
String id,
Class<? extends BaseCircleData> type,
float radius,
float speed,
BossFactory factory) {
return new BossSpec(id, type, radius, speed, factory);
}
public interface BossFactory {
BaseCircleData create(Circle circle, Vector2 velocity, BossEffectsHost host);
}
public static final class BossSpec {
private final String id;
private final Class<? extends BaseCircleData> type;
private final float radius;
private final float speed;
private final BossFactory factory;
private BossSpec(
String id,
Class<? extends BaseCircleData> type,
float radius,
float speed,
BossFactory factory) {
this.id = Objects.requireNonNull(id, "id");
this.type = Objects.requireNonNull(type, "type");
this.radius = radius;
this.speed = speed;
this.factory = Objects.requireNonNull(factory, "factory");
}
public String id() {
return id;
}
public Class<? extends BaseCircleData> type() {
return type;
}
public float radius() {
return radius;
}
public float speed() {
return speed;
}
public BaseCircleData create(Circle circle, Vector2 velocity, BossEffectsHost host) {
return factory.create(circle, velocity, host);
}
}
}
@@ -0,0 +1,142 @@
package ru.project.tower.entities.bullets;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class BulletData implements Pool.Poolable {
public Circle bullet;
public Vector2 velocity;
public int damage;
private boolean isEnemyBullet;
private boolean isCrit = false;
private Color color;
public int piercesLeft = 0;
public boolean homing = false;
public float aoeRadius = 0f;
public float trailParticleSpawnBudget = 0f;
public BulletData() {
this.bullet = new Circle(0f, 0f, 1f);
this.velocity = new Vector2(0f, 0f);
this.damage = 1;
this.isEnemyBullet = false;
this.color = null;
}
public BulletData(Circle bullet, Vector2 velocity, int damage) {
this.bullet = bullet;
this.velocity = velocity;
this.damage = damage;
this.isEnemyBullet = false;
this.color = null;
}
public BulletData(Circle bullet, Vector2 velocity, boolean isEnemyBullet) {
this.bullet = bullet;
this.velocity = velocity;
this.damage = isEnemyBullet ? 10 : 1;
this.isEnemyBullet = isEnemyBullet;
this.color = null;
}
public void configureAsPlayerBullet(
float x, float y, float radius, float vx, float vy, int damage) {
bullet.x = x;
bullet.y = y;
bullet.radius = radius;
velocity.x = vx;
velocity.y = vy;
this.damage = damage;
this.isEnemyBullet = false;
this.isCrit = false;
this.color = null;
this.piercesLeft = 0;
this.homing = false;
this.aoeRadius = 0f;
this.trailParticleSpawnBudget = 0f;
}
public void configureAsEnemyBullet(
float x, float y, float radius, float vx, float vy, int damage) {
bullet.x = x;
bullet.y = y;
bullet.radius = radius;
velocity.x = vx;
velocity.y = vy;
this.damage = damage;
this.isEnemyBullet = true;
this.isCrit = false;
this.color = null;
this.piercesLeft = 0;
this.homing = false;
this.aoeRadius = 0f;
this.trailParticleSpawnBudget = 0f;
}
@Override
public void reset() {
bullet.x = 0f;
bullet.y = 0f;
bullet.radius = 1f;
velocity.x = 0f;
velocity.y = 0f;
damage = 1;
isEnemyBullet = false;
isCrit = false;
color = null;
piercesLeft = 0;
homing = false;
aoeRadius = 0f;
trailParticleSpawnBudget = 0f;
}
public float getX() {
return bullet.x;
}
public float getY() {
return bullet.y;
}
public float getRadius() {
return bullet.radius;
}
public void move(float delta) {
bullet.x += velocity.x * delta;
bullet.y += velocity.y * delta;
}
public Circle getCircle() {
return bullet;
}
public boolean isEnemyBullet() {
return isEnemyBullet;
}
public void setColor(Color color) {
this.color = color;
}
public Color getColor() {
return color;
}
public void setCrit(boolean isCrit) {
this.isCrit = isCrit;
}
public boolean isCrit() {
return isCrit;
}
}
@@ -0,0 +1,37 @@
package ru.project.tower.entities.bullets;
import ru.project.tower.entities.circles.BaseCircleData;
public interface BulletEffectsSink {
void onEnemyHit(BaseCircleData enemy, int damage, boolean isCrit);
void onEnemyKilled(BaseCircleData enemy);
void onEnemyBulletHitPlayer(BulletData bullet);
void onCrit(BaseCircleData enemy, int damage);
}
@@ -0,0 +1,30 @@
package ru.project.tower.entities.bullets;
public final class BulletMultipliers {
public final float speedMultiplier;
public final float damageMultiplier;
public final boolean isCrit;
public final float critDamageMultiplier;
public BulletMultipliers(
float speedMultiplier,
float damageMultiplier,
boolean isCrit,
float critDamageMultiplier) {
this.speedMultiplier = speedMultiplier;
this.damageMultiplier = damageMultiplier;
this.isCrit = isCrit;
this.critDamageMultiplier = critDamageMultiplier;
}
public static BulletMultipliers defaults() {
return new BulletMultipliers(1f, 1f, false, 1f);
}
}
@@ -0,0 +1,447 @@
package ru.project.tower.entities.bullets;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.Pools;
import java.util.Objects;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.RangeChecks;
public class BulletSystem {
private static final int DEFAULT_BASE_DAMAGE = 1;
private static final float DEFAULT_BASE_SPEED = 300f;
private static final int TICK_MISS = 0;
private static final int TICK_HIT = 1;
private static final int TICK_ENEMY_INDEX_DIRTY = 2;
private int bulletDamage = DEFAULT_BASE_DAMAGE;
private float bulletSpeed = DEFAULT_BASE_SPEED;
private int runDamageBonus = 0;
private int runPierceBonus = 0;
private float runAoeBonus = 0f;
private final Array<BulletData> bullets = new Array<>();
private final BulletTalentProvider talentProvider;
private final EnemySpatialIndex enemySpatialIndex = new EnemySpatialIndex();
private int lastPrimaryCandidateChecksForTests;
private float primaryHitTime;
public BulletSystem(BulletTalentProvider talentProvider) {
this.talentProvider = Objects.requireNonNull(talentProvider, "talentProvider");
}
public Array<BulletData> getBullets() {
return bullets;
}
int getLastPrimaryCandidateChecksForTests() {
return lastPrimaryCandidateChecksForTests;
}
public int getBaseDamage() {
return bulletDamage;
}
public float getBaseSpeed() {
return bulletSpeed;
}
public int getRunDamageBonus() {
return runDamageBonus;
}
public int getRunPierceBonus() {
return runPierceBonus;
}
public float getRunAoeBonus() {
return runAoeBonus;
}
public void fireBullet(
float fromX,
float fromY,
float angleRad,
float bulletRadius,
float gameScale,
BulletMultipliers mult) {
fireBullet(
fromX,
fromY,
angleRad,
bulletRadius,
gameScale,
mult.speedMultiplier,
mult.damageMultiplier,
mult.isCrit,
mult.critDamageMultiplier);
}
public void fireBullet(
float fromX,
float fromY,
float angleRad,
float bulletRadius,
float gameScale,
float speedMultiplier,
float damageMultiplier,
boolean isCrit,
float critDamageMultiplier) {
float effectiveSpeed = bulletSpeed * speedMultiplier * gameScale;
int baseDmg = bulletDamage + runDamageBonus;
int damage = (int) (baseDmg * damageMultiplier);
if (isCrit) {
damage = (int) (damage * critDamageMultiplier);
}
float vx = (float) Math.cos(angleRad) * effectiveSpeed;
float vy = (float) Math.sin(angleRad) * effectiveSpeed;
BulletData bullet = Pools.obtain(BulletData.class);
bullet.configureAsPlayerBullet(fromX, fromY, bulletRadius, vx, vy, damage);
bullet.setCrit(isCrit);
bullet.piercesLeft = talentProvider.pierceLevel() + runPierceBonus;
bullet.aoeRadius = talentProvider.aoeLevel() * 30f + runAoeBonus;
bullets.add(bullet);
}
public void fireTurretShot(
float fromX,
float fromY,
float angleRad,
float bulletRadius,
float gameScale,
int damage) {
float speed = bulletSpeed * gameScale;
float vx = (float) Math.cos(angleRad) * speed;
float vy = (float) Math.sin(angleRad) * speed;
BulletData bullet = Pools.obtain(BulletData.class);
bullet.configureAsPlayerBullet(fromX, fromY, bulletRadius, vx, vy, damage);
bullets.add(bullet);
}
public void addEnemyBullet(BulletData bullet) {
bullets.add(bullet);
}
public void fireEnemyBullet(
float fromX, float fromY, float bulletRadius, float vx, float vy, int damage) {
BulletData bullet = Pools.obtain(BulletData.class);
bullet.configureAsEnemyBullet(fromX, fromY, bulletRadius, vx, vy, damage);
bullets.add(bullet);
}
public void tick(
float delta,
Array<BaseCircleData> enemies,
Rectangle playerSquare,
BulletEffectsSink sink) {
lastPrimaryCandidateChecksForTests = 0;
boolean enemyIndexDirty = true;
for (int i = 0; i < bullets.size; i++) {
BulletData bulletData = bullets.get(i);
float px = bulletData.bullet.x;
float py = bulletData.bullet.y;
float mx = bulletData.velocity.x * delta;
float my = bulletData.velocity.y * delta;
int tickResult;
if (bulletData.isEnemyBullet()) {
tickResult =
tickEnemyBullet(bulletData, mx, my, playerSquare, sink)
? TICK_HIT
: TICK_MISS;
} else {
if (enemyIndexDirty || enemySpatialIndex.enemyCount() != enemies.size) {
enemySpatialIndex.build(enemies);
enemyIndexDirty = false;
}
tickResult = tickPlayerBullet(bulletData, px, py, mx, my, enemies, sink);
if ((tickResult & TICK_ENEMY_INDEX_DIRTY) != 0) {
enemyIndexDirty = true;
}
}
if ((tickResult & TICK_HIT) != 0 && finishBulletHit(bulletData, i)) {
i--;
}
}
}
private boolean tickEnemyBullet(
BulletData bulletData,
float moveX,
float moveY,
Rectangle playerSquare,
BulletEffectsSink sink) {
bulletData.bullet.x += moveX;
bulletData.bullet.y += moveY;
float squareCenterX = playerSquare.x + playerSquare.width / 2f;
float squareCenterY = playerSquare.y + playerSquare.height / 2f;
float collideAt =
bulletData.bullet.radius + Math.min(playerSquare.width, playerSquare.height) / 2f;
if (RangeChecks.withinRadiusExclusive(
bulletData.bullet.x,
bulletData.bullet.y,
squareCenterX,
squareCenterY,
collideAt)) {
sink.onEnemyBulletHitPlayer(bulletData);
return true;
}
return false;
}
private int tickPlayerBullet(
BulletData bulletData,
float previousX,
float previousY,
float moveX,
float moveY,
Array<BaseCircleData> enemies,
BulletEffectsSink sink) {
int primaryIndex =
findPrimaryHitIndex(bulletData, previousX, previousY, moveX, moveY, enemies);
if (primaryIndex < 0) {
return finishPlayerBulletMiss(bulletData, previousX, previousY, moveX, moveY);
}
BaseCircleData primary = enemies.get(primaryIndex);
movePlayerBulletToPrimaryHit(bulletData, previousX, previousY, moveX, moveY);
return applyPrimaryHit(bulletData, primary, enemies, sink);
}
private int findPrimaryHitIndex(
BulletData bulletData,
float previousX,
float previousY,
float moveX,
float moveY,
Array<BaseCircleData> enemies) {
int earliestIndex = -1;
float earliestTime = Float.POSITIVE_INFINITY;
float a = moveX * moveX + moveY * moveY;
int candidateCount =
queryPrimaryHitCandidates(bulletData, previousX, previousY, moveX, moveY);
for (int candidate = 0; candidate < candidateCount; candidate++) {
int j = enemySpatialIndex.candidateAt(candidate);
BaseCircleData enemy = enemies.get(j);
lastPrimaryCandidateChecksForTests++;
float t =
primaryCollisionTime(bulletData, enemy, previousX, previousY, moveX, moveY, a);
if (t == Float.POSITIVE_INFINITY) continue;
if (isEarlierPrimaryHit(t, earliestTime, j, earliestIndex)) {
earliestTime = t;
earliestIndex = j;
}
}
primaryHitTime = earliestTime;
return earliestIndex;
}
private int queryPrimaryHitCandidates(
BulletData bulletData, float previousX, float previousY, float moveX, float moveY) {
float sweepPadding = bulletData.bullet.radius + enemySpatialIndex.maxEnemyRadius();
return enemySpatialIndex.queryRect(
Math.min(previousX, previousX + moveX) - sweepPadding,
Math.min(previousY, previousY + moveY) - sweepPadding,
Math.max(previousX, previousX + moveX) + sweepPadding,
Math.max(previousY, previousY + moveY) + sweepPadding);
}
private float primaryCollisionTime(
BulletData bulletData,
BaseCircleData enemy,
float previousX,
float previousY,
float moveX,
float moveY,
float a) {
float radiusSum = bulletData.bullet.radius + enemy.circle.radius;
float dx = previousX - enemy.circle.x;
float dy = previousY - enemy.circle.y;
float c = dx * dx + dy * dy - radiusSum * radiusSum;
if (c <= 0f) return 0f;
if (a < 1e-6f) return Float.POSITIVE_INFINITY;
float b = 2f * (dx * moveX + dy * moveY);
float disc = b * b - 4f * a * c;
if (disc < 0f) return Float.POSITIVE_INFINITY;
float t = (-b - (float) Math.sqrt(disc)) / (2f * a);
if (t < 0f || t > 1f) return Float.POSITIVE_INFINITY;
return t;
}
private boolean isEarlierPrimaryHit(float t, float earliestTime, int j, int earliestIndex) {
return t < earliestTime
|| (Float.compare(t, earliestTime) == 0
&& (earliestIndex < 0 || j < earliestIndex));
}
private int finishPlayerBulletMiss(
BulletData bulletData, float previousX, float previousY, float moveX, float moveY) {
bulletData.bullet.x = previousX + moveX;
bulletData.bullet.y = previousY + moveY;
return TICK_MISS;
}
private void movePlayerBulletToPrimaryHit(
BulletData bulletData, float previousX, float previousY, float moveX, float moveY) {
bulletData.bullet.x = previousX + primaryHitTime * moveX;
bulletData.bullet.y = previousY + primaryHitTime * moveY;
}
private int applyPrimaryHit(
BulletData bulletData,
BaseCircleData primary,
Array<BaseCircleData> enemies,
BulletEffectsSink sink) {
applyPlayerDamage(primary, bulletData.damage, bulletData.isCrit(), sink);
if (bulletData.isCrit()) {
sink.onCrit(primary, bulletData.damage);
}
int result = TICK_HIT;
if (bulletData.aoeRadius > 0f) {
result |= applyAoeDamage(bulletData, primary, enemies, sink);
}
if (primary.health <= 0) {
sink.onEnemyKilled(primary);
result |= TICK_ENEMY_INDEX_DIRTY;
}
return result;
}
private int applyAoeDamage(
BulletData bulletData,
BaseCircleData primary,
Array<BaseCircleData> enemies,
BulletEffectsSink sink) {
float bx = bulletData.bullet.x;
float by = bulletData.bullet.y;
float aoeRadius2 = RangeChecks.radiusSquared(bulletData.aoeRadius);
int aoeCandidateCount = enemySpatialIndex.queryRadius(bx, by, bulletData.aoeRadius);
enemySpatialIndex.sortCandidatesDescending(aoeCandidateCount);
int result = 0;
for (int candidate = 0; candidate < aoeCandidateCount; candidate++) {
int k = enemySpatialIndex.candidateAt(candidate);
BaseCircleData other = enemies.get(k);
if (other == primary) continue;
if (RangeChecks.distanceSquared(bx, by, other.circle.x, other.circle.y) <= aoeRadius2) {
applyPlayerDamage(other, bulletData.damage, false, sink);
if (other.health <= 0) {
sink.onEnemyKilled(other);
result |= TICK_ENEMY_INDEX_DIRTY;
}
}
}
return result;
}
private void applyPlayerDamage(
BaseCircleData enemy, int damage, boolean crit, BulletEffectsSink sink) {
enemy.health -= damage;
sink.onEnemyHit(enemy, damage, crit);
}
private boolean finishBulletHit(BulletData bulletData, int bulletIndex) {
if (bulletData.piercesLeft > 0 && !bulletData.isEnemyBullet()) {
bulletData.piercesLeft--;
return false;
}
Pools.free(bullets.removeIndex(bulletIndex));
return true;
}
public int getEffectiveDamage(float externalDamageMultiplier) {
int base = bulletDamage + runDamageBonus;
return (int) (base * externalDamageMultiplier);
}
public float getEffectiveSpeed(float externalSpeedMultiplier) {
return bulletSpeed * externalSpeedMultiplier;
}
public void increaseBaseDamage(int delta) {
bulletDamage += delta;
}
public void increaseBaseSpeed(float delta) {
bulletSpeed += delta;
}
public void addRunDamageBonus(int delta) {
runDamageBonus += delta;
}
public void addRunPierceBonus(int delta) {
runPierceBonus += delta;
}
public void addRunAoeBonus(float delta) {
runAoeBonus += delta;
}
public void updateRadius(float newRadius) {
for (int i = 0; i < bullets.size; i++) {
bullets.get(i).bullet.radius = newRadius;
}
}
public void clear() {
for (int i = 0; i < bullets.size; i++) {
Pools.free(bullets.get(i));
}
bullets.clear();
}
public void resetToBaseline() {
bulletDamage = DEFAULT_BASE_DAMAGE;
bulletSpeed = DEFAULT_BASE_SPEED;
runDamageBonus = 0;
runPierceBonus = 0;
runAoeBonus = 0f;
}
public void applyTalentBaseStats() {
bulletDamage += (int) talentProvider.damageBonus();
bulletSpeed += talentProvider.speedBonus();
}
public void startNewRun() {
clear();
resetToBaseline();
applyTalentBaseStats();
}
}
@@ -0,0 +1,67 @@
package ru.project.tower.entities.bullets;
import java.util.Objects;
import ru.project.tower.talents.TalentData;
import ru.project.tower.talents.TalentTree;
public interface BulletTalentProvider {
int pierceLevel();
int aoeLevel();
float damageBonus();
float speedBonus();
static BulletTalentProvider none() {
return new BulletTalentProvider() {
@Override
public int pierceLevel() {
return 0;
}
@Override
public int aoeLevel() {
return 0;
}
@Override
public float damageBonus() {
return 0f;
}
@Override
public float speedBonus() {
return 0f;
}
};
}
static BulletTalentProvider fromTalentTree(TalentTree talentTree) {
Objects.requireNonNull(talentTree, "talentTree");
return new BulletTalentProvider() {
@Override
public int pierceLevel() {
return talentTree.getCurrentLevel("keystone_pierce");
}
@Override
public int aoeLevel() {
return talentTree.getCurrentLevel("keystone_aoe");
}
@Override
public float damageBonus() {
return talentTree.getStatBonus(TalentData.TalentType.DAMAGE);
}
@Override
public float speedBonus() {
return talentTree.getStatBonus(TalentData.TalentType.SPEED);
}
};
}
}
@@ -0,0 +1,40 @@
package ru.project.tower.entities.bullets;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.systems.CircleSpatialGrid;
final class EnemySpatialIndex {
private final CircleSpatialGrid grid = new CircleSpatialGrid();
void build(Array<BaseCircleData> enemies) {
grid.build(enemies);
}
int enemyCount() {
return grid.itemCount();
}
float maxEnemyRadius() {
return grid.maxRadius();
}
int queryRect(float minX, float minY, float maxX, float maxY) {
return grid.queryRect(minX, minY, maxX, maxY);
}
int queryRadius(float x, float y, float radius) {
return grid.queryRadius(x, y, radius);
}
void sortCandidatesDescending(int count) {
grid.sortCandidatesDescending(count);
}
int candidateAt(int index) {
return grid.candidateAt(index);
}
}
@@ -0,0 +1,174 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
import com.badlogic.gdx.utils.Pools;
import com.badlogic.gdx.utils.ReflectionPool;
public abstract class BaseCircleData implements Pool.Poolable {
private static final int SPAWN_POOL_INITIAL_CAPACITY = 16;
private static final int SPAWN_POOL_MAX_CAPACITY = 4096;
static {
Pools.set(
BasicCircleData.class,
new ReflectionPool<>(
BasicCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
FastCircleData.class,
new ReflectionPool<>(
FastCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
StrongCircleData.class,
new ReflectionPool<>(
StrongCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
SplitterCircleData.class,
new ReflectionPool<>(
SplitterCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
SpawnerCircleData.class,
new ReflectionPool<>(
SpawnerCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
HealerCircleData.class,
new ReflectionPool<>(
HealerCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
DasherCircleData.class,
new ReflectionPool<>(
DasherCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
Pools.set(
SniperCircleData.class,
new ReflectionPool<>(
SniperCircleData.class,
SPAWN_POOL_INITIAL_CAPACITY,
SPAWN_POOL_MAX_CAPACITY));
}
public Circle circle;
public Vector2 velocity;
public int health;
public int damage;
private boolean frozen = false;
private boolean elite = false;
protected BaseCircleData() {
this(new Circle(0f, 0f, 0f), new Vector2(0f, 0f), 0, 0);
}
public BaseCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this.circle = circle;
this.velocity = velocity;
this.health = health;
this.damage = damage;
}
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
circle.x = x;
circle.y = y;
circle.radius = radius;
velocity.x = vx;
velocity.y = vy;
this.health = health;
this.damage = damage;
frozen = false;
elite = false;
}
@Override
public void reset() {
configure(0f, 0f, 0f, 0f, 0f, 0, 0);
}
public static void freeIfSpawnPooled(BaseCircleData circleData) {
Class<?> type = circleData.getClass();
if (type == BasicCircleData.class
|| type == FastCircleData.class
|| type == StrongCircleData.class
|| type == SplitterCircleData.class
|| type == SpawnerCircleData.class
|| type == HealerCircleData.class
|| type == DasherCircleData.class
|| type == SniperCircleData.class) {
Pools.free(circleData);
}
}
public boolean isElite() {
return elite;
}
public void makeElite() {
if (elite) return;
elite = true;
this.health *= 2;
this.damage = Math.max(1, (int) (this.damage * 1.5f));
this.circle.radius *= 1.15f;
}
protected int getBaseReward() {
return 1;
}
public abstract float[] getColor();
public float getX() {
return circle.x;
}
public float getY() {
return circle.y;
}
public float getRadius() {
return circle.radius;
}
public void move(float delta) {
if (!frozen) {
circle.x += velocity.x * delta;
circle.y += velocity.y * delta;
}
}
public boolean isFrozen() {
return frozen;
}
public void setFrozen(boolean frozen) {
this.frozen = frozen;
}
public void takeDamage(int damage) {
health -= damage;
}
public boolean isDead() {
return health <= 0;
}
public final int getReward() {
return getBaseReward() * (elite ? 3 : 1);
}
}
@@ -0,0 +1,42 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class BasicCircleData extends BaseCircleData {
private static final float[] COLOR = {1f, 0.5f, 0.5f, 1f};
private int baseReward = 1;
public BasicCircleData() {
super();
}
public BasicCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
baseReward = 1;
}
public void markSpawnerChild() {
markNoRewardSpawn();
}
public void markNoRewardSpawn() {
baseReward = 0;
}
@Override
public float[] getColor() {
return COLOR;
}
@Override
protected int getBaseReward() {
return baseReward;
}
}
@@ -0,0 +1,110 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class BonusBallData extends BaseCircleData implements Pool.Poolable {
public enum BonusType {
BULLET_SPEED,
DAMAGE,
FIRE_RATE,
SHIELD_PULSE,
HEAL,
CRIT_WINDOW,
PICKUP_MAGNET,
ABILITY_CHARGE,
COIN_BURST
}
private BonusType bonusType;
private float pulseTime = 0;
public BonusBallData() {
super(new Circle(0f, 0f, 1f), new Vector2(0f, 0f), 1, 0);
this.bonusType = null;
}
public BonusBallData(Circle circle, Vector2 velocity, BonusType bonusType) {
super(circle, velocity, 1, 0);
this.bonusType = bonusType;
}
public void configure(float x, float y, float radius, float vx, float vy, BonusType type) {
circle.x = x;
circle.y = y;
circle.radius = radius;
velocity.x = vx;
velocity.y = vy;
health = 1;
damage = 0;
setFrozen(false);
this.bonusType = type;
this.pulseTime = 0f;
}
public BonusType getBonusType() {
return bonusType;
}
public void update(float delta) {
pulseTime += delta;
move(delta);
}
private static final float[] BULLET_SPEED_COLOR = {0.30f, 0.85f, 1.00f, 1.0f};
private static final float[] DAMAGE_COLOR = {1.00f, 0.36f, 0.32f, 1.0f};
private static final float[] FIRE_RATE_COLOR = {1.00f, 0.86f, 0.25f, 1.0f};
private static final float[] SHIELD_COLOR = {0.28f, 0.55f, 1.00f, 1.0f};
private static final float[] HEAL_COLOR = {0.25f, 1.00f, 0.55f, 1.0f};
private static final float[] CRIT_COLOR = {1.00f, 0.35f, 0.95f, 1.0f};
private static final float[] MAGNET_COLOR = {0.55f, 1.00f, 0.95f, 1.0f};
private static final float[] ABILITY_COLOR = {0.72f, 0.50f, 1.00f, 1.0f};
private static final float[] COIN_COLOR = {1.00f, 0.72f, 0.18f, 1.0f};
@Override
public float[] getColor() {
if (bonusType == null) return COIN_COLOR;
switch (bonusType) {
case BULLET_SPEED:
return BULLET_SPEED_COLOR;
case DAMAGE:
return DAMAGE_COLOR;
case FIRE_RATE:
return FIRE_RATE_COLOR;
case SHIELD_PULSE:
return SHIELD_COLOR;
case HEAL:
return HEAL_COLOR;
case CRIT_WINDOW:
return CRIT_COLOR;
case PICKUP_MAGNET:
return MAGNET_COLOR;
case ABILITY_CHARGE:
return ABILITY_COLOR;
case COIN_BURST:
default:
return COIN_COLOR;
}
}
public float getGlowAmount() {
return 0.6f + 0.4f * (float) Math.sin(pulseTime * 4f);
}
@Override
public void reset() {
circle.x = 0f;
circle.y = 0f;
circle.radius = 1f;
velocity.x = 0f;
velocity.y = 0f;
health = 1;
damage = 0;
setFrozen(false);
bonusType = null;
pulseTime = 0f;
}
}
@@ -0,0 +1,122 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.RangeChecks;
public class DasherCircleData extends BaseCircleData {
private static final long WINDUP_MS = 800L;
private static final long DASH_MS = 400L;
private static final float DASH_SPEED_MULT = 3f;
private static final float CHARGE_RANGE = 280f;
private enum State {
IDLE,
WINDUP,
DASHING
}
private State state = State.IDLE;
private long stateEnter = 0L;
private final Vector2 baseVelocity;
private final GameClock clock;
public DasherCircleData() {
super();
this.baseVelocity = new Vector2();
this.clock = GameClock.SYSTEM;
}
public DasherCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this(circle, velocity, health, damage, GameClock.SYSTEM);
}
public DasherCircleData(
Circle circle, Vector2 velocity, int health, int damage, GameClock clock) {
super(circle, velocity, health, damage);
this.baseVelocity = new Vector2(velocity);
this.clock = clock;
}
protected long now() {
return clock.nowMs();
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
baseVelocity.set(vx, vy);
state = State.IDLE;
stateEnter = 0L;
}
@Override
public void reset() {
super.reset();
baseVelocity.setZero();
state = State.IDLE;
stateEnter = 0L;
}
public boolean isWindup() {
return state == State.WINDUP;
}
public boolean isDashing() {
return state == State.DASHING;
}
public void tick(float squareX, float squareY) {
long t = now();
switch (state) {
case IDLE:
if (RangeChecks.withinRadiusExclusive(
getX(), getY(), squareX, squareY, CHARGE_RANGE)) {
state = State.WINDUP;
stateEnter = t;
velocity.scl(0.3f);
}
break;
case WINDUP:
if (t - stateEnter > WINDUP_MS) {
state = State.DASHING;
stateEnter = t;
float dx = squareX - getX(), dy = squareY - getY();
float len = (float) Math.sqrt(dx * dx + dy * dy);
if (len > 0f) {
velocity.set(dx / len, dy / len).scl(baseVelocity.len() * DASH_SPEED_MULT);
}
}
break;
case DASHING:
if (t - stateEnter > DASH_MS) {
state = State.IDLE;
velocity.set(baseVelocity);
}
break;
}
}
private static final float[] COLOR_WINDUP = {1f, 0.9f, 0.3f, 1f};
private static final float[] COLOR_DASHING = {1f, 0.4f, 0.1f, 1f};
private static final float[] COLOR_IDLE = {0.9f, 0.6f, 0.2f, 1f};
@Override
public float[] getColor() {
switch (state) {
case WINDUP:
return COLOR_WINDUP;
case DASHING:
return COLOR_DASHING;
default:
return COLOR_IDLE;
}
}
@Override
protected int getBaseReward() {
return 3;
}
}
@@ -0,0 +1,21 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class FastCircleData extends BaseCircleData {
private static final float[] COLOR = {1f, 1f, 0f, 1f};
public FastCircleData() {
super();
}
public FastCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
}
@Override
public float[] getColor() {
return COLOR;
}
}
@@ -0,0 +1,81 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
public class HealerCircleData extends BaseCircleData {
private static final long HEAL_INTERVAL_MS = 1500L;
private static final float HEAL_RADIUS = 120f;
private static final int HEAL_AMOUNT = 3;
private long lastHealTime;
private final GameClock clock;
public HealerCircleData() {
super();
this.clock = GameClock.SYSTEM;
this.lastHealTime = 0L;
}
public HealerCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this(circle, velocity, health, damage, GameClock.SYSTEM);
}
public HealerCircleData(
Circle circle, Vector2 velocity, int health, int damage, GameClock clock) {
super(circle, velocity, health, damage);
this.clock = clock;
this.lastHealTime = now();
}
protected long now() {
return clock.nowMs();
}
public void resetHealTimer() {
this.lastHealTime = now();
}
public boolean shouldPulse() {
if (now() - lastHealTime > HEAL_INTERVAL_MS) {
lastHealTime = now();
return true;
}
return false;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
this.lastHealTime = now();
}
@Override
public void reset() {
super.reset();
this.lastHealTime = 0L;
}
public static float getHealRadius() {
return HEAL_RADIUS;
}
public static int getHealAmount() {
return HEAL_AMOUNT;
}
private static final float[] COLOR = {0.2f, 1.0f, 0.5f, 1f};
@Override
public float[] getColor() {
return COLOR;
}
@Override
protected int getBaseReward() {
return 4;
}
}
@@ -0,0 +1,115 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.RangeChecks;
public class SniperCircleData extends BaseCircleData {
private static final float TARGET_RANGE = 100f;
private static final long FIRE_INTERVAL_MS = 2000L;
private static final float PROJECTILE_SPEED = 200f;
private static final float PROJECTILE_RADIUS = 4f;
private long lastShotAt = 0L;
private float playerX = 0f;
private float playerY = 0f;
private final GameClock clock;
public SniperCircleData() {
super();
this.clock = GameClock.SYSTEM;
this.lastShotAt = 0L;
}
public SniperCircleData(Circle circle, Vector2 velocity, int health, int damage) {
this(circle, velocity, health, damage, GameClock.SYSTEM);
}
public SniperCircleData(
Circle circle, Vector2 velocity, int health, int damage, GameClock clock) {
super(circle, velocity, health, damage);
this.clock = clock;
this.lastShotAt = now();
}
protected long now() {
return clock.nowMs();
}
public void setPlayerPosition(float x, float y) {
this.playerX = x;
this.playerY = y;
}
public void resetFireTimer() {
this.lastShotAt = now();
}
@Override
public void move(float delta) {
if (!RangeChecks.withinRadiusInclusive(getX(), getY(), playerX, playerY, TARGET_RANGE)) {
super.move(delta);
} else {
velocity.set(0f, 0f);
}
}
public boolean shouldShoot() {
if (now() - lastShotAt > FIRE_INTERVAL_MS) {
lastShotAt = now();
return true;
}
return false;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
lastShotAt = now();
playerX = 0f;
playerY = 0f;
}
@Override
public void reset() {
super.reset();
lastShotAt = 0L;
playerX = 0f;
playerY = 0f;
}
public boolean isHoldingPosition() {
return RangeChecks.withinRadiusInclusive(getX(), getY(), playerX, playerY, TARGET_RANGE);
}
public static float getTargetRange() {
return TARGET_RANGE;
}
public static long getFireIntervalMs() {
return FIRE_INTERVAL_MS;
}
public static float getProjectileSpeed() {
return PROJECTILE_SPEED;
}
public static float getProjectileRadius() {
return PROJECTILE_RADIUS;
}
private static final float[] COLOR = {1f, 0.2f, 0.2f, 1f};
@Override
public float[] getColor() {
return COLOR;
}
@Override
protected int getBaseReward() {
return 3;
}
}
@@ -0,0 +1,67 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.support.GameClock;
public class SpawnerCircleData extends BaseCircleData {
private long lastSpawnTime = 0;
private static final long SPAWN_INTERVAL = 2000;
private final GameClock clock;
public SpawnerCircleData() {
this(GameClock.SYSTEM);
}
private SpawnerCircleData(GameClock clock) {
super();
this.clock = clock;
}
public SpawnerCircleData(Circle circle, Vector2 velocity, int health) {
this(circle, velocity, health, GameClock.SYSTEM);
}
public SpawnerCircleData(Circle circle, Vector2 velocity, int health, GameClock clock) {
super(circle, velocity, health, 0);
this.clock = clock;
}
public void configure(float x, float y, float radius, float vx, float vy, int health) {
super.configure(x, y, radius, vx, vy, health, 0);
lastSpawnTime = 0L;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
configure(x, y, radius, vx, vy, health);
}
@Override
public void reset() {
super.reset();
lastSpawnTime = 0L;
}
private static final float[] COLOR = {0.5f, 0f, 0.2f, 1f};
@Override
public float[] getColor() {
return COLOR;
}
public boolean shouldSpawnCircle() {
long currentTime = clock.nowMs();
if (currentTime - lastSpawnTime > SPAWN_INTERVAL) {
lastSpawnTime = currentTime;
return true;
}
return false;
}
@Override
protected int getBaseReward() {
return 5;
}
}
@@ -0,0 +1,51 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class SplitterCircleData extends BaseCircleData {
private static final float[] COLOR_SHARD = {0.8f, 0.6f, 1f, 1f};
private static final float[] COLOR_FULL = {0.6f, 0f, 0.8f, 1f};
public boolean isShard = false;
public SplitterCircleData() {
super();
}
public SplitterCircleData(
Circle circle, Vector2 velocity, int health, int damage, boolean isShard) {
super(circle, velocity, health, damage);
this.isShard = isShard;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
configure(x, y, radius, vx, vy, health, damage, false);
}
public void configure(
float x,
float y,
float radius,
float vx,
float vy,
int health,
int damage,
boolean isShard) {
super.configure(x, y, radius, vx, vy, health, damage);
this.isShard = isShard;
}
@Override
public void reset() {
super.reset();
isShard = false;
}
@Override
public float[] getColor() {
return isShard ? COLOR_SHARD : COLOR_FULL;
}
}
@@ -0,0 +1,51 @@
package ru.project.tower.entities.circles;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class StrongCircleData extends BaseCircleData {
private int maxHealth;
private final float[] colorScratch = {0.3f, 0.3f, 0f, 1f};
public StrongCircleData() {
super();
this.maxHealth = 0;
}
public StrongCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
this.maxHealth = health;
}
@Override
public void configure(
float x, float y, float radius, float vx, float vy, int health, int damage) {
super.configure(x, y, radius, vx, vy, health, damage);
this.maxHealth = health;
colorScratch[0] = 0.3f;
colorScratch[1] = 0.3f;
colorScratch[2] = health == 0 ? 0f : 1f;
colorScratch[3] = 1f;
}
@Override
public void reset() {
super.reset();
this.maxHealth = 0;
colorScratch[0] = 0.3f;
colorScratch[1] = 0.3f;
colorScratch[2] = 0f;
colorScratch[3] = 1f;
}
@Override
public float[] getColor() {
colorScratch[2] = (float) health / maxHealth;
return colorScratch;
}
@Override
protected int getBaseReward() {
return 3;
}
}
@@ -0,0 +1,371 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
import ru.project.tower.support.MathUtilsRandomSource;
import ru.project.tower.support.RandomSource;
public class GlacialWardenData extends BaseCircleData {
private static final float WARDEN_SPEED = 60f;
private static final int WARDEN_HEALTH = 200;
private static final int WARDEN_DAMAGE = 15;
private static final float WARDEN_RADIUS = 45f;
private static final float AURA_RADIUS = 250f;
private static final float ATTACK_SPEED_SLOW = 0.5f;
private static final float PROJECTILE_SPEED_SLOW = 0.6f;
private static final float FREEZE_ATTACK_SPEED_MULTIPLIER = 0.25f;
private static final float FREEZE_PROJECTILE_SPEED_MULTIPLIER = 0.25f;
private static final long SHARD_COOLDOWN = 3000;
private static final int SHARDS_PER_VOLLEY = 7;
private static final float SHARD_SPEED = 220f;
private static final float SHARD_RADIUS = 7f;
private static final int SHARD_DAMAGE = 8;
private static final long SHARD_DAMAGE_COOLDOWN_MS = 1000L;
private static final float FREEZE_CHANCE = 0.15f;
private static final long FREEZE_DURATION = 500;
private static final long PRISON_COOLDOWN = 10000;
private static final long PRISON_CAST_TIME = 2000;
private static final long PRISON_DURATION = 4000;
private static final int PRISON_DAMAGE = 5;
private static final float PRISON_RADIUS = 80f;
private long lastShardTime;
private long lastPrisonTime;
private boolean isCastingPrison;
private long prisonCastStartTime;
private boolean isPrisonActive;
private long prisonStartTime;
private Vector2 prisonChargePosition;
private Array<IceShard> activeShards;
private boolean isShielded = true;
private long lastShieldBreakTime = 0;
private long shieldStartTime = 0;
private static final long SHIELD_DURATION = 10000;
private static final long SHIELD_COOLDOWN = 5000;
private static final float FREEZE_RADIUS = 100f;
private long lastFreezeTime = 0;
private static final long FREEZE_COOLDOWN = 8000;
private final GameClock clock;
private final RandomSource random;
private boolean freezeFieldActive;
private long freezeFieldEndTime;
private boolean playerInsideAura;
private float activeAttackSpeedMultiplier = 1f;
private float activeProjectileSpeedMultiplier = 1f;
public GlacialWardenData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public GlacialWardenData(Circle circle, Vector2 velocity, GameClock clock) {
this(circle, velocity, clock, new MathUtilsRandomSource());
}
public GlacialWardenData(
Circle circle, Vector2 velocity, GameClock clock, RandomSource random) {
super(circle, velocity, WARDEN_HEALTH, WARDEN_DAMAGE);
this.clock = clock;
this.random = random;
this.isCastingPrison = false;
this.isPrisonActive = false;
this.prisonChargePosition = new Vector2();
this.activeShards = new Array<>();
shieldStartTime = now();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
if (!isCastingPrison) {
super.move(delta);
}
if (isPrisonActive && timeSince(prisonStartTime) > PRISON_DURATION) {
isPrisonActive = false;
}
if (isCastingPrison && timeSince(prisonCastStartTime) > PRISON_CAST_TIME) {
isCastingPrison = false;
isPrisonActive = true;
prisonStartTime = now();
}
for (int i = activeShards.size - 1; i >= 0; i--) {
IceShard shard = activeShards.get(i);
shard.update(delta);
if (!shard.isActive()) {
activeShards.removeIndex(i);
}
}
}
private static final float[] COLOR_SHIELDED = {0.7f, 0.9f, 1.0f, 1.0f};
private static final float[] COLOR_DEFAULT = {0.4f, 0.6f, 0.8f, 1.0f};
@Override
public float[] getColor() {
return isShielded ? COLOR_SHIELDED : COLOR_DEFAULT;
}
public boolean shouldShootShards() {
return timeSince(lastShardTime) > SHARD_COOLDOWN;
}
public void markShardsShot() {
lastShardTime = now();
}
public boolean shouldStartPrison() {
return !isCastingPrison && !isPrisonActive && timeSince(lastPrisonTime) > PRISON_COOLDOWN;
}
public void startPrisonCast(float targetX, float targetY) {
isCastingPrison = true;
prisonCastStartTime = now();
lastPrisonTime = now();
prisonChargePosition.set(targetX, targetY);
}
public void addIceShard(float x, float y, Vector2 velocity) {
IceShard shard = new IceShard(x, y, velocity, clock, random);
activeShards.add(shard);
}
public float getAuraRadius() {
return AURA_RADIUS;
}
public static float getStaticAuraRadius() {
return AURA_RADIUS;
}
public float getAttackSpeedSlow() {
return ATTACK_SPEED_SLOW;
}
public float getProjectileSpeedSlow() {
return PROJECTILE_SPEED_SLOW;
}
public static float getFreezeAttackSpeedMultiplier() {
return FREEZE_ATTACK_SPEED_MULTIPLIER;
}
public static float getFreezeProjectileSpeedMultiplier() {
return FREEZE_PROJECTILE_SPEED_MULTIPLIER;
}
public static float getShardRadius() {
return SHARD_RADIUS;
}
public static float getShardSpeed() {
return SHARD_SPEED;
}
public static int getShardDamage() {
return SHARD_DAMAGE;
}
public static long getShardDamageCooldownMs() {
return SHARD_DAMAGE_COOLDOWN_MS;
}
public static int getShardsPerVolley() {
return SHARDS_PER_VOLLEY;
}
public static float getFreezeChance() {
return FREEZE_CHANCE;
}
public static long getFreezeDuration() {
return FREEZE_DURATION;
}
public static float getPrisonRadius() {
return PRISON_RADIUS;
}
public static int getPrisonDamage() {
return PRISON_DAMAGE;
}
public boolean isCastingPrison() {
return isCastingPrison;
}
public boolean isPrisonActive() {
return isPrisonActive;
}
public float getPrisonCastProgress() {
if (!isCastingPrison) return 0f;
return Math.min(timeSince(prisonCastStartTime) / (float) PRISON_CAST_TIME, 1f);
}
public Vector2 getPrisonChargePosition() {
return prisonChargePosition;
}
public Array<IceShard> getActiveShards() {
return activeShards;
}
@Override
protected int getBaseReward() {
return 200;
}
public static class IceShard {
private static final float LIFETIME = 2000f;
public float x, y;
public Vector2 velocity;
public long creationTime;
public float rotation;
public float rotationSpeed;
private final GameClock clock;
public IceShard(float x, float y, Vector2 velocity) {
this(x, y, velocity, GameClock.SYSTEM);
}
public IceShard(float x, float y, Vector2 velocity, GameClock clock) {
this(x, y, velocity, clock, new MathUtilsRandomSource());
}
public IceShard(float x, float y, Vector2 velocity, GameClock clock, RandomSource random) {
this.x = x;
this.y = y;
this.velocity = velocity;
this.clock = clock;
this.creationTime = clock.nowMs();
this.rotation = random.angle();
this.rotationSpeed = random.range(-5f, 5f);
}
public void update(float delta) {
x += velocity.x * delta;
y += velocity.y * delta;
rotation += rotationSpeed * delta;
}
public boolean isActive() {
return clock.nowMs() - creationTime < LIFETIME;
}
public float getAlpha() {
float age = (clock.nowMs() - creationTime) / LIFETIME;
return 1f - age * age;
}
}
public static float getStaticRadius() {
return 35f;
}
public static float getStaticSpeed() {
return 45f;
}
public void update() {
long currentTime = now();
if (isShielded && currentTime - shieldStartTime > SHIELD_DURATION) {
isShielded = false;
lastShieldBreakTime = currentTime;
} else if (!isShielded && currentTime - lastShieldBreakTime > SHIELD_COOLDOWN) {
isShielded = true;
shieldStartTime = currentTime;
}
}
public boolean shouldFreeze() {
long currentTime = now();
if (currentTime - lastFreezeTime > FREEZE_COOLDOWN) {
lastFreezeTime = currentTime;
return true;
}
return false;
}
public float getFreezeRadius() {
return FREEZE_RADIUS;
}
public void activateFreezeField() {
freezeFieldActive = true;
freezeFieldEndTime = now() + FREEZE_DURATION;
updateActiveCombatMultipliers();
}
public boolean isFreezeFieldActive() {
boolean active = updateFreezeFieldState();
updateActiveCombatMultipliers();
return active;
}
private boolean updateFreezeFieldState() {
if (freezeFieldActive && now() >= freezeFieldEndTime) {
freezeFieldActive = false;
}
return freezeFieldActive;
}
public void updatePlayerAuraState(boolean insideAura) {
playerInsideAura = insideAura;
updateActiveCombatMultipliers();
}
private void updateActiveCombatMultipliers() {
boolean frozen = updateFreezeFieldState();
if (frozen) {
activeAttackSpeedMultiplier = FREEZE_ATTACK_SPEED_MULTIPLIER;
activeProjectileSpeedMultiplier = FREEZE_PROJECTILE_SPEED_MULTIPLIER;
return;
}
activeAttackSpeedMultiplier = playerInsideAura ? ATTACK_SPEED_SLOW : 1f;
activeProjectileSpeedMultiplier = playerInsideAura ? PROJECTILE_SPEED_SLOW : 1f;
}
public boolean isPlayerInsideAura() {
return playerInsideAura;
}
public float getActiveAttackSpeedMultiplier() {
updateActiveCombatMultipliers();
return activeAttackSpeedMultiplier;
}
public float getActiveProjectileSpeedMultiplier() {
updateActiveCombatMultipliers();
return activeProjectileSpeedMultiplier;
}
public boolean isShielded() {
return isShielded;
}
@Override
public void takeDamage(int damage) {
if (!isShielded) {
super.takeDamage(damage);
}
}
}
@@ -0,0 +1,284 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class IllusionMasterData extends BaseCircleData {
private static final float ILLUSION_MASTER_RADIUS = 38f;
private static final float ILLUSION_MASTER_SPEED = 90f;
private static final int ILLUSION_MASTER_HEALTH = 180;
private static final int ILLUSION_MASTER_DAMAGE = 25;
private static final long FORM_CHANGE_INTERVAL = 8000;
private static final float FAST_FORM_SPEED = 150f;
private static final float ARMORED_FORM_SPEED = 30f;
private static final float DAMAGE_MULTIPLIER_FAST = 2.0f;
private static final float DAMAGE_MULTIPLIER_ARMORED = 0.5f;
private static final long ILLUSION_ATTACK_COOLDOWN = 5000;
private static final int FAKE_PROJECTILES_COUNT = 8;
private static final int REAL_PROJECTILES_COUNT = 3;
private static final float PROJECTILE_SPEED = 180f;
private static final float PROJECTILE_RADIUS = 8f;
private static final int PROJECTILE_DAMAGE = 12;
private static final float COLOR_CHANGE_SPEED = 2f;
private static final float DISTORTION_INTENSITY = 0.3f;
private static final long DISTORTION_DURATION = 3000;
private BossForm currentForm;
private long lastFormChangeTime;
private long lastIllusionAttackTime;
private float colorPhase;
private boolean isDistortionActive;
private long distortionStartTime;
private float baseSpeed;
private Array<IllusionData> illusions;
private long lastIllusionTime = 0;
private long lastProjectileTime = 0;
private static final long ILLUSION_COOLDOWN = 10000;
private static final long ILLUSION_DURATION_MS = 8000L;
private static final int ILLUSION_CONTACT_DAMAGE = 5;
private static final long ILLUSION_DAMAGE_COOLDOWN_MS = 1000L;
private static final long PROJECTILE_COOLDOWN = 3000;
private static final int MAX_ILLUSIONS = 3;
private static final int PROJECTILES_PER_VOLLEY = 5;
private static final float ILLUSION_HEALTH_PERCENT = 0.3f;
private final GameClock clock;
public enum BossForm {
FAST,
ARMORED
}
public IllusionMasterData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public IllusionMasterData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, ILLUSION_MASTER_HEALTH, ILLUSION_MASTER_DAMAGE);
this.clock = clock;
this.currentForm = BossForm.FAST;
this.lastFormChangeTime = now();
this.lastIllusionAttackTime = now();
this.colorPhase = 0f;
this.isDistortionActive = false;
this.baseSpeed = FAST_FORM_SPEED;
this.illusions = new Array<>();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
colorPhase += delta * COLOR_CHANGE_SPEED;
if (colorPhase > MathUtils.PI2) {
colorPhase -= MathUtils.PI2;
}
if (timeSince(lastFormChangeTime) > FORM_CHANGE_INTERVAL) {
switchForm();
}
super.move(delta);
for (int i = illusions.size - 1; i >= 0; i--) {
IllusionData illusion = illusions.get(i);
if (illusion.isDead() || illusion.isExpired(now())) {
illusions.removeIndex(i);
} else {
illusion.move(delta);
}
}
}
private void switchForm() {
currentForm = (currentForm == BossForm.FAST) ? BossForm.ARMORED : BossForm.FAST;
lastFormChangeTime = now();
baseSpeed = (currentForm == BossForm.FAST) ? FAST_FORM_SPEED : ARMORED_FORM_SPEED;
velocity.nor().scl(baseSpeed);
activateDistortion();
}
public void activateDistortion() {
isDistortionActive = true;
distortionStartTime = now();
}
private final float[] colorScratch = {0f, 0f, 0f, 1f};
@Override
public float[] getColor() {
colorScratch[0] = 0.5f + 0.5f * MathUtils.sin(colorPhase);
colorScratch[1] = 0.5f + 0.5f * MathUtils.sin(colorPhase + MathUtils.PI2 / 3);
colorScratch[2] = 0.5f + 0.5f * MathUtils.sin(colorPhase + 2 * MathUtils.PI2 / 3);
return colorScratch;
}
public boolean shouldAttack() {
return timeSince(lastIllusionAttackTime) > ILLUSION_ATTACK_COOLDOWN;
}
public void markAttackUsed() {
lastIllusionAttackTime = now();
}
@Override
public void takeDamage(int damage) {
float multiplier =
(currentForm == BossForm.FAST) ? DAMAGE_MULTIPLIER_FAST : DAMAGE_MULTIPLIER_ARMORED;
super.takeDamage((int) (damage * multiplier));
}
public boolean isDistortionActive() {
if (isDistortionActive && timeSince(distortionStartTime) > DISTORTION_DURATION) {
isDistortionActive = false;
}
return isDistortionActive;
}
public float getDistortionIntensity() {
if (!isDistortionActive) return 0f;
float progress = timeSince(distortionStartTime) / (float) DISTORTION_DURATION;
return DISTORTION_INTENSITY * (1f - progress * progress);
}
public BossForm getCurrentForm() {
return currentForm;
}
public static float getProjectileRadius() {
return PROJECTILE_RADIUS;
}
public static float getProjectileSpeed() {
return PROJECTILE_SPEED;
}
public static int getProjectileDamage() {
return PROJECTILE_DAMAGE;
}
public static int getFakeProjectilesCount() {
return FAKE_PROJECTILES_COUNT;
}
public static int getRealProjectilesCount() {
return REAL_PROJECTILES_COUNT;
}
@Override
protected int getBaseReward() {
return 225;
}
public static float getStaticRadius() {
return 25f;
}
public static float getStaticSpeed() {
return 65f;
}
public boolean shouldCreateIllusions() {
long currentTime = now();
if (currentTime - lastIllusionTime > ILLUSION_COOLDOWN && illusions.size < MAX_ILLUSIONS) {
lastIllusionTime = currentTime;
return true;
}
return false;
}
public boolean shouldShootProjectiles() {
long currentTime = now();
if (currentTime - lastProjectileTime > PROJECTILE_COOLDOWN) {
lastProjectileTime = currentTime;
return true;
}
return false;
}
public int getProjectilesPerVolley() {
return PROJECTILES_PER_VOLLEY;
}
public static long getIllusionDurationMs() {
return ILLUSION_DURATION_MS;
}
public static int getIllusionContactDamage() {
return ILLUSION_CONTACT_DAMAGE;
}
public static long getIllusionDamageCooldownMs() {
return ILLUSION_DAMAGE_COOLDOWN_MS;
}
public IllusionData createIllusion(float x, float y) {
Circle illusionCircle = new Circle(x, y, circle.radius);
Vector2 illusionVelocity = velocity.cpy();
int illusionHealth = (int) (health * ILLUSION_HEALTH_PERCENT);
IllusionData illusion =
new IllusionData(
illusionCircle,
illusionVelocity,
illusionHealth,
ILLUSION_CONTACT_DAMAGE,
now());
illusions.add(illusion);
return illusion;
}
public void removeIllusion(IllusionData illusion) {
illusions.removeValue(illusion, true);
}
public Array<IllusionData> getIllusions() {
return illusions;
}
public static class IllusionData extends BaseCircleData {
private final long creationTime;
public IllusionData(
Circle circle, Vector2 velocity, int health, int damage, long creationTime) {
super(circle, velocity, health, damage);
this.creationTime = creationTime;
}
public long getCreationTime() {
return creationTime;
}
boolean isExpired(long nowMs) {
return nowMs - creationTime > ILLUSION_DURATION_MS;
}
private final float[] illusionColor = {0.8f, 0.3f, 0.8f, 0.7f};
@Override
public float[] getColor() {
return illusionColor;
}
@Override
protected int getBaseReward() {
return 0;
}
}
}
@@ -0,0 +1,185 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class JuggernautData extends BaseCircleData {
private static final float JUGGERNAUT_CHARGE_SPEED = 300f;
private static final int JUGGERNAUT_BASE_HEALTH = 300;
private static final int JUGGERNAUT_DAMAGE = 25;
private static final float JUGGERNAUT_RADIUS = 45f;
private static final int ARMOR_LAYERS = 3;
private static final long INVULNERABILITY_DURATION = 2000;
private static final long CHARGE_PREPARATION_TIME = 2000;
private static final long CHARGE_COOLDOWN = 5000;
private static final long CHARGE_DURATION = 2000;
private static final float CHARGE_SPEED_MULTIPLIER = 3.0f;
private static final long SHOCKWAVE_COOLDOWN = 10000;
private static final float SHOCKWAVE_RADIUS = 200f;
private static final float SHOCKWAVE_FORCE = 500f;
private int currentLayer;
private long lastLayerBreakTime;
private boolean isCharging;
private boolean isPreparingCharge;
private long chargeStartTime;
private long lastChargeTime;
private long lastShockwaveTime;
private Vector2 chargeDirection;
private Vector2 normalVelocity;
private Vector2 chargeVelocity;
private final GameClock clock;
public JuggernautData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public JuggernautData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, JUGGERNAUT_BASE_HEALTH, JUGGERNAUT_DAMAGE);
this.clock = clock;
this.currentLayer = ARMOR_LAYERS;
this.isCharging = false;
this.isPreparingCharge = false;
this.chargeDirection = new Vector2();
this.normalVelocity = velocity.cpy();
this.chargeVelocity = velocity.cpy().scl(CHARGE_SPEED_MULTIPLIER);
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
if (isCharging) {
circle.x += chargeDirection.x * JUGGERNAUT_CHARGE_SPEED * delta;
circle.y += chargeDirection.y * JUGGERNAUT_CHARGE_SPEED * delta;
} else {
super.move(delta);
}
}
private static final float[] COLOR = {0.8f, 0.4f, 0.0f, 1.0f};
@Override
public float[] getColor() {
return COLOR;
}
public boolean isInvulnerable() {
return timeSince(lastLayerBreakTime) < INVULNERABILITY_DURATION;
}
@Override
public void takeDamage(int damage) {
if (isInvulnerable()) return;
super.takeDamage(damage);
if (health <= 0 && currentLayer > 1) {
currentLayer--;
health = JUGGERNAUT_BASE_HEALTH / ARMOR_LAYERS;
lastLayerBreakTime = now();
lastShockwaveTime = 0;
}
}
public boolean shouldStartCharge() {
long currentTime = now();
if (!isCharging && !isPreparingCharge && currentTime - lastChargeTime > CHARGE_COOLDOWN) {
isPreparingCharge = true;
chargeStartTime = currentTime;
return true;
}
return false;
}
public boolean shouldExecuteCharge() {
if (isPreparingCharge && timeSince(chargeStartTime) > CHARGE_PREPARATION_TIME) {
isPreparingCharge = false;
isCharging = true;
lastChargeTime = now();
velocity.set(chargeVelocity);
return true;
}
return false;
}
public void setChargeDirection(float x, float y) {
float length = (float) Math.sqrt(x * x + y * y);
if (length > 0) {
chargeDirection.set(x / length, y / length);
}
}
public boolean shouldShockwave() {
return timeSince(lastShockwaveTime) > SHOCKWAVE_COOLDOWN;
}
public void performShockwave() {
lastShockwaveTime = now();
}
public void endCharge() {
isCharging = false;
isPreparingCharge = false;
velocity.set(normalVelocity);
}
public boolean isCharging() {
return isCharging;
}
public boolean isPreparingCharge() {
return isPreparingCharge;
}
public float getChargeProgress() {
if (!isPreparingCharge) return 0f;
return Math.min(timeSince(chargeStartTime) / (float) CHARGE_PREPARATION_TIME, 1f);
}
public int getCurrentLayer() {
return currentLayer;
}
public static float getShockwaveRadius() {
return SHOCKWAVE_RADIUS;
}
public static float getShockwaveForce() {
return SHOCKWAVE_FORCE;
}
public static float getStaticRadius() {
return 40f;
}
public static float getStaticSpeed() {
return 40f;
}
@Override
protected int getBaseReward() {
return 200;
}
public void update() {
long currentTime = now();
if (isCharging && currentTime - lastChargeTime > CHARGE_DURATION) {
isCharging = false;
velocity.set(normalVelocity);
}
}
}
@@ -0,0 +1,240 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class ShadowWeaverData extends BaseCircleData {
private static final float SHADOW_WEAVER_SPEED = 100f;
private static final int SHADOW_WEAVER_BASE_HEALTH = 200;
private static final int SHADOW_WEAVER_DAMAGE = 15;
private static final float SHADOW_WEAVER_RADIUS = 40f;
private static final long TELEPORT_COOLDOWN = 5000;
private static final long TELEPORT_DURATION = 1000;
private static final float TELEPORT_MAX_DISTANCE = 300f;
private static final int MAX_CLONES = 3;
private static final long CLONE_DURATION = 8000;
private static final int CLONE_HEALTH = 30;
private static final int CLONE_DAMAGE = 5;
private static final long CLONE_DAMAGE_COOLDOWN_MS = 1000L;
private static final long PROJECTILE_COOLDOWN = 2000;
private static final float PROJECTILE_SPEED = 200f;
private static final float PROJECTILE_RADIUS = 8f;
private static final int PROJECTILE_DAMAGE = 10;
private boolean isTeleporting;
private long lastTeleportTime;
private long teleportStartTime;
private Vector2 teleportTarget;
private Vector2 originalPosition;
private long lastProjectileTime;
private float teleportProgress;
private Array<ShadowCloneData> clones;
private boolean isInvisible = false;
private long lastInvisibilityTime = 0;
private long invisibilityStartTime = 0;
private static final long INVISIBILITY_COOLDOWN = 7000;
private static final long INVISIBILITY_DURATION = 3000;
private static final float INVISIBLE_SPEED_MULTIPLIER = 1.5f;
private Vector2 normalVelocity;
private Vector2 invisibleVelocity;
private final GameClock clock;
public ShadowWeaverData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public ShadowWeaverData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, SHADOW_WEAVER_BASE_HEALTH, SHADOW_WEAVER_DAMAGE);
this.clock = clock;
this.isTeleporting = false;
this.teleportTarget = new Vector2();
this.originalPosition = new Vector2();
this.clones = new Array<>();
this.normalVelocity = velocity.cpy();
this.invisibleVelocity = velocity.cpy().scl(INVISIBLE_SPEED_MULTIPLIER);
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
if (isTeleporting) {
teleportProgress =
Math.min(1f, timeSince(teleportStartTime) / (float) TELEPORT_DURATION);
circle.x =
originalPosition.x + (teleportTarget.x - originalPosition.x) * teleportProgress;
circle.y =
originalPosition.y + (teleportTarget.y - originalPosition.y) * teleportProgress;
if (teleportProgress >= 1f) {
isTeleporting = false;
lastTeleportTime = now();
}
} else {
super.move(delta);
}
for (int i = clones.size - 1; i >= 0; i--) {
ShadowCloneData clone = clones.get(i);
if (timeSince(clone.getCreationTime()) > CLONE_DURATION) {
clones.removeIndex(i);
} else {
clone.move(delta);
}
}
}
private final float[] colorScratch = {0.3f, 0.0f, 0.5f, 1.0f};
@Override
public float[] getColor() {
colorScratch[3] = isInvisible ? 0.3f : 1.0f;
return colorScratch;
}
public boolean shouldTeleport() {
return !isTeleporting && timeSince(lastTeleportTime) > TELEPORT_COOLDOWN;
}
public void startTeleport(float targetX, float targetY) {
isTeleporting = true;
teleportStartTime = now();
teleportProgress = 0f;
originalPosition.set(circle.x, circle.y);
teleportTarget.set(targetX, targetY);
}
public void createClones() {
if (clones.size < MAX_CLONES) {
for (int i = 0; i < MAX_CLONES; i++) {
float angle = (float) (i * Math.PI * 2 / MAX_CLONES);
float distance = 100f;
float cloneX = circle.x + (float) Math.cos(angle) * distance;
float cloneY = circle.y + (float) Math.sin(angle) * distance;
Circle cloneCircle = new Circle(cloneX, cloneY, SHADOW_WEAVER_RADIUS);
Vector2 cloneVelocity = new Vector2(velocity).scl(0.8f);
ShadowCloneData clone = new ShadowCloneData(cloneCircle, cloneVelocity);
clones.add(clone);
}
}
}
public boolean shouldShoot() {
return timeSince(lastProjectileTime) > PROJECTILE_COOLDOWN;
}
public void markProjectileShot() {
lastProjectileTime = now();
}
public boolean isTeleporting() {
return isTeleporting;
}
public float getTeleportProgress() {
return teleportProgress;
}
public Array<ShadowCloneData> getClones() {
return clones;
}
public static int getCloneDamage() {
return CLONE_DAMAGE;
}
public static long getCloneDamageCooldownMs() {
return CLONE_DAMAGE_COOLDOWN_MS;
}
public static float getProjectileRadius() {
return PROJECTILE_RADIUS;
}
public static float getProjectileSpeed() {
return PROJECTILE_SPEED;
}
public static int getProjectileDamage() {
return PROJECTILE_DAMAGE;
}
@Override
protected int getBaseReward() {
return 200;
}
public static float getStaticRadius() {
return 25f;
}
public static float getStaticSpeed() {
return 70f;
}
public boolean shouldBecomeInvisible() {
long currentTime = now();
if (!isInvisible && currentTime - lastInvisibilityTime > INVISIBILITY_COOLDOWN) {
isInvisible = true;
invisibilityStartTime = currentTime;
lastInvisibilityTime = currentTime;
velocity.set(invisibleVelocity);
return true;
}
return false;
}
public void update() {
long currentTime = now();
if (isInvisible && currentTime - invisibilityStartTime > INVISIBILITY_DURATION) {
isInvisible = false;
velocity.set(normalVelocity);
}
}
public boolean isInvisible() {
return isInvisible;
}
public class ShadowCloneData extends BaseCircleData {
private long creationTime;
public ShadowCloneData(Circle circle, Vector2 velocity) {
super(circle, velocity, CLONE_HEALTH, CLONE_DAMAGE);
this.creationTime = ShadowWeaverData.this.now();
}
public long getCreationTime() {
return creationTime;
}
private final float[] cloneColorScratch = {0f, 0f, 0f, 0.5f};
@Override
public float[] getColor() {
float[] bossColor = ShadowWeaverData.this.getColor();
cloneColorScratch[0] = bossColor[0];
cloneColorScratch[1] = bossColor[1];
cloneColorScratch[2] = bossColor[2];
return cloneColorScratch;
}
}
}
@@ -0,0 +1,344 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.BossEffectsHost;
import ru.project.tower.support.GameClock;
public class SwarmQueenData extends BaseCircleData {
private static final float QUEEN_RADIUS = 40f;
private static final int QUEEN_HEALTH = 200;
private static final float QUEEN_SPEED = 40f;
private static final int QUEEN_DAMAGE = 15;
private static final int MINIONS_PER_SPAWN = 6;
private static final long MINION_SPAWN_COOLDOWN = 6000;
private static final long VULNERABILITY_DURATION = 2000;
private static final int BULLETS_PER_VOLLEY = 12;
private static final long BULLET_VOLLEY_COOLDOWN = 8000;
private static final float BULLET_SPEED = 200f;
private static final float BULLET_RADIUS = 5f;
private static final int BULLET_DAMAGE = 10;
private static final int ORBITAL_EYES_COUNT = 4;
private static final float ORBITAL_RADIUS = 60f;
private static final float ORBITAL_SPEED = 2f;
private static final int ORBITAL_PARTICLES_COUNT = 12;
private static final float ORBITAL_PARTICLE_RADIUS = 3f;
private static final float ORBITAL_PARTICLE_SPEED = 3f;
private long lastMinionSpawnTime;
private long lastBulletVolleyTime;
private boolean isVulnerable;
private long vulnerabilityStartTime;
private float orbitalAngle;
private float particleAngle = 0f;
private Array<Vector2> orbitalEyePositions;
private Array<Vector2> orbitalParticles;
private static final long CHARGING_DURATION = 1000;
private long chargingStartTime = 0;
private boolean isChargingComplete = false;
private static final Color BASE_COLOR = new Color(0.5f, 0.1f, 0.5f, 1f);
private static final Color CHARGING_COLOR = new Color(0.8f, 0.2f, 0.8f, 1f);
private static final Color VULNERABLE_COLOR = new Color(0.3f, 0.3f, 0.8f, 1f);
private static final Color MINION_COLOR = new Color(0.4f, 0.1f, 0.4f, 1f);
private static final Color PROJECTILE_COLOR = new Color(0.8f, 0.2f, 0.8f, 1f);
private final Color scratchBase = new Color();
private final Color scratchMinion = new Color();
private final Color scratchProjectile = new Color();
private final float[] scratchColorArr = new float[4];
private static final float EYE_RADIUS = 8f;
private static final float EYE_GLOW_MULTIPLIER = 1.5f;
private static final float PARTICLE_SPAWN_RATE = 0.1f;
private float particleTimer = 0f;
private final BossEffectsHost host;
private final GameClock clock;
public SwarmQueenData(Circle circle, Vector2 velocity, BossEffectsHost host) {
this(circle, velocity, host, GameClock.SYSTEM);
}
public SwarmQueenData(Circle circle, Vector2 velocity, BossEffectsHost host, GameClock clock) {
super(circle, velocity, QUEEN_HEALTH, QUEEN_DAMAGE);
this.host = host;
this.clock = clock;
this.orbitalEyePositions = new Array<>(ORBITAL_EYES_COUNT);
this.orbitalParticles = new Array<>(ORBITAL_PARTICLES_COUNT);
for (int i = 0; i < ORBITAL_EYES_COUNT; i++) {
this.orbitalEyePositions.add(new Vector2());
}
for (int i = 0; i < ORBITAL_PARTICLES_COUNT; i++) {
this.orbitalParticles.add(new Vector2());
}
updateOrbitalEyes();
updateOrbitalParticles();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
super.move(delta);
orbitalAngle += ORBITAL_SPEED * delta;
updateOrbitalEyes();
particleAngle += ORBITAL_PARTICLE_SPEED * delta;
updateOrbitalParticles();
if (isVulnerable && timeSince(vulnerabilityStartTime) > VULNERABILITY_DURATION) {
isVulnerable = false;
}
}
private void updateOrbitalEyes() {
float angleStep = (float) (2 * Math.PI / ORBITAL_EYES_COUNT);
for (int i = 0; i < ORBITAL_EYES_COUNT; i++) {
float angle = orbitalAngle + i * angleStep;
float x = getX() + ORBITAL_RADIUS * (float) Math.cos(angle);
float y = getY() + ORBITAL_RADIUS * (float) Math.sin(angle);
orbitalEyePositions.get(i).set(x, y);
}
}
private void updateOrbitalParticles() {
float baseRadius = circle.radius * 1.5f;
float angleStep = (float) (2 * Math.PI / ORBITAL_PARTICLES_COUNT);
for (int i = 0; i < ORBITAL_PARTICLES_COUNT; i++) {
float angle = particleAngle + i * angleStep;
float radiusOffset = 5f * (float) Math.sin(now() / 200.0 + i * 0.5f);
float radius = baseRadius + radiusOffset;
float x = getX() + radius * (float) Math.cos(angle);
float y = getY() + radius * (float) Math.sin(angle);
orbitalParticles.get(i).set(x, y);
}
}
private Color getBaseColor() {
scratchBase.set(BASE_COLOR);
if (isCharging()) {
scratchBase.lerp(CHARGING_COLOR, getChargeProgress());
} else if (isVulnerable()) {
scratchBase.lerp(VULNERABLE_COLOR, 0.7f);
}
scratchBase.a = 0.8f + 0.2f * (float) Math.sin(now() / 200.0);
return scratchBase;
}
@Override
public float[] getColor() {
Color color = getBaseColor();
scratchColorArr[0] = color.r;
scratchColorArr[1] = color.g;
scratchColorArr[2] = color.b;
scratchColorArr[3] = color.a;
return scratchColorArr;
}
public Color getOrbitalParticleColor() {
return getBaseColor();
}
public boolean shouldSpawnMinions() {
return timeSince(lastMinionSpawnTime) > MINION_SPAWN_COOLDOWN;
}
public void onMinionSpawn() {
long currentTime = now();
lastMinionSpawnTime = currentTime;
isVulnerable = true;
vulnerabilityStartTime = currentTime;
}
public boolean isReadyToFireProjectiles() {
return timeSince(lastBulletVolleyTime) > BULLET_VOLLEY_COOLDOWN;
}
public void onProjectileAttackStart() {
lastBulletVolleyTime = now();
}
public static float getStaticRadius() {
return QUEEN_RADIUS;
}
public static float getStaticSpeed() {
return QUEEN_SPEED;
}
public static int getMinionsPerWave() {
return MINIONS_PER_SPAWN;
}
public static int getBulletsPerVolley() {
return BULLETS_PER_VOLLEY;
}
public static float getBulletSpeed() {
return BULLET_SPEED;
}
public static float getBulletRadius() {
return BULLET_RADIUS;
}
public static int getBulletDamage() {
return BULLET_DAMAGE;
}
public Array<Vector2> getOrbitalEyePositions() {
return orbitalEyePositions;
}
public Array<Vector2> getOrbitalParticles() {
return orbitalParticles;
}
public float getOrbitalParticleRadius() {
return ORBITAL_PARTICLE_RADIUS;
}
public boolean isVulnerable() {
return isVulnerable;
}
@Override
public void takeDamage(int damage) {
if (isVulnerable) {
super.takeDamage(damage * 2);
} else {
super.takeDamage(damage);
}
}
@Override
protected int getBaseReward() {
return 200;
}
public boolean isCharging() {
if (isReadyToFireProjectiles() && !isChargingComplete) {
if (chargingStartTime == 0) {
chargingStartTime = now();
}
boolean charging = timeSince(chargingStartTime) < CHARGING_DURATION;
if (!charging) {
isChargingComplete = true;
}
return charging;
}
return false;
}
public void finishCharging() {
chargingStartTime = 0;
isChargingComplete = false;
onProjectileAttackStart();
}
public float getChargeProgress() {
if (chargingStartTime == 0) {
return 0f;
}
float progress = (float) timeSince(chargingStartTime) / CHARGING_DURATION;
return Math.min(progress, 1f);
}
public Color getMinionColor() {
scratchMinion.set(MINION_COLOR);
if (isVulnerable()) {
scratchMinion.lerp(VULNERABLE_COLOR, 0.5f);
}
scratchMinion.a = 0.9f + 0.1f * (float) Math.sin(now() / 300.0);
return scratchMinion;
}
public Color getProjectileColor() {
scratchProjectile.set(PROJECTILE_COLOR);
if (isVulnerable()) {
scratchProjectile.lerp(VULNERABLE_COLOR, 0.3f);
}
scratchProjectile.a = 0.7f + 0.3f * (float) Math.sin(now() / 150.0);
return scratchProjectile;
}
public float getEyeRadius() {
float baseRadius = EYE_RADIUS;
if (isCharging()) {
baseRadius *= (1f + 0.5f * getChargeProgress());
}
return baseRadius * (1f + 0.1f * (float) Math.sin(now() / 150.0));
}
public float getEyeGlow() {
float baseGlow = 1f;
if (isCharging()) {
baseGlow = 1f + getChargeProgress() * 2f;
} else if (isVulnerable()) {
baseGlow = 0.7f;
}
return baseGlow * (1f + 0.2f * (float) Math.sin(now() / 200.0));
}
public void updateParticles(float delta) {
particleTimer += delta;
if (particleTimer >= PARTICLE_SPAWN_RATE) {
particleTimer = 0f;
if (isCharging()) {
spawnChargeParticles();
} else if (isVulnerable()) {
spawnVulnerabilityParticles();
}
}
}
private final Color scratchParticleColor = new Color();
private void spawnChargeParticles() {
if (host != null) {
scratchParticleColor.set(CHARGING_COLOR);
scratchParticleColor.a = 0.7f + 0.3f * getChargeProgress();
host.createBossParticles(getX(), getY(), circle.radius, scratchParticleColor, 1.5f, 3);
}
}
private void spawnVulnerabilityParticles() {
if (host != null) {
scratchParticleColor.set(VULNERABLE_COLOR);
scratchParticleColor.a = 0.6f;
host.createBossParticles(getX(), getY(), circle.radius, scratchParticleColor, 1.2f, 2);
}
}
}
@@ -0,0 +1,256 @@
package ru.project.tower.entities.circles.bosses;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import ru.project.tower.entities.circles.BaseCircleData;
import ru.project.tower.support.GameClock;
public class VolatileReactorData extends BaseCircleData {
private static final float REACTOR_SPEED = 80f;
private static final int REACTOR_BASE_HEALTH = 250;
private static final int REACTOR_DAMAGE = 20;
private static final float REACTOR_RADIUS = 40f;
private static final long HEAT_CYCLE_DURATION = 15000;
private static final int EXPLOSION_DAMAGE = 50;
private static final float EXPLOSION_RADIUS = 300f;
private static final long COOLDOWN_DURATION = 5000;
private static final long FIRE_ZONE_INTERVAL = 3000;
private static final long FIRE_ZONE_DURATION = 5000;
private static final float FIRE_ZONE_RADIUS = 60f;
private static final int FIRE_ZONE_DAMAGE = 5;
private static final long FIRE_ZONE_DAMAGE_COOLDOWN_MS = 1000L;
private static final long MISSILE_COOLDOWN = 7000;
private static final int MISSILES_PER_VOLLEY = 5;
private static final float MISSILE_SPEED = 250f;
private static final float MISSILE_RADIUS = 6f;
private static final int MISSILE_DAMAGE = 8;
private float heatLevel;
private long cycleStartTime;
private boolean isExploding;
private boolean isCoolingDown;
private long lastFireZoneTime;
private long lastMissileTime;
private Array<FireZone> fireZones;
private int energyLevel = 0;
private long lastEnergyGainTime = 0;
private static final long ENERGY_GAIN_INTERVAL = 1000;
private static final int MAX_ENERGY = 100;
private static final int ENERGY_GAIN_AMOUNT = 10;
private long explosionStartTime = 0;
private static final long EXPLOSION_DURATION = 500;
private final GameClock clock;
public VolatileReactorData(Circle circle, Vector2 velocity) {
this(circle, velocity, GameClock.SYSTEM);
}
public VolatileReactorData(Circle circle, Vector2 velocity, GameClock clock) {
super(circle, velocity, REACTOR_BASE_HEALTH, REACTOR_DAMAGE);
this.clock = clock;
this.heatLevel = 0f;
this.cycleStartTime = now();
this.isExploding = false;
this.isCoolingDown = false;
this.fireZones = new Array<>();
}
private long now() {
return clock.nowMs();
}
private long timeSince(long pastMs) {
return now() - pastMs;
}
@Override
public void move(float delta) {
super.move(delta);
if (!isCoolingDown) {
heatLevel = Math.min(1f, timeSince(cycleStartTime) / (float) HEAT_CYCLE_DURATION);
if (heatLevel >= 1f && !isExploding) {
isExploding = true;
}
} else {
heatLevel = Math.max(0f, 1f - timeSince(cycleStartTime) / (float) COOLDOWN_DURATION);
if (heatLevel <= 0f) {
isCoolingDown = false;
cycleStartTime = now();
}
}
for (int i = fireZones.size - 1; i >= 0; i--) {
FireZone zone = fireZones.get(i);
if (timeSince(zone.creationTime) > FIRE_ZONE_DURATION) {
fireZones.removeIndex(i);
}
}
}
private final float[] colorScratch = {0f, 0f, 0.1f, 1f};
@Override
public float[] getColor() {
float pulseIntensity = 0.5f + 0.5f * (float) Math.sin(now() * 0.01f);
float heat = heatLevel + pulseIntensity * 0.2f;
colorScratch[0] = 0.8f + heat * 0.2f;
colorScratch[1] = 0.4f - heat * 0.3f;
return colorScratch;
}
public void explode() {
isExploding = false;
isCoolingDown = true;
cycleStartTime = now();
}
public boolean shouldCreateFireZone() {
return timeSince(lastFireZoneTime) > FIRE_ZONE_INTERVAL;
}
public void createFireZone() {
FireZone zone = new FireZone(circle.x, circle.y, FIRE_ZONE_RADIUS, now(), clock);
fireZones.add(zone);
lastFireZoneTime = now();
}
public boolean shouldShootMissiles() {
return timeSince(lastMissileTime) > MISSILE_COOLDOWN;
}
public void markMissileShot() {
lastMissileTime = now();
}
public float getHeatLevel() {
return heatLevel;
}
public boolean isExploding() {
return isExploding;
}
public Array<FireZone> getFireZones() {
return fireZones;
}
public static float getFireZoneRadius() {
return FIRE_ZONE_RADIUS;
}
public static int getFireZoneDamage() {
return FIRE_ZONE_DAMAGE;
}
public static long getFireZoneDamageCooldownMs() {
return FIRE_ZONE_DAMAGE_COOLDOWN_MS;
}
public static float getMissileRadius() {
return MISSILE_RADIUS;
}
public static float getMissileSpeed() {
return MISSILE_SPEED;
}
public static int getMissileDamage() {
return MISSILE_DAMAGE;
}
public static int getMissilesPerVolley() {
return MISSILES_PER_VOLLEY;
}
public static float getExplosionRadius() {
return EXPLOSION_RADIUS;
}
public static int getExplosionDamage() {
return EXPLOSION_DAMAGE;
}
@Override
protected int getBaseReward() {
return 225;
}
public static class FireZone {
public float x, y;
public float radius;
public long creationTime;
private final GameClock clock;
public FireZone(float x, float y, float radius, long creationTime) {
this(x, y, radius, creationTime, GameClock.SYSTEM);
}
public FireZone(float x, float y, float radius, long creationTime, GameClock clock) {
this.x = x;
this.y = y;
this.radius = radius;
this.creationTime = creationTime;
this.clock = clock;
}
public float getAlpha() {
float age = (clock.nowMs() - creationTime) / (float) FIRE_ZONE_DURATION;
return 1f - age;
}
private final float[] zoneColorScratch = {1f, 0.3f, 0.1f, 0f};
public float[] getColor() {
float intensity = 0.5f + 0.5f * (float) Math.sin(clock.nowMs() * 0.005f);
zoneColorScratch[3] = getAlpha() * (0.3f + intensity * 0.2f);
return zoneColorScratch;
}
}
public static float getStaticRadius() {
return REACTOR_RADIUS;
}
public static float getStaticSpeed() {
return REACTOR_SPEED;
}
public void update() {
long currentTime = now();
if (!isExploding && currentTime - lastEnergyGainTime > ENERGY_GAIN_INTERVAL) {
energyLevel = Math.min(MAX_ENERGY, energyLevel + ENERGY_GAIN_AMOUNT);
lastEnergyGainTime = currentTime;
}
if (isExploding && currentTime - explosionStartTime > EXPLOSION_DURATION) {
isExploding = false;
energyLevel = 0;
}
}
public boolean shouldExplode() {
return energyLevel >= MAX_ENERGY && !isExploding;
}
public void startExplosion() {
if (!isExploding) {
isExploding = true;
explosionStartTime = now();
}
}
public int getEnergyLevel() {
return energyLevel;
}
}
@@ -0,0 +1,74 @@
package ru.project.tower.entities.particles;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class FloatingText implements Pool.Poolable {
public final Vector2 position = new Vector2();
public final Vector2 velocity = new Vector2();
public String text;
public Color color;
public float lifetime;
public float maxLifetime;
public float scale;
public float initialScale;
public FloatingText() {}
public void configure(float x, float y, String text, Color srcColor) {
configure(x, y, text, srcColor, 1.0f, 1.0f, 50f);
}
public void configure(
float x,
float y,
String text,
Color srcColor,
float maxLifetime,
float initialScale,
float riseSpeed) {
position.set(x, y);
velocity.set(0f, riseSpeed);
this.text = text;
if (this.color == null) this.color = new Color();
this.color.set(srcColor);
this.maxLifetime = maxLifetime;
this.lifetime = maxLifetime;
this.initialScale = initialScale;
this.scale = initialScale;
}
public void update(float delta) {
position.add(velocity.x * delta, velocity.y * delta);
lifetime -= delta;
velocity.y *= 0.95f;
float life = Math.max(0f, lifetime / maxLifetime);
scale = initialScale * (0.85f + 0.15f * life);
color.a = Math.min(1f, life / 0.4f);
}
public boolean isAlive() {
return lifetime > 0;
}
@Override
public void reset() {
position.set(0f, 0f);
velocity.set(0f, 0f);
text = null;
if (color != null) color.set(0f, 0f, 0f, 0f);
lifetime = 0f;
maxLifetime = 0f;
scale = 0f;
initialScale = 0f;
}
}
@@ -0,0 +1,48 @@
package ru.project.tower.entities.particles;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Pool;
public class NeonParticle implements Pool.Poolable {
public final Vector2 position = new Vector2();
public final Vector2 velocity = new Vector2();
public final Color color = new Color();
public float size;
public float lifetime;
public float maxLifetime;
public NeonParticle() {}
public void configure(float x, float y, Color color) {
position.set(x, y);
velocity.set(MathUtils.random(-20f, 20f), MathUtils.random(-20f, 20f));
size = MathUtils.random(2f, 4f);
maxLifetime = MathUtils.random(0.5f, 2.0f);
lifetime = maxLifetime;
this.color.set(color);
}
public void update(float delta) {
position.add(velocity.x * delta, velocity.y * delta);
lifetime -= delta;
size = Math.max(0, size - delta);
}
@Override
public void reset() {
position.set(0f, 0f);
velocity.set(0f, 0f);
size = 0f;
lifetime = 0f;
maxLifetime = 0f;
color.set(0f, 0f, 0f, 0f);
}
}
@@ -0,0 +1,47 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Screen;
import java.util.Objects;
import ru.project.tower.GameContext;
import ru.project.tower.screens.AbilitySelectionScreen;
import ru.project.tower.screens.AbilityShopScreen;
import ru.project.tower.screens.GameScreen;
import ru.project.tower.screens.MainScreen;
import ru.project.tower.screens.TalentTreeScreen;
import ru.project.tower.screens.abilityselection.AbilitySelectionDependencies;
import ru.project.tower.screens.abilityshop.AbilityShopDependencies;
import ru.project.tower.screens.gamescreen.GameScreenDependencies;
import ru.project.tower.screens.talenttree.TalentTreeScreenDependencies;
public final class DefaultScreenFactory implements ScreenFactory {
private final GameContext ctx;
public DefaultScreenFactory(GameContext ctx) {
this.ctx = Objects.requireNonNull(ctx, "ctx");
}
@Override
public Screen createMainMenu(ScreenNavigator navigator) {
return new MainScreen(navigator);
}
@Override
public Screen createAbilitySelection(ScreenNavigator navigator) {
return new AbilitySelectionScreen(navigator, AbilitySelectionDependencies.from(ctx));
}
@Override
public Screen createAbilityShop(ScreenNavigator navigator) {
return new AbilityShopScreen(navigator, AbilityShopDependencies.from(ctx));
}
@Override
public Screen createTalentTree(ScreenNavigator navigator) {
return new TalentTreeScreen(navigator, TalentTreeScreenDependencies.from(ctx));
}
@Override
public Screen createGameScreen(ScreenNavigator navigator) {
return new GameScreen(navigator, GameScreenDependencies.from(ctx));
}
}
@@ -0,0 +1,76 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Screen;
import java.util.Objects;
public final class GameScreenNavigator implements ScreenNavigator {
private final ScreenHost host;
private final ScreenFactory factory;
public GameScreenNavigator(ScreenHost host, ScreenFactory factory) {
this.host = Objects.requireNonNull(host, "host");
this.factory = Objects.requireNonNull(factory, "factory");
}
public static GameScreenNavigator forGame(Game game, ScreenFactory factory) {
return new GameScreenNavigator(new GdxGameScreenHost(game), factory);
}
@Override
public void showMainMenu() {
transitionTo(factory.createMainMenu(this));
}
@Override
public void showAbilitySelection() {
transitionTo(factory.createAbilitySelection(this));
}
@Override
public void showAbilityShop() {
transitionTo(factory.createAbilityShop(this));
}
@Override
public void showTalentTree() {
transitionTo(factory.createTalentTree(this));
}
@Override
public void showGameScreen() {
transitionTo(factory.createGameScreen(this));
}
private void transitionTo(Screen nextScreen) {
Screen previousScreen = host.currentScreen();
host.setScreen(Objects.requireNonNull(nextScreen, "nextScreen"));
if (previousScreen != null && previousScreen != nextScreen) {
previousScreen.dispose();
}
}
public interface ScreenHost {
Screen currentScreen();
void setScreen(Screen screen);
}
private static final class GdxGameScreenHost implements ScreenHost {
private final Game game;
private GdxGameScreenHost(Game game) {
this.game = Objects.requireNonNull(game, "game");
}
@Override
public Screen currentScreen() {
return game.getScreen();
}
@Override
public void setScreen(Screen screen) {
game.setScreen(screen);
}
}
}
@@ -0,0 +1,15 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Screen;
public interface ScreenFactory {
Screen createMainMenu(ScreenNavigator navigator);
Screen createAbilitySelection(ScreenNavigator navigator);
Screen createAbilityShop(ScreenNavigator navigator);
Screen createTalentTree(ScreenNavigator navigator);
Screen createGameScreen(ScreenNavigator navigator);
}
@@ -0,0 +1,12 @@
package ru.project.tower.navigation;
import com.badlogic.gdx.Game;
import ru.project.tower.GameContext;
public final class ScreenNavigation {
private ScreenNavigation() {}
public static ScreenNavigator forGame(Game game, GameContext ctx) {
return GameScreenNavigator.forGame(game, new DefaultScreenFactory(ctx));
}
}
@@ -0,0 +1,13 @@
package ru.project.tower.navigation;
public interface ScreenNavigator {
void showMainMenu();
void showAbilitySelection();
void showAbilityShop();
void showTalentTree();
void showGameScreen();
}
@@ -0,0 +1,189 @@
package ru.project.tower.player;
import java.util.Objects;
import ru.project.tower.balance.EndlessBalanceSpec;
import ru.project.tower.systems.BonusBallSystem;
import ru.project.tower.talents.TalentTree;
import ru.project.tower.upgrades.UpgradeSystem;
import ru.project.tower.upgrades.shop.ShopUpgradeSystem;
public final class EffectiveCombatStats {
private static final float CRIT_KEYSTONE_CHANCE_PER_LEVEL = 0.10f;
private static final String CRIT_KEYSTONE_ID = "keystone_crit";
private final float damage;
private final float shotCooldownMs;
private final float bulletSpeedMultiplier;
private final float critChance;
private final float critMultiplier;
private final int healthRegenRate;
private final int pierceBonus;
private final float aoeRadiusBonus;
private final float controlRating;
private final float executeDamageBonus;
private final float bossDamageBonus;
private final float controlledDamageBonus;
private final float abilityScalingFactor;
private EffectiveCombatStats(
float damage,
float shotCooldownMs,
float bulletSpeedMultiplier,
float critChance,
float critMultiplier,
int healthRegenRate,
int pierceBonus,
float aoeRadiusBonus,
float controlRating,
float executeDamageBonus,
float bossDamageBonus,
float controlledDamageBonus,
float abilityScalingFactor) {
this.damage = damage;
this.shotCooldownMs = shotCooldownMs;
this.bulletSpeedMultiplier = bulletSpeedMultiplier;
this.critChance = critChance;
this.critMultiplier = critMultiplier;
this.healthRegenRate = healthRegenRate;
this.pierceBonus = pierceBonus;
this.aoeRadiusBonus = aoeRadiusBonus;
this.controlRating = controlRating;
this.executeDamageBonus = executeDamageBonus;
this.bossDamageBonus = bossDamageBonus;
this.controlledDamageBonus = controlledDamageBonus;
this.abilityScalingFactor = abilityScalingFactor;
}
public static EffectiveCombatStats fromSources(
PlayerCombatState combatState,
ShopUpgradeSystem shopUpgradeSystem,
UpgradeSystem upgradeSystem,
BonusBallSystem bonusBallSystem,
TalentTree talentTree,
int currentWave,
float baseDamage,
float runDamageBonus,
int runPierceBonus,
float runAoeRadiusBonus,
float controlRating) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
Objects.requireNonNull(talentTree, "talentTree");
float damage =
(baseDamage + shopUpgradeSystem.damageBonus() + runDamageBonus)
* bonusBallSystem.getDamageMultiplier();
return new EffectiveCombatStats(
damage,
shotCooldownMs(
combatState,
shopUpgradeSystem.cooldownReduction(),
upgradeSystem,
bonusBallSystem),
bonusBallSystem.getSpeedMultiplier(),
critChance(combatState, shopUpgradeSystem, upgradeSystem, talentTree),
critMultiplier(combatState, shopUpgradeSystem),
combatState.getHealthRegenRate() + shopUpgradeSystem.regenBonus(),
runPierceBonus + shopUpgradeSystem.pierceBonus(),
runAoeRadiusBonus + shopUpgradeSystem.aoeRadiusBonus(),
controlRating,
shopUpgradeSystem.executeDamageBonus(),
shopUpgradeSystem.bossDamageBonus(),
shopUpgradeSystem.controlledDamageBonus(),
PlayerRunCombatCalculator.abilityScalingFactor(currentWave));
}
public static float shotCooldownMs(
PlayerCombatState combatState,
float shopCooldownReduction,
UpgradeSystem upgradeSystem,
BonusBallSystem bonusBallSystem) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(bonusBallSystem, "bonusBallSystem");
float cooldown =
(combatState.getShotCooldownMs() - shopCooldownReduction)
* upgradeSystem.getRunCooldownMult()
* bonusBallSystem.getCooldownMultiplier();
return Math.max(PlayerCombatState.MIN_SHOT_COOLDOWN_MS, cooldown);
}
public static float critChance(
PlayerCombatState combatState,
ShopUpgradeSystem shopUpgradeSystem,
UpgradeSystem upgradeSystem,
TalentTree talentTree) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
Objects.requireNonNull(upgradeSystem, "upgradeSystem");
Objects.requireNonNull(talentTree, "talentTree");
int critKeystone = talentTree.getCurrentLevel(CRIT_KEYSTONE_ID);
return Math.min(
EndlessBalanceSpec.CRIT_CHANCE_CAP,
combatState.getBaseCritChance()
+ shopUpgradeSystem.critChanceBonus()
+ critKeystone * CRIT_KEYSTONE_CHANCE_PER_LEVEL
+ upgradeSystem.getRunCritBonus());
}
public static float critMultiplier(
PlayerCombatState combatState, ShopUpgradeSystem shopUpgradeSystem) {
Objects.requireNonNull(combatState, "combatState");
Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem");
return combatState.getBaseCritMultiplier() + shopUpgradeSystem.critMultiplierBonus();
}
public float damage() {
return damage;
}
public float shotCooldownMs() {
return shotCooldownMs;
}
public float bulletSpeedMultiplier() {
return bulletSpeedMultiplier;
}
public float critChance() {
return critChance;
}
public float critMultiplier() {
return critMultiplier;
}
public int healthRegenRate() {
return healthRegenRate;
}
public int pierceBonus() {
return pierceBonus;
}
public float aoeRadiusBonus() {
return aoeRadiusBonus;
}
public float controlRating() {
return controlRating;
}
public float executeDamageBonus() {
return executeDamageBonus;
}
public float bossDamageBonus() {
return bossDamageBonus;
}
public float controlledDamageBonus() {
return controlledDamageBonus;
}
public float abilityScalingFactor() {
return abilityScalingFactor;
}
}
@@ -0,0 +1,365 @@
package ru.project.tower.player;
import java.util.Objects;
public final class PlayerCombatState {
public static final int DEFAULT_MAX_HEALTH = 100;
public static final float DEFAULT_SHOT_COOLDOWN_MS = 800f;
public static final float MIN_SHOT_COOLDOWN_MS = 100f;
public static final long HEALTH_REGEN_INTERVAL_MS = 1_000L;
public static final float DEFAULT_BASE_CRIT_CHANCE = 0.05f;
public static final float DEFAULT_BASE_CRIT_MULTIPLIER = 1.5f;
private int health;
private int maxHealth;
private int healthRegenRate;
private int money;
private long lastDamageTimeMs;
private long lastHealthRegenTimeMs;
private float shotCooldownMs;
private float baseCritChance;
private float baseCritMultiplier;
private int gameOverCurrencyAward;
private int maxWaveReached;
private boolean gameOverCurrencyAwarded;
private int runCurrencyEarned;
public PlayerCombatState() {
resetForNewRun(DEFAULT_MAX_HEALTH, DEFAULT_SHOT_COOLDOWN_MS, 0);
baseCritChance = DEFAULT_BASE_CRIT_CHANCE;
baseCritMultiplier = DEFAULT_BASE_CRIT_MULTIPLIER;
}
public boolean damageIfVulnerable(int damage, long damageCooldownMs, long nowMs) {
return damageIfVulnerable(damage, damageCooldownMs, nowMs, DamageBlocker.NONE);
}
public boolean damageIfVulnerable(
int damage, long damageCooldownMs, long nowMs, DamageBlocker blocker) {
if (damageCooldownMs > 0L && nowMs - lastDamageTimeMs <= damageCooldownMs) {
return false;
}
DamageBlocker activeBlocker = blocker == null ? DamageBlocker.NONE : blocker;
if (!activeBlocker.blocksDamage(nowMs)) {
health -= damage;
}
lastDamageTimeMs = nowMs;
return true;
}
public boolean regenerateIfReady(long nowMs) {
if (nowMs - lastHealthRegenTimeMs <= HEALTH_REGEN_INTERVAL_MS) {
return false;
}
health = Math.min(maxHealth, health + healthRegenRate);
lastHealthRegenTimeMs = nowMs;
return true;
}
public void resetForNewRun(int maxHealth, float shotCooldownMs, int healthRegenRate) {
money = 0;
this.maxHealth = maxHealth;
health = maxHealth;
this.healthRegenRate = healthRegenRate;
this.shotCooldownMs = shotCooldownMs;
lastDamageTimeMs = 0L;
lastHealthRegenTimeMs = 0L;
gameOverCurrencyAward = 0;
gameOverCurrencyAwarded = false;
runCurrencyEarned = 0;
}
public Snapshot snapshot() {
return new Snapshot(
money,
health,
maxHealth,
healthRegenRate,
lastDamageTimeMs,
lastHealthRegenTimeMs,
shotCooldownMs,
baseCritChance,
baseCritMultiplier,
gameOverCurrencyAward,
maxWaveReached,
gameOverCurrencyAwarded,
runCurrencyEarned);
}
public void apply(Snapshot snapshot) {
Snapshot source = Objects.requireNonNull(snapshot, "snapshot");
money = source.money();
health = source.health();
maxHealth = source.maxHealth();
healthRegenRate = source.healthRegenRate();
lastDamageTimeMs = source.lastDamageTimeMs();
lastHealthRegenTimeMs = source.lastHealthRegenTimeMs();
shotCooldownMs = source.shotCooldownMs();
baseCritChance = source.baseCritChance();
baseCritMultiplier = source.baseCritMultiplier();
gameOverCurrencyAward = source.gameOverCurrencyAward();
maxWaveReached = source.maxWaveReached();
gameOverCurrencyAwarded = source.gameOverCurrencyAwarded();
runCurrencyEarned = source.runCurrencyEarned();
}
public int awardGameOverCurrencyOnce(int currentWave) {
if (gameOverCurrencyAwarded) {
return 0;
}
gameOverCurrencyAward = currentWave;
maxWaveReached = Math.max(maxWaveReached, currentWave);
gameOverCurrencyAwarded = true;
return gameOverCurrencyAward;
}
public int getHealth() {
return health;
}
public void setHealth(int health) {
this.health = health;
}
public int getMaxHealth() {
return maxHealth;
}
public void setMaxHealth(int maxHealth) {
this.maxHealth = maxHealth;
}
public int getHealthRegenRate() {
return healthRegenRate;
}
public void setHealthRegenRate(int healthRegenRate) {
this.healthRegenRate = healthRegenRate;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public void addMoney(int amount) {
if (amount > 0) {
money += amount;
}
}
public boolean spendMoney(int amount) {
if (amount <= 0) {
return true;
}
if (money < amount) {
return false;
}
money -= amount;
return true;
}
public int getRunCurrencyEarned() {
return runCurrencyEarned;
}
public void addRunCurrencyEarned(int amount) {
if (amount > 0) {
runCurrencyEarned += amount;
}
}
public long getLastDamageTimeMs() {
return lastDamageTimeMs;
}
public void setLastDamageTimeMs(long lastDamageTimeMs) {
this.lastDamageTimeMs = lastDamageTimeMs;
}
public long getLastHealthRegenTimeMs() {
return lastHealthRegenTimeMs;
}
public void setLastHealthRegenTimeMs(long lastHealthRegenTimeMs) {
this.lastHealthRegenTimeMs = lastHealthRegenTimeMs;
}
public float getShotCooldownMs() {
return shotCooldownMs;
}
public void setShotCooldownMs(float shotCooldownMs) {
this.shotCooldownMs = shotCooldownMs;
}
public void reduceShotCooldown(float amountMs) {
shotCooldownMs = Math.max(MIN_SHOT_COOLDOWN_MS, shotCooldownMs - amountMs);
}
public float getBaseCritChance() {
return baseCritChance;
}
public void setBaseCritChance(float baseCritChance) {
this.baseCritChance = baseCritChance;
}
public float getBaseCritMultiplier() {
return baseCritMultiplier;
}
public void setBaseCritMultiplier(float baseCritMultiplier) {
this.baseCritMultiplier = baseCritMultiplier;
}
public int getGameOverCurrencyAward() {
return gameOverCurrencyAward;
}
public int getMaxWaveReached() {
return maxWaveReached;
}
public boolean isGameOverCurrencyAwarded() {
return gameOverCurrencyAwarded;
}
public interface DamageBlocker {
DamageBlocker NONE = nowMs -> false;
boolean blocksDamage(long nowMs);
}
public static final class Snapshot {
private final int money;
private final int health;
private final int maxHealth;
private final int healthRegenRate;
private final long lastDamageTimeMs;
private final long lastHealthRegenTimeMs;
private final float shotCooldownMs;
private final float baseCritChance;
private final float baseCritMultiplier;
private final int gameOverCurrencyAward;
private final int maxWaveReached;
private final boolean gameOverCurrencyAwarded;
private final int runCurrencyEarned;
public Snapshot(
int money,
int health,
int maxHealth,
int healthRegenRate,
long lastDamageTimeMs,
long lastHealthRegenTimeMs,
float shotCooldownMs,
float baseCritChance,
float baseCritMultiplier,
int gameOverCurrencyAward,
int maxWaveReached,
boolean gameOverCurrencyAwarded) {
this(
money,
health,
maxHealth,
healthRegenRate,
lastDamageTimeMs,
lastHealthRegenTimeMs,
shotCooldownMs,
baseCritChance,
baseCritMultiplier,
gameOverCurrencyAward,
maxWaveReached,
gameOverCurrencyAwarded,
0);
}
public Snapshot(
int money,
int health,
int maxHealth,
int healthRegenRate,
long lastDamageTimeMs,
long lastHealthRegenTimeMs,
float shotCooldownMs,
float baseCritChance,
float baseCritMultiplier,
int gameOverCurrencyAward,
int maxWaveReached,
boolean gameOverCurrencyAwarded,
int runCurrencyEarned) {
this.money = money;
this.health = health;
this.maxHealth = maxHealth;
this.healthRegenRate = healthRegenRate;
this.lastDamageTimeMs = lastDamageTimeMs;
this.lastHealthRegenTimeMs = lastHealthRegenTimeMs;
this.shotCooldownMs = shotCooldownMs;
this.baseCritChance = baseCritChance;
this.baseCritMultiplier = baseCritMultiplier;
this.gameOverCurrencyAward = gameOverCurrencyAward;
this.maxWaveReached = maxWaveReached;
this.gameOverCurrencyAwarded = gameOverCurrencyAwarded;
this.runCurrencyEarned = runCurrencyEarned;
}
public int money() {
return money;
}
public int health() {
return health;
}
public int maxHealth() {
return maxHealth;
}
public int healthRegenRate() {
return healthRegenRate;
}
public long lastDamageTimeMs() {
return lastDamageTimeMs;
}
public long lastHealthRegenTimeMs() {
return lastHealthRegenTimeMs;
}
public float shotCooldownMs() {
return shotCooldownMs;
}
public float baseCritChance() {
return baseCritChance;
}
public float baseCritMultiplier() {
return baseCritMultiplier;
}
public int gameOverCurrencyAward() {
return gameOverCurrencyAward;
}
public int maxWaveReached() {
return maxWaveReached;
}
public boolean gameOverCurrencyAwarded() {
return gameOverCurrencyAwarded;
}
public int runCurrencyEarned() {
return runCurrencyEarned;
}
}
}

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