commit 1cd5c8b03c55fab94820c478bf173591a8d3ea04 Author: Andrey Limasov Date: Tue Jun 16 10:57:01 2026 +0300 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..67df35c --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9c3575 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6bf500 --- /dev/null +++ b/README.md @@ -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. diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..1a5629c --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..97846ed --- /dev/null +++ b/android/build.gradle @@ -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" diff --git a/android/ic_launcher-web.png b/android/ic_launcher-web.png new file mode 100644 index 0000000..fd910c5 Binary files /dev/null and b/android/ic_launcher-web.png differ diff --git a/android/libs/arm64-v8a/libgdx-freetype.so b/android/libs/arm64-v8a/libgdx-freetype.so new file mode 100644 index 0000000..e603acd Binary files /dev/null and b/android/libs/arm64-v8a/libgdx-freetype.so differ diff --git a/android/libs/arm64-v8a/libgdx.so b/android/libs/arm64-v8a/libgdx.so new file mode 100644 index 0000000..3692509 Binary files /dev/null and b/android/libs/arm64-v8a/libgdx.so differ diff --git a/android/libs/armeabi-v7a/libgdx-freetype.so b/android/libs/armeabi-v7a/libgdx-freetype.so new file mode 100644 index 0000000..c41ddd0 Binary files /dev/null and b/android/libs/armeabi-v7a/libgdx-freetype.so differ diff --git a/android/libs/armeabi-v7a/libgdx.so b/android/libs/armeabi-v7a/libgdx.so new file mode 100644 index 0000000..3fa255e Binary files /dev/null and b/android/libs/armeabi-v7a/libgdx.so differ diff --git a/android/libs/x86/libgdx-freetype.so b/android/libs/x86/libgdx-freetype.so new file mode 100644 index 0000000..ca71ea7 Binary files /dev/null and b/android/libs/x86/libgdx-freetype.so differ diff --git a/android/libs/x86/libgdx.so b/android/libs/x86/libgdx.so new file mode 100644 index 0000000..d196574 Binary files /dev/null and b/android/libs/x86/libgdx.so differ diff --git a/android/libs/x86_64/libgdx-freetype.so b/android/libs/x86_64/libgdx-freetype.so new file mode 100644 index 0000000..2532bb7 Binary files /dev/null and b/android/libs/x86_64/libgdx-freetype.so differ diff --git a/android/libs/x86_64/libgdx.so b/android/libs/x86_64/libgdx.so new file mode 100644 index 0000000..f8d9513 Binary files /dev/null and b/android/libs/x86_64/libgdx.so differ diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro new file mode 100644 index 0000000..35b7942 --- /dev/null +++ b/android/proguard-rules.pro @@ -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 diff --git a/android/project.properties b/android/project.properties new file mode 100644 index 0000000..aab9a82 --- /dev/null +++ b/android/project.properties @@ -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 diff --git a/android/res/drawable-anydpi-v26/ic_launcher.xml b/android/res/drawable-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6c7313a --- /dev/null +++ b/android/res/drawable-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/res/drawable-anydpi-v26/ic_launcher_foreground.xml b/android/res/drawable-anydpi-v26/ic_launcher_foreground.xml new file mode 100644 index 0000000..4439d22 --- /dev/null +++ b/android/res/drawable-anydpi-v26/ic_launcher_foreground.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/android/res/drawable-hdpi/ic_launcher.png b/android/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..46b36d4 Binary files /dev/null and b/android/res/drawable-hdpi/ic_launcher.png differ diff --git a/android/res/drawable-mdpi/ic_launcher.png b/android/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..c162dec Binary files /dev/null and b/android/res/drawable-mdpi/ic_launcher.png differ diff --git a/android/res/drawable-xhdpi/ic_launcher.png b/android/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..8693030 Binary files /dev/null and b/android/res/drawable-xhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxhdpi/ic_launcher.png b/android/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..fcc0814 Binary files /dev/null and b/android/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxxhdpi/ic_launcher.png b/android/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9c26815 Binary files /dev/null and b/android/res/drawable-xxxhdpi/ic_launcher.png differ diff --git a/android/res/values/color.xml b/android/res/values/color.xml new file mode 100644 index 0000000..c3d3cfc --- /dev/null +++ b/android/res/values/color.xml @@ -0,0 +1,4 @@ + + + #F5A623FF + diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml new file mode 100644 index 0000000..7b2b200 --- /dev/null +++ b/android/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Tower + diff --git a/android/res/values/styles.xml b/android/res/values/styles.xml new file mode 100644 index 0000000..ce3571e --- /dev/null +++ b/android/res/values/styles.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/java/ru/project/tower/android/AndroidLauncher.java b/android/src/main/java/ru/project/tower/android/AndroidLauncher.java new file mode 100644 index 0000000..025ceb8 --- /dev/null +++ b/android/src/main/java/ru/project/tower/android/AndroidLauncher.java @@ -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); + } +} \ No newline at end of file diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..944e008 --- /dev/null +++ b/assets/.gitkeep @@ -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. \ No newline at end of file diff --git a/assets/assets.txt b/assets/assets.txt new file mode 100644 index 0000000..036a748 --- /dev/null +++ b/assets/assets.txt @@ -0,0 +1,9 @@ +.gitkeep +hs_err_pid15412.log +libgdx.png +pop.mp3 +pop.wav +roboto.ttf +shot.mp3 +shot.wav +ui.ttf diff --git a/assets/hs_err_pid15412.log b/assets/hs_err_pid15412.log new file mode 100644 index 0000000..864b2c7 --- /dev/null +++ b/assets/hs_err_pid15412.log @@ -0,0 +1,1353 @@ +# +# A fatal error has been detected by the Java Runtime Environment: +# +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ff944419693, pid=15412, tid=7916 +# +# JRE version: OpenJDK Runtime Environment (17.0.14+10) (build 17.0.14+10-LTS) +# Java VM: OpenJDK 64-Bit Server VM (17.0.14+10-LTS, mixed mode, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) +# Problematic frame: +# C [msvcrt.dll+0x79693] +# +# No core dump will be written. Minidumps are not enabled by default on client versions of Windows +# +# If you would like to submit a bug report, please visit: +# https://bell-sw.com/support +# The crash happened outside the Java Virtual Machine in native code. +# See problematic frame for where to report the bug. +# + +--------------- S U M M A R Y ------------ + +Command Line: -Dfile.encoding=UTF-8 -Duser.country=RU -Duser.language=ru -Duser.variant ru.project.tower.lwjgl3.Lwjgl3Launcher + +Host: AMD FX(tm)-8350 Eight-Core Processor , 8 cores, 15G, Windows 11 , 64 bit Build 22621 (10.0.22621.3958) +Time: Wed May 21 19:59:54 2025 RTZ 2 (s 11 , 64 bit Build 22621 (10.0.22621.3958) elapsed time: 7.023220 seconds (0d 0h 0m 7s) + +--------------- T H R E A D --------------- + +Current thread (0x000001fee418e9e0): JavaThread "main" [_thread_in_native, id=7916, stack(0x0000003ffa700000,0x0000003ffa800000)] + +Stack: [0x0000003ffa700000,0x0000003ffa800000], sp=0x0000003ffa7feae8, free space=1018k +Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) +C [msvcrt.dll+0x79693] +C [gdx64.dll+0x20e6] + +Java frames: (J=compiled Java code, j=interpreted, Vv=VM code) +J 1185 com.badlogic.gdx.utils.BufferUtils.copyJni([FLjava/nio/Buffer;II)V (0 bytes) @ 0x000001fef155dd98 [0x000001fef155dd20+0x0000000000000078] +J 1249 c1 com.badlogic.gdx.utils.BufferUtils.copy([FLjava/nio/Buffer;II)V (45 bytes) @ 0x000001fee9c3360c [0x000001fee9c33180+0x000000000000048c] +J 1278 c1 com.badlogic.gdx.graphics.glutils.VertexBufferObject.setVertices([FII)V (38 bytes) @ 0x000001fee9c40794 [0x000001fee9c40700+0x0000000000000094] +J 1241 c1 com.badlogic.gdx.graphics.g2d.SpriteBatch.flush()V (206 bytes) @ 0x000001fee9c2dc24 [0x000001fee9c2d860+0x00000000000003c4] +J 1396 c1 com.badlogic.gdx.graphics.g2d.SpriteBatch.end()V (66 bytes) @ 0x000001fee9c7ae1c [0x000001fee9c7ad20+0x00000000000000fc] +j ru.project.tower.AbilitySelectionScreen.render(F)V+242 +J 1493 c1 com.badlogic.gdx.backends.lwjgl3.Lwjgl3Window.update()Z (312 bytes) @ 0x000001fee9ca5a2c [0x000001fee9ca4460+0x00000000000015cc] +j com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop()V+120 +j com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(Lcom/badlogic/gdx/ApplicationListener;Lcom/badlogic/gdx/backends/lwjgl3/Lwjgl3ApplicationConfiguration;)V+269 +j ru.project.tower.lwjgl3.Lwjgl3Launcher.createApplication()Lcom/badlogic/gdx/backends/lwjgl3/Lwjgl3Application;+14 +j ru.project.tower.lwjgl3.Lwjgl3Launcher.main([Ljava/lang/String;)V+7 +v ~StubRoutines::call_stub + +siginfo: EXCEPTION_ACCESS_VIOLATION (0xc0000005), writing address 0x000001fe8d657000 + + +Registers: +RAX=0x000001fe8d656980, RBX=0x0000000710a00010, RCX=0x000001fe8d657010, RDX=0xfffffe08833a9690 +RSP=0x0000003ffa7feae8, RBP=0x0000003ffa7febc0, RSI=0x000000000000076c, RDI=0x0000000000000000 +R8 =0x0000000000000020, R9 =0x000000000000005c, R10=0x00007ff878c1c2f0, R11=0x0000000710a01dc0 +R12=0x000001fee418ec98, R13=0x000001fe8d656980, R14=0x0000003ffa7feb80, R15=0x000001fee418e9e0 +RIP=0x00007ff944419693, EFLAGS=0x0000000000010206 + + +Register to memory mapping: + +RIP=0x00007ff944419693 msvcrt.dll +RAX=0x000001fe8d656980 points into unknown readable memory: 0x4410800041980000 | 00 00 98 41 00 80 10 44 +RBX=0x0000000710a00010 is pointing into object: [F +{0x0000000710a00000} - klass: {type array float} + - length: 20000 +RCX=0x000001fe8d657010 is an unknown value +RDX=0xfffffe08833a9690 is an unknown value +RSP=0x0000003ffa7feae8 is pointing into the stack for thread: 0x000001fee418e9e0 +RBP=0x0000003ffa7febc0 is pointing into the stack for thread: 0x000001fee418e9e0 +RSI=0x000000000000076c is an unknown value +RDI=0x0 is NULL +R8 =0x0000000000000020 is an unknown value +R9 =0x000000000000005c is an unknown value +R10=0x00007ff878c1c2f0 jvm.dll +R11=0x0000000710a01dc0 is pointing into object: [F +{0x0000000710a00000} - klass: {type array float} + - length: 20000 +R12=0x000001fee418ec98 points into unknown readable memory: 0x000001fe8a5c3db0 | b0 3d 5c 8a fe 01 00 00 +R13=0x000001fe8d656980 points into unknown readable memory: 0x4410800041980000 | 00 00 98 41 00 80 10 44 +R14=0x0000003ffa7feb80 is pointing into the stack for thread: 0x000001fee418e9e0 +R15=0x000001fee418e9e0 is a thread + + +Top of Stack: (sp=0x0000003ffa7feae8) +0x0000003ffa7feae8: 000000006e7020e6 0000000000000158 +0x0000003ffa7feaf8: 000000000000076c 000001fe86866d3b +0x0000003ffa7feb08: 0000003ffa7feb70 000001fe86868f48 +0x0000003ffa7feb18: 0000000000000158 000000070f4658d0 +0x0000003ffa7feb28: 0000000000000000 0000000000000000 +0x0000003ffa7feb38: 0000003ffa7fee00 0000003ffa7febb0 +0x0000003ffa7feb48: 000001fef155de38 0000000710a922c8 +0x0000003ffa7feb58: 000001fe964d3ce8 0000000000000384 +0x0000003ffa7feb68: 000001fef0f88439 000000000000076c +0x0000003ffa7feb78: 0000000000000000 0000000710a00000 +0x0000003ffa7feb88: 000000070f4658d0 0000003ffa7fee00 +0x0000003ffa7feb98: 0000000000000000 000001fe9797dae8 +0x0000003ffa7feba8: 000001fe9797dae8 000000070f464eb0 +0x0000003ffa7febb8: 0000000000000000 0000003ffa7fee50 +0x0000003ffa7febc8: 000001fee9c3360c 0000000000000de1 +0x0000003ffa7febd8: 000001fe8b5097c8 0000000000000000 +0x0000003ffa7febe8: 00007ff8d710aa53 000001fe974b6080 +0x0000003ffa7febf8: 000000070f4658d0 000000000000076c +0x0000003ffa7fec08: 0000000710a00000 0000003ffa7fec60 +0x0000003ffa7fec18: 000001fe86866da0 0000003ffa7fee50 +0x0000003ffa7fec28: 000001fee9c40794 0000003ffa7feca0 +0x0000003ffa7fec38: 0000000000000de1 000000070f400078 +0x0000003ffa7fec48: 000001fef155e20c 0000000000000000 +0x0000003ffa7fec58: 0000003ffa7fecb0 0000003ffa7fee50 +0x0000003ffa7fec68: 000001fe974b6080 000000070f400078 +0x0000003ffa7fec78: 0000000710a78be8 0000038400004e20 +0x0000003ffa7fec88: 000000070f50a010 000003840000076c +0x0000003ffa7fec98: 0000000000000008 000000070f464f28 +0x0000003ffa7feca8: 41a0000000000000 0000003ffa7fee50 +0x0000003ffa7fecb8: 000001fee9c2dc24 0000003ffa7fee50 +0x0000003ffa7fecc8: 000001fee9c1acf4 0000000000000000 +0x0000003ffa7fecd8: 000001fef1562185 00000000000052d3 + +Instructions: (pc=0x00007ff944419693) +0x00007ff944419593: 8b 54 02 f8 4c 89 19 4a 89 54 01 f8 c3 49 83 f8 +0x00007ff9444195a3: 20 77 5a 0f 10 02 42 0f 10 4c 02 f0 0f 11 01 42 +0x00007ff9444195b3: 0f 11 4c 01 f0 c3 0f 1f 80 00 00 00 00 4d 85 c0 +0x00007ff9444195c3: 74 15 48 2b d1 72 16 44 8a 1c 11 48 ff c1 49 ff +0x00007ff9444195d3: c8 44 88 59 ff 75 f0 c3 0f 1f 44 00 00 49 03 c8 +0x00007ff9444195e3: 44 8a 5c 11 ff 48 ff c9 49 ff c8 44 88 19 75 f0 +0x00007ff9444195f3: c3 66 66 66 66 0f 1f 84 00 00 00 00 00 4e 8d 1c +0x00007ff944419603: 02 48 2b d1 73 09 4c 3b d9 0f 87 6e 01 00 00 0f +0x00007ff944419613: 10 04 11 48 83 c1 10 f6 c1 0f 74 12 48 83 e1 f0 +0x00007ff944419623: 0f 10 0c 11 0f 11 00 0f 28 c1 48 83 c1 10 4c 03 +0x00007ff944419633: c0 4c 2b c1 4d 8b c8 49 c1 e9 06 74 6f 49 81 f9 +0x00007ff944419643: 00 10 00 00 0f 87 b3 00 00 00 49 83 e0 3f eb 2d +0x00007ff944419653: 66 66 66 66 66 66 66 0f 1f 84 00 00 00 00 00 66 +0x00007ff944419663: 66 66 66 66 66 66 0f 1f 84 00 00 00 00 00 66 66 +0x00007ff944419673: 66 66 66 66 66 0f 1f 84 00 00 00 00 00 0f 10 0c +0x00007ff944419683: 11 0f 10 54 11 10 0f 10 5c 11 20 0f 10 64 11 30 +0x00007ff944419693: 0f 29 41 f0 48 83 c1 40 49 ff c9 0f 29 49 c0 0f +0x00007ff9444196a3: 29 51 d0 0f 29 59 e0 0f 28 c4 75 d1 4d 8b c8 49 +0x00007ff9444196b3: c1 e9 04 74 19 0f 1f 84 00 00 00 00 00 0f 29 41 +0x00007ff9444196c3: f0 0f 10 04 11 48 83 c1 10 49 ff c9 75 ef 49 83 +0x00007ff9444196d3: e0 0f 74 0e 4e 8d 5c 01 f0 41 0f 10 0c 13 41 0f +0x00007ff9444196e3: 11 0b 0f 29 41 f0 c3 66 66 66 66 66 66 66 0f 1f +0x00007ff9444196f3: 84 00 00 00 00 00 0f 1f 80 00 00 00 00 4d 8b c8 +0x00007ff944419703: 49 c1 e9 06 49 83 e0 3f 0f 18 44 11 40 eb 2e 66 +0x00007ff944419713: 66 66 66 66 66 66 0f 1f 84 00 00 00 00 00 66 66 +0x00007ff944419723: 66 66 66 66 66 0f 1f 84 00 00 00 00 00 66 66 66 +0x00007ff944419733: 66 66 66 66 0f 1f 84 00 00 00 00 00 90 0f 10 0c +0x00007ff944419743: 11 0f 10 54 11 10 0f 10 5c 11 20 0f 10 64 11 30 +0x00007ff944419753: 0f 2b 41 f0 48 83 c1 40 0f 18 44 11 40 49 ff c9 +0x00007ff944419763: 0f 2b 49 c0 0f 2b 51 d0 0f 2b 59 e0 0f 28 c4 75 +0x00007ff944419773: cc 0f ae f8 e9 33 ff ff ff 0f 1f 40 00 49 03 c8 +0x00007ff944419783: 0f 10 44 11 f0 48 83 e9 10 49 83 e8 10 f6 c1 0f + + +Stack slot to memory mapping: +stack at sp + 0 slots: 0x000000006e7020e6 gdx64.dll +stack at sp + 1 slots: 0x0000000000000158 is an unknown value +stack at sp + 2 slots: 0x000000000000076c is an unknown value +stack at sp + 3 slots: 0x000001fe86866d3b is pointing into metadata +stack at sp + 4 slots: 0x0000003ffa7feb70 is pointing into the stack for thread: 0x000001fee418e9e0 +stack at sp + 5 slots: 0x000001fe86868f48 is pointing into metadata +stack at sp + 6 slots: 0x0000000000000158 is an unknown value +stack at sp + 7 slots: 0x000000070f4658d0 is an oop: java.nio.DirectByteBuffer +{0x000000070f4658d0} - klass: 'java/nio/DirectByteBuffer' + - ---- fields (total size 8 words): + - private 'mark' 'I' @12 -1 (ffffffff) + - 'address' 'J' @16 2192805554560 (8d656980 1fe) + - private 'position' 'I' @24 0 + - private 'limit' 'I' @28 7600 (1db0) + - private 'capacity' 'I' @32 80000 (13880) + - final 'segment' 'Ljdk/internal/access/foreign/MemorySegmentProxy;' @36 NULL (0) + - final 'offset' 'I' @40 0 + - 'isReadOnly' 'Z' @44 false + - 'bigEndian' 'Z' @45 false + - 'nativeByteOrder' 'Z' @46 true + - final 'hb' '[B' @48 NULL (0) + - private final 'isSync' 'Z' @47 false + - private final 'fd' 'Ljava/io/FileDescriptor;' @52 NULL (0) + - private final 'att' 'Ljava/lang/Object;' @56 NULL (0) + - private final 'cleaner' 'Ljdk/internal/ref/Cleaner;' @60 NULL (0) + + +Compiled method (n/a) 7135 1185 n 0 com.badlogic.gdx.utils.BufferUtils::copyJni (native) + total in heap [0x000001fef155db90,0x000001fef155df78] = 1000 + relocation [0x000001fef155dce8,0x000001fef155dd18] = 48 + main code [0x000001fef155dd20,0x000001fef155df70] = 592 + oops [0x000001fef155df70,0x000001fef155df78] = 8 + +[Constant Pool (empty)] + +[MachCode] +[Entry Point] + # {method} {0x000001fe86b3cd28} 'copyJni' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils' + # parm0: rdx:rdx = '[F' + # parm1: r8:r8 = 'java/nio/Buffer' + # parm2: r9 = int + # parm3: rdi = int + # [sp+0x80] (sp of caller) + 0x000001fef155dd20: 448b 5208 | 49bb 0000 | 0000 0800 | 0000 4d03 | d349 3bc2 | 0f84 0600 + + 0x000001fef155dd38: ; {runtime_call ic_miss_stub} + 0x000001fef155dd38: 0000 e941 | 33a4 ff90 +[Verified Entry Point] + 0x000001fef155dd40: 8984 2400 | 90ff ff55 | 488b ec48 | 83ec 7048 | 897c 2428 | 4c89 4c24 | 204c 8944 | 2438 4983 + 0x000001fef155dd60: f800 4c8d | 4c24 384c | 0f44 4c24 | 3848 8954 | 2430 4883 | fa00 4c8d | 4424 304c | 0f44 4424 + 0x000001fef155dd80: ; {oop(a 'java/lang/Class'{0x000000070f464eb0} = 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fef155dd80: 3049 beb0 | 4e46 0f07 | 0000 004c | 8974 2460 | 4c8d 7424 | 6049 8bd6 + + 0x000001fef155dd98: ; {internal_word} + 0x000001fef155dd98: c5f8 7749 | ba98 dd55 | f1fe 0100 | 004d 8997 | a002 0000 | 4989 a798 + + 0x000001fef155ddb0: ; {external_word} + 0x000001fef155ddb0: 0200 0049 | ba12 1ac0 | 78f8 7f00 | 0041 803a | 000f 8452 | 0000 0052 | 4150 4151 + + 0x000001fef155ddcc: ; {metadata({method} {0x000001fe86b3cd28} 'copyJni' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fef155ddcc: 48ba 20cd | b386 fe01 | 0000 498b | cf48 83ec | 20f7 c40f | 0000 000f | 841a 0000 | 0048 83ec + 0x000001fef155ddec: ; {runtime_call} + 0x000001fef155ddec: 0849 ba20 | 6775 78f8 | 7f00 0041 | ffd2 4883 | c408 e90d + + 0x000001fef155de00: ; {runtime_call} + 0x000001fef155de00: 0000 0049 | ba20 6775 | 78f8 7f00 | 0041 ffd2 | 4883 c420 | 4159 4158 | 5a49 8d8f | b802 0000 + 0x000001fef155de20: 41c7 8748 | 0300 0004 + + 0x000001fef155de28: ; {runtime_call} + 0x000001fef155de28: 0000 0049 | ba80 2070 | 6e00 0000 | 0041 ffd2 | c5f8 7741 | c787 4803 | 0000 0500 | 0000 f083 + 0x000001fef155de48: 4424 c000 | 493b af50 | 0300 000f | 8711 0000 | 0041 81bf | 3803 0000 | 0000 0000 | 0f84 2400 + 0x000001fef155de68: 0000 c5f8 | 7749 8bcf | 4c8b e448 | 83ec 2048 + + 0x000001fef155de78: ; {runtime_call} + 0x000001fef155de78: 83e4 f049 | ba60 7985 | 78f8 7f00 | 0041 ffd2 | 498b e44d | 33e4 41c7 | 8748 0300 | 0008 0000 + 0x000001fef155de98: 0041 81bf | b803 0000 | 0200 0000 | 0f84 9b00 + + 0x000001fef155dea8: ; {external_word} + 0x000001fef155dea8: 0000 49ba | 121a c078 | f87f 0000 | 4180 3a00 | 0f84 4800 + + 0x000001fef155debc: ; {metadata({method} {0x000001fe86b3cd28} 'copyJni' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fef155debc: 0000 48ba | 20cd b386 | fe01 0000 | 498b cf48 | 83ec 20f7 | c40f 0000 | 000f 841a | 0000 0048 + 0x000001fef155dedc: ; {runtime_call} + 0x000001fef155dedc: 83ec 0849 | ba20 6775 | 78f8 7f00 | 0041 ffd2 | 4883 c408 | e90d 0000 + + 0x000001fef155def4: ; {runtime_call} + 0x000001fef155def4: 0049 ba20 | 6775 78f8 | 7f00 0041 | ffd2 4883 | c420 49c7 | 8798 0200 | 0000 0000 | 0049 c787 + 0x000001fef155df14: a002 0000 | 0000 0000 | c5f8 7749 | 8b8f d800 | 0000 c781 | 0001 0000 | 0000 0000 | c949 817f + 0x000001fef155df34: 0800 0000 | 000f 8501 | 0000 00c3 + + 0x000001fef155df40: ; {runtime_call StubRoutines (1)} + 0x000001fef155df40: e9bb 2f9f | ffc5 f877 | 4c8b e448 | 83ec 2048 + + 0x000001fef155df50: ; {runtime_call} + 0x000001fef155df50: 83e4 f049 | bac0 9775 | 78f8 7f00 | 0041 ffd2 | 498b e44d | 33e4 e93f | ffff fff4 | f4f4 f4f4 +[/MachCode] + + +Compiled method (c1) 7149 1249 3 com.badlogic.gdx.utils.BufferUtils::copy (45 bytes) + total in heap [0x000001fee9c32f90,0x000001fee9c33908] = 2424 + relocation [0x000001fee9c330e8,0x000001fee9c33170] = 136 + main code [0x000001fee9c33180,0x000001fee9c33760] = 1504 + stub code [0x000001fee9c33760,0x000001fee9c337d8] = 120 + oops [0x000001fee9c337d8,0x000001fee9c337e0] = 8 + metadata [0x000001fee9c337e0,0x000001fee9c337e8] = 8 + scopes data [0x000001fee9c337e8,0x000001fee9c33840] = 88 + scopes pcs [0x000001fee9c33840,0x000001fee9c338e0] = 160 + dependencies [0x000001fee9c338e0,0x000001fee9c338e8] = 8 + nul chk table [0x000001fee9c338e8,0x000001fee9c33908] = 32 + +[Constant Pool (empty)] + +[MachCode] +[Verified Entry Point] + # {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils' + # parm0: rdx:rdx = '[F' + # parm1: r8:r8 = 'java/nio/Buffer' + # parm2: r9 = int + # parm3: rdi = int + # [sp+0x60] (sp of caller) + 0x000001fee9c33180: 8984 2400 | 90ff ff55 | 4883 ec50 | 4c89 4424 + + 0x000001fee9c33190: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c33190: 2848 be50 | aecc 86fe | 0100 008b | 9ecc 0000 | 0083 c302 | 899e cc00 | 0000 81e3 | fe07 0000 + 0x000001fee9c331b0: 83fb 000f | 841e 0500 | 0049 83f8 + + 0x000001fee9c331bc: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c331bc: 0075 1648 | b850 aecc | 86fe 0100 | 0080 8809 | 0100 0001 | e9d5 0000 + + 0x000001fee9c331d4: ; {metadata('java/nio/ByteBuffer')} + 0x000001fee9c331d4: 0048 b900 | f606 0008 | 0000 0041 | 8b40 0849 | ba00 0000 | 0008 0000 | 0049 03c2 | 483b 4840 + 0x000001fee9c331f4: 0f85 9400 + + 0x000001fee9c331f8: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c331f8: 0000 48b8 | 50ae cc86 | fe01 0000 | 418b 4808 | 49ba 0000 | 0000 0800 | 0000 4903 | ca48 3b88 + 0x000001fee9c33218: 2001 0000 | 750d 4883 | 8028 0100 | 0001 e984 | 0000 0048 | 3b88 3001 | 0000 750d | 4883 8038 + 0x000001fee9c33238: 0100 0001 | e96e 0000 | 0048 81b8 | 2001 0000 | 0000 0000 | 7517 4889 | 8820 0100 | 0048 c780 + 0x000001fee9c33258: 2801 0000 | 0100 0000 | e94a 0000 | 0048 81b8 | 3001 0000 | 0000 0000 | 7517 4889 | 8830 0100 + 0x000001fee9c33278: 0048 c780 | 3801 0000 | 0100 0000 | e926 0000 | 00e9 2100 + + 0x000001fee9c3328c: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c3328c: 0000 48b8 | 50ae cc86 | fe01 0000 | 4883 a810 | 0100 0001 | e905 0000 | 00e9 0500 | 0000 4833 + 0x000001fee9c332ac: f6eb 0a48 | be01 0000 | 0000 0000 | 0083 fe00 + + 0x000001fee9c332bc: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c332bc: 48be 50ae | cc86 fe01 | 0000 48c7 | c358 0100 | 000f 8507 | 0000 0048 | c7c3 4801 | 0000 488b + 0x000001fee9c332dc: 041e 488d | 4001 4889 | 041e 0f85 | 1002 0000 | 4983 f800 + + 0x000001fee9c332f0: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c332f0: 7516 48b8 | 50ae cc86 | fe01 0000 | 8088 b101 | 0000 01e9 | d500 0000 + + 0x000001fee9c33308: ; {metadata('java/nio/FloatBuffer')} + 0x000001fee9c33308: 48b9 f8ad | 0800 0800 | 0000 418b | 4008 49ba | 0000 0000 | 0800 0000 | 4903 c248 | 3b48 400f + 0x000001fee9c33328: 8594 0000 + + 0x000001fee9c3332c: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c3332c: 0048 b850 | aecc 86fe | 0100 0041 | 8b48 0849 | ba00 0000 | 0008 0000 | 0049 03ca | 483b 88c8 + 0x000001fee9c3334c: 0100 0075 | 0d48 8380 | d001 0000 | 01e9 8400 | 0000 483b | 88d8 0100 | 0075 0d48 | 8380 e001 + 0x000001fee9c3336c: 0000 01e9 | 6e00 0000 | 4881 b8c8 | 0100 0000 | 0000 0075 | 1748 8988 | c801 0000 | 48c7 80d0 + 0x000001fee9c3338c: 0100 0001 | 0000 00e9 | 4a00 0000 | 4881 b8d8 | 0100 0000 | 0000 0075 | 1748 8988 | d801 0000 + 0x000001fee9c333ac: 48c7 80e0 | 0100 0001 | 0000 00e9 | 2600 0000 | e921 0000 + + 0x000001fee9c333c0: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c333c0: 0048 b850 | aecc 86fe | 0100 0048 | 83a8 b801 | 0000 01e9 | 0500 0000 | e905 0000 | 0048 33f6 + 0x000001fee9c333e0: eb0a 48be | 0100 0000 | 0000 0000 + + 0x000001fee9c333ec: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c333ec: 83fe 0048 | be50 aecc | 86fe 0100 | 0048 c7c3 | f001 0000 | 0f84 0700 | 0000 48c7 | c300 0200 + 0x000001fee9c3340c: 0048 8b04 | 1e48 8d40 | 0148 8904 | 1e0f 84d0 | 0100 0089 | 7c24 3448 | 8954 2438 + + 0x000001fee9c33428: ; implicit exception: dispatches to 0x000001fee9c336f8 + 0x000001fee9c33428: 493b 0049 + + 0x000001fee9c3342c: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c3342c: 8bf0 48bb | 50ae cc86 | fe01 0000 | 8b76 0849 | ba00 0000 | 0008 0000 | 0049 03f2 | 483b b320 + 0x000001fee9c3344c: 0200 0075 | 0d48 8383 | 2802 0000 | 01e9 6600 | 0000 483b | b330 0200 | 0075 0d48 | 8383 3802 + 0x000001fee9c3346c: 0000 01e9 | 5000 0000 | 4881 bb20 | 0200 0000 | 0000 0075 | 1748 89b3 | 2002 0000 | 48c7 8328 + 0x000001fee9c3348c: 0200 0001 | 0000 00e9 | 2c00 0000 | 4881 bb30 | 0200 0000 | 0000 0075 | 1748 89b3 | 3002 0000 + 0x000001fee9c334ac: 48c7 8338 | 0200 0001 | 0000 00e9 | 0800 0000 | 4883 8310 | 0200 0001 | 498b f04d | 8bc1 488b + 0x000001fee9c334cc: d644 894c | 2430 6666 | 9048 b8ff | ffff ffff + + 0x000001fee9c334dc: ; {virtual_call} + 0x000001fee9c334dc: ffff ffe8 + + 0x000001fee9c334e0: ; ImmutableOopMap {[56]=Oop [40]=Oop } + ;*invokevirtual limit {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@27 (line 56) + 0x000001fee9c334e0: 9ce1 3607 | 8b7c 2434 | 448b 4c24 | 304c 8b44 | 2428 488b | 5424 38e9 | f300 0000 | 897c 2434 + 0x000001fee9c33500: 4489 4c24 | 3048 8954 | 2438 493b | 0049 8bd0 + + 0x000001fee9c33510: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c33510: 48be 50ae | cc86 fe01 | 0000 8b52 | 0849 ba00 | 0000 0008 | 0000 0049 | 03d2 483b | 9678 0100 + 0x000001fee9c33530: 0075 0d48 | 8386 8001 | 0000 01e9 | 6600 0000 | 483b 9688 | 0100 0075 | 0d48 8386 | 9001 0000 + 0x000001fee9c33550: 01e9 5000 | 0000 4881 | be78 0100 | 0000 0000 | 0075 1748 | 8996 7801 | 0000 48c7 | 8680 0100 + 0x000001fee9c33570: 0001 0000 | 00e9 2c00 | 0000 4881 | be88 0100 | 0000 0000 | 0075 1748 | 8996 8801 | 0000 48c7 + 0x000001fee9c33590: 8690 0100 | 0001 0000 | 00e9 0800 | 0000 4883 | 8668 0100 | 0001 448b | 4c24 3041 | c1e1 0249 + 0x000001fee9c335b0: 8bf0 4d8b | c148 8bd6 | 0f1f 4400 | 0048 b8d8 | b403 0008 + + 0x000001fee9c335c4: ; {virtual_call} + 0x000001fee9c335c4: 0000 00e8 + + 0x000001fee9c335c8: ; ImmutableOopMap {[56]=Oop [40]=Oop } + ;*invokevirtual limit {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@11 (line 55) + 0x000001fee9c335c8: 3479 9407 + + 0x000001fee9c335cc: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c335cc: 48ba 50ae | cc86 fe01 | 0000 ff82 | a001 0000 | 8b7c 2434 | 448b 4c24 | 304c 8b44 | 2428 488b + 0x000001fee9c335ec: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c335ec: 5424 3848 | be50 aecc | 86fe 0100 | 0048 8386 | 4802 0000 | 0149 8bf0 + + 0x000001fee9c33604: ; {static_call} + 0x000001fee9c33604: 4c8b c6e8 + + 0x000001fee9c33608: ; ImmutableOopMap {[40]=Oop } + ;*invokestatic copyJni {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@35 (line 58) + 0x000001fee9c33608: 34a7 9207 | 488b 5424 | 2848 3b02 + + 0x000001fee9c33614: ; {metadata(method data for {method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c33614: 4c8b c248 | be50 aecc | 86fe 0100 | 0045 8b40 | 0849 ba00 | 0000 0008 | 0000 004d | 03c2 4c3b + 0x000001fee9c33634: 8668 0200 | 0075 0d48 | 8386 7002 | 0000 01e9 | 6600 0000 | 4c3b 8678 | 0200 0075 | 0d48 8386 + 0x000001fee9c33654: 8002 0000 | 01e9 5000 | 0000 4881 | be68 0200 | 0000 0000 | 0075 174c | 8986 6802 | 0000 48c7 + 0x000001fee9c33674: 8670 0200 | 0001 0000 | 00e9 2c00 | 0000 4881 | be78 0200 | 0000 0000 | 0075 174c | 8986 7802 + 0x000001fee9c33694: 0000 48c7 | 8680 0200 | 0001 0000 | 00e9 0800 | 0000 4883 | 8658 0200 | 0001 41b8 | 0000 0000 + 0x000001fee9c336b4: 9048 b8d8 | b403 0008 + + 0x000001fee9c336bc: ; {virtual_call} + 0x000001fee9c336bc: 0000 00e8 + + 0x000001fee9c336c0: ; ImmutableOopMap {} + ;*invokevirtual position {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@40 (line 59) + 0x000001fee9c336c0: 7c17 f9ff | 4883 c450 + + 0x000001fee9c336c8: ; {poll_return} + 0x000001fee9c336c8: 5d49 3ba7 | 5003 0000 | 0f87 3100 + + 0x000001fee9c336d4: ; {metadata({method} {0x000001fe86b3a1c0} 'copy' '([FLjava/nio/Buffer;II)V' in 'com/badlogic/gdx/utils/BufferUtils')} + 0x000001fee9c336d4: 0000 c349 | bab8 a1b3 | 86fe 0100 | 004c 8954 | 2408 48c7 | 0424 ffff + + 0x000001fee9c336ec: ; {runtime_call counter_overflow Runtime1 stub} + 0x000001fee9c336ec: ffff e80d + + 0x000001fee9c336f0: ; ImmutableOopMap {rdx=Oop r8=Oop [40]=Oop } + ;*synchronization entry + ; - com.badlogic.gdx.utils.BufferUtils::copy@-1 (line 54) + 0x000001fee9c336f0: 7141 07e9 | c1fa ffff + + 0x000001fee9c336f8: ; {runtime_call throw_null_pointer_exception Runtime1 stub} + 0x000001fee9c336f8: e823 1441 + + 0x000001fee9c336fc: ; ImmutableOopMap {r8=Oop [40]=Oop [56]=Oop } + ;*invokevirtual limit {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@27 (line 56) + ; {runtime_call throw_null_pointer_exception Runtime1 stub} + 0x000001fee9c336fc: 07e8 1e14 + + 0x000001fee9c33700: ; ImmutableOopMap {[56]=Oop r8=Oop [40]=Oop } + ;*invokevirtual limit {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@11 (line 55) + ; {runtime_call throw_null_pointer_exception Runtime1 stub} + 0x000001fee9c33700: 4107 e819 + + 0x000001fee9c33704: ; ImmutableOopMap {rdx=Oop [40]=Oop } + ;*invokevirtual position {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.utils.BufferUtils::copy@40 (line 59) + ; {internal_word} + 0x000001fee9c33704: 1441 0749 | bac9 36c3 | e9fe 0100 | 004d 8997 | 6803 0000 + + 0x000001fee9c33718: ; {runtime_call SafepointBlob} + 0x000001fee9c33718: e963 3e37 | 0790 9049 | 8b87 e003 | 0000 49c7 | 87e0 0300 | 0000 0000 | 0049 c787 | e803 0000 + 0x000001fee9c33738: 0000 0000 | 4883 c450 + + 0x000001fee9c33740: ; {runtime_call unwind_exception Runtime1 stub} + 0x000001fee9c33740: 5de9 ba04 | 4107 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 +[Stub Code] + 0x000001fee9c33760: ; {no_reloc} + 0x000001fee9c33760: 0f1f 4400 + + 0x000001fee9c33764: ; {static_stub} + 0x000001fee9c33764: 0048 bb00 | 0000 0000 + + 0x000001fee9c3376c: ; {runtime_call} + 0x000001fee9c3376c: 0000 00e9 | fbff ffff + + 0x000001fee9c33774: ; {static_stub} + 0x000001fee9c33774: 9048 bb00 | 0000 0000 + + 0x000001fee9c3377c: ; {runtime_call} + 0x000001fee9c3377c: 0000 00e9 | fbff ffff + + 0x000001fee9c33784: ; {static_stub} + 0x000001fee9c33784: 9048 bb00 | 0000 0000 + + 0x000001fee9c3378c: ; {runtime_call} + 0x000001fee9c3378c: 0000 00e9 | fbff ffff + + 0x000001fee9c33794: ; {static_stub} + 0x000001fee9c33794: 9048 bb00 | 0000 0000 + + 0x000001fee9c3379c: ; {runtime_call} + 0x000001fee9c3379c: 0000 00e9 | fbff ffff +[Exception Handler] + 0x000001fee9c337a4: ; {runtime_call handle_exception_from_callee Runtime1 stub} + 0x000001fee9c337a4: e857 3d41 + + 0x000001fee9c337a8: ; {external_word} + 0x000001fee9c337a8: 0748 b9e0 | d899 78f8 | 7f00 0048 + + 0x000001fee9c337b4: ; {runtime_call} + 0x000001fee9c337b4: 83e4 f049 | ba90 5c62 | 78f8 7f00 | 0041 ffd2 + + 0x000001fee9c337c4: ; {section_word} + 0x000001fee9c337c4: f449 bac5 | 37c3 e9fe | 0100 0041 + + 0x000001fee9c337d0: ; {runtime_call DeoptimizationBlob} + 0x000001fee9c337d0: 52e9 ca34 | 3707 f4f4 +[/MachCode] + + +Compiled method (c1) 7181 1278 3 com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices (38 bytes) + total in heap [0x000001fee9c40510,0x000001fee9c40df0] = 2272 + relocation [0x000001fee9c40668,0x000001fee9c406f0] = 136 + main code [0x000001fee9c40700,0x000001fee9c40b60] = 1120 + stub code [0x000001fee9c40b60,0x000001fee9c40bd8] = 120 + oops [0x000001fee9c40bd8,0x000001fee9c40be0] = 8 + metadata [0x000001fee9c40be0,0x000001fee9c40c28] = 72 + scopes data [0x000001fee9c40c28,0x000001fee9c40cf8] = 208 + scopes pcs [0x000001fee9c40cf8,0x000001fee9c40dc8] = 208 + dependencies [0x000001fee9c40dc8,0x000001fee9c40dd8] = 16 + nul chk table [0x000001fee9c40dd8,0x000001fee9c40df0] = 24 + +[Constant Pool (empty)] + +[MachCode] +[Entry Point] + # {method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject' + # this: rdx:rdx = 'com/badlogic/gdx/graphics/glutils/VertexBufferObject' + # parm0: r8:r8 = '[F' + # parm1: r9 = int + # parm2: rdi = int + # [sp+0x90] (sp of caller) + 0x000001fee9c40700: 448b 5208 | 49bb 0000 | 0000 0800 | 0000 4d03 | d34c 3bd0 + + 0x000001fee9c40714: ; {runtime_call ic_miss_stub} + 0x000001fee9c40714: 0f85 6609 | 3607 660f | 1f44 0000 +[Verified Entry Point] + 0x000001fee9c40720: 8984 2400 | 90ff ff55 | 4881 ec80 | 0000 0048 | 8954 2458 + + 0x000001fee9c40734: ; {metadata(method data for {method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject')} + 0x000001fee9c40734: 498b f149 | b900 86cc | 86fe 0100 | 0041 8b99 | cc00 0000 | 83c3 0241 | 8999 cc00 | 0000 81e3 + 0x000001fee9c40754: fe07 0000 | 83fb 000f | 84f1 0200 | 00c6 4215 | 0144 8b4a | 2049 c1e1 + + 0x000001fee9c4076c: ; {metadata(method data for {method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject')} + 0x000001fee9c4076c: 0348 bb00 | 86cc 86fe | 0100 0048 | 8383 1001 | 0000 0149 | 8bd0 4d8b | c14c 8bcf | 897c 2460 + 0x000001fee9c4078c: ; {static_call} + 0x000001fee9c4078c: 488b fee8 + + 0x000001fee9c40790: ; ImmutableOopMap {[88]=Oop } + ;*invokestatic copy {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@12 (line 142) + 0x000001fee9c40790: ec29 ffff | 488b 5424 | 588b 721c | 48c1 e603 + + 0x000001fee9c407a0: ; implicit exception: dispatches to 0x000001fee9c40a73 + 0x000001fee9c407a0: 483b 064c + + 0x000001fee9c407a4: ; {metadata(method data for {method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject')} + 0x000001fee9c407a4: 8bc6 48bf | 0086 cc86 | fe01 0000 | 458b 4008 | 49ba 0000 | 0000 0800 | 0000 4d03 | c24c 3b87 + 0x000001fee9c407c4: 3001 0000 | 750d 4883 | 8738 0100 | 0001 e966 | 0000 004c | 3b87 4001 | 0000 750d | 4883 8748 + 0x000001fee9c407e4: 0100 0001 | e950 0000 | 0048 81bf | 3001 0000 | 0000 0000 | 7517 4c89 | 8730 0100 | 0048 c787 + 0x000001fee9c40804: 3801 0000 | 0100 0000 | e92c 0000 | 0048 81bf | 4001 0000 | 0000 0000 | 7517 4c89 | 8740 0100 + 0x000001fee9c40824: 0048 c787 | 4801 0000 | 0100 0000 | e908 0000 | 0048 8387 | 2001 0000 + + 0x000001fee9c4083c: ; {metadata(method data for {method} {0x000001fe86704648} 'position' '(I)Ljava/nio/Buffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c4083c: 0149 b8b8 | 9795 86fe | 0100 0041 | 8bb8 cc00 | 0000 83c7 | 0241 89b8 | cc00 0000 | 81e7 feff + 0x000001fee9c4085c: 1f00 83ff | 000f 8411 | 0200 004c + + 0x000001fee9c40868: ; {metadata(method data for {method} {0x000001fe86704648} 'position' '(I)Ljava/nio/Buffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c40868: 8bc6 48bf | b897 9586 | fe01 0000 | 4883 8710 | 0100 0001 + + 0x000001fee9c4087c: ; {metadata(method data for {method} {0x000001fe86703330} 'position' '(I)Ljava/nio/FloatBuffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c4087c: 49b8 8893 | 9586 fe01 | 0000 418b | b8cc 0000 | 0083 c702 | 4189 b8cc | 0000 0081 | e7fe ff1f + 0x000001fee9c4089c: 0083 ff00 | 0f84 f301 | 0000 4c8b + + 0x000001fee9c408a8: ; {metadata(method data for {method} {0x000001fe86703330} 'position' '(I)Ljava/nio/FloatBuffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c408a8: c648 bf88 | 9395 86fe | 0100 0048 | 8387 1001 | 0000 0141 | b800 0000 | 0048 8bd6 | 4889 7424 + 0x000001fee9c408c8: 6866 0f1f + + 0x000001fee9c408cc: ; {optimized virtual_call} + 0x000001fee9c408cc: 4400 00e8 + + 0x000001fee9c408d0: ; ImmutableOopMap {[88]=Oop [104]=Oop } + ;*invokespecial position {reexecute=0 rethrow=0 return_oop=0} + ; - java.nio.FloatBuffer::position@2 (line 1516) + ; - java.nio.FloatBuffer::position@2 (line 267) + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@20 (line 143) + 0x000001fee9c408d0: cc92 9007 | 488b 5424 | 588b 721c | 48c1 e603 + + 0x000001fee9c408e0: ; implicit exception: dispatches to 0x000001fee9c40aba + 0x000001fee9c408e0: 483b 064c + + 0x000001fee9c408e4: ; {metadata(method data for {method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject')} + 0x000001fee9c408e4: 8bc6 48bf | 0086 cc86 | fe01 0000 | 458b 4008 | 49ba 0000 | 0000 0800 | 0000 4d03 | c24c 3b87 + 0x000001fee9c40904: 6801 0000 | 750d 4883 | 8770 0100 | 0001 e966 | 0000 004c | 3b87 7801 | 0000 750d | 4883 8780 + 0x000001fee9c40924: 0100 0001 | e950 0000 | 0048 81bf | 6801 0000 | 0000 0000 | 7517 4c89 | 8768 0100 | 0048 c787 + 0x000001fee9c40944: 7001 0000 | 0100 0000 | e92c 0000 | 0048 81bf | 7801 0000 | 0000 0000 | 7517 4c89 | 8778 0100 + 0x000001fee9c40964: 0048 c787 | 8001 0000 | 0100 0000 | e908 0000 | 0048 8387 | 5801 0000 + + 0x000001fee9c4097c: ; {metadata(method data for {method} {0x000001fe867045a0} 'limit' '(I)Ljava/nio/Buffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c4097c: 0149 b8d8 | 9495 86fe | 0100 0041 | 8bb8 cc00 | 0000 83c7 | 0241 89b8 | cc00 0000 | 81e7 feff + 0x000001fee9c4099c: 1f00 83ff | 000f 8418 | 0100 004c + + 0x000001fee9c409a8: ; {metadata(method data for {method} {0x000001fe867045a0} 'limit' '(I)Ljava/nio/Buffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c409a8: 8bc6 48bf | d894 9586 | fe01 0000 | 4883 8710 | 0100 0001 + + 0x000001fee9c409bc: ; {metadata(method data for {method} {0x000001fe867033e0} 'limit' '(I)Ljava/nio/FloatBuffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c409bc: 49b8 6896 | 9586 fe01 | 0000 418b | b8cc 0000 | 0083 c702 | 4189 b8cc | 0000 0081 | e7fe ff1f + 0x000001fee9c409dc: 0083 ff00 | 0f84 fa00 | 0000 4c8b + + 0x000001fee9c409e8: ; {metadata(method data for {method} {0x000001fe867033e0} 'limit' '(I)Ljava/nio/FloatBuffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c409e8: c648 bf68 | 9695 86fe | 0100 0048 | 8387 1001 | 0000 0144 | 8b44 2460 | 488b d648 | 8974 2470 + 0x000001fee9c40a08: 0f1f 8000 + + 0x000001fee9c40a0c: ; {optimized virtual_call} + 0x000001fee9c40a0c: 0000 00e8 + + 0x000001fee9c40a10: ; ImmutableOopMap {[112]=Oop [88]=Oop } + ;*invokespecial limit {reexecute=0 rethrow=0 return_oop=0} + ; - java.nio.FloatBuffer::limit@2 (line 1529) + ; - java.nio.FloatBuffer::limit@2 (line 267) + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@29 (line 144) + 0x000001fee9c40a10: 0c63 9207 | 488b 5424 + + 0x000001fee9c40a18: ; {metadata(method data for {method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject')} + 0x000001fee9c40a18: 5848 be00 | 86cc 86fe | 0100 0048 | 8386 9001 | 0000 0148 | 8b54 2458 | 0f1f 8000 + + 0x000001fee9c40a34: ; {optimized virtual_call} + 0x000001fee9c40a34: 0000 00e8 + + 0x000001fee9c40a38: ; ImmutableOopMap {} + ;*invokespecial bufferChanged {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@34 (line 145) + 0x000001fee9c40a38: 2431 ffff | 4881 c480 | 0000 005d + + 0x000001fee9c40a44: ; {poll_return} + 0x000001fee9c40a44: 493b a750 | 0300 000f | 87b0 0000 + + 0x000001fee9c40a50: ; {metadata({method} {0x000001fe86b9e198} 'setVertices' '([FII)V' in 'com/badlogic/gdx/graphics/glutils/VertexBufferObject')} + 0x000001fee9c40a50: 00c3 49ba | 90e1 b986 | fe01 0000 | 4c89 5424 | 0848 c704 | 24ff ffff + + 0x000001fee9c40a68: ; {runtime_call counter_overflow Runtime1 stub} + 0x000001fee9c40a68: ffe8 929d + + 0x000001fee9c40a6c: ; ImmutableOopMap {rdx=Oop [88]=Oop r8=Oop } + ;*synchronization entry + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@-1 (line 141) + 0x000001fee9c40a6c: 4007 e9ee + + 0x000001fee9c40a70: ; {runtime_call throw_null_pointer_exception Runtime1 stub} + 0x000001fee9c40a70: fcff ffe8 + + 0x000001fee9c40a74: ; ImmutableOopMap {rdx=Oop [88]=Oop rsi=Oop } + ;*invokevirtual position {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@20 (line 143) + 0x000001fee9c40a74: a840 4007 + + 0x000001fee9c40a78: ; {metadata({method} {0x000001fe86704648} 'position' '(I)Ljava/nio/Buffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c40a78: 49ba 4046 | 7086 fe01 | 0000 4c89 | 5424 0848 | c704 24ff + + 0x000001fee9c40a8c: ; {runtime_call counter_overflow Runtime1 stub} + 0x000001fee9c40a8c: ffff ffe8 + + 0x000001fee9c40a90: ; ImmutableOopMap {rdx=Oop [88]=Oop rsi=Oop } + ;*synchronization entry + ; - java.nio.FloatBuffer::position@-1 (line 267) + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@20 (line 143) + 0x000001fee9c40a90: 6c9d 4007 | e9ce fdff + + 0x000001fee9c40a98: ; {metadata({method} {0x000001fe86703330} 'position' '(I)Ljava/nio/FloatBuffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c40a98: ff49 ba28 | 3370 86fe | 0100 004c | 8954 2408 | 48c7 0424 | ffff ffff + + 0x000001fee9c40ab0: ; {runtime_call counter_overflow Runtime1 stub} + 0x000001fee9c40ab0: e84b 9d40 + + 0x000001fee9c40ab4: ; ImmutableOopMap {rdx=Oop [88]=Oop rsi=Oop } + ;*synchronization entry + ; - java.nio.FloatBuffer::position@-1 (line 1516) + ; - java.nio.FloatBuffer::position@2 (line 267) + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@20 (line 143) + 0x000001fee9c40ab4: 07e9 ecfd + + 0x000001fee9c40ab8: ; {runtime_call throw_null_pointer_exception Runtime1 stub} + 0x000001fee9c40ab8: ffff e861 + + 0x000001fee9c40abc: ; ImmutableOopMap {rdx=Oop [88]=Oop rsi=Oop } + ;*invokevirtual limit {reexecute=0 rethrow=0 return_oop=0} + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@29 (line 144) + ; {metadata({method} {0x000001fe867045a0} 'limit' '(I)Ljava/nio/Buffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c40abc: 4040 0749 | ba98 4570 | 86fe 0100 | 004c 8954 | 2408 48c7 | 0424 ffff + + 0x000001fee9c40ad4: ; {runtime_call counter_overflow Runtime1 stub} + 0x000001fee9c40ad4: ffff e825 + + 0x000001fee9c40ad8: ; ImmutableOopMap {rdx=Oop [88]=Oop rsi=Oop } + ;*synchronization entry + ; - java.nio.FloatBuffer::limit@-1 (line 267) + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@29 (line 144) + 0x000001fee9c40ad8: 9d40 07e9 | c7fe ffff + + 0x000001fee9c40ae0: ; {metadata({method} {0x000001fe867033e0} 'limit' '(I)Ljava/nio/FloatBuffer;' in 'java/nio/FloatBuffer')} + 0x000001fee9c40ae0: 49ba d833 | 7086 fe01 | 0000 4c89 | 5424 0848 | c704 24ff + + 0x000001fee9c40af4: ; {runtime_call counter_overflow Runtime1 stub} + 0x000001fee9c40af4: ffff ffe8 + + 0x000001fee9c40af8: ; ImmutableOopMap {rdx=Oop [88]=Oop rsi=Oop } + ;*synchronization entry + ; - java.nio.FloatBuffer::limit@-1 (line 1529) + ; - java.nio.FloatBuffer::limit@2 (line 267) + ; - com.badlogic.gdx.graphics.glutils.VertexBufferObject::setVertices@29 (line 144) + 0x000001fee9c40af8: 049d 4007 | e9e5 feff + + 0x000001fee9c40b00: ; {internal_word} + 0x000001fee9c40b00: ff49 ba44 | 0ac4 e9fe | 0100 004d | 8997 6803 + + 0x000001fee9c40b10: ; {runtime_call SafepointBlob} + 0x000001fee9c40b10: 0000 e969 | 6a36 0790 | 9049 8b87 | e003 0000 | 49c7 87e0 | 0300 0000 | 0000 0049 | c787 e803 + 0x000001fee9c40b30: 0000 0000 | 0000 4881 | c480 0000 + + 0x000001fee9c40b3c: ; {runtime_call unwind_exception Runtime1 stub} + 0x000001fee9c40b3c: 005d e9bd | 3040 07f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 | f4f4 f4f4 + 0x000001fee9c40b5c: f4f4 f4f4 +[Stub Code] + 0x000001fee9c40b60: ; {no_reloc} + 0x000001fee9c40b60: 0f1f 4400 + + 0x000001fee9c40b64: ; {static_stub} + 0x000001fee9c40b64: 0048 bb00 | 0000 0000 + + 0x000001fee9c40b6c: ; {runtime_call} + 0x000001fee9c40b6c: 0000 00e9 | fbff ffff + + 0x000001fee9c40b74: ; {static_stub} + 0x000001fee9c40b74: 9048 bb00 | 0000 0000 + + 0x000001fee9c40b7c: ; {runtime_call} + 0x000001fee9c40b7c: 0000 00e9 | fbff ffff + + 0x000001fee9c40b84: ; {static_stub} + 0x000001fee9c40b84: 9048 bb00 | 0000 0000 + + 0x000001fee9c40b8c: ; {runtime_call} + 0x000001fee9c40b8c: 0000 00e9 | fbff ffff + + 0x000001fee9c40b94: ; {static_stub} + 0x000001fee9c40b94: 9048 bb00 | 0000 0000 + + 0x000001fee9c40b9c: ; {runtime_call} + 0x000001fee9c40b9c: 0000 00e9 | fbff ffff +[Exception Handler] + 0x000001fee9c40ba4: ; {runtime_call handle_exception_from_callee Runtime1 stub} + 0x000001fee9c40ba4: e857 6940 + + 0x000001fee9c40ba8: ; {external_word} + 0x000001fee9c40ba8: 0748 b9e0 | d899 78f8 | 7f00 0048 + + 0x000001fee9c40bb4: ; {runtime_call} + 0x000001fee9c40bb4: 83e4 f049 | ba90 5c62 | 78f8 7f00 | 0041 ffd2 + + 0x000001fee9c40bc4: ; {section_word} + 0x000001fee9c40bc4: f449 bac5 | 0bc4 e9fe | 0100 0041 + + 0x000001fee9c40bd0: ; {runtime_call DeoptimizationBlob} + 0x000001fee9c40bd0: 52e9 ca60 | 3607 f4f4 +[/MachCode] + + +--------------- P R O C E S S --------------- + +Threads class SMR info: +_java_thread_list=0x000001fe8b4299e0, length=16, elements={ +0x000001fee418e9e0, 0x000001feff05e900, 0x000001feff05f780, 0x000001feff0806f0, +0x000001feff0814c0, 0x000001feff081e80, 0x000001feff0a2f30, 0x000001feff0a4230, +0x000001feff0a85f0, 0x000001feff0ab000, 0x000001feff225560, 0x000001feff22cf40, +0x000001fe8aba1080, 0x000001fe96662110, 0x000001fe96a6ee30, 0x000001fe96a6f350 +} + +Java Threads: ( => current thread ) +=>0x000001fee418e9e0 JavaThread "main" [_thread_in_native, id=7916, stack(0x0000003ffa700000,0x0000003ffa800000)] + 0x000001feff05e900 JavaThread "Reference Handler" daemon [_thread_blocked, id=17168, stack(0x0000003ffae00000,0x0000003ffaf00000)] + 0x000001feff05f780 JavaThread "Finalizer" daemon [_thread_blocked, id=12976, stack(0x0000003ffaf00000,0x0000003ffb000000)] + 0x000001feff0806f0 JavaThread "Signal Dispatcher" daemon [_thread_blocked, id=13020, stack(0x0000003ffb000000,0x0000003ffb100000)] + 0x000001feff0814c0 JavaThread "Attach Listener" daemon [_thread_blocked, id=16888, stack(0x0000003ffb100000,0x0000003ffb200000)] + 0x000001feff081e80 JavaThread "Service Thread" daemon [_thread_blocked, id=8796, stack(0x0000003ffb200000,0x0000003ffb300000)] + 0x000001feff0a2f30 JavaThread "Monitor Deflation Thread" daemon [_thread_blocked, id=11896, stack(0x0000003ffb300000,0x0000003ffb400000)] + 0x000001feff0a4230 JavaThread "C2 CompilerThread0" daemon [_thread_blocked, id=4992, stack(0x0000003ffb400000,0x0000003ffb500000)] + 0x000001feff0a85f0 JavaThread "C1 CompilerThread0" daemon [_thread_blocked, id=8800, stack(0x0000003ffb500000,0x0000003ffb600000)] + 0x000001feff0ab000 JavaThread "Sweeper thread" daemon [_thread_blocked, id=14176, stack(0x0000003ffb600000,0x0000003ffb700000)] + 0x000001feff225560 JavaThread "Notification Thread" daemon [_thread_blocked, id=12472, stack(0x0000003ffb700000,0x0000003ffb800000)] + 0x000001feff22cf40 JavaThread "Common-Cleaner" daemon [_thread_blocked, id=9388, stack(0x0000003ffb900000,0x0000003ffba00000)] + 0x000001fe8aba1080 JavaThread "Thread-0" daemon [_thread_blocked, id=17156, stack(0x0000003ffc100000,0x0000003ffc200000)] + 0x000001fe96662110 JavaThread "LWJGL3 Timer" daemon [_thread_blocked, id=6676, stack(0x0000003ffca00000,0x0000003ffcb00000)] + 0x000001fe96a6ee30 JavaThread "C2 CompilerThread1" daemon [_thread_blocked, id=5212, stack(0x0000003ffd200000,0x0000003ffd300000)] + 0x000001fe96a6f350 JavaThread "C2 CompilerThread2" daemon [_thread_blocked, id=12788, stack(0x0000003ffd300000,0x0000003ffd400000)] + +Other Threads: + 0x000001feff03b050 VMThread "VM Thread" [stack: 0x0000003ffad00000,0x0000003ffae00000] [id=16528] + 0x000001feff227a50 WatcherThread [stack: 0x0000003ffb800000,0x0000003ffb900000] [id=3276] + 0x000001fee62d48e0 GCTaskThread "GC Thread#0" [stack: 0x0000003ffa800000,0x0000003ffa900000] [id=4328] + 0x000001fe966a90b0 GCTaskThread "GC Thread#1" [stack: 0x0000003ffba00000,0x0000003ffbb00000] [id=7256] + 0x000001fe966a9370 GCTaskThread "GC Thread#2" [stack: 0x0000003ffcb00000,0x0000003ffcc00000] [id=15032] + 0x000001fe966a9630 GCTaskThread "GC Thread#3" [stack: 0x0000003ffcc00000,0x0000003ffcd00000] [id=17112] + 0x000001fe966a98f0 GCTaskThread "GC Thread#4" [stack: 0x0000003ffcd00000,0x0000003ffce00000] [id=16156] + 0x000001fe966a9fc0 GCTaskThread "GC Thread#5" [stack: 0x0000003ffce00000,0x0000003ffcf00000] [id=16948] + 0x000001feff2fcfc0 GCTaskThread "GC Thread#6" [stack: 0x0000003ffcf00000,0x0000003ffd000000] [id=13888] + 0x000001feff2fd280 GCTaskThread "GC Thread#7" [stack: 0x0000003ffd000000,0x0000003ffd100000] [id=9320] + 0x000001fefe5627b0 ConcurrentGCThread "G1 Main Marker" [stack: 0x0000003ffa900000,0x0000003ffaa00000] [id=2384] + 0x000001fefe562fe0 ConcurrentGCThread "G1 Conc#0" [stack: 0x0000003ffaa00000,0x0000003ffab00000] [id=16496] + 0x000001fee630eaf0 ConcurrentGCThread "G1 Refine#0" [stack: 0x0000003ffab00000,0x0000003ffac00000] [id=15516] + 0x000001fefe60e640 ConcurrentGCThread "G1 Service" [stack: 0x0000003ffac00000,0x0000003ffad00000] [id=17160] + +Threads with active compile tasks: + +VM state: not at safepoint (normal execution) + +VM Mutex/Monitor currently owned by a thread: None + +Heap address: 0x0000000700c00000, size: 4084 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 + +CDS archive(s) not mapped +Compressed class space mapped at: 0x0000000800000000-0x0000000840000000, reserved size: 1073741824 +Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x40000000 + +GC Precious Log: + CPUs: 8 total, 8 available + Memory: 16331M + Large Page Support: Disabled + NUMA Support: Disabled + Compressed Oops: Enabled (Zero based) + Heap Region Size: 2M + Heap Min Capacity: 8M + Heap Initial Capacity: 256M + Heap Max Capacity: 4084M + Pre-touch: Disabled + Parallel Workers: 8 + Concurrent Workers: 2 + Concurrent Refinement Workers: 8 + Periodic GC: Disabled + +Heap: + garbage-first heap total 262144K, used 6911K [0x0000000700c00000, 0x0000000800000000) + region size 2048K, 5 young (10240K), 2 survivors (4096K) + Metaspace used 14902K, committed 15040K, reserved 1114112K + class space used 1206K, committed 1280K, reserved 1048576K + +Heap Regions: E=young(eden), S=young(survivor), O=old, HS=humongous(starts), HC=humongous(continues), CS=collection set, F=free, OA=open archive, CA=closed archive, TAMS=top-at-mark-start (previous, next) +| 0|0x0000000700c00000, 0x0000000700e00000, 0x0000000700e00000|100%|HS| |TAMS 0x0000000700c00000, 0x0000000700c00000| Complete +| 1|0x0000000700e00000, 0x0000000700e00000, 0x0000000701000000| 0%| F| |TAMS 0x0000000700e00000, 0x0000000700e00000| Untracked +| 2|0x0000000701000000, 0x0000000701000000, 0x0000000701200000| 0%| F| |TAMS 0x0000000701000000, 0x0000000701000000| Untracked +| 3|0x0000000701200000, 0x0000000701200000, 0x0000000701400000| 0%| F| |TAMS 0x0000000701200000, 0x0000000701200000| Untracked +| 4|0x0000000701400000, 0x0000000701400000, 0x0000000701600000| 0%| F| |TAMS 0x0000000701400000, 0x0000000701400000| Untracked +| 5|0x0000000701600000, 0x0000000701600000, 0x0000000701800000| 0%| F| |TAMS 0x0000000701600000, 0x0000000701600000| Untracked +| 6|0x0000000701800000, 0x0000000701800000, 0x0000000701a00000| 0%| F| |TAMS 0x0000000701800000, 0x0000000701800000| Untracked +| 7|0x0000000701a00000, 0x0000000701a00000, 0x0000000701c00000| 0%| F| |TAMS 0x0000000701a00000, 0x0000000701a00000| Untracked +| 8|0x0000000701c00000, 0x0000000701c00000, 0x0000000701e00000| 0%| F| |TAMS 0x0000000701c00000, 0x0000000701c00000| Untracked +| 9|0x0000000701e00000, 0x0000000701e00000, 0x0000000702000000| 0%| F| |TAMS 0x0000000701e00000, 0x0000000701e00000| Untracked +| 10|0x0000000702000000, 0x0000000702000000, 0x0000000702200000| 0%| F| |TAMS 0x0000000702000000, 0x0000000702000000| Untracked +| 11|0x0000000702200000, 0x0000000702200000, 0x0000000702400000| 0%| F| |TAMS 0x0000000702200000, 0x0000000702200000| Untracked +| 12|0x0000000702400000, 0x0000000702400000, 0x0000000702600000| 0%| F| |TAMS 0x0000000702400000, 0x0000000702400000| Untracked +| 13|0x0000000702600000, 0x0000000702600000, 0x0000000702800000| 0%| F| |TAMS 0x0000000702600000, 0x0000000702600000| Untracked +| 14|0x0000000702800000, 0x0000000702800000, 0x0000000702a00000| 0%| F| |TAMS 0x0000000702800000, 0x0000000702800000| Untracked +| 15|0x0000000702a00000, 0x0000000702a00000, 0x0000000702c00000| 0%| F| |TAMS 0x0000000702a00000, 0x0000000702a00000| Untracked +| 16|0x0000000702c00000, 0x0000000702c00000, 0x0000000702e00000| 0%| F| |TAMS 0x0000000702c00000, 0x0000000702c00000| Untracked +| 17|0x0000000702e00000, 0x0000000702e00000, 0x0000000703000000| 0%| F| |TAMS 0x0000000702e00000, 0x0000000702e00000| Untracked +| 18|0x0000000703000000, 0x0000000703000000, 0x0000000703200000| 0%| F| |TAMS 0x0000000703000000, 0x0000000703000000| Untracked +| 19|0x0000000703200000, 0x0000000703200000, 0x0000000703400000| 0%| F| |TAMS 0x0000000703200000, 0x0000000703200000| Untracked +| 20|0x0000000703400000, 0x0000000703400000, 0x0000000703600000| 0%| F| |TAMS 0x0000000703400000, 0x0000000703400000| Untracked +| 21|0x0000000703600000, 0x0000000703600000, 0x0000000703800000| 0%| F| |TAMS 0x0000000703600000, 0x0000000703600000| Untracked +| 22|0x0000000703800000, 0x0000000703800000, 0x0000000703a00000| 0%| F| |TAMS 0x0000000703800000, 0x0000000703800000| Untracked +| 23|0x0000000703a00000, 0x0000000703a00000, 0x0000000703c00000| 0%| F| |TAMS 0x0000000703a00000, 0x0000000703a00000| Untracked +| 24|0x0000000703c00000, 0x0000000703c00000, 0x0000000703e00000| 0%| F| |TAMS 0x0000000703c00000, 0x0000000703c00000| Untracked +| 25|0x0000000703e00000, 0x0000000703e00000, 0x0000000704000000| 0%| F| |TAMS 0x0000000703e00000, 0x0000000703e00000| Untracked +| 26|0x0000000704000000, 0x0000000704000000, 0x0000000704200000| 0%| F| |TAMS 0x0000000704000000, 0x0000000704000000| Untracked +| 27|0x0000000704200000, 0x0000000704200000, 0x0000000704400000| 0%| F| |TAMS 0x0000000704200000, 0x0000000704200000| Untracked +| 28|0x0000000704400000, 0x0000000704400000, 0x0000000704600000| 0%| F| |TAMS 0x0000000704400000, 0x0000000704400000| Untracked +| 29|0x0000000704600000, 0x0000000704600000, 0x0000000704800000| 0%| F| |TAMS 0x0000000704600000, 0x0000000704600000| Untracked +| 30|0x0000000704800000, 0x0000000704800000, 0x0000000704a00000| 0%| F| |TAMS 0x0000000704800000, 0x0000000704800000| Untracked +| 31|0x0000000704a00000, 0x0000000704a00000, 0x0000000704c00000| 0%| F| |TAMS 0x0000000704a00000, 0x0000000704a00000| Untracked +| 32|0x0000000704c00000, 0x0000000704c00000, 0x0000000704e00000| 0%| F| |TAMS 0x0000000704c00000, 0x0000000704c00000| Untracked +| 33|0x0000000704e00000, 0x0000000704e00000, 0x0000000705000000| 0%| F| |TAMS 0x0000000704e00000, 0x0000000704e00000| Untracked +| 34|0x0000000705000000, 0x0000000705000000, 0x0000000705200000| 0%| F| |TAMS 0x0000000705000000, 0x0000000705000000| Untracked +| 35|0x0000000705200000, 0x0000000705200000, 0x0000000705400000| 0%| F| |TAMS 0x0000000705200000, 0x0000000705200000| Untracked +| 36|0x0000000705400000, 0x0000000705400000, 0x0000000705600000| 0%| F| |TAMS 0x0000000705400000, 0x0000000705400000| Untracked +| 37|0x0000000705600000, 0x0000000705600000, 0x0000000705800000| 0%| F| |TAMS 0x0000000705600000, 0x0000000705600000| Untracked +| 38|0x0000000705800000, 0x0000000705800000, 0x0000000705a00000| 0%| F| |TAMS 0x0000000705800000, 0x0000000705800000| Untracked +| 39|0x0000000705a00000, 0x0000000705a00000, 0x0000000705c00000| 0%| F| |TAMS 0x0000000705a00000, 0x0000000705a00000| Untracked +| 40|0x0000000705c00000, 0x0000000705c00000, 0x0000000705e00000| 0%| F| |TAMS 0x0000000705c00000, 0x0000000705c00000| Untracked +| 41|0x0000000705e00000, 0x0000000705e00000, 0x0000000706000000| 0%| F| |TAMS 0x0000000705e00000, 0x0000000705e00000| Untracked +| 42|0x0000000706000000, 0x0000000706000000, 0x0000000706200000| 0%| F| |TAMS 0x0000000706000000, 0x0000000706000000| Untracked +| 43|0x0000000706200000, 0x0000000706200000, 0x0000000706400000| 0%| F| |TAMS 0x0000000706200000, 0x0000000706200000| Untracked +| 44|0x0000000706400000, 0x0000000706400000, 0x0000000706600000| 0%| F| |TAMS 0x0000000706400000, 0x0000000706400000| Untracked +| 45|0x0000000706600000, 0x0000000706600000, 0x0000000706800000| 0%| F| |TAMS 0x0000000706600000, 0x0000000706600000| Untracked +| 46|0x0000000706800000, 0x0000000706800000, 0x0000000706a00000| 0%| F| |TAMS 0x0000000706800000, 0x0000000706800000| Untracked +| 47|0x0000000706a00000, 0x0000000706a00000, 0x0000000706c00000| 0%| F| |TAMS 0x0000000706a00000, 0x0000000706a00000| Untracked +| 48|0x0000000706c00000, 0x0000000706c00000, 0x0000000706e00000| 0%| F| |TAMS 0x0000000706c00000, 0x0000000706c00000| Untracked +| 49|0x0000000706e00000, 0x0000000706e00000, 0x0000000707000000| 0%| F| |TAMS 0x0000000706e00000, 0x0000000706e00000| Untracked +| 50|0x0000000707000000, 0x0000000707000000, 0x0000000707200000| 0%| F| |TAMS 0x0000000707000000, 0x0000000707000000| Untracked +| 51|0x0000000707200000, 0x0000000707200000, 0x0000000707400000| 0%| F| |TAMS 0x0000000707200000, 0x0000000707200000| Untracked +| 52|0x0000000707400000, 0x0000000707400000, 0x0000000707600000| 0%| F| |TAMS 0x0000000707400000, 0x0000000707400000| Untracked +| 53|0x0000000707600000, 0x0000000707600000, 0x0000000707800000| 0%| F| |TAMS 0x0000000707600000, 0x0000000707600000| Untracked +| 54|0x0000000707800000, 0x0000000707800000, 0x0000000707a00000| 0%| F| |TAMS 0x0000000707800000, 0x0000000707800000| Untracked +| 55|0x0000000707a00000, 0x0000000707a00000, 0x0000000707c00000| 0%| F| |TAMS 0x0000000707a00000, 0x0000000707a00000| Untracked +| 56|0x0000000707c00000, 0x0000000707c00000, 0x0000000707e00000| 0%| F| |TAMS 0x0000000707c00000, 0x0000000707c00000| Untracked +| 57|0x0000000707e00000, 0x0000000707e00000, 0x0000000708000000| 0%| F| |TAMS 0x0000000707e00000, 0x0000000707e00000| Untracked +| 58|0x0000000708000000, 0x0000000708000000, 0x0000000708200000| 0%| F| |TAMS 0x0000000708000000, 0x0000000708000000| Untracked +| 59|0x0000000708200000, 0x0000000708200000, 0x0000000708400000| 0%| F| |TAMS 0x0000000708200000, 0x0000000708200000| Untracked +| 60|0x0000000708400000, 0x0000000708400000, 0x0000000708600000| 0%| F| |TAMS 0x0000000708400000, 0x0000000708400000| Untracked +| 61|0x0000000708600000, 0x0000000708600000, 0x0000000708800000| 0%| F| |TAMS 0x0000000708600000, 0x0000000708600000| Untracked +| 62|0x0000000708800000, 0x0000000708800000, 0x0000000708a00000| 0%| F| |TAMS 0x0000000708800000, 0x0000000708800000| Untracked +| 63|0x0000000708a00000, 0x0000000708a00000, 0x0000000708c00000| 0%| F| |TAMS 0x0000000708a00000, 0x0000000708a00000| Untracked +| 64|0x0000000708c00000, 0x0000000708c00000, 0x0000000708e00000| 0%| F| |TAMS 0x0000000708c00000, 0x0000000708c00000| Untracked +| 65|0x0000000708e00000, 0x0000000708e00000, 0x0000000709000000| 0%| F| |TAMS 0x0000000708e00000, 0x0000000708e00000| Untracked +| 66|0x0000000709000000, 0x0000000709000000, 0x0000000709200000| 0%| F| |TAMS 0x0000000709000000, 0x0000000709000000| Untracked +| 67|0x0000000709200000, 0x0000000709200000, 0x0000000709400000| 0%| F| |TAMS 0x0000000709200000, 0x0000000709200000| Untracked +| 68|0x0000000709400000, 0x0000000709400000, 0x0000000709600000| 0%| F| |TAMS 0x0000000709400000, 0x0000000709400000| Untracked +| 69|0x0000000709600000, 0x0000000709600000, 0x0000000709800000| 0%| F| |TAMS 0x0000000709600000, 0x0000000709600000| Untracked +| 70|0x0000000709800000, 0x0000000709800000, 0x0000000709a00000| 0%| F| |TAMS 0x0000000709800000, 0x0000000709800000| Untracked +| 71|0x0000000709a00000, 0x0000000709a00000, 0x0000000709c00000| 0%| F| |TAMS 0x0000000709a00000, 0x0000000709a00000| Untracked +| 72|0x0000000709c00000, 0x0000000709c00000, 0x0000000709e00000| 0%| F| |TAMS 0x0000000709c00000, 0x0000000709c00000| Untracked +| 73|0x0000000709e00000, 0x0000000709e00000, 0x000000070a000000| 0%| F| |TAMS 0x0000000709e00000, 0x0000000709e00000| Untracked +| 74|0x000000070a000000, 0x000000070a000000, 0x000000070a200000| 0%| F| |TAMS 0x000000070a000000, 0x000000070a000000| Untracked +| 75|0x000000070a200000, 0x000000070a200000, 0x000000070a400000| 0%| F| |TAMS 0x000000070a200000, 0x000000070a200000| Untracked +| 76|0x000000070a400000, 0x000000070a400000, 0x000000070a600000| 0%| F| |TAMS 0x000000070a400000, 0x000000070a400000| Untracked +| 77|0x000000070a600000, 0x000000070a600000, 0x000000070a800000| 0%| F| |TAMS 0x000000070a600000, 0x000000070a600000| Untracked +| 78|0x000000070a800000, 0x000000070a800000, 0x000000070aa00000| 0%| F| |TAMS 0x000000070a800000, 0x000000070a800000| Untracked +| 79|0x000000070aa00000, 0x000000070aa00000, 0x000000070ac00000| 0%| F| |TAMS 0x000000070aa00000, 0x000000070aa00000| Untracked +| 80|0x000000070ac00000, 0x000000070ac00000, 0x000000070ae00000| 0%| F| |TAMS 0x000000070ac00000, 0x000000070ac00000| Untracked +| 81|0x000000070ae00000, 0x000000070ae00000, 0x000000070b000000| 0%| F| |TAMS 0x000000070ae00000, 0x000000070ae00000| Untracked +| 82|0x000000070b000000, 0x000000070b000000, 0x000000070b200000| 0%| F| |TAMS 0x000000070b000000, 0x000000070b000000| Untracked +| 83|0x000000070b200000, 0x000000070b200000, 0x000000070b400000| 0%| F| |TAMS 0x000000070b200000, 0x000000070b200000| Untracked +| 84|0x000000070b400000, 0x000000070b400000, 0x000000070b600000| 0%| F| |TAMS 0x000000070b400000, 0x000000070b400000| Untracked +| 85|0x000000070b600000, 0x000000070b600000, 0x000000070b800000| 0%| F| |TAMS 0x000000070b600000, 0x000000070b600000| Untracked +| 86|0x000000070b800000, 0x000000070b800000, 0x000000070ba00000| 0%| F| |TAMS 0x000000070b800000, 0x000000070b800000| Untracked +| 87|0x000000070ba00000, 0x000000070ba00000, 0x000000070bc00000| 0%| F| |TAMS 0x000000070ba00000, 0x000000070ba00000| Untracked +| 88|0x000000070bc00000, 0x000000070bc00000, 0x000000070be00000| 0%| F| |TAMS 0x000000070bc00000, 0x000000070bc00000| Untracked +| 89|0x000000070be00000, 0x000000070be00000, 0x000000070c000000| 0%| F| |TAMS 0x000000070be00000, 0x000000070be00000| Untracked +| 90|0x000000070c000000, 0x000000070c000000, 0x000000070c200000| 0%| F| |TAMS 0x000000070c000000, 0x000000070c000000| Untracked +| 91|0x000000070c200000, 0x000000070c200000, 0x000000070c400000| 0%| F| |TAMS 0x000000070c200000, 0x000000070c200000| Untracked +| 92|0x000000070c400000, 0x000000070c400000, 0x000000070c600000| 0%| F| |TAMS 0x000000070c400000, 0x000000070c400000| Untracked +| 93|0x000000070c600000, 0x000000070c600000, 0x000000070c800000| 0%| F| |TAMS 0x000000070c600000, 0x000000070c600000| Untracked +| 94|0x000000070c800000, 0x000000070c800000, 0x000000070ca00000| 0%| F| |TAMS 0x000000070c800000, 0x000000070c800000| Untracked +| 95|0x000000070ca00000, 0x000000070ca00000, 0x000000070cc00000| 0%| F| |TAMS 0x000000070ca00000, 0x000000070ca00000| Untracked +| 96|0x000000070cc00000, 0x000000070cc00000, 0x000000070ce00000| 0%| F| |TAMS 0x000000070cc00000, 0x000000070cc00000| Untracked +| 97|0x000000070ce00000, 0x000000070ce00000, 0x000000070d000000| 0%| F| |TAMS 0x000000070ce00000, 0x000000070ce00000| Untracked +| 98|0x000000070d000000, 0x000000070d000000, 0x000000070d200000| 0%| F| |TAMS 0x000000070d000000, 0x000000070d000000| Untracked +| 99|0x000000070d200000, 0x000000070d200000, 0x000000070d400000| 0%| F| |TAMS 0x000000070d200000, 0x000000070d200000| Untracked +| 100|0x000000070d400000, 0x000000070d400000, 0x000000070d600000| 0%| F| |TAMS 0x000000070d400000, 0x000000070d400000| Untracked +| 101|0x000000070d600000, 0x000000070d600000, 0x000000070d800000| 0%| F| |TAMS 0x000000070d600000, 0x000000070d600000| Untracked +| 102|0x000000070d800000, 0x000000070d800000, 0x000000070da00000| 0%| F| |TAMS 0x000000070d800000, 0x000000070d800000| Untracked +| 103|0x000000070da00000, 0x000000070da00000, 0x000000070dc00000| 0%| F| |TAMS 0x000000070da00000, 0x000000070da00000| Untracked +| 104|0x000000070dc00000, 0x000000070dc00000, 0x000000070de00000| 0%| F| |TAMS 0x000000070dc00000, 0x000000070dc00000| Untracked +| 105|0x000000070de00000, 0x000000070de00000, 0x000000070e000000| 0%| F| |TAMS 0x000000070de00000, 0x000000070de00000| Untracked +| 106|0x000000070e000000, 0x000000070e000000, 0x000000070e200000| 0%| F| |TAMS 0x000000070e000000, 0x000000070e000000| Untracked +| 107|0x000000070e200000, 0x000000070e200000, 0x000000070e400000| 0%| F| |TAMS 0x000000070e200000, 0x000000070e200000| Untracked +| 108|0x000000070e400000, 0x000000070e400000, 0x000000070e600000| 0%| F| |TAMS 0x000000070e400000, 0x000000070e400000| Untracked +| 109|0x000000070e600000, 0x000000070e600000, 0x000000070e800000| 0%| F| |TAMS 0x000000070e600000, 0x000000070e600000| Untracked +| 110|0x000000070e800000, 0x000000070e800000, 0x000000070ea00000| 0%| F| |TAMS 0x000000070e800000, 0x000000070e800000| Untracked +| 111|0x000000070ea00000, 0x000000070ea00000, 0x000000070ec00000| 0%| F| |TAMS 0x000000070ea00000, 0x000000070ea00000| Untracked +| 112|0x000000070ec00000, 0x000000070ec00000, 0x000000070ee00000| 0%| F| |TAMS 0x000000070ec00000, 0x000000070ec00000| Untracked +| 113|0x000000070ee00000, 0x000000070ee00000, 0x000000070f000000| 0%| F| |TAMS 0x000000070ee00000, 0x000000070ee00000| Untracked +| 114|0x000000070f000000, 0x000000070f000000, 0x000000070f200000| 0%| F| |TAMS 0x000000070f000000, 0x000000070f000000| Untracked +| 115|0x000000070f200000, 0x000000070f2bffd0, 0x000000070f400000| 37%| S|CS|TAMS 0x000000070f200000, 0x000000070f200000| Complete +| 116|0x000000070f400000, 0x000000070f600000, 0x000000070f600000|100%| S|CS|TAMS 0x000000070f400000, 0x000000070f400000| Complete +| 117|0x000000070f600000, 0x000000070f600000, 0x000000070f800000| 0%| F| |TAMS 0x000000070f600000, 0x000000070f600000| Untracked +| 118|0x000000070f800000, 0x000000070f800000, 0x000000070fa00000| 0%| F| |TAMS 0x000000070f800000, 0x000000070f800000| Untracked +| 119|0x000000070fa00000, 0x000000070fa00000, 0x000000070fc00000| 0%| F| |TAMS 0x000000070fa00000, 0x000000070fa00000| Untracked +| 120|0x000000070fc00000, 0x000000070fc00000, 0x000000070fe00000| 0%| F| |TAMS 0x000000070fc00000, 0x000000070fc00000| Untracked +| 121|0x000000070fe00000, 0x000000070fe00000, 0x0000000710000000| 0%| F| |TAMS 0x000000070fe00000, 0x000000070fe00000| Untracked +| 122|0x0000000710000000, 0x0000000710000000, 0x0000000710200000| 0%| F| |TAMS 0x0000000710000000, 0x0000000710000000| Untracked +| 123|0x0000000710200000, 0x0000000710200000, 0x0000000710400000| 0%| F| |TAMS 0x0000000710200000, 0x0000000710200000| Untracked +| 124|0x0000000710400000, 0x0000000710400000, 0x0000000710600000| 0%| F| |TAMS 0x0000000710400000, 0x0000000710400000| Untracked +| 125|0x0000000710600000, 0x0000000710800000, 0x0000000710800000|100%| E| |TAMS 0x0000000710600000, 0x0000000710600000| Complete +| 126|0x0000000710800000, 0x0000000710a00000, 0x0000000710a00000|100%| E| |TAMS 0x0000000710800000, 0x0000000710800000| Complete +| 127|0x0000000710a00000, 0x0000000710c00000, 0x0000000710c00000|100%| E|CS|TAMS 0x0000000710a00000, 0x0000000710a00000| Complete + +Card table byte_map: [0x000001fef9550000,0x000001fef9d50000] _byte_map_base: 0x000001fef5d4a000 + +Marking Bits (Prev, Next): (CMBitMap*) 0x000001fee62d5f10, (CMBitMap*) 0x000001fee62d5f50 + Prev Bits: [0x000001fefa550000, 0x000001fefe520000) + Next Bits: [0x000001fe80000000, 0x000001fe83fd0000) + +Polling page: 0x000001fee41f0000 + +Metaspace: + +Usage: + Non-class: 13.37 MB used. + Class: 1.18 MB used. + Both: 14.55 MB used. + +Virtual space: + Non-class space: 64.00 MB reserved, 13.44 MB ( 21%) committed, 1 nodes. + Class space: 1.00 GB reserved, 1.25 MB ( <1%) committed, 1 nodes. + Both: 1.06 GB reserved, 14.69 MB ( 1%) committed. + +Chunk freelists: + Non-Class: 2.16 MB + Class: 14.63 MB + Both: 16.79 MB + +MaxMetaspaceSize: unlimited +CompressedClassSpaceSize: 1.00 GB +Initial GC threshold: 21.00 MB +Current GC threshold: 21.00 MB +CDS: off +MetaspaceReclaimPolicy: balanced + - commit_granule_bytes: 65536. + - commit_granule_words: 8192. + - virtual_space_node_default_size: 8388608. + - enlarge_chunks_in_place: 1. + - new_chunks_are_fully_committed: 0. + - uncommit_free_chunks: 1. + - use_allocation_guard: 0. + - handle_deallocations: 1. + + +Internal statistics: + +num_allocs_failed_limit: 0. +num_arena_births: 72. +num_arena_deaths: 0. +num_vsnodes_births: 2. +num_vsnodes_deaths: 0. +num_space_committed: 233. +num_space_uncommitted: 0. +num_chunks_returned_to_freelist: 0. +num_chunks_taken_from_freelist: 310. +num_chunk_merges: 0. +num_chunk_splits: 212. +num_chunks_enlarged: 173. +num_inconsistent_stats: 0. + +CodeHeap 'non-profiled nmethods': size=120000Kb used=711Kb max_used=711Kb free=119288Kb + bounds [0x000001fef14f0000, 0x000001fef1760000, 0x000001fef8a20000] +CodeHeap 'profiled nmethods': size=120000Kb used=3100Kb max_used=3100Kb free=116899Kb + bounds [0x000001fee9a20000, 0x000001fee9d30000, 0x000001fef0f50000] +CodeHeap 'non-nmethods': size=5760Kb used=1660Kb max_used=1689Kb free=4099Kb + bounds [0x000001fef0f50000, 0x000001fef11c0000, 0x000001fef14f0000] + total_blobs=2724 nmethods=1615 adapters=1023 + compilation: enabled + stopped_count=0, restarted_count=0 + full_count=0 + +Compilation events (20 events): +Event: 6.953 Thread 0x000001feff0a85f0 1609 3 com.badlogic.gdx.math.Vector2:: (15 bytes) +Event: 6.953 Thread 0x000001feff0a85f0 nmethod 1609 0x000001fee9d19590 code [0x000001fee9d19720, 0x000001fee9d19898] +Event: 6.953 Thread 0x000001feff0a85f0 1610 3 com.badlogic.gdx.math.RandomXS128::nextInt (8 bytes) +Event: 6.953 Thread 0x000001feff0a85f0 nmethod 1610 0x000001fee9d19990 code [0x000001fee9d19b40, 0x000001fee9d19c88] +Event: 6.953 Thread 0x000001feff0a85f0 1611 3 com.badlogic.gdx.math.RandomXS128::nextLong (47 bytes) +Event: 6.953 Thread 0x000001feff0a85f0 nmethod 1611 0x000001fee9d19d10 code [0x000001fee9d19ee0, 0x000001fee9d1a238] +Event: 6.955 Thread 0x000001feff0a85f0 1612 3 java.io.WinNTFileSystem::normalize (143 bytes) +Event: 6.956 Thread 0x000001feff0a85f0 nmethod 1612 0x000001fee9d1a390 code [0x000001fee9d1a620, 0x000001fee9d1b218] +Event: 6.970 Thread 0x000001fe96a6ee30 nmethod 1590% 0x000001fef1594310 code [0x000001fef1594520, 0x000001fef1594fd8] +Event: 6.970 Thread 0x000001fe96a6ee30 1587 4 javazoom.jl.decoder.BitReserve::hgetbits (105 bytes) +Event: 6.973 Thread 0x000001fe96a6ee30 nmethod 1587 0x000001fef1595a10 code [0x000001fef1595ba0, 0x000001fef1595d78] +Event: 6.973 Thread 0x000001fe96a6ee30 1595 % 4 javazoom.jl.decoder.LayerIIIDecoder::antialias @ 90 (198 bytes) +Event: 6.989 Thread 0x000001feff0a85f0 1613 s 3 java.io.BufferedInputStream::read (49 bytes) +Event: 6.989 Thread 0x000001feff0a85f0 nmethod 1613 0x000001fee9d1b690 code [0x000001fee9d1b860, 0x000001fee9d1bcd8] +Event: 6.990 Thread 0x000001feff0a85f0 1614 3 java.nio.charset.CoderResult::isOverflow (14 bytes) +Event: 6.990 Thread 0x000001feff0a85f0 nmethod 1614 0x000001fee9d1be90 code [0x000001fee9d1c020, 0x000001fee9d1c178] +Event: 6.992 Thread 0x000001fe96a6ee30 nmethod 1595% 0x000001fef1595f10 code [0x000001fef15960a0, 0x000001fef1596658] +Event: 6.992 Thread 0x000001fe96a6ee30 1588 4 javazoom.jl.decoder.LayerIIIDecoder::inv_mdct (2339 bytes) +Event: 6.999 Thread 0x000001feff0a85f0 1615 % 3 com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator::generateData @ 951 (1422 bytes) +Event: 7.012 Thread 0x000001feff0a85f0 nmethod 1615% 0x000001fee9d1c210 code [0x000001fee9d1cd80, 0x000001fee9d23cf8] + +GC Heap History (2 events): +Event: 5.511 GC heap before +{Heap before GC invocations=0 (full 0): + garbage-first heap total 262144K, used 22528K [0x0000000700c00000, 0x0000000800000000) + region size 2048K, 11 young (22528K), 0 survivors (0K) + Metaspace used 13638K, committed 13760K, reserved 1114112K + class space used 1090K, committed 1152K, reserved 1048576K +} +Event: 5.517 GC heap after +{Heap after GC invocations=1 (full 0): + garbage-first heap total 262144K, used 2815K [0x0000000700c00000, 0x0000000800000000) + region size 2048K, 2 young (4096K), 2 survivors (4096K) + Metaspace used 13638K, committed 13760K, reserved 1114112K + class space used 1090K, committed 1152K, reserved 1048576K +} + +Dll operation events (10 events): +Event: 0.012 Loaded shared library C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\java.dll +Event: 0.052 Loaded shared library C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\jsvml.dll +Event: 0.220 Loaded shared library C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\net.dll +Event: 0.222 Loaded shared library C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\nio.dll +Event: 0.230 Loaded shared library C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\zip.dll +Event: 0.323 Loaded shared library C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\jimage.dll +Event: 0.334 Loaded shared library C:\ProgramData\libGDX-temp\libgdxUser_101931883_GDX1_13_1\c39de338\gdx64.dll +Event: 0.471 Loaded shared library C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\lwjgl.dll +Event: 0.989 Loaded shared library C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\lwjgl_opengl.dll +Event: 1.344 Loaded shared library C:\Users\kekke\AppData\Local\Temp\libgdxkekke\e35c7bf4\gdx-freetype64.dll + +Deoptimization events (20 events): +Event: 6.988 Thread 0x000001fee418e9e0 Uncommon trap: trap_request=0xffffffc6 fr.pc=0x000001fef15843c0 relative=0x0000000000000200 +Event: 6.988 Thread 0x000001fee418e9e0 Uncommon trap: reason=bimorphic_or_optimized_type_check action=maybe_recompile pc=0x000001fef15843c0 method=com.badlogic.gdx.utils.ObjectMap.place(Ljava/lang/Object;)I @ 1 c2 +Event: 6.988 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fef15843c0 sp=0x0000003ffa7feaa0 +Event: 6.988 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa69a3 sp=0x0000003ffa7fe9e0 mode 2 +Event: 6.989 Thread 0x000001fee418e9e0 Uncommon trap: trap_request=0xffffff45 fr.pc=0x000001fef156b31c relative=0x00000000000004dc +Event: 6.989 Thread 0x000001fee418e9e0 Uncommon trap: reason=unstable_if action=reinterpret pc=0x000001fef156b31c method=java.lang.String.getBytes([BIIBI)V @ 6 c2 +Event: 6.989 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fef156b31c sp=0x0000003ffa7feb70 +Event: 6.989 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa69a3 sp=0x0000003ffa7fe9b8 mode 2 +Event: 6.990 Thread 0x000001fee418e9e0 Uncommon trap: trap_request=0xffffff45 fr.pc=0x000001fef156bbdc relative=0x000000000000013c +Event: 6.990 Thread 0x000001fee418e9e0 Uncommon trap: reason=unstable_if action=reinterpret pc=0x000001fef156bbdc method=java.lang.String.getBytes([BIIBI)V @ 6 c2 +Event: 6.990 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fef156bbdc sp=0x0000003ffa7feae0 +Event: 6.990 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa69a3 sp=0x0000003ffa7fe9a0 mode 2 +Event: 6.996 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fee9bc95a1 sp=0x0000003ffa7fe660 +Event: 6.996 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa7143 sp=0x0000003ffa7fdb88 mode 0 +Event: 6.999 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fee9bc95a1 sp=0x0000003ffa7fe660 +Event: 6.999 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa7143 sp=0x0000003ffa7fdb88 mode 0 +Event: 7.001 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fee9bc95a1 sp=0x0000003ffa7fe660 +Event: 7.001 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa7143 sp=0x0000003ffa7fdb88 mode 0 +Event: 7.003 Thread 0x000001fee418e9e0 DEOPT PACKING pc=0x000001fee9bc95a1 sp=0x0000003ffa7fe660 +Event: 7.003 Thread 0x000001fee418e9e0 DEOPT UNPACKING pc=0x000001fef0fa7143 sp=0x0000003ffa7fdb88 mode 0 + +Classes loaded (20 events): +Event: 6.982 Loading class jdk/internal/util/xml/impl/ParserSAX +Event: 6.983 Loading class jdk/internal/org/xml/sax/XMLReader +Event: 6.983 Loading class jdk/internal/org/xml/sax/XMLReader done +Event: 6.983 Loading class jdk/internal/org/xml/sax/Locator +Event: 6.983 Loading class jdk/internal/org/xml/sax/Locator done +Event: 6.983 Loading class jdk/internal/util/xml/impl/Parser +Event: 6.984 Loading class jdk/internal/util/xml/impl/Parser done +Event: 6.984 Loading class jdk/internal/util/xml/impl/ParserSAX done +Event: 6.984 Loading class jdk/internal/util/xml/impl/Attrs +Event: 6.985 Loading class jdk/internal/org/xml/sax/Attributes +Event: 6.985 Loading class jdk/internal/org/xml/sax/Attributes done +Event: 6.985 Loading class jdk/internal/util/xml/impl/Attrs done +Event: 6.985 Loading class jdk/internal/util/xml/impl/Pair +Event: 6.985 Loading class jdk/internal/util/xml/impl/Pair done +Event: 6.985 Loading class jdk/internal/org/xml/sax/InputSource +Event: 6.985 Loading class jdk/internal/org/xml/sax/InputSource done +Event: 6.986 Loading class jdk/internal/util/xml/impl/Input +Event: 6.986 Loading class jdk/internal/util/xml/impl/Input done +Event: 6.987 Loading class jdk/internal/util/xml/impl/ReaderUTF8 +Event: 6.987 Loading class jdk/internal/util/xml/impl/ReaderUTF8 done + +Classes unloaded (0 events): +No events + +Classes redefined (0 events): +No events + +Internal exceptions (20 events): +Event: 0.547 Thread 0x000001fee418e9e0 Exception (0x00000007105f0810) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.549 Thread 0x000001fee418e9e0 Exception (0x0000000710205780) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.550 Thread 0x000001fee418e9e0 Exception (0x000000071020b890) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.551 Thread 0x000001fee418e9e0 Exception (0x000000071020f8d8) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.552 Thread 0x000001fee418e9e0 Exception (0x0000000710216ff0) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.576 Thread 0x000001fee418e9e0 Exception (0x00000007102c8708) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.576 Thread 0x000001fee418e9e0 Exception (0x00000007102c8a98) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.623 Thread 0x000001fee418e9e0 Exception (0x000000071038dd30) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.623 Thread 0x000001fee418e9e0 Exception (0x000000071038e090) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.692 Thread 0x000001fee418e9e0 Exception (0x000000071005a610) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.692 Thread 0x000001fee418e9e0 Exception (0x000000071005a990) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.758 Thread 0x000001fee418e9e0 Exception (0x00000007100a8a88) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.972 Thread 0x000001fee418e9e0 Exception (0x000000070fe84128) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 0.987 Thread 0x000001fee418e9e0 Exception (0x000000070febf008) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 0.987 Thread 0x000001fee418e9e0 Exception (0x000000070febf3a8) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 1.001 Thread 0x000001fee418e9e0 Exception (0x000000070fecfc20) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 1.001 Thread 0x000001fee418e9e0 Exception (0x000000070fecfef0) +thrown [s\src\hotspot\share\prims\jni.cpp, line 516] +Event: 1.347 Thread 0x000001fee418e9e0 Exception (0x000000070fbdb978) +thrown [s\src\hotspot\share\interpreter\linkResolver.cpp, line 759] +Event: 5.518 Thread 0x000001fee418e9e0 Implicit null exception at 0x000001fef156a215 to 0x000001fef156a47c +Event: 5.533 Thread 0x000001fee418e9e0 Implicit null exception at 0x000001fef157587d to 0x000001fef15762d0 + +VM Operations (20 events): +Event: 1.113 Executing VM operation: HandshakeAllThreads +Event: 1.114 Executing VM operation: HandshakeAllThreads done +Event: 1.114 Executing VM operation: Cleanup +Event: 1.114 Executing VM operation: Cleanup done +Event: 1.365 Executing VM operation: HandshakeAllThreads +Event: 1.365 Executing VM operation: HandshakeAllThreads done +Event: 2.374 Executing VM operation: Cleanup +Event: 2.374 Executing VM operation: Cleanup done +Event: 3.374 Executing VM operation: Cleanup +Event: 3.374 Executing VM operation: Cleanup done +Event: 4.374 Executing VM operation: Cleanup +Event: 4.374 Executing VM operation: Cleanup done +Event: 5.375 Executing VM operation: Cleanup +Event: 5.375 Executing VM operation: Cleanup done +Event: 5.511 Executing VM operation: G1CollectForAllocation +Event: 5.517 Executing VM operation: G1CollectForAllocation done +Event: 5.529 Executing VM operation: HandshakeAllThreads +Event: 5.529 Executing VM operation: HandshakeAllThreads done +Event: 6.529 Executing VM operation: Cleanup +Event: 6.529 Executing VM operation: Cleanup done + +Memory protections (0 events): +No events + +Nmethod flushes (0 events): +No events + +Events (18 events): +Event: 0.052 Thread 0x000001fee418e9e0 Thread added: 0x000001fee418e9e0 +Event: 0.064 Thread 0x000001fee418e9e0 Thread added: 0x000001feff05e900 +Event: 0.065 Thread 0x000001fee418e9e0 Thread added: 0x000001feff05f780 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff0806f0 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff0814c0 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff081e80 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff0a2f30 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff0a4230 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff0a85f0 +Event: 0.091 Thread 0x000001fee418e9e0 Thread added: 0x000001feff0ab000 +Event: 0.199 Thread 0x000001fee418e9e0 Thread added: 0x000001feff225560 +Event: 0.205 Thread 0x000001fee418e9e0 Thread added: 0x000001feff22cf40 +Event: 0.278 Thread 0x000001feff0a85f0 Thread added: 0x000001fe8a128590 +Event: 0.769 Thread 0x000001fee418e9e0 Thread added: 0x000001fe8aba1080 +Event: 1.533 Thread 0x000001fee418e9e0 Thread added: 0x000001fe96662110 +Event: 1.635 Thread 0x000001fe8a128590 Thread exited: 0x000001fe8a128590 +Event: 6.854 Thread 0x000001feff0a85f0 Thread added: 0x000001fe96a6ee30 +Event: 6.870 Thread 0x000001feff0a85f0 Thread added: 0x000001fe96a6f350 + + +Dynamic libraries: +0x00007ff68d960000 - 0x00007ff68d96e000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\java.exe +0x00007ff946430000 - 0x00007ff946647000 C:\WINDOWS\SYSTEM32\ntdll.dll +0x00007ff944f90000 - 0x00007ff945054000 C:\WINDOWS\System32\KERNEL32.DLL +0x00007ff943740000 - 0x00007ff943af7000 C:\WINDOWS\System32\KERNELBASE.dll +0x00007ff943e60000 - 0x00007ff943f71000 C:\WINDOWS\System32\ucrtbase.dll +0x00007ff8e44b0000 - 0x00007ff8e44c7000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\jli.dll +0x00007ff8e44d0000 - 0x00007ff8e44eb000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\VCRUNTIME140.dll +0x00007ff9451f0000 - 0x00007ff94539f000 C:\WINDOWS\System32\USER32.dll +0x00007ff943b00000 - 0x00007ff943b26000 C:\WINDOWS\System32\win32u.dll +0x00007ff92f280000 - 0x00007ff92f513000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.22621.3672_none_2713b9d173822955\COMCTL32.dll +0x00007ff9450c0000 - 0x00007ff9450e9000 C:\WINDOWS\System32\GDI32.dll +0x00007ff944040000 - 0x00007ff944158000 C:\WINDOWS\System32\gdi32full.dll +0x00007ff9443a0000 - 0x00007ff944447000 C:\WINDOWS\System32\msvcrt.dll +0x00007ff943d40000 - 0x00007ff943dda000 C:\WINDOWS\System32\msvcp_win.dll +0x00007ff9453a0000 - 0x00007ff9453d1000 C:\WINDOWS\System32\IMM32.DLL +0x00007ff9036c0000 - 0x00007ff9036cc000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\vcruntime140_1.dll +0x00007ff89c2e0000 - 0x00007ff89c36e000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\msvcp140.dll +0x00007ff878070000 - 0x00007ff878ce0000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\server\jvm.dll +0x00007ff944ed0000 - 0x00007ff944f82000 C:\WINDOWS\System32\ADVAPI32.dll +0x00007ff9453f0000 - 0x00007ff945498000 C:\WINDOWS\System32\sechost.dll +0x00007ff943b30000 - 0x00007ff943b58000 C:\WINDOWS\System32\bcrypt.dll +0x00007ff9447e0000 - 0x00007ff9448f4000 C:\WINDOWS\System32\RPCRT4.dll +0x00007ff946370000 - 0x00007ff9463e1000 C:\WINDOWS\System32\WS2_32.dll +0x00007ff943610000 - 0x00007ff94365d000 C:\WINDOWS\SYSTEM32\POWRPROF.dll +0x00007ff93ce60000 - 0x00007ff93ce6a000 C:\WINDOWS\SYSTEM32\VERSION.dll +0x00007ff936610000 - 0x00007ff936644000 C:\WINDOWS\SYSTEM32\WINMM.dll +0x00007ff9435f0000 - 0x00007ff943603000 C:\WINDOWS\SYSTEM32\UMPDC.dll +0x00007ff942730000 - 0x00007ff942748000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll +0x00007ff903630000 - 0x00007ff90363a000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\jimage.dll +0x00007ff93d780000 - 0x00007ff93d9b2000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL +0x00007ff944450000 - 0x00007ff9447de000 C:\WINDOWS\System32\combase.dll +0x00007ff944cd0000 - 0x00007ff944da7000 C:\WINDOWS\System32\OLEAUT32.dll +0x00007ff9294f0000 - 0x00007ff929522000 C:\WINDOWS\SYSTEM32\dbgcore.DLL +0x00007ff943de0000 - 0x00007ff943e5b000 C:\WINDOWS\System32\bcryptPrimitives.dll +0x00007ff8bfbc0000 - 0x00007ff8bfbe5000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\java.dll +0x00007ff8c19d0000 - 0x00007ff8c19e8000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\zip.dll +0x00007ff89c200000 - 0x00007ff89c2d7000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\jsvml.dll +0x00007ff9454a0000 - 0x00007ff945d09000 C:\WINDOWS\System32\SHELL32.dll +0x00007ff9416b0000 - 0x00007ff941faf000 C:\WINDOWS\SYSTEM32\windows.storage.dll +0x00007ff941570000 - 0x00007ff9416af000 C:\WINDOWS\SYSTEM32\wintypes.dll +0x00007ff944a60000 - 0x00007ff944b59000 C:\WINDOWS\System32\SHCORE.dll +0x00007ff945060000 - 0x00007ff9450be000 C:\WINDOWS\System32\shlwapi.dll +0x00007ff943670000 - 0x00007ff943697000 C:\WINDOWS\SYSTEM32\profapi.dll +0x00007ff8c1370000 - 0x00007ff8c1389000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\net.dll +0x00007ff940320000 - 0x00007ff940456000 C:\WINDOWS\SYSTEM32\WINHTTP.dll +0x00007ff942c00000 - 0x00007ff942c69000 C:\WINDOWS\system32\mswsock.dll +0x00007ff8bfba0000 - 0x00007ff8bfbb6000 C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\nio.dll +0x000000006e700000 - 0x000000006e72d000 C:\ProgramData\libGDX-temp\libgdxUser_101931883_GDX1_13_1\c39de338\gdx64.dll +0x00007ff89d560000 - 0x00007ff89d5db000 C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\lwjgl.dll +0x00007ff89aaf0000 - 0x00007ff89ad60000 C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\jemalloc.dll +0x00007ff89aa60000 - 0x00007ff89aae2000 C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\glfw.dll +0x00007ff8be380000 - 0x00007ff8be3c6000 C:\WINDOWS\SYSTEM32\dinput8.dll +0x00007ff93c990000 - 0x00007ff93c9a1000 C:\WINDOWS\SYSTEM32\xinput1_4.dll +0x00007ff943340000 - 0x00007ff94338e000 C:\WINDOWS\SYSTEM32\cfgmgr32.dll +0x00007ff943310000 - 0x00007ff94333c000 C:\WINDOWS\SYSTEM32\DEVOBJ.dll +0x00007ff940ff0000 - 0x00007ff94101b000 C:\WINDOWS\SYSTEM32\dwmapi.dll +0x00007ff92fe10000 - 0x00007ff930023000 C:\WINDOWS\SYSTEM32\inputhost.dll +0x00007ff93fc90000 - 0x00007ff93fdc3000 C:\WINDOWS\SYSTEM32\CoreMessaging.dll +0x00007ff940c90000 - 0x00007ff940d41000 C:\WINDOWS\system32\uxtheme.dll +0x00007ff942e70000 - 0x00007ff942e7c000 C:\WINDOWS\SYSTEM32\CRYPTBASE.DLL +0x00007ff944900000 - 0x00007ff944a60000 C:\WINDOWS\System32\MSCTF.dll +0x00007ff899350000 - 0x00007ff899517000 C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\OpenAL.dll +0x00007ff9441f0000 - 0x00007ff944395000 C:\WINDOWS\System32\ole32.dll +0x00007ff944bc0000 - 0x00007ff944c70000 C:\WINDOWS\System32\clbcatq.dll +0x00007ff93c5d0000 - 0x00007ff93c66e000 C:\WINDOWS\System32\MMDevApi.dll +0x00007ff933be0000 - 0x00007ff933dcd000 C:\WINDOWS\SYSTEM32\AUDIOSES.DLL +0x00007ff941160000 - 0x00007ff941175000 C:\WINDOWS\SYSTEM32\resourcepolicyclient.dll +0x00007ff8f9010000 - 0x00007ff8f9110000 C:\WINDOWS\SYSTEM32\opengl32.dll +0x00007ff8efee0000 - 0x00007ff8eff0d000 C:\WINDOWS\SYSTEM32\GLU32.dll +0x00007ff940da0000 - 0x00007ff940dd7000 C:\WINDOWS\SYSTEM32\dxcore.dll +0x00007ff93c7c0000 - 0x00007ff93c904000 C:\Windows\System32\AppXDeploymentClient.dll +0x00007ff8e9530000 - 0x00007ff8e9560000 C:\WINDOWS\System32\DriverStore\FileRepository\u0415182.inf_amd64_bacb8c8cee2fc393\B415056\atig6pxx.dll +0x00007ff8d6960000 - 0x00007ff8da77f000 C:\WINDOWS\System32\DriverStore\FileRepository\u0415182.inf_amd64_bacb8c8cee2fc393\B415056\atio6axx.dll +0x00007ff945d20000 - 0x00007ff946194000 C:\WINDOWS\System32\SETUPAPI.dll +0x00007ff943cd0000 - 0x00007ff943d3c000 C:\WINDOWS\System32\WINTRUST.dll +0x00007ff943b60000 - 0x00007ff943cc6000 C:\WINDOWS\System32\CRYPT32.dll +0x00007ff942eb0000 - 0x00007ff942ec2000 C:\WINDOWS\SYSTEM32\MSASN1.dll +0x00007ff936130000 - 0x00007ff93616c000 C:\WINDOWS\System32\DriverStore\FileRepository\u0415182.inf_amd64_bacb8c8cee2fc393\B415056\amdihk64.dll +0x00007ff93a430000 - 0x00007ff93a4ed000 C:\WINDOWS\SYSTEM32\mscms.dll +0x00007ff8a33f0000 - 0x00007ff8a343a000 C:\WINDOWS\SYSTEM32\icm32.dll +0x00007ff89e9a0000 - 0x00007ff89e9fe000 C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64\lwjgl_opengl.dll +0x00007ff932820000 - 0x00007ff932963000 C:\WINDOWS\SYSTEM32\textinputframework.dll +0x00007ff93bb00000 - 0x00007ff93be6d000 C:\WINDOWS\SYSTEM32\CoreUIComponents.dll +0x000000006a340000 - 0x000000006a3f4000 C:\Users\kekke\AppData\Local\Temp\libgdxkekke\e35c7bf4\gdx-freetype64.dll + +dbghelp: loaded successfully - version: 4.0.5 - missing functions: none +symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.22621.3672_none_2713b9d173822955;C:\Users\kekke\.gradle\jdks\bellsoft-17-amd64-windows.2\bin\server;C:\ProgramData\libGDX-temp\libgdxUser_101931883_GDX1_13_1\c39de338;C:\Users\kekke\AppData\Local\Temp\lwjgl_kekke\3.3.3+5\x64;C:\WINDOWS\System32\DriverStore\FileRepository\u0415182.inf_amd64_bacb8c8cee2fc393\B415056;C:\Users\kekke\AppData\Local\Temp\libgdxkekke\e35c7bf4 + +VM Arguments: +jvm_args: -Dfile.encoding=UTF-8 -Duser.country=RU -Duser.language=ru -Duser.variant +java_command: ru.project.tower.lwjgl3.Lwjgl3Launcher +java_class_path (initial): G:\TowerAn\lwjgl3\build\classes\java\main;G:\TowerAn\lwjgl3\build\resources\main;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\com.badlogicgames.gdx\gdx-backend-lwjgl3\1.13.1\c8d8f1c3ccd17fe2202a1d581e179c4ba0ccc45d\gdx-backend-lwjgl3-1.13.1.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\com.badlogicgames.gdx\gdx-freetype-platform\1.13.1\7e81d0eb330b7bbbb307cd1a0dc2648648b42b94\gdx-freetype-platform-1.13.1-natives-desktop.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\com.badlogicgames.gdx\gdx-platform\1.13.1\8493b7d917097a6d091db2554bf0056881f96738\gdx-platform-1.13.1-natives-desktop.jar;G:\TowerAn\core\build\libs\core-1.0.0.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\com.badlogicgames.gdx\gdx-freetype\1.13.1\a386c9b7a10b28f91b2de47f66b12c23a2c3abc2\gdx-freetype-1.13.1.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\com.badlogicgames.gdx\gdx\1.13.1\734d9a5ca877bbce0c595dbdc3165fe6a22b7a30\gdx-1.13.1.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\efa1eb78c5ccd840e9f329717109b5e892d72f8e\lwjgl-glfw-3.3.3.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\a03684c5e4b1b1dbbe0d29dbbdc27b985b6840f2\lwjgl-glfw-3.3.3-natives-linux.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\d9af485c32545b37dd5359b163161d42d7534dcf\lwjgl-glfw-3.3.3-natives-linux-arm32.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\492a0f11f85b85899a6568f07511160c1b87cd38\lwjgl-glfw-3.3.3-natives-linux-arm64.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\a1bf400f6bc64e6195596cb1430dafda46090751\lwjgl-glfw-3.3.3-natives-macos.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\ee8cc78d0a4a5b3b4600fade6d927c9fc320c858\lwjgl-glfw-3.3.3-natives-macos-arm64.jar;C:\Users\kekke\.gradle\caches\modules-2\files-2.1\org.lwjgl\lwjgl-glfw\3.3.3\e449e28b4891fc423c54 +Launcher Type: SUN_STANDARD + +[Global flags] + intx CICompilerCount = 4 {product} {ergonomic} + uint ConcGCThreads = 2 {product} {ergonomic} + uint G1ConcRefinementThreads = 8 {product} {ergonomic} + size_t G1HeapRegionSize = 2097152 {product} {ergonomic} + uintx GCDrainStackTargetSize = 64 {product} {ergonomic} + size_t InitialHeapSize = 268435456 {product} {ergonomic} + size_t MarkStackSize = 4194304 {product} {ergonomic} + size_t MaxHeapSize = 4282384384 {product} {ergonomic} + size_t MaxNewSize = 2569011200 {product} {ergonomic} + size_t MinHeapDeltaBytes = 2097152 {product} {ergonomic} + size_t MinHeapSize = 8388608 {product} {ergonomic} + uintx NonNMethodCodeHeapSize = 5839372 {pd product} {ergonomic} + uintx NonProfiledCodeHeapSize = 122909434 {pd product} {ergonomic} + uintx ProfiledCodeHeapSize = 122909434 {pd product} {ergonomic} + uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic} + bool SegmentedCodeCache = true {product} {ergonomic} + size_t SoftMaxHeapSize = 4282384384 {manageable} {ergonomic} + bool UseCompressedClassPointers = true {product lp64_product} {ergonomic} + bool UseCompressedOops = true {product lp64_product} {ergonomic} + bool UseG1GC = true {product} {ergonomic} + bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} + +Logging: +Log output configuration: + #0: stdout all=warning uptime,level,tags + #1: stderr all=off uptime,level,tags + +Environment Variables: +CLASSPATH=G:\TowerAn\\gradle\wrapper\gradle-wrapper.jar +PATH=C:\Program Files (x86)\Common Files\Oracle\Java\java8path;C:\Program Files (x86)\Common Files\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files (x86)\Common Files\Oracle\Java\java8path;C:\Program Files (x86)\Common Files\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Users\kekke\AppData\Local\Microsoft\WindowsApps;C:\Users\kekke\AppData\Local\Programs\cursor\resources\app\bin;c:\program files (x86)\steam\ext\bin +USERNAME=kekke +LANG=en_US.UTF-8 +OS=Windows_NT +PROCESSOR_IDENTIFIER=AMD64 Family 21 Model 2 Stepping 0, AuthenticAMD +TMP=C:\Users\kekke\AppData\Local\Temp +TEMP=C:\Users\kekke\AppData\Local\Temp + + + +Periodic native trim disabled + + +--------------- S Y S T E M --------------- + +OS: + Windows 11 , 64 bit Build 22621 (10.0.22621.3958) +OS uptime: 0 days 10:36 hours + +CPU: total 8 (initial active 8) (8 cores per cpu, 1 threads per core) family 21 model 2 stepping 0 microcode 0x0, cx8, cmov, fxsr, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4a, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, aes, clmul, bmi1, fma, vzeroupper, clflush +Processor Information for all 8 processors : + Max Mhz: 4000, Current Mhz: 4000, Mhz Limit: 4000 + +Memory: 4k page, system-wide physical 16331M (3585M free) +TotalPageFile size 23598M (AvailPageFile size 2492M) +current process WorkingSet (physical memory assigned to process): 172M, peak: 176M +current process commit charge ("private bytes"): 464M, peak: 468M + +vm_info: OpenJDK 64-Bit Server VM (17.0.14+10-LTS) for windows-amd64 JRE (17.0.14+10-LTS), built on Jan 14 2025 23:58:31 by "" with MS VC++ 17.1 (VS2022) + +END. diff --git a/assets/libgdx.png b/assets/libgdx.png new file mode 100644 index 0000000..6c7bfca Binary files /dev/null and b/assets/libgdx.png differ diff --git a/assets/pop.mp3 b/assets/pop.mp3 new file mode 100644 index 0000000..4903e9c Binary files /dev/null and b/assets/pop.mp3 differ diff --git a/assets/pop.wav b/assets/pop.wav new file mode 100644 index 0000000..1c0788c Binary files /dev/null and b/assets/pop.wav differ diff --git a/assets/roboto.ttf b/assets/roboto.ttf new file mode 100644 index 0000000..5af42d4 Binary files /dev/null and b/assets/roboto.ttf differ diff --git a/assets/shot.mp3 b/assets/shot.mp3 new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/assets/shot.mp3 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/shot.wav b/assets/shot.wav new file mode 100644 index 0000000..95e480c Binary files /dev/null and b/assets/shot.wav differ diff --git a/assets/ui.ttf b/assets/ui.ttf new file mode 100644 index 0000000..6fcafd9 Binary files /dev/null and b/assets/ui.ttf differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3a801b8 --- /dev/null +++ b/build.gradle @@ -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' diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..b895d59 --- /dev/null +++ b/core/build.gradle @@ -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() +} diff --git a/core/src/main/java/ru/project/tower/DebugLog.java b/core/src/main/java/ru/project/tower/DebugLog.java new file mode 100644 index 0000000..47b2396 --- /dev/null +++ b/core/src/main/java/ru/project/tower/DebugLog.java @@ -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); + } +} diff --git a/core/src/main/java/ru/project/tower/GameContext.java b/core/src/main/java/ru/project/tower/GameContext.java new file mode 100644 index 0000000..1679656 --- /dev/null +++ b/core/src/main/java/ru/project/tower/GameContext.java @@ -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(); + } +} diff --git a/core/src/main/java/ru/project/tower/GameState.java b/core/src/main/java/ru/project/tower/GameState.java new file mode 100644 index 0000000..cbc736a --- /dev/null +++ b/core/src/main/java/ru/project/tower/GameState.java @@ -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 selectedAbilities = new Array<>(); + + private RunSnapshot savedRunSnapshot; + private Array 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 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 abilityIds) { + DebugLog.log("GameState", "Setting selected ability ids, count: " + abilityIds.size); + selectedAbilities.clear(); + for (String abilityId : abilityIds) { + Optional 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 getSelectedAbilities() { + DebugLog.log("GameState", "Getting selected abilities, count: " + selectedAbilities.size); + return copyOf(selectedAbilities); + } + + public Array getSelectedAbilityIds() { + Array abilityIds = new Array<>(); + for (AbilityKind kind : selectedAbilities) { + abilityIds.add(kind.persistedId()); + } + return abilityIds; + } + + public Array getSpawnedBosses() { + return copyOf(spawnedBosses); + } + + public void setSpawnedBosses(Array spawnedBosses) { + this.spawnedBosses.clear(); + this.spawnedBosses.addAll(spawnedBosses); + } + + private static Array copyOf(Array source) { + Array copy = new Array<>(); + copy.addAll(source); + return copy; + } + + private static final class RunSnapshot { + private final Snapshot snapshot; + private final Array selectedAbilities; + private final Array spawnedBosses; + + private RunSnapshot( + Snapshot snapshot, + Array selectedAbilities, + Array 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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/Main.java b/core/src/main/java/ru/project/tower/Main.java new file mode 100644 index 0000000..c894c4a --- /dev/null +++ b/core/src/main/java/ru/project/tower/Main.java @@ -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(); + } +} diff --git a/core/src/main/java/ru/project/tower/abilities/AbilityEffect.java b/core/src/main/java/ru/project/tower/abilities/AbilityEffect.java new file mode 100644 index 0000000..f89478d --- /dev/null +++ b/core/src/main/java/ru/project/tower/abilities/AbilityEffect.java @@ -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)); + } +} diff --git a/core/src/main/java/ru/project/tower/abilities/AbilityHost.java b/core/src/main/java/ru/project/tower/abilities/AbilityHost.java new file mode 100644 index 0000000..cad6a1e --- /dev/null +++ b/core/src/main/java/ru/project/tower/abilities/AbilityHost.java @@ -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); +} diff --git a/core/src/main/java/ru/project/tower/abilities/AbilityKind.java b/core/src/main/java/ru/project/tower/abilities/AbilityKind.java new file mode 100644 index 0000000..a6b3b23 --- /dev/null +++ b/core/src/main/java/ru/project/tower/abilities/AbilityKind.java @@ -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 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(); + } +} diff --git a/core/src/main/java/ru/project/tower/abilities/AbilitySystem.java b/core/src/main/java/ru/project/tower/abilities/AbilitySystem.java new file mode 100644 index 0000000..d12ab53 --- /dev/null +++ b/core/src/main/java/ru/project/tower/abilities/AbilitySystem.java @@ -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 circles; + private final PlayerAbilities playerAbilities; + private final Array 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 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 activeEffects = new Array<>(); + private final Array activeTurrets = new Array<>(); + + public AbilitySystem( + Rectangle playerSquare, + Array circles, + PlayerAbilities playerAbilities, + Array selectedAbilities, + RandomSource random, + AbilityHost host) { + this( + playerSquare, + circles, + playerAbilities, + selectedAbilities, + random, + host, + GameClock.SYSTEM); + } + + public AbilitySystem( + Rectangle playerSquare, + Array circles, + PlayerAbilities playerAbilities, + Array 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 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 getActiveEffects() { + return activeEffects; + } + + public Array getActiveTurrets() { + return activeTurrets; + } +} diff --git a/core/src/main/java/ru/project/tower/abilities/PlayerAbilities.java b/core/src/main/java/ru/project/tower/abilities/PlayerAbilities.java new file mode 100644 index 0000000..4e8a4f2 --- /dev/null +++ b/core/src/main/java/ru/project/tower/abilities/PlayerAbilities.java @@ -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 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 getPurchasedAbilities() { + try { + Array 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 getPurchasedAbilityIds() { + Array 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 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 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(); + } +} diff --git a/core/src/main/java/ru/project/tower/abilities/Turret.java b/core/src/main/java/ru/project/tower/abilities/Turret.java new file mode 100644 index 0000000..39215e2 --- /dev/null +++ b/core/src/main/java/ru/project/tower/abilities/Turret.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceEffectRegistry.java b/core/src/main/java/ru/project/tower/balance/BalanceEffectRegistry.java new file mode 100644 index 0000000..07e1e96 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceEffectRegistry.java @@ -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 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 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); + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceEffectSpec.java b/core/src/main/java/ru/project/tower/balance/BalanceEffectSpec.java new file mode 100644 index 0000000..b58a0b6 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceEffectSpec.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceReportExporter.java b/core/src/main/java/ru/project/tower/balance/BalanceReportExporter.java new file mode 100644 index 0000000..62c1559 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceReportExporter.java @@ -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 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); + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceRouteReport.java b/core/src/main/java/ru/project/tower/balance/BalanceRouteReport.java new file mode 100644 index 0000000..745e459 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceRouteReport.java @@ -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 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 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 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 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 emptyAxes() { + EnumMap axes = new EnumMap<>(PowerAxis.class); + for (PowerAxis axis : PowerAxis.values()) { + axes.put(axis, 0f); + } + return axes; + } + + private static EnumMap completeAxes(Map source) { + EnumMap axes = emptyAxes(); + if (source != null) { + axes.putAll(source); + } + return axes; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceRouteSimulator.java b/core/src/main/java/ru/project/tower/balance/BalanceRouteSimulator.java new file mode 100644 index 0000000..c532697 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceRouteSimulator.java @@ -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 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); + } + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceRouteTarget.java b/core/src/main/java/ru/project/tower/balance/BalanceRouteTarget.java new file mode 100644 index 0000000..6004a15 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceRouteTarget.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceRuntimeHarness.java b/core/src/main/java/ru/project/tower/balance/BalanceRuntimeHarness.java new file mode 100644 index 0000000..c52c408 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceRuntimeHarness.java @@ -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 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 circles = new Array<>(); + private final Array runtimeBonusBalls = new Array<>(); + private final PlayerAbilities playerAbilities; + private final Array 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 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 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 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 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() {} + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BalanceSimulationHarness.java b/core/src/main/java/ru/project/tower/balance/BalanceSimulationHarness.java new file mode 100644 index 0000000..421a678 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BalanceSimulationHarness.java @@ -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 runRepresentativeSuite(long[] seeds) { + BalanceRouteSimulator routeSimulator = new BalanceRouteSimulator(); + List 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 runRuntimeBackedSuite(long[] seeds) { + BalanceRuntimeHarness runtimeHarness = new BalanceRuntimeHarness(); + List 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 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)); + } +} diff --git a/core/src/main/java/ru/project/tower/balance/BossBalanceSpec.java b/core/src/main/java/ru/project/tower/balance/BossBalanceSpec.java new file mode 100644 index 0000000..a1e65b0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/BossBalanceSpec.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/EndlessBalanceSpec.java b/core/src/main/java/ru/project/tower/balance/EndlessBalanceSpec.java new file mode 100644 index 0000000..8bfcc37 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/EndlessBalanceSpec.java @@ -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 ratings) { + float effectiveDps = 10f * offenseMultiplier(ratings); + return bossHealthForWave(wave) / effectiveDps; + } + + public static float offenseMultiplier(Map 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 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 ratings, PowerAxis axis, float scale) { + return axisMultiplier(cappedValue(ratings, axis) * scale); + } + + private static float cappedValue(Map 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 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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/balance/EnemyComposition.java b/core/src/main/java/ru/project/tower/balance/EnemyComposition.java new file mode 100644 index 0000000..e6303f0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/EnemyComposition.java @@ -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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/balance/MetaProgressionPacing.java b/core/src/main/java/ru/project/tower/balance/MetaProgressionPacing.java new file mode 100644 index 0000000..2b6073d --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/MetaProgressionPacing.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/PowerAxis.java b/core/src/main/java/ru/project/tower/balance/PowerAxis.java new file mode 100644 index 0000000..cd9cc6f --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/PowerAxis.java @@ -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 +} diff --git a/core/src/main/java/ru/project/tower/balance/SeededUpgradeDraftDeck.java b/core/src/main/java/ru/project/tower/balance/SeededUpgradeDraftDeck.java new file mode 100644 index 0000000..a1193ea --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/SeededUpgradeDraftDeck.java @@ -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 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 pickedCopies; + private final String[] activeOfferIds; + private final int rerollsUsed; + + public Snapshot(Map pickedCopies, String[] activeOfferIds) { + this(pickedCopies, activeOfferIds, 0); + } + + public Snapshot( + Map 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 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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/balance/SimulationReport.java b/core/src/main/java/ru/project/tower/balance/SimulationReport.java new file mode 100644 index 0000000..303c321 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/SimulationReport.java @@ -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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/StrategyProfile.java b/core/src/main/java/ru/project/tower/balance/StrategyProfile.java new file mode 100644 index 0000000..55645fe --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/StrategyProfile.java @@ -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 axisWeights; + private final float metaRating; + + private StrategyProfile(String id, Map 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 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 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; + } +} diff --git a/core/src/main/java/ru/project/tower/balance/UpgradeCard.java b/core/src/main/java/ru/project/tower/balance/UpgradeCard.java new file mode 100644 index 0000000..b8f46fa --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/UpgradeCard.java @@ -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(); + } +} diff --git a/core/src/main/java/ru/project/tower/balance/UpgradeCardRarity.java b/core/src/main/java/ru/project/tower/balance/UpgradeCardRarity.java new file mode 100644 index 0000000..f483853 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/UpgradeCardRarity.java @@ -0,0 +1,7 @@ +package ru.project.tower.balance; + +public enum UpgradeCardRarity { + COMMON, + UNCOMMON, + RARE +} diff --git a/core/src/main/java/ru/project/tower/balance/UpgradeCardRegistry.java b/core/src/main/java/ru/project/tower/balance/UpgradeCardRegistry.java new file mode 100644 index 0000000..8276fd1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/balance/UpgradeCardRegistry.java @@ -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); + } +} diff --git a/core/src/main/java/ru/project/tower/content/BossContentRegistry.java b/core/src/main/java/ru/project/tower/content/BossContentRegistry.java new file mode 100644 index 0000000..0a38c87 --- /dev/null +++ b/core/src/main/java/ru/project/tower/content/BossContentRegistry.java @@ -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 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 type; + private final float radius; + private final float speed; + private final BossFactory factory; + + private BossSpec( + String id, + Class 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 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); + } + } +} diff --git a/core/src/main/java/ru/project/tower/entities/bullets/BulletData.java b/core/src/main/java/ru/project/tower/entities/bullets/BulletData.java new file mode 100644 index 0000000..f0ea73f --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/bullets/BulletData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/bullets/BulletEffectsSink.java b/core/src/main/java/ru/project/tower/entities/bullets/BulletEffectsSink.java new file mode 100644 index 0000000..2b0339b --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/bullets/BulletEffectsSink.java @@ -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); +} diff --git a/core/src/main/java/ru/project/tower/entities/bullets/BulletMultipliers.java b/core/src/main/java/ru/project/tower/entities/bullets/BulletMultipliers.java new file mode 100644 index 0000000..a416af6 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/bullets/BulletMultipliers.java @@ -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); + } +} diff --git a/core/src/main/java/ru/project/tower/entities/bullets/BulletSystem.java b/core/src/main/java/ru/project/tower/entities/bullets/BulletSystem.java new file mode 100644 index 0000000..be1d95e --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/bullets/BulletSystem.java @@ -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 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 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 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 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 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 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 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(); + } +} diff --git a/core/src/main/java/ru/project/tower/entities/bullets/BulletTalentProvider.java b/core/src/main/java/ru/project/tower/entities/bullets/BulletTalentProvider.java new file mode 100644 index 0000000..321c253 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/bullets/BulletTalentProvider.java @@ -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); + } + }; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/bullets/EnemySpatialIndex.java b/core/src/main/java/ru/project/tower/entities/bullets/EnemySpatialIndex.java new file mode 100644 index 0000000..fc05719 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/bullets/EnemySpatialIndex.java @@ -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 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); + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/BaseCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/BaseCircleData.java new file mode 100644 index 0000000..51d3e21 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/BaseCircleData.java @@ -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); + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/BasicCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/BasicCircleData.java new file mode 100644 index 0000000..948c00d --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/BasicCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/BonusBallData.java b/core/src/main/java/ru/project/tower/entities/circles/BonusBallData.java new file mode 100644 index 0000000..75fbd0f --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/BonusBallData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/DasherCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/DasherCircleData.java new file mode 100644 index 0000000..e9d1049 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/DasherCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/FastCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/FastCircleData.java new file mode 100644 index 0000000..83a56c6 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/FastCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/HealerCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/HealerCircleData.java new file mode 100644 index 0000000..33bc5cf --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/HealerCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/SniperCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/SniperCircleData.java new file mode 100644 index 0000000..1aff7a0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/SniperCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/SpawnerCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/SpawnerCircleData.java new file mode 100644 index 0000000..b800907 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/SpawnerCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/SplitterCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/SplitterCircleData.java new file mode 100644 index 0000000..f55a962 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/SplitterCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/StrongCircleData.java b/core/src/main/java/ru/project/tower/entities/circles/StrongCircleData.java new file mode 100644 index 0000000..32d87b9 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/StrongCircleData.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/bosses/GlacialWardenData.java b/core/src/main/java/ru/project/tower/entities/circles/bosses/GlacialWardenData.java new file mode 100644 index 0000000..c57c7f2 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/bosses/GlacialWardenData.java @@ -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 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 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); + } + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/bosses/IllusionMasterData.java b/core/src/main/java/ru/project/tower/entities/circles/bosses/IllusionMasterData.java new file mode 100644 index 0000000..ceed189 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/bosses/IllusionMasterData.java @@ -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 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 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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/bosses/JuggernautData.java b/core/src/main/java/ru/project/tower/entities/circles/bosses/JuggernautData.java new file mode 100644 index 0000000..f84403a --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/bosses/JuggernautData.java @@ -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); + } + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/bosses/ShadowWeaverData.java b/core/src/main/java/ru/project/tower/entities/circles/bosses/ShadowWeaverData.java new file mode 100644 index 0000000..ddd1543 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/bosses/ShadowWeaverData.java @@ -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 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 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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/bosses/SwarmQueenData.java b/core/src/main/java/ru/project/tower/entities/circles/bosses/SwarmQueenData.java new file mode 100644 index 0000000..297656e --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/bosses/SwarmQueenData.java @@ -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 orbitalEyePositions; + private Array 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 getOrbitalEyePositions() { + return orbitalEyePositions; + } + + public Array 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); + } + } +} diff --git a/core/src/main/java/ru/project/tower/entities/circles/bosses/VolatileReactorData.java b/core/src/main/java/ru/project/tower/entities/circles/bosses/VolatileReactorData.java new file mode 100644 index 0000000..f6cdcf0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/circles/bosses/VolatileReactorData.java @@ -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 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 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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/particles/FloatingText.java b/core/src/main/java/ru/project/tower/entities/particles/FloatingText.java new file mode 100644 index 0000000..da74e3d --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/particles/FloatingText.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/entities/particles/NeonParticle.java b/core/src/main/java/ru/project/tower/entities/particles/NeonParticle.java new file mode 100644 index 0000000..6b4c78e --- /dev/null +++ b/core/src/main/java/ru/project/tower/entities/particles/NeonParticle.java @@ -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); + } +} diff --git a/core/src/main/java/ru/project/tower/navigation/DefaultScreenFactory.java b/core/src/main/java/ru/project/tower/navigation/DefaultScreenFactory.java new file mode 100644 index 0000000..81f3ebb --- /dev/null +++ b/core/src/main/java/ru/project/tower/navigation/DefaultScreenFactory.java @@ -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)); + } +} diff --git a/core/src/main/java/ru/project/tower/navigation/GameScreenNavigator.java b/core/src/main/java/ru/project/tower/navigation/GameScreenNavigator.java new file mode 100644 index 0000000..4969b1c --- /dev/null +++ b/core/src/main/java/ru/project/tower/navigation/GameScreenNavigator.java @@ -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); + } + } +} diff --git a/core/src/main/java/ru/project/tower/navigation/ScreenFactory.java b/core/src/main/java/ru/project/tower/navigation/ScreenFactory.java new file mode 100644 index 0000000..2fa3b47 --- /dev/null +++ b/core/src/main/java/ru/project/tower/navigation/ScreenFactory.java @@ -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); +} diff --git a/core/src/main/java/ru/project/tower/navigation/ScreenNavigation.java b/core/src/main/java/ru/project/tower/navigation/ScreenNavigation.java new file mode 100644 index 0000000..a9b51d4 --- /dev/null +++ b/core/src/main/java/ru/project/tower/navigation/ScreenNavigation.java @@ -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)); + } +} diff --git a/core/src/main/java/ru/project/tower/navigation/ScreenNavigator.java b/core/src/main/java/ru/project/tower/navigation/ScreenNavigator.java new file mode 100644 index 0000000..43797ee --- /dev/null +++ b/core/src/main/java/ru/project/tower/navigation/ScreenNavigator.java @@ -0,0 +1,13 @@ +package ru.project.tower.navigation; + +public interface ScreenNavigator { + void showMainMenu(); + + void showAbilitySelection(); + + void showAbilityShop(); + + void showTalentTree(); + + void showGameScreen(); +} diff --git a/core/src/main/java/ru/project/tower/player/EffectiveCombatStats.java b/core/src/main/java/ru/project/tower/player/EffectiveCombatStats.java new file mode 100644 index 0000000..a65ff28 --- /dev/null +++ b/core/src/main/java/ru/project/tower/player/EffectiveCombatStats.java @@ -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; + } +} diff --git a/core/src/main/java/ru/project/tower/player/PlayerCombatState.java b/core/src/main/java/ru/project/tower/player/PlayerCombatState.java new file mode 100644 index 0000000..6cb033a --- /dev/null +++ b/core/src/main/java/ru/project/tower/player/PlayerCombatState.java @@ -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; + } + } +} diff --git a/core/src/main/java/ru/project/tower/player/PlayerRunCombatCalculator.java b/core/src/main/java/ru/project/tower/player/PlayerRunCombatCalculator.java new file mode 100644 index 0000000..05ae1c3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/player/PlayerRunCombatCalculator.java @@ -0,0 +1,100 @@ +package ru.project.tower.player; + +import java.util.Objects; +import ru.project.tower.systems.BonusBallSystem; +import ru.project.tower.talents.TalentData; +import ru.project.tower.talents.TalentTree; +import ru.project.tower.upgrades.UpgradeSystem; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; + +public final class PlayerRunCombatCalculator { + private static final float ABILITY_WAVE_SCALING_BASE = 1.15f; + private static final float MIN_COMBAT_SPEED_MULTIPLIER = 0.1f; + + private PlayerRunCombatCalculator() {} + + public static float abilityScalingFactor(int currentWave) { + return (float) Math.pow(ABILITY_WAVE_SCALING_BASE, currentWave - 1); + } + + public static void resetCombatStateForNewRun( + PlayerCombatState combatState, TalentTree talentTree, long nowMs) { + Objects.requireNonNull(combatState, "combatState"); + Objects.requireNonNull(talentTree, "talentTree"); + + int maxHealth = + PlayerCombatState.DEFAULT_MAX_HEALTH + + (int) talentTree.getStatBonus(TalentData.TalentType.HEALTH); + float shotCooldown = + PlayerCombatState.DEFAULT_SHOT_COOLDOWN_MS + - talentTree.getStatBonus(TalentData.TalentType.COOLDOWN); + if (shotCooldown < PlayerCombatState.MIN_SHOT_COOLDOWN_MS) { + shotCooldown = PlayerCombatState.MIN_SHOT_COOLDOWN_MS; + } + int healthRegenRate = (int) talentTree.getStatBonus(TalentData.TalentType.REGEN); + + combatState.resetForNewRun(maxHealth, shotCooldown, healthRegenRate); + combatState.setLastDamageTimeMs(nowMs); + combatState.setLastHealthRegenTimeMs(nowMs); + } + + public static float effectiveShotCooldown( + PlayerCombatState combatState, + UpgradeSystem upgradeSystem, + BonusBallSystem bonusBallSystem) { + Objects.requireNonNull(combatState, "combatState"); + Objects.requireNonNull(upgradeSystem, "upgradeSystem"); + Objects.requireNonNull(bonusBallSystem, "bonusBallSystem"); + + return EffectiveCombatStats.shotCooldownMs(combatState, 0f, upgradeSystem, bonusBallSystem); + } + + public static float effectiveShotCooldown( + PlayerCombatState combatState, + UpgradeSystem upgradeSystem, + BonusBallSystem bonusBallSystem, + float attackSpeedMultiplier) { + float baseCooldown = effectiveShotCooldown(combatState, upgradeSystem, bonusBallSystem); + return applyAttackSpeedMultiplierToCooldown(baseCooldown, attackSpeedMultiplier); + } + + public static float applyAttackSpeedMultiplierToCooldown( + float baseCooldownMs, float attackSpeedMultiplier) { + float boundedMultiplier = boundedCombatSpeedMultiplier(attackSpeedMultiplier); + return Math.max(PlayerCombatState.MIN_SHOT_COOLDOWN_MS, baseCooldownMs / boundedMultiplier); + } + + public static float applyProjectileSpeedMultiplier( + float baseSpeedMultiplier, float projectileSpeedMultiplier) { + return baseSpeedMultiplier * boundedCombatSpeedMultiplier(projectileSpeedMultiplier); + } + + private static float boundedCombatSpeedMultiplier(float multiplier) { + if (Float.isNaN(multiplier)) { + return 1f; + } + return Math.max(MIN_COMBAT_SPEED_MULTIPLIER, multiplier); + } + + public static float effectiveCritChance( + PlayerCombatState combatState, + ShopUpgradeSystem shopUpgradeSystem, + UpgradeSystem upgradeSystem, + TalentTree talentTree) { + Objects.requireNonNull(combatState, "combatState"); + Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem"); + Objects.requireNonNull(upgradeSystem, "upgradeSystem"); + Objects.requireNonNull(talentTree, "talentTree"); + + return EffectiveCombatStats.critChance( + combatState, shopUpgradeSystem, upgradeSystem, talentTree); + } + + public static float effectiveCritMultiplier( + PlayerCombatState combatState, ShopUpgradeSystem shopUpgradeSystem) { + Objects.requireNonNull(combatState, "combatState"); + Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem"); + + return EffectiveCombatStats.critMultiplier(combatState, shopUpgradeSystem); + } +} diff --git a/core/src/main/java/ru/project/tower/player/PlayerStats.java b/core/src/main/java/ru/project/tower/player/PlayerStats.java new file mode 100644 index 0000000..820fa06 --- /dev/null +++ b/core/src/main/java/ru/project/tower/player/PlayerStats.java @@ -0,0 +1,226 @@ +package ru.project.tower.player; + +import com.badlogic.gdx.Preferences; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + + + + +public class PlayerStats { + private static final String CURRENCY_KEY = "player_currency"; + private static final String MAX_WAVE_KEY = "max_wave_reached"; + private static final String TALENT_NODE_KEY_PREFIX = "talent_node_"; + private static final String[] TALENT_NODE_IDS = { + "health_1", + "damage_1", + "cooldown_1", + "speed_1", + "regen_1", + "health_2", + "damage_2", + "cooldown_2", + "speed_2", + "control_1", + "economy_1", + "ability_1", + "utility_1", + "regen_2", + "ability_2", + "attack_precision", + "control_execute", + "engineering_turret", + "survival_barrier", + "economy_reroll", + "utility_bonus", + "ability_blast", + "keystone_pierce", + "keystone_crit", + "keystone_aoe", + "keystone_splash", + "keystone_turret", + "keystone_control_execute", + "keystone_shield_regen", + "keystone_economy_reroll" + }; + private static final Set VALID_TALENT_NODE_IDS = + Collections.unmodifiableSet(new HashSet<>(Arrays.asList(TALENT_NODE_IDS))); + private int currency; + private int maxWaveReached; + private final Preferences preferences; + private int saveBatchDepth; + private boolean flushPending; + + + + private final Map talentLevels = new HashMap<>(); + + private static final String DAMAGE_TALENT_KEY = "talent_damage_level"; + private static final String HEALTH_TALENT_KEY = "talent_health_level"; + private static final String SPEED_TALENT_KEY = "talent_speed_level"; + private static final String COOLDOWN_TALENT_KEY = "talent_cooldown_level"; + private static final String REGEN_TALENT_KEY = "talent_regen_level"; + + + + + public PlayerStats(Preferences preferences) { + this.preferences = preferences; + + currency = preferences.getInteger(CURRENCY_KEY, 0); + maxWaveReached = preferences.getInteger(MAX_WAVE_KEY, 0); + + + + for (String id : TALENT_NODE_IDS) { + talentLevels.put(id, preferences.getInteger(TALENT_NODE_KEY_PREFIX + id, 0)); + } + seedLegacyLevel("damage_1", DAMAGE_TALENT_KEY); + seedLegacyLevel("health_1", HEALTH_TALENT_KEY); + seedLegacyLevel("speed_1", SPEED_TALENT_KEY); + seedLegacyLevel("cooldown_1", COOLDOWN_TALENT_KEY); + seedLegacyLevel("regen_1", REGEN_TALENT_KEY); + } + + private void seedLegacyLevel(String nodeId, String legacyKey) { + if (talentLevels.get(nodeId) == 0) { + int legacy = preferences.getInteger(legacyKey, 0); + if (legacy > 0) { + talentLevels.put(nodeId, legacy); + } + } + } + + + + + public void withBatchedSaves(Runnable changes) { + saveBatchDepth++; + try { + changes.run(); + } finally { + saveBatchDepth--; + if (saveBatchDepth == 0 && flushPending) { + flushPending = false; + preferences.flush(); + } + } + } + + + + + public int getCurrency() { + return currency; + } + + + + + public void addCurrency(int amount) { + if (amount > 0) { + currency += amount; + saveData(); + } + } + + + + + + public boolean spendCurrency(int amount) { + if (amount <= 0) { + return true; + } + + if (currency >= amount) { + currency -= amount; + saveData(); + return true; + } + + return false; + } + + + + + public int getMaxWaveReached() { + return maxWaveReached; + } + + + + + public void updateMaxWave(int waveNumber) { + if (waveNumber > maxWaveReached) { + maxWaveReached = waveNumber; + saveData(); + } + } + + + public int getTalentNodeLevel(String nodeId) { + Integer v = talentLevels.get(nodeId); + return v == null ? 0 : v; + } + + public void incrementTalentNode(String nodeId) { + if (!VALID_TALENT_NODE_IDS.contains(nodeId)) { + throw new IllegalArgumentException("Unknown talent node id: " + nodeId); + } + talentLevels.put(nodeId, getTalentNodeLevel(nodeId) + 1); + saveData(); + } + + + + public int getDamageTalentLevel() { + return getTalentNodeLevel("damage_1") + getTalentNodeLevel("damage_2"); + } + + public int getHealthTalentLevel() { + return getTalentNodeLevel("health_1") + getTalentNodeLevel("health_2"); + } + + public int getSpeedTalentLevel() { + return getTalentNodeLevel("speed_1") + getTalentNodeLevel("speed_2"); + } + + public int getCooldownTalentLevel() { + return getTalentNodeLevel("cooldown_1") + getTalentNodeLevel("cooldown_2"); + } + + + + public int getRegenTalentLevel() { + return getTalentNodeLevel("regen_1") + getTalentNodeLevel("regen_2"); + } + + + + + private void saveData() { + preferences.putInteger(CURRENCY_KEY, currency); + preferences.putInteger(MAX_WAVE_KEY, maxWaveReached); + + for (String id : TALENT_NODE_IDS) { + preferences.putInteger(TALENT_NODE_KEY_PREFIX + id, getTalentNodeLevel(id)); + } + + flushData(); + } + + private void flushData() { + if (saveBatchDepth > 0) { + flushPending = true; + return; + } + + preferences.flush(); + } +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayColor.java b/core/src/main/java/ru/project/tower/run/GameplayColor.java new file mode 100644 index 0000000..214a878 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayColor.java @@ -0,0 +1,11 @@ +package ru.project.tower.run; + +public enum GameplayColor { + NEON_RED, + NEON_YELLOW, + NEON_GREEN, + NEON_CYAN, + NEON_BLUE, + NEON_PURPLE, + WHITE +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayEvent.java b/core/src/main/java/ru/project/tower/run/GameplayEvent.java new file mode 100644 index 0000000..a776fa3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayEvent.java @@ -0,0 +1,314 @@ +package ru.project.tower.run; + +import java.util.Objects; + +public abstract class GameplayEvent { + private GameplayEvent() {} + + public static final class EnemyKilled extends GameplayEvent { + public final String enemyKind; + public final GameplayPosition position; + public final int reward; + public final boolean boss; + + public EnemyKilled(String enemyKind, GameplayPosition position, int reward, boolean boss) { + this.enemyKind = requireText(enemyKind, "enemyKind"); + this.position = Objects.requireNonNull(position, "position"); + this.reward = reward; + this.boss = boss; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof EnemyKilled)) return false; + EnemyKilled that = (EnemyKilled) other; + return reward == that.reward + && boss == that.boss + && enemyKind.equals(that.enemyKind) + && position.equals(that.position); + } + + @Override + public int hashCode() { + return Objects.hash(enemyKind, position, reward, boss); + } + } + + public static final class PlayerDamaged extends GameplayEvent { + public final int damage; + public final int healthAfterDamage; + public final GameplayPosition position; + + public PlayerDamaged(int damage, int healthAfterDamage, GameplayPosition position) { + this.damage = damage; + this.healthAfterDamage = healthAfterDamage; + this.position = Objects.requireNonNull(position, "position"); + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof PlayerDamaged)) return false; + PlayerDamaged that = (PlayerDamaged) other; + return damage == that.damage + && healthAfterDamage == that.healthAfterDamage + && position.equals(that.position); + } + + @Override + public int hashCode() { + return Objects.hash(damage, healthAfterDamage, position); + } + } + + public static final class WaveCleared extends GameplayEvent { + public final int clearedWave; + public final int moneyBonus; + public final int currencyReward; + public final boolean shouldShowUpgrade; + public final GameplayPosition playerPosition; + + public WaveCleared( + int clearedWave, + int moneyBonus, + int currencyReward, + boolean shouldShowUpgrade, + GameplayPosition playerPosition) { + this.clearedWave = clearedWave; + this.moneyBonus = moneyBonus; + this.currencyReward = currencyReward; + this.shouldShowUpgrade = shouldShowUpgrade; + this.playerPosition = Objects.requireNonNull(playerPosition, "playerPosition"); + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof WaveCleared)) return false; + WaveCleared that = (WaveCleared) other; + return clearedWave == that.clearedWave + && moneyBonus == that.moneyBonus + && currencyReward == that.currencyReward + && shouldShowUpgrade == that.shouldShowUpgrade + && playerPosition.equals(that.playerPosition); + } + + @Override + public int hashCode() { + return Objects.hash( + clearedWave, moneyBonus, currencyReward, shouldShowUpgrade, playerPosition); + } + } + + public static final class BonusCollected extends GameplayEvent { + public final String bonusKind; + public final GameplayPosition position; + public final String displayText; + + public BonusCollected(String bonusKind, GameplayPosition position, String displayText) { + this.bonusKind = requireText(bonusKind, "bonusKind"); + this.position = Objects.requireNonNull(position, "position"); + this.displayText = requireText(displayText, "displayText"); + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof BonusCollected)) return false; + BonusCollected that = (BonusCollected) other; + return bonusKind.equals(that.bonusKind) + && position.equals(that.position) + && displayText.equals(that.displayText); + } + + @Override + public int hashCode() { + return Objects.hash(bonusKind, position, displayText); + } + } + + public static final class GameOver extends GameplayEvent { + public final int reachedWave; + public final int currencyEarned; + + public GameOver(int reachedWave, int currencyEarned) { + this.reachedWave = reachedWave; + this.currencyEarned = currencyEarned; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof GameOver)) return false; + GameOver that = (GameOver) other; + return reachedWave == that.reachedWave && currencyEarned == that.currencyEarned; + } + + @Override + public int hashCode() { + return Objects.hash(reachedWave, currencyEarned); + } + } + + public static final class CriticalHit extends GameplayEvent { + public final String enemyKind; + public final GameplayPosition position; + public final int damage; + + public CriticalHit(String enemyKind, GameplayPosition position, int damage) { + this.enemyKind = requireText(enemyKind, "enemyKind"); + this.position = Objects.requireNonNull(position, "position"); + this.damage = damage; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof CriticalHit)) return false; + CriticalHit that = (CriticalHit) other; + return damage == that.damage + && enemyKind.equals(that.enemyKind) + && position.equals(that.position); + } + + @Override + public int hashCode() { + return Objects.hash(enemyKind, position, damage); + } + } + + public static final class ExplosionRequested extends GameplayEvent { + public final GameplayPosition position; + public final GameplayColor color; + public final int particleCount; + + public ExplosionRequested( + GameplayPosition position, GameplayColor color, int particleCount) { + this.position = Objects.requireNonNull(position, "position"); + this.color = Objects.requireNonNull(color, "color"); + this.particleCount = particleCount; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ExplosionRequested)) return false; + ExplosionRequested that = (ExplosionRequested) other; + return particleCount == that.particleCount + && position.equals(that.position) + && color == that.color; + } + + @Override + public int hashCode() { + return Objects.hash(position, color, particleCount); + } + } + + public static final class FloatingTextRequested extends GameplayEvent { + public final GameplayPosition position; + public final String text; + public final GameplayColor color; + public final float durationSeconds; + public final float scale; + public final float riseDistance; + + public FloatingTextRequested( + GameplayPosition position, + String text, + GameplayColor color, + float durationSeconds, + float scale, + float riseDistance) { + this.position = Objects.requireNonNull(position, "position"); + this.text = requireText(text, "text"); + this.color = Objects.requireNonNull(color, "color"); + this.durationSeconds = durationSeconds; + this.scale = scale; + this.riseDistance = riseDistance; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof FloatingTextRequested)) return false; + FloatingTextRequested that = (FloatingTextRequested) other; + return Float.compare(that.durationSeconds, durationSeconds) == 0 + && Float.compare(that.scale, scale) == 0 + && Float.compare(that.riseDistance, riseDistance) == 0 + && position.equals(that.position) + && text.equals(that.text) + && color == that.color; + } + + @Override + public int hashCode() { + return Objects.hash(position, text, color, durationSeconds, scale, riseDistance); + } + } + + public static final class ShakeRequested extends GameplayEvent { + public final float intensity; + public final float hitstopSeconds; + + public ShakeRequested(float intensity, float hitstopSeconds) { + this.intensity = intensity; + this.hitstopSeconds = hitstopSeconds; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof ShakeRequested)) return false; + ShakeRequested that = (ShakeRequested) other; + return Float.compare(that.intensity, intensity) == 0 + && Float.compare(that.hitstopSeconds, hitstopSeconds) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(intensity, hitstopSeconds); + } + } + + public static final class SoundRequested extends GameplayEvent { + public final GameplaySound sound; + public final float volume; + public final float pitch; + public final float pan; + + public SoundRequested(GameplaySound sound, float volume, float pitch, float pan) { + this.sound = Objects.requireNonNull(sound, "sound"); + this.volume = volume; + this.pitch = pitch; + this.pan = pan; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof SoundRequested)) return false; + SoundRequested that = (SoundRequested) other; + return Float.compare(that.volume, volume) == 0 + && Float.compare(that.pitch, pitch) == 0 + && Float.compare(that.pan, pan) == 0 + && sound == that.sound; + } + + @Override + public int hashCode() { + return Objects.hash(sound, volume, pitch, pan); + } + } + + private static String requireText(String value, String fieldName) { + if (value == null) { + throw new NullPointerException(fieldName); + } + if (value.isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be empty"); + } + return value; + } +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayEventDispatcher.java b/core/src/main/java/ru/project/tower/run/GameplayEventDispatcher.java new file mode 100644 index 0000000..837ace4 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayEventDispatcher.java @@ -0,0 +1,61 @@ +package ru.project.tower.run; + +import java.util.Objects; + + + + + + +public abstract class GameplayEventDispatcher implements GameplayEventSink { + @Override + public final void emit(GameplayEvent event) { + Objects.requireNonNull(event, "event"); + + if (event instanceof GameplayEvent.EnemyKilled) { + onEnemyKilled((GameplayEvent.EnemyKilled) event); + } else if (event instanceof GameplayEvent.PlayerDamaged) { + onPlayerDamaged((GameplayEvent.PlayerDamaged) event); + } else if (event instanceof GameplayEvent.WaveCleared) { + onWaveCleared((GameplayEvent.WaveCleared) event); + } else if (event instanceof GameplayEvent.BonusCollected) { + onBonusCollected((GameplayEvent.BonusCollected) event); + } else if (event instanceof GameplayEvent.GameOver) { + onGameOver((GameplayEvent.GameOver) event); + } else if (event instanceof GameplayEvent.CriticalHit) { + onCriticalHit((GameplayEvent.CriticalHit) event); + } else if (event instanceof GameplayEvent.ExplosionRequested) { + onExplosionRequested((GameplayEvent.ExplosionRequested) event); + } else if (event instanceof GameplayEvent.FloatingTextRequested) { + onFloatingTextRequested((GameplayEvent.FloatingTextRequested) event); + } else if (event instanceof GameplayEvent.ShakeRequested) { + onShakeRequested((GameplayEvent.ShakeRequested) event); + } else if (event instanceof GameplayEvent.SoundRequested) { + onSoundRequested((GameplayEvent.SoundRequested) event); + } else { + onUnhandled(event); + } + } + + protected void onEnemyKilled(GameplayEvent.EnemyKilled event) {} + + protected void onPlayerDamaged(GameplayEvent.PlayerDamaged event) {} + + protected void onWaveCleared(GameplayEvent.WaveCleared event) {} + + protected void onBonusCollected(GameplayEvent.BonusCollected event) {} + + protected void onGameOver(GameplayEvent.GameOver event) {} + + protected void onCriticalHit(GameplayEvent.CriticalHit event) {} + + protected void onExplosionRequested(GameplayEvent.ExplosionRequested event) {} + + protected void onFloatingTextRequested(GameplayEvent.FloatingTextRequested event) {} + + protected void onShakeRequested(GameplayEvent.ShakeRequested event) {} + + protected void onSoundRequested(GameplayEvent.SoundRequested event) {} + + protected void onUnhandled(GameplayEvent event) {} +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayEventSink.java b/core/src/main/java/ru/project/tower/run/GameplayEventSink.java new file mode 100644 index 0000000..feb86ac --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayEventSink.java @@ -0,0 +1,10 @@ +package ru.project.tower.run; + + + + + + +public interface GameplayEventSink { + void emit(GameplayEvent event); +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayEventSinks.java b/core/src/main/java/ru/project/tower/run/GameplayEventSinks.java new file mode 100644 index 0000000..52cd1a7 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayEventSinks.java @@ -0,0 +1,31 @@ +package ru.project.tower.run; + +import java.util.Objects; + + + + + +public final class GameplayEventSinks { + private static final GameplayEventSink NOOP = + new GameplayEventSink() { + @Override + public void emit(GameplayEvent event) { + Objects.requireNonNull(event, "event"); + } + }; + + private GameplayEventSinks() {} + + public static GameplayEventSink noop() { + return NOOP; + } + + public static void emitAll(GameplayEventSink sink, GameplayEvent... events) { + Objects.requireNonNull(sink, "sink"); + Objects.requireNonNull(events, "events"); + for (GameplayEvent event : events) { + sink.emit(Objects.requireNonNull(event, "event")); + } + } +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayEvents.java b/core/src/main/java/ru/project/tower/run/GameplayEvents.java new file mode 100644 index 0000000..3d91301 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayEvents.java @@ -0,0 +1,78 @@ +package ru.project.tower.run; + + + + + +public final class GameplayEvents { + private GameplayEvents() {} + + public static GameplayPosition position(float x, float y) { + return new GameplayPosition(x, y); + } + + public static GameplayEvent.EnemyKilled enemyKilled( + String enemyKind, float x, float y, int reward, boolean boss) { + return new GameplayEvent.EnemyKilled(enemyKind, position(x, y), reward, boss); + } + + public static GameplayEvent.PlayerDamaged playerDamaged( + int damage, int healthAfterDamage, float x, float y) { + return new GameplayEvent.PlayerDamaged(damage, healthAfterDamage, position(x, y)); + } + + public static GameplayEvent.WaveCleared waveCleared( + int clearedWave, + int moneyBonus, + int currencyReward, + boolean shouldShowUpgrade, + float playerX, + float playerY) { + return new GameplayEvent.WaveCleared( + clearedWave, + moneyBonus, + currencyReward, + shouldShowUpgrade, + position(playerX, playerY)); + } + + public static GameplayEvent.BonusCollected bonusCollected( + String bonusKind, float x, float y, String displayText) { + return new GameplayEvent.BonusCollected(bonusKind, position(x, y), displayText); + } + + public static GameplayEvent.GameOver gameOver(int reachedWave, int currencyEarned) { + return new GameplayEvent.GameOver(reachedWave, currencyEarned); + } + + public static GameplayEvent.CriticalHit criticalHit( + String enemyKind, float x, float y, int damage) { + return new GameplayEvent.CriticalHit(enemyKind, position(x, y), damage); + } + + public static GameplayEvent.ExplosionRequested explosion( + float x, float y, GameplayColor color, int particleCount) { + return new GameplayEvent.ExplosionRequested(position(x, y), color, particleCount); + } + + public static GameplayEvent.FloatingTextRequested floatingText( + float x, + float y, + String text, + GameplayColor color, + float durationSeconds, + float scale, + float riseDistance) { + return new GameplayEvent.FloatingTextRequested( + position(x, y), text, color, durationSeconds, scale, riseDistance); + } + + public static GameplayEvent.ShakeRequested shake(float intensity, float hitstopSeconds) { + return new GameplayEvent.ShakeRequested(intensity, hitstopSeconds); + } + + public static GameplayEvent.SoundRequested sound( + GameplaySound sound, float volume, float pitch, float pan) { + return new GameplayEvent.SoundRequested(sound, volume, pitch, pan); + } +} diff --git a/core/src/main/java/ru/project/tower/run/GameplayPosition.java b/core/src/main/java/ru/project/tower/run/GameplayPosition.java new file mode 100644 index 0000000..e0204d0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplayPosition.java @@ -0,0 +1,31 @@ +package ru.project.tower.run; + +public final class GameplayPosition { + public final float x; + public final float y; + + public GameplayPosition(float x, float y) { + this.x = x; + this.y = y; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (!(other instanceof GameplayPosition)) return false; + GameplayPosition that = (GameplayPosition) other; + return Float.compare(that.x, x) == 0 && Float.compare(that.y, y) == 0; + } + + @Override + public int hashCode() { + int result = Float.floatToIntBits(x); + result = 31 * result + Float.floatToIntBits(y); + return result; + } + + @Override + public String toString() { + return "GameplayPosition{" + "x=" + x + ", y=" + y + '}'; + } +} diff --git a/core/src/main/java/ru/project/tower/run/GameplaySound.java b/core/src/main/java/ru/project/tower/run/GameplaySound.java new file mode 100644 index 0000000..7b6dd1b --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/GameplaySound.java @@ -0,0 +1,6 @@ +package ru.project.tower.run; + +public enum GameplaySound { + SHOT, + POP +} diff --git a/core/src/main/java/ru/project/tower/run/RunBulletSnapshot.java b/core/src/main/java/ru/project/tower/run/RunBulletSnapshot.java new file mode 100644 index 0000000..57f7a22 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunBulletSnapshot.java @@ -0,0 +1,19 @@ +package ru.project.tower.run; + +public final class RunBulletSnapshot { + private final float baseSpeed; + private final int baseDamage; + + public RunBulletSnapshot(float baseSpeed, int baseDamage) { + this.baseSpeed = baseSpeed; + this.baseDamage = baseDamage; + } + + public float baseSpeed() { + return baseSpeed; + } + + public int baseDamage() { + return baseDamage; + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunFrameEvents.java b/core/src/main/java/ru/project/tower/run/RunFrameEvents.java new file mode 100644 index 0000000..b3553f1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunFrameEvents.java @@ -0,0 +1,13 @@ +package ru.project.tower.run; + +public interface RunFrameEvents { + void regeneratePlayer(long nowMs); + + void autoShootIfReady(long nowMs); + + boolean damagePlayerIfVulnerable(int damage, long cooldownMs); + + boolean isPlayerDead(); + + void gameOver(); +} diff --git a/core/src/main/java/ru/project/tower/run/RunFrameOrchestrator.java b/core/src/main/java/ru/project/tower/run/RunFrameOrchestrator.java new file mode 100644 index 0000000..1856c6d --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunFrameOrchestrator.java @@ -0,0 +1,264 @@ +package ru.project.tower.run; + +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.entities.bullets.BulletEffectsSink; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.systems.BonusBallSystem; +import ru.project.tower.systems.CollisionSystem; +import ru.project.tower.systems.EnemySimulationSystem; +import ru.project.tower.systems.SpecialCircleSystem; +import ru.project.tower.waves.SpawnController; +import ru.project.tower.waves.WaveController; +import ru.project.tower.waves.WaveRunOrchestrator; + +public final class RunFrameOrchestrator { + private static final long CONTACT_DAMAGE_COOLDOWN_MS = 1_000L; + private static final float MAX_SIMULATION_DELTA_SECONDS = 1f / 15f; + + private final Array circles; + private final Rectangle square; + private final RunFrameEvents events; + private final FrameOperations operations; + + public RunFrameOrchestrator(Config config) { + this(config.circles, config.square, config.events, new DefaultFrameOperations(config)); + } + + RunFrameOrchestrator( + Array circles, + Rectangle square, + RunFrameEvents events, + FrameOperations operations) { + this.circles = Objects.requireNonNull(circles, "circles"); + this.square = Objects.requireNonNull(square, "square"); + this.events = Objects.requireNonNull(events, "events"); + this.operations = Objects.requireNonNull(operations, "operations"); + } + + public boolean update( + float delta, + long nowMs, + float viewportWidth, + float viewportHeight, + float gameScale, + boolean upgradePickActive) { + float simulationDelta = clampSimulationDelta(delta); + operations.updateWave(nowMs, circles.size, upgradePickActive); + if (upgradePickActive) { + return false; + } + events.regeneratePlayer(nowMs); + + if (operations.isWaveActive()) { + events.autoShootIfReady(nowMs); + } + + operations.updateBonusBalls(simulationDelta, viewportWidth, viewportHeight); + operations.processSpawnerChildren(); + operations.tickAbilities(nowMs); + moveCircles(simulationDelta); + operations.updateSpecialCircles(gameScale); + operations.tickBullets(simulationDelta); + + if (resolveContactDamage()) { + return true; + } + + operations.separateCircles(); + steerAndRemoveOutOfBounds(viewportWidth, viewportHeight, gameScale); + return false; + } + + private static float clampSimulationDelta(float delta) { + if (delta <= 0f) { + return 0f; + } + return Math.min(delta, MAX_SIMULATION_DELTA_SECONDS); + } + + private void moveCircles(float delta) { + for (BaseCircleData circleData : circles) { + operations.prepareCircleForMove(circleData); + circleData.move(delta); + } + } + + private boolean resolveContactDamage() { + for (BaseCircleData circleData : circles) { + if (operations.resolveCircleSquare(circleData) + && events.damagePlayerIfVulnerable( + circleData.damage, CONTACT_DAMAGE_COOLDOWN_MS) + && events.isPlayerDead()) { + events.gameOver(); + return true; + } + } + return false; + } + + private void steerAndRemoveOutOfBounds( + float viewportWidth, float viewportHeight, float gameScale) { + for (int i = 0; i < circles.size; i++) { + BaseCircleData circleData = circles.get(i); + Circle circle = circleData.circle; + + operations.steerTowardPlayer(circleData, gameScale); + + if (operations.shouldRemoveOutOfBounds(circle, viewportWidth, viewportHeight)) { + BaseCircleData.freeIfSpawnPooled(circles.removeIndex(i)); + i--; + } + } + } + + interface FrameOperations { + void updateWave(long nowMs, int activeEnemyCount, boolean upgradePickActive); + + boolean isWaveActive(); + + void updateBonusBalls(float delta, float viewportWidth, float viewportHeight); + + void processSpawnerChildren(); + + void tickAbilities(long nowMs); + + void prepareCircleForMove(BaseCircleData circleData); + + void updateSpecialCircles(float gameScale); + + void tickBullets(float delta); + + boolean resolveCircleSquare(BaseCircleData circleData); + + void separateCircles(); + + void steerTowardPlayer(BaseCircleData circleData, float gameScale); + + boolean shouldRemoveOutOfBounds(Circle circle, float viewportWidth, float viewportHeight); + } + + public static final class Config { + private final Array circles; + private final Rectangle square; + private final WaveController waveController; + private final WaveRunOrchestrator waveRunOrchestrator; + private final BonusBallSystem bonusBallSystem; + private final SpawnController spawnController; + private final AbilitySystem abilitySystem; + private final BulletSystem bulletSystem; + private final BulletEffectsSink bulletEffects; + private final SpecialCircleSystem specialCircleSystem; + private final SpecialCircleSystem.Host specialCircleHost; + private final RunFrameEvents events; + + public Config( + Array circles, + Rectangle square, + WaveController waveController, + WaveRunOrchestrator waveRunOrchestrator, + BonusBallSystem bonusBallSystem, + SpawnController spawnController, + AbilitySystem abilitySystem, + BulletSystem bulletSystem, + BulletEffectsSink bulletEffects, + SpecialCircleSystem specialCircleSystem, + SpecialCircleSystem.Host specialCircleHost, + RunFrameEvents events) { + this.circles = Objects.requireNonNull(circles, "circles"); + this.square = Objects.requireNonNull(square, "square"); + this.waveController = Objects.requireNonNull(waveController, "waveController"); + this.waveRunOrchestrator = + Objects.requireNonNull(waveRunOrchestrator, "waveRunOrchestrator"); + this.bonusBallSystem = Objects.requireNonNull(bonusBallSystem, "bonusBallSystem"); + this.spawnController = Objects.requireNonNull(spawnController, "spawnController"); + this.abilitySystem = Objects.requireNonNull(abilitySystem, "abilitySystem"); + this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem"); + this.bulletEffects = Objects.requireNonNull(bulletEffects, "bulletEffects"); + this.specialCircleSystem = + Objects.requireNonNull(specialCircleSystem, "specialCircleSystem"); + this.specialCircleHost = Objects.requireNonNull(specialCircleHost, "specialCircleHost"); + this.events = Objects.requireNonNull(events, "events"); + } + } + + private static final class DefaultFrameOperations implements FrameOperations { + private final Config config; + + private DefaultFrameOperations(Config config) { + this.config = config; + } + + @Override + public void updateWave(long nowMs, int activeEnemyCount, boolean upgradePickActive) { + config.waveRunOrchestrator.update(nowMs, activeEnemyCount, upgradePickActive); + } + + @Override + public boolean isWaveActive() { + return config.waveController.isWaveActive(); + } + + @Override + public void updateBonusBalls(float delta, float viewportWidth, float viewportHeight) { + config.bonusBallSystem.update(delta, viewportWidth, viewportHeight); + } + + @Override + public void processSpawnerChildren() { + config.spawnController.processSpawnerChildren(); + } + + @Override + public void tickAbilities(long nowMs) { + config.abilitySystem.tick(nowMs); + } + + @Override + public void prepareCircleForMove(BaseCircleData circleData) { + config.specialCircleSystem.prepareForMovement(circleData, config.square); + } + + @Override + public void updateSpecialCircles(float gameScale) { + config.specialCircleSystem.update( + config.circles, + config.square, + config.bulletSystem, + config.specialCircleHost, + gameScale); + } + + @Override + public void tickBullets(float delta) { + config.bulletSystem.tick(delta, config.circles, config.square, config.bulletEffects); + } + + @Override + public boolean resolveCircleSquare(BaseCircleData circleData) { + return CollisionSystem.resolveCircleSquare(circleData, config.square); + } + + @Override + public void separateCircles() { + CollisionSystem.separateCircles( + config.circles, EnemySimulationSystem.DEFAULT_SEPARATION_ITERATIONS); + } + + @Override + public void steerTowardPlayer(BaseCircleData circleData, float gameScale) { + EnemySimulationSystem.steerTowardPlayer(circleData, config.square, gameScale); + } + + @Override + public boolean shouldRemoveOutOfBounds( + Circle circle, float viewportWidth, float viewportHeight) { + return EnemySimulationSystem.shouldRemoveOutOfBounds( + circle, viewportWidth, viewportHeight); + } + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunRngStreams.java b/core/src/main/java/ru/project/tower/run/RunRngStreams.java new file mode 100644 index 0000000..ec8f29e --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunRngStreams.java @@ -0,0 +1,142 @@ +package ru.project.tower.run; + +import java.util.Objects; +import ru.project.tower.support.SeededRandomSource; + + +public final class RunRngStreams { + private static final String WAVE_COMPOSITION = "wave-composition"; + private static final String BONUS_DROPS = "bonus-drops"; + private static final String BOSS_ORDER = "boss-order"; + private static final String UPGRADE_DRAFTS = "upgrade-drafts"; + private static final String COMBAT = "combat"; + + private final long runSeed; + private final SeededRandomSource waveComposition; + private final SeededRandomSource bonusDrops; + private final SeededRandomSource bossOrder; + private final SeededRandomSource upgradeDrafts; + private final SeededRandomSource combat; + + private RunRngStreams( + long runSeed, + SeededRandomSource waveComposition, + SeededRandomSource bonusDrops, + SeededRandomSource bossOrder, + SeededRandomSource upgradeDrafts, + SeededRandomSource combat) { + this.runSeed = runSeed; + this.waveComposition = Objects.requireNonNull(waveComposition, "waveComposition"); + this.bonusDrops = Objects.requireNonNull(bonusDrops, "bonusDrops"); + this.bossOrder = Objects.requireNonNull(bossOrder, "bossOrder"); + this.upgradeDrafts = Objects.requireNonNull(upgradeDrafts, "upgradeDrafts"); + this.combat = Objects.requireNonNull(combat, "combat"); + } + + public static RunRngStreams fromSeed(long runSeed) { + return new RunRngStreams( + runSeed, + stream(runSeed, WAVE_COMPOSITION), + stream(runSeed, BONUS_DROPS), + stream(runSeed, BOSS_ORDER), + stream(runSeed, UPGRADE_DRAFTS), + stream(runSeed, COMBAT)); + } + + public static RunRngStreams fromSnapshot(Snapshot snapshot) { + Snapshot source = Objects.requireNonNull(snapshot, "snapshot"); + return new RunRngStreams( + source.runSeed(), + SeededRandomSource.fromSnapshot(source.waveComposition()), + SeededRandomSource.fromSnapshot(source.bonusDrops()), + SeededRandomSource.fromSnapshot(source.bossOrder()), + SeededRandomSource.fromSnapshot(source.upgradeDrafts()), + SeededRandomSource.fromSnapshot(source.combat())); + } + + private static SeededRandomSource stream(long runSeed, String name) { + return new SeededRandomSource(SeededRandomSource.forkSeed(runSeed, name)); + } + + public long runSeed() { + return runSeed; + } + + public SeededRandomSource waveComposition() { + return waveComposition; + } + + public SeededRandomSource bonusDrops() { + return bonusDrops; + } + + public SeededRandomSource bossOrder() { + return bossOrder; + } + + public SeededRandomSource upgradeDrafts() { + return upgradeDrafts; + } + + public SeededRandomSource combat() { + return combat; + } + + public Snapshot snapshot() { + return new Snapshot( + runSeed, + waveComposition.snapshot(SeededRandomSource.forkSeed(runSeed, WAVE_COMPOSITION)), + bonusDrops.snapshot(SeededRandomSource.forkSeed(runSeed, BONUS_DROPS)), + bossOrder.snapshot(SeededRandomSource.forkSeed(runSeed, BOSS_ORDER)), + upgradeDrafts.snapshot(SeededRandomSource.forkSeed(runSeed, UPGRADE_DRAFTS)), + combat.snapshot(SeededRandomSource.forkSeed(runSeed, COMBAT))); + } + + public static final class Snapshot { + private final long runSeed; + private final SeededRandomSource.Snapshot waveComposition; + private final SeededRandomSource.Snapshot bonusDrops; + private final SeededRandomSource.Snapshot bossOrder; + private final SeededRandomSource.Snapshot upgradeDrafts; + private final SeededRandomSource.Snapshot combat; + + public Snapshot( + long runSeed, + SeededRandomSource.Snapshot waveComposition, + SeededRandomSource.Snapshot bonusDrops, + SeededRandomSource.Snapshot bossOrder, + SeededRandomSource.Snapshot upgradeDrafts, + SeededRandomSource.Snapshot combat) { + this.runSeed = runSeed; + this.waveComposition = Objects.requireNonNull(waveComposition, "waveComposition"); + this.bonusDrops = Objects.requireNonNull(bonusDrops, "bonusDrops"); + this.bossOrder = Objects.requireNonNull(bossOrder, "bossOrder"); + this.upgradeDrafts = Objects.requireNonNull(upgradeDrafts, "upgradeDrafts"); + this.combat = Objects.requireNonNull(combat, "combat"); + } + + public long runSeed() { + return runSeed; + } + + public SeededRandomSource.Snapshot waveComposition() { + return waveComposition; + } + + public SeededRandomSource.Snapshot bonusDrops() { + return bonusDrops; + } + + public SeededRandomSource.Snapshot bossOrder() { + return bossOrder; + } + + public SeededRandomSource.Snapshot upgradeDrafts() { + return upgradeDrafts; + } + + public SeededRandomSource.Snapshot combat() { + return combat; + } + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunSession.java b/core/src/main/java/ru/project/tower/run/RunSession.java new file mode 100644 index 0000000..3395daf --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunSession.java @@ -0,0 +1,103 @@ +package ru.project.tower.run; + +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.systems.BonusBallSystem; +import ru.project.tower.upgrades.UpgradeSystem; +import ru.project.tower.waves.BossSpawnController; +import ru.project.tower.waves.SpawnController; + +public final class RunSession { + private final Array circles; + private final Array spawnedBosses; + private final BulletSystem bulletSystem; + private final BonusBallSystem bonusBallSystem; + private final SpawnController spawnController; + private final BossSpawnController bossSpawnController; + private final AbilitySystem abilitySystem; + private final UpgradeSystem upgradeSystem; + private final RunRngStreams rngStreams; + + RunSession( + Array circles, + Array spawnedBosses, + BulletSystem bulletSystem, + BonusBallSystem bonusBallSystem, + SpawnController spawnController, + BossSpawnController bossSpawnController, + AbilitySystem abilitySystem, + UpgradeSystem upgradeSystem, + RunRngStreams rngStreams) { + this.circles = Objects.requireNonNull(circles, "circles"); + this.spawnedBosses = Objects.requireNonNull(spawnedBosses, "spawnedBosses"); + this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem"); + this.bonusBallSystem = Objects.requireNonNull(bonusBallSystem, "bonusBallSystem"); + this.spawnController = Objects.requireNonNull(spawnController, "spawnController"); + this.bossSpawnController = + Objects.requireNonNull(bossSpawnController, "bossSpawnController"); + this.abilitySystem = Objects.requireNonNull(abilitySystem, "abilitySystem"); + this.upgradeSystem = Objects.requireNonNull(upgradeSystem, "upgradeSystem"); + this.rngStreams = rngStreams; + } + + public SpawnController spawnController() { + return spawnController; + } + + public BossSpawnController bossSpawnController() { + return bossSpawnController; + } + + public BonusBallSystem bonusBallSystem() { + return bonusBallSystem; + } + + public AbilitySystem abilitySystem() { + return abilitySystem; + } + + public UpgradeSystem upgradeSystem() { + return upgradeSystem; + } + + public RunRngStreams rngStreams() { + return rngStreams; + } + + public void setRenderScale(float gameScale, float adaptedCircleRadius) { + spawnController.setRenderScale(gameScale, adaptedCircleRadius); + bossSpawnController.setRenderScale(gameScale); + } + + public void resizeActiveCircles( + float playerCenterDeltaX, float playerCenterDeltaY, float adaptedCircleRadius) { + for (BaseCircleData circleData : circles) { + circleData.circle.x += playerCenterDeltaX; + circleData.circle.y += playerCenterDeltaY; + circleData.circle.radius = adaptedCircleRadius; + } + } + + public void resetForNewRun() { + clearCircles(); + bonusBallSystem.clear(); + bulletSystem.startNewRun(); + abilitySystem.resetForNewRun(); + upgradeSystem.resetForNewRun(); + spawnedBosses.clear(); + } + + public void dispose() { + resetForNewRun(); + } + + private void clearCircles() { + for (int i = 0; i < circles.size; i++) { + BaseCircleData.freeIfSpawnPooled(circles.get(i)); + } + circles.clear(); + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunSessionConfig.java b/core/src/main/java/ru/project/tower/run/RunSessionConfig.java new file mode 100644 index 0000000..542b06f --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunSessionConfig.java @@ -0,0 +1,53 @@ +package ru.project.tower.run; + +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.abilities.AbilityHost; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.BonusBallData; +import ru.project.tower.support.BossEffectsHost; +import ru.project.tower.support.GameClock; +import ru.project.tower.upgrades.UpgradeHost; + +public final class RunSessionConfig { + final Rectangle square; + final Array circles; + final Array bonusBalls; + final BulletSystem bulletSystem; + final PlayerAbilities playerAbilities; + final Array selectedAbilities; + final Array spawnedBosses; + final GameClock clock; + final BossEffectsHost bossEffectsHost; + final AbilityHost abilityHost; + final UpgradeHost upgradeHost; + + public RunSessionConfig( + Rectangle square, + Array circles, + Array bonusBalls, + BulletSystem bulletSystem, + PlayerAbilities playerAbilities, + Array selectedAbilities, + Array spawnedBosses, + GameClock clock, + BossEffectsHost bossEffectsHost, + AbilityHost abilityHost, + UpgradeHost upgradeHost) { + this.square = Objects.requireNonNull(square, "square"); + this.circles = Objects.requireNonNull(circles, "circles"); + this.bonusBalls = Objects.requireNonNull(bonusBalls, "bonusBalls"); + this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem"); + this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities"); + this.selectedAbilities = Objects.requireNonNull(selectedAbilities, "selectedAbilities"); + this.spawnedBosses = Objects.requireNonNull(spawnedBosses, "spawnedBosses"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.bossEffectsHost = Objects.requireNonNull(bossEffectsHost, "bossEffectsHost"); + this.abilityHost = Objects.requireNonNull(abilityHost, "abilityHost"); + this.upgradeHost = Objects.requireNonNull(upgradeHost, "upgradeHost"); + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunSessionFactory.java b/core/src/main/java/ru/project/tower/run/RunSessionFactory.java new file mode 100644 index 0000000..cc2d06e --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunSessionFactory.java @@ -0,0 +1,76 @@ +package ru.project.tower.run; + +import java.util.Objects; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.support.RandomSource; +import ru.project.tower.support.ViewportSize; +import ru.project.tower.systems.BonusBallSystem; +import ru.project.tower.upgrades.UpgradeSystem; +import ru.project.tower.waves.BossSpawnController; +import ru.project.tower.waves.SpawnController; + +public final class RunSessionFactory { + private final RandomSource random; + private final RunRngStreams rngStreams; + private final ViewportSize viewport; + + public RunSessionFactory(RandomSource random, ViewportSize viewport) { + this.random = Objects.requireNonNull(random, "random"); + this.rngStreams = null; + this.viewport = Objects.requireNonNull(viewport, "viewport"); + } + + public RunSessionFactory(RunRngStreams rngStreams, ViewportSize viewport) { + this.random = null; + this.rngStreams = Objects.requireNonNull(rngStreams, "rngStreams"); + this.viewport = Objects.requireNonNull(viewport, "viewport"); + } + + public RunSession create(RunSessionConfig config) { + Objects.requireNonNull(config, "config"); + RandomSource waveRandom = rngStreams == null ? random : rngStreams.waveComposition(); + RandomSource bonusRandom = rngStreams == null ? random : rngStreams.bonusDrops(); + RandomSource bossRandom = rngStreams == null ? random : rngStreams.bossOrder(); + RandomSource upgradeRandom = rngStreams == null ? random : rngStreams.upgradeDrafts(); + RandomSource combatRandom = rngStreams == null ? random : rngStreams.combat(); + BonusBallSystem bonusBallSystem = new BonusBallSystem(config.bonusBalls); + SpawnController spawnController = + new SpawnController( + config.square, + config.circles, + config.bonusBalls, + waveRandom, + bonusRandom, + viewport); + BossSpawnController bossSpawnController = + new BossSpawnController( + config.circles, + config.spawnedBosses, + config.bulletSystem, + config.square, + bossRandom, + viewport, + config.bossEffectsHost); + AbilitySystem abilitySystem = + new AbilitySystem( + config.square, + config.circles, + config.playerAbilities, + config.selectedAbilities, + combatRandom, + config.abilityHost, + config.clock); + UpgradeSystem upgradeSystem = new UpgradeSystem(upgradeRandom, config.upgradeHost); + + return new RunSession( + config.circles, + config.spawnedBosses, + config.bulletSystem, + bonusBallSystem, + spawnController, + bossSpawnController, + abilitySystem, + upgradeSystem, + rngStreams); + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunSnapshotCoordinator.java b/core/src/main/java/ru/project/tower/run/RunSnapshotCoordinator.java new file mode 100644 index 0000000..e17c3db --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunSnapshotCoordinator.java @@ -0,0 +1,109 @@ +package ru.project.tower.run; + +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.GameState; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; +import ru.project.tower.waves.WaveController; +import ru.project.tower.waves.WaveTuning; + +public final class RunSnapshotCoordinator { + + private final Host host; + + public interface Host { + GameState gameState(); + + Array selectedAbilities(); + + Array spawnedBosses(); + + PlayerCombatState playerCombatState(); + + BulletSystem bulletSystem(); + + ShopUpgradeSystem shopUpgradeSystem(); + + WaveController waveController(); + + void applyWaveTuning(WaveTuning tuning); + + default RunRngStreams rngStreams() { + return null; + } + } + + public RunSnapshotCoordinator(Host host) { + this.host = Objects.requireNonNull(host, "host"); + } + + public RunStartMode resolveStartMode() { + return host.gameState().isReturnedFromShop() + ? RunStartMode.RESTORE_FROM_SHOP_SNAPSHOT + : RunStartMode.FRESH_RUN; + } + + public GameState.Snapshot restoreRunSnapshot(RunStartMode startMode) { + if (startMode != RunStartMode.RESTORE_FROM_SHOP_SNAPSHOT) { + return null; + } + return host.gameState().restoreGameState(); + } + + public void applyRestoredRunSnapshot(RunStartMode startMode, GameState.Snapshot snapshot) { + if (startMode == RunStartMode.RESTORE_FROM_SHOP_SNAPSHOT) { + applyGameSnapshot(snapshot); + } + } + + public void saveGameSnapshot() { + host.gameState().setSelectedAbilities(host.selectedAbilities()); + host.gameState().setSpawnedBosses(host.spawnedBosses()); + host.gameState().saveGameState(captureGameSnapshot()); + } + + GameState.Snapshot captureGameSnapshot() { + WaveController waveController = host.waveController(); + return RunSnapshotMapper.toGameStateSnapshot( + new RunSnapshotState( + host.playerCombatState().snapshot(), + new RunBulletSnapshot( + host.bulletSystem().getBaseSpeed(), + host.bulletSystem().getBaseDamage()), + host.shopUpgradeSystem().snapshot(), + new GameState.WaveSnapshot( + waveController.getCurrentWave(), + waveController.isWaveActive(), + waveController.getWaveStartTime(), + waveController.getEnemiesSpawnedThisWave(), + waveController.getEnemiesPerWave()), + host.rngStreams() == null ? null : host.rngStreams().snapshot())); + } + + void applyGameSnapshot(GameState.Snapshot snapshot) { + if (snapshot == null) return; + + RunSnapshotState runState = + RunSnapshotMapper.toRunSnapshotState(snapshot, host.playerCombatState().snapshot()); + host.playerCombatState().apply(runState.combat()); + host.bulletSystem() + .increaseBaseSpeed( + runState.bullet().baseSpeed() - host.bulletSystem().getBaseSpeed()); + host.bulletSystem() + .increaseBaseDamage( + runState.bullet().baseDamage() - host.bulletSystem().getBaseDamage()); + host.shopUpgradeSystem().apply(runState.shopUpgrades()); + + WaveController waveController = host.waveController(); + GameState.WaveSnapshot wave = runState.wave(); + waveController.setCurrentWave(wave.getCurrentWave()); + waveController.setWaveActive(wave.isWaveActive()); + waveController.setWaveStartTime(wave.getWaveStartTime()); + waveController.setEnemiesSpawnedThisWave(wave.getEnemiesSpawnedThisWave()); + waveController.setEnemiesPerWave(wave.getEnemiesPerWave()); + host.applyWaveTuning(waveController.currentWaveTuning()); + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunSnapshotMapper.java b/core/src/main/java/ru/project/tower/run/RunSnapshotMapper.java new file mode 100644 index 0000000..de6b004 --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunSnapshotMapper.java @@ -0,0 +1,82 @@ +package ru.project.tower.run; + +import java.util.Objects; +import ru.project.tower.GameState; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.upgrades.shop.ShopUpgradeSnapshot; + +public final class RunSnapshotMapper { + + private RunSnapshotMapper() {} + + public static GameState.Snapshot toGameStateSnapshot(RunSnapshotState state) { + RunSnapshotState source = Objects.requireNonNull(state, "state"); + PlayerCombatState.Snapshot combat = source.combat(); + RunBulletSnapshot bullet = source.bullet(); + + return new GameState.Snapshot( + combat.money(), + combat.runCurrencyEarned(), + new GameState.HealthSnapshot( + combat.health(), combat.maxHealth(), combat.healthRegenRate()), + new GameState.BulletSnapshot( + bullet.baseSpeed(), bullet.baseDamage(), combat.shotCooldownMs()), + toGameStateUpgradeSnapshot(source.shopUpgrades()), + source.wave(), + source.rng()); + } + + public static RunSnapshotState toRunSnapshotState( + GameState.Snapshot snapshot, PlayerCombatState.Snapshot combatDefaults) { + GameState.Snapshot source = Objects.requireNonNull(snapshot, "snapshot"); + PlayerCombatState.Snapshot defaults = + Objects.requireNonNull(combatDefaults, "combatDefaults"); + GameState.HealthSnapshot health = source.getHealth(); + GameState.BulletSnapshot bullet = source.getBulletStats(); + + return new RunSnapshotState( + new PlayerCombatState.Snapshot( + source.getMoney(), + health.getSquareHealth(), + health.getMaxSquareHealth(), + health.getHealthRegenRate(), + defaults.lastDamageTimeMs(), + defaults.lastHealthRegenTimeMs(), + bullet.getShotCooldown(), + defaults.baseCritChance(), + defaults.baseCritMultiplier(), + defaults.gameOverCurrencyAward(), + defaults.maxWaveReached(), + defaults.gameOverCurrencyAwarded(), + source.getRunCurrencyEarned()), + new RunBulletSnapshot(bullet.getBulletSpeed(), bullet.getBulletDamage()), + toShopUpgradeSnapshot(source.getUpgrades()), + source.getWave(), + source.getRng()); + } + + public static GameState.UpgradeSnapshot toGameStateUpgradeSnapshot( + ShopUpgradeSnapshot snapshot) { + ShopUpgradeSnapshot source = Objects.requireNonNull(snapshot, "snapshot"); + return new GameState.UpgradeSnapshot( + source.getHealthLevel(), + source.getDamageLevel(), + source.getSpeedLevel(), + source.getCooldownLevel(), + source.getRegenLevel(), + source.getCritChanceLevel(), + source.getCritMultiplierLevel()); + } + + public static ShopUpgradeSnapshot toShopUpgradeSnapshot(GameState.UpgradeSnapshot snapshot) { + GameState.UpgradeSnapshot source = Objects.requireNonNull(snapshot, "snapshot"); + return ShopUpgradeSnapshot.of( + source.getHealthUpgradeLevel(), + source.getDamageUpgradeLevel(), + source.getSpeedUpgradeLevel(), + source.getCooldownUpgradeLevel(), + source.getRegenUpgradeLevel(), + source.getCritChanceUpgradeLevel(), + source.getCritMultiplierUpgradeLevel()); + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunSnapshotState.java b/core/src/main/java/ru/project/tower/run/RunSnapshotState.java new file mode 100644 index 0000000..16c191d --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunSnapshotState.java @@ -0,0 +1,61 @@ +package ru.project.tower.run; + +import java.util.Objects; +import ru.project.tower.GameState; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.upgrades.shop.ShopUpgradeSnapshot; + +public final class RunSnapshotState { + private final PlayerCombatState.Snapshot combat; + private final RunBulletSnapshot bullet; + private final ShopUpgradeSnapshot shopUpgrades; + private final GameState.WaveSnapshot wave; + private final RunRngStreams.Snapshot rng; + + + + + + + + public RunSnapshotState( + PlayerCombatState.Snapshot combat, + RunBulletSnapshot bullet, + ShopUpgradeSnapshot shopUpgrades, + GameState.WaveSnapshot wave) { + this(combat, bullet, shopUpgrades, wave, null); + } + + public RunSnapshotState( + PlayerCombatState.Snapshot combat, + RunBulletSnapshot bullet, + ShopUpgradeSnapshot shopUpgrades, + GameState.WaveSnapshot wave, + RunRngStreams.Snapshot rng) { + this.combat = Objects.requireNonNull(combat, "combat"); + this.bullet = Objects.requireNonNull(bullet, "bullet"); + this.shopUpgrades = Objects.requireNonNull(shopUpgrades, "shopUpgrades"); + this.wave = Objects.requireNonNull(wave, "wave"); + this.rng = rng; + } + + public PlayerCombatState.Snapshot combat() { + return combat; + } + + public RunBulletSnapshot bullet() { + return bullet; + } + + public ShopUpgradeSnapshot shopUpgrades() { + return shopUpgrades; + } + + public GameState.WaveSnapshot wave() { + return wave; + } + + public RunRngStreams.Snapshot rng() { + return rng; + } +} diff --git a/core/src/main/java/ru/project/tower/run/RunStartMode.java b/core/src/main/java/ru/project/tower/run/RunStartMode.java new file mode 100644 index 0000000..57987fc --- /dev/null +++ b/core/src/main/java/ru/project/tower/run/RunStartMode.java @@ -0,0 +1,7 @@ +package ru.project.tower.run; + +public enum RunStartMode { + FRESH_RUN, + RESTART, + RESTORE_FROM_SHOP_SNAPSHOT +} diff --git a/core/src/main/java/ru/project/tower/screens/AbilitySelectionScreen.java b/core/src/main/java/ru/project/tower/screens/AbilitySelectionScreen.java new file mode 100644 index 0000000..1e63c32 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/AbilitySelectionScreen.java @@ -0,0 +1,649 @@ +package ru.project.tower.screens; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.InputAdapter; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.DebugLog; +import ru.project.tower.GameContext; +import ru.project.tower.GameState; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.navigation.ScreenNavigation; +import ru.project.tower.navigation.ScreenNavigator; +import ru.project.tower.screens.abilityselection.AbilitySelectionDependencies; +import ru.project.tower.screens.abilityselection.AbilitySelectionLayout; +import ru.project.tower.screens.abilityselection.AbilitySelectionScaleMetrics; +import ru.project.tower.screens.common.NeonMenuParticleSystem; +import ru.project.tower.support.RobotoFontFactory; +import ru.project.tower.support.ScreenPalette; + + + + +public class AbilitySelectionScreen implements Screen { + private static final int MAX_ABILITY_BUTTONS = 7; + + private SpriteBatch batch; + private ShapeRenderer shapeRenderer; + private BitmapFont titleFont; + private BitmapFont regularFont; + private final ScreenNavigator navigator; + private final PlayerAbilities playerAbilities; + private final GameState gameState; + + private final Color neonBlue = ScreenPalette.BLUE.create(); + private final Color neonPink = ScreenPalette.PINK.create(); + private final Color neonCyan = ScreenPalette.CYAN.create(); + private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create(); + private final Color backgroundColor = ScreenPalette.SCREEN.create(); + private final Color neonRed = ScreenPalette.RED.create(); + private final Color neonGreen = ScreenPalette.GREEN.create(); + private final Color neonYellow = ScreenPalette.CYAN.create(); + private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create(); + private final Color scratchColor = new Color(); + private final Color scratchColor2 = new Color(); + private final Color scratchColor3 = new Color(); + private final GlyphLayout titleLayout = new GlyphLayout(); + private final GlyphLayout abilityButtonTextLayout = new GlyphLayout(); + private final GlyphLayout startButtonTextLayout = new GlyphLayout(); + private final GlyphLayout backButtonTextLayout = new GlyphLayout(); + + private float uiScale; + private int adaptedFontSize; + private float time = 0; + + private final NeonMenuParticleSystem particles = new NeonMenuParticleSystem(); + + private final Array abilityButtons = new Array<>(MAX_ABILITY_BUTTONS); + private final Rectangle[] abilityButtonPool = { + new Rectangle(), + new Rectangle(), + new Rectangle(), + new Rectangle(), + new Rectangle(), + new Rectangle(), + new Rectangle() + }; + private final Array availableAbilities = new Array<>(); + + private final Array selectedAbilities = new Array<>(); + + private final Rectangle startGameButton = new Rectangle(); + private final Rectangle backButton = new Rectangle(); + + + + + public AbilitySelectionScreen(Game game, GameContext ctx) { + this(ScreenNavigation.forGame(game, ctx), AbilitySelectionDependencies.from(ctx)); + } + + public AbilitySelectionScreen( + ScreenNavigator navigator, AbilitySelectionDependencies dependencies) { + this.navigator = navigator; + this.playerAbilities = dependencies.playerAbilities(); + this.gameState = dependencies.gameState(); + try { + DebugLog.log("AbilitySelectionScreen", "Constructing AbilitySelectionScreen"); + DebugLog.log("AbilitySelectionScreen", "Navigator set"); + } catch (Exception e) { + Gdx.app.error("AbilitySelectionScreen", "Error in constructor: ", e); + throw e; + } + } + + @Override + public void show() { + try { + DebugLog.log("AbilitySelectionScreen", "Starting show() method"); + + initializeRenderResources(); + calculateUIScale(); + DebugLog.log("AbilitySelectionScreen", "Calculated UI scale"); + loadFonts(); + + initializeParticles(); + + populateAvailableAbilities(); + + updateButtonLayout(Gdx.graphics.getWidth(), Gdx.graphics.getHeight()); + + installInputProcessor(); + } catch (Exception e) { + Gdx.app.error("AbilitySelectionScreen", "Error in show method: ", e); + throw e; + } + } + + private void initializeRenderResources() { + batch = new SpriteBatch(); + DebugLog.log("AbilitySelectionScreen", "Created SpriteBatch"); + + shapeRenderer = new ShapeRenderer(); + DebugLog.log("AbilitySelectionScreen", "Created ShapeRenderer"); + } + + private void loadFonts() { + try { + if (!RobotoFontFactory.exists()) { + Gdx.app.error("AbilitySelectionScreen", "Font file roboto.ttf not found!"); + throw new RuntimeException("Font file not found"); + } + logFontFile(); + + RobotoFontFactory.FontSpec spec = + RobotoFontFactory.spec( + (int) (adaptedFontSize * 1.3f), + 1.5f, + neonPurple, + Color.WHITE, + 1, + 1, + fontShadowColor); + + titleFont = RobotoFontFactory.generate(spec); + DebugLog.log("AbilitySelectionScreen", "Generated title font"); + + regularFont = + RobotoFontFactory.generate( + spec.withSize((int) (adaptedFontSize * 0.9f)).withBorderWidth(1.0f)); + DebugLog.log("AbilitySelectionScreen", "Generated regular font"); + } catch (Exception e) { + Gdx.app.error("AbilitySelectionScreen", "Error loading fonts: ", e); + throw e; + } + } + + private void initializeParticles() { + particles.init( + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + neonBlue, + neonPink, + neonCyan, + neonPurple); + } + + private void populateAvailableAbilities() { + Array purchasedAbilities = playerAbilities.getPurchasedAbilities(); + + logAbilities("Available purchased abilities: ", "Available ability: ", purchasedAbilities); + + availableAbilities.clear(); + selectedAbilities.clear(); + availableAbilities.addAll(purchasedAbilities); + logAvailableAbilitiesPopulated(); + } + + private void updateButtonLayout(int width, int height) { + AbilitySelectionLayout layout = + AbilitySelectionLayout.calculate(width, height, uiScale, availableAbilities.size); + + createAbilityButtons(layout); + + setRect(startGameButton, layout.startGameButton()); + setRect(backButton, layout.backButton()); + } + + private void installInputProcessor() { + Gdx.input.setInputProcessor( + new InputAdapter() { + @Override + public boolean touchDown(int screenX, int screenY, int pointer, int button) { + float touchX = screenX; + float touchY = Gdx.graphics.getHeight() - screenY; + + for (int i = 0; i < abilityButtons.size; i++) { + if (i < availableAbilities.size + && abilityButtons.get(i).contains(touchX, touchY)) { + toggleAbilitySelection(availableAbilities.get(i)); + return true; + } + } + + if (startGameButton.contains(touchX, touchY)) { + startGame(); + return true; + } + + if (backButton.contains(touchX, touchY)) { + navigator.showMainMenu(); + return true; + } + + return false; + } + }); + } + + private void logFontFile() { + if (DebugLog.DEBUG) { + DebugLog.log("AbilitySelectionScreen", "Font file exists: roboto.ttf"); + } + } + + private void logAvailableAbilitiesPopulated() { + if (DebugLog.DEBUG) { + DebugLog.log( + "AbilitySelectionScreen", + "Populated availableAbilities, total: " + availableAbilities.size); + } + } + + + + + private void createAbilityButtons(AbilitySelectionLayout layout) { + abilityButtons.clear(); + + for (int i = 0; i < layout.abilityButtonCount(); i++) { + Rectangle abilityButton = abilityButtonPool[i]; + setRect(abilityButton, layout.abilityButton(i)); + abilityButtons.add(abilityButton); + } + } + + private void setRect(Rectangle target, AbilitySelectionLayout.Rect source) { + target.set(source.x(), source.y(), source.width(), source.height()); + } + + + + + private void calculateUIScale() { + AbilitySelectionScaleMetrics metrics = + AbilitySelectionScaleMetrics.calculate( + Gdx.graphics.getWidth(), Gdx.graphics.getHeight()); + uiScale = metrics.uiScale(); + adaptedFontSize = metrics.adaptedFontSize(); + } + + + + + private void toggleAbilitySelection(AbilityKind ability) { + if (DebugLog.DEBUG) { + DebugLog.log("AbilitySelectionScreen", "Toggling ability: " + ability.persistedId()); + } + if (selectedAbilities.contains(ability, true)) { + selectedAbilities.removeValue(ability, true); + if (DebugLog.DEBUG) { + DebugLog.log( + "AbilitySelectionScreen", + "Removed ability: " + + ability.persistedId() + + ", total: " + + selectedAbilities.size); + } + } else { + if (selectedAbilities.size < 3) { + selectedAbilities.add(ability); + if (DebugLog.DEBUG) { + DebugLog.log( + "AbilitySelectionScreen", + "Added ability: " + + ability.persistedId() + + ", total: " + + selectedAbilities.size); + } + } + } + } + + + + + private void startGame() { + try { + DebugLog.log("AbilitySelectionScreen", "Starting game..."); + + if (navigator == null) { + Gdx.app.error("AbilitySelectionScreen", "Screen navigator is null!"); + throw new RuntimeException("Screen navigator is null"); + } + + if (gameState == null) { + Gdx.app.error("AbilitySelectionScreen", "GameState instance is null!"); + throw new RuntimeException("GameState instance is null"); + } + DebugLog.log("AbilitySelectionScreen", "Got GameState instance"); + + gameState.resetGameState(); + DebugLog.log("AbilitySelectionScreen", "Reset game state"); + + logAbilities( + "Selected abilities before setting: ", + "Selected ability before setting: ", + selectedAbilities); + + gameState.setSelectedAbilities(selectedAbilities); + DebugLog.log("AbilitySelectionScreen", "Set selected abilities to GameState"); + + Array checkAbilities = gameState.getSelectedAbilities(); + logAbilities( + "Checking set abilities count: ", "Checking set ability: ", checkAbilities); + + try { + DebugLog.log("AbilitySelectionScreen", "Navigating to GameScreen"); + navigator.showGameScreen(); + DebugLog.log("AbilitySelectionScreen", "Game started successfully"); + } catch (Exception e) { + Gdx.app.error( + "AbilitySelectionScreen", "Error creating or setting GameScreen: ", e); + throw e; + } + } catch (Exception e) { + Gdx.app.error("AbilitySelectionScreen", "Error in startGame: ", e); + throw e; + } + } + + private void logAbilities( + String countPrefix, String abilityPrefix, Array abilities) { + if (DebugLog.DEBUG) { + DebugLog.log("AbilitySelectionScreen", countPrefix + abilities.size); + for (AbilityKind ability : abilities) { + DebugLog.log("AbilitySelectionScreen", abilityPrefix + ability.persistedId()); + } + } + } + + private void renderNeonButton(Rectangle button, Color color, boolean isSelected) { + float glow = (float) (0.5f + 0.5f * Math.sin(time * 2.0f)); + + float mouseX = Gdx.input.getX(); + float mouseY = Gdx.graphics.getHeight() - Gdx.input.getY(); + boolean isHovered = button.contains(mouseX, mouseY); + boolean isPressed = isHovered && Gdx.input.isTouched(); + Color borderColor = buttonBorderColor(color, isHovered, isPressed); + + renderButtonFillAndGlow(button, borderColor, isSelected, isHovered, glow); + renderButtonBorder(button, borderColor, isSelected, isHovered, glow); + } + + private Color buttonBorderColor(Color color, boolean isHovered, boolean isPressed) { + if (isPressed) return neonPink; + if (isHovered) return neonCyan; + return color; + } + + private void renderButtonFillAndGlow( + Rectangle button, + Color borderColor, + boolean isSelected, + boolean isHovered, + float glow) { + shapeRenderer.set(ShapeType.Filled); + if (isSelected) { + shapeRenderer.setColor(0.025f, 0.09f, 0.14f, 0.96f); + } else { + shapeRenderer.setColor(0.035f, 0.043f, 0.071f, 0.96f); + } + shapeRenderer.rect(button.x, button.y, button.width, button.height); + + if (isSelected || isHovered) { + scratchColor.set(borderColor); + scratchColor.a = isSelected ? 0.18f : 0.08f; + shapeRenderer.setColor(scratchColor); + shapeRenderer.rect(button.x, button.y, button.width, button.height); + } + + if (isSelected) { + scratchColor2.set(borderColor); + scratchColor2.a = 0.16f * glow; + shapeRenderer.setColor(scratchColor2); + shapeRenderer.rect( + button.x, button.y + button.height - 5f * uiScale, button.width, 3f * uiScale); + } + } + + private void renderButtonBorder( + Rectangle button, + Color borderColor, + boolean isSelected, + boolean isHovered, + float glow) { + + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Line); + + scratchColor3.set(borderColor); + scratchColor3.a = isSelected ? 0.95f : isHovered ? 0.85f : 0.7f; + shapeRenderer.setColor(scratchColor3); + + int thickness = isSelected ? 2 : 1; + for (int i = 0; i < thickness; i++) { + shapeRenderer.rect( + button.x + i, button.y + i, button.width - i * 2, button.height - i * 2); + } + + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Filled); + } + + private void renderNeonGrid() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + + float alpha = 0.18f + 0.06f * (float) Math.sin(time * 0.5f); + shapeRenderer.setColor(ScreenPalette.BLUE.set(scratchColor, alpha)); + + float spacing = 50f * uiScale; + float offset = (time * 10f) % spacing; + + for (float y = offset; y < Gdx.graphics.getHeight(); y += spacing) { + shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y); + } + + shapeRenderer.setColor(ScreenPalette.BLUE.set(scratchColor, alpha)); + for (float x = offset; x < Gdx.graphics.getWidth(); x += spacing) { + shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight()); + } + + shapeRenderer.end(); + } + + @Override + public void render(float delta) { + try { + updateFrameState(delta); + clearBackground(); + renderBackgroundEffects(); + renderButtonShapes(); + renderButtonText(); + } catch (Exception e) { + Gdx.app.error("AbilitySelectionScreen", "Error in render method: ", e); + throw e; + } + } + + private void updateFrameState(float delta) { + time += delta; + + particles.update( + delta, + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + neonBlue, + neonPink, + neonCyan, + neonPurple); + } + + private void clearBackground() { + Gdx.gl.glClearColor( + backgroundColor.r, backgroundColor.g, backgroundColor.b, backgroundColor.a); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + } + + private void renderBackgroundEffects() { + renderNeonGrid(); + particles.render(shapeRenderer); + } + + private void renderButtonShapes() { + shapeRenderer.begin(ShapeType.Filled); + for (int i = 0; i < abilityButtons.size; i++) { + if (i < availableAbilities.size) { + AbilityKind ability = availableAbilities.get(i); + boolean isSelected = selectedAbilities.contains(ability, true); + Color buttonColor = getAbilityColor(ability); + renderNeonButton(abilityButtons.get(i), buttonColor, isSelected); + } + } + + renderNeonButton(startGameButton, neonGreen, false); + renderNeonButton(backButton, neonRed, false); + shapeRenderer.end(); + } + + private void renderButtonText() { + batch.begin(); + try { + renderTitleText(); + renderAbilityButtonText(); + renderStartButtonText(); + renderBackButtonText(); + batch.end(); + } catch (Exception e) { + Gdx.app.error("AbilitySelectionScreen", "Error rendering text: ", e); + } finally { + if (batch.isDrawing()) batch.end(); + } + } + + private void renderTitleText() { + String titleText = "Выберите способности"; + titleLayout.setText(titleFont, titleText); + float titleX = (Gdx.graphics.getWidth() - titleLayout.width) / 2; + float titleY = Gdx.graphics.getHeight() - 80 * uiScale; + titleFont.draw(batch, titleText, titleX, titleY); + } + + private void renderAbilityButtonText() { + for (int i = 0; i < abilityButtons.size; i++) { + if (i < availableAbilities.size) { + Rectangle button = abilityButtons.get(i); + AbilityKind ability = availableAbilities.get(i); + String buttonText = getAbilityName(ability); + + abilityButtonTextLayout.setText(regularFont, buttonText); + float textX = button.x + (button.width - abilityButtonTextLayout.width) / 2; + float textY = button.y + (button.height + abilityButtonTextLayout.height) / 2; + regularFont.draw(batch, buttonText, textX, textY); + } + } + } + + private String startButtonText(boolean wrap) { + if (selectedAbilities.size == 1) { + return wrap ? "Начать\nс 1 способностью" : "Начать с 1 способностью"; + } + if (selectedAbilities.size == 0) { + return wrap ? "Начать\nбез способностей" : "Начать без способностей"; + } + return "Начать игру"; + } + + private void renderStartButtonText() { + String startButtonText = startButtonText(false); + startButtonTextLayout.setText(regularFont, startButtonText); + float horizontalPadding = 20f * uiScale; + if (startButtonTextLayout.width > startGameButton.width - horizontalPadding * 2f) { + startButtonText = startButtonText(true); + startButtonTextLayout.setText(regularFont, startButtonText); + } + float startTextX = + startGameButton.x + (startGameButton.width - startButtonTextLayout.width) / 2; + float startTextY = + startGameButton.y + (startGameButton.height + startButtonTextLayout.height) / 2; + regularFont.draw(batch, startButtonText, startTextX, startTextY); + } + + private void renderBackButtonText() { + String backText = "<"; + backButtonTextLayout.setText(regularFont, backText); + float backTextX = backButton.x + (backButton.width - backButtonTextLayout.width) / 2; + float backTextY = backButton.y + (backButton.height + backButtonTextLayout.height) / 2; + regularFont.draw(batch, backText, backTextX, backTextY); + } + + + + + private Color getAbilityColor(AbilityKind ability) { + switch (ability) { + case FREEZE: + return neonCyan; + case EXPLOSION: + return neonRed; + case SHIELD: + return neonBlue; + case IMPULSE: + return neonPurple; + case DASH: + return neonCyan; + case PULL: + return neonPurple; + case TURRET: + return neonGreen; + default: + return neonYellow; + } + } + + + + + private String getAbilityName(AbilityKind ability) { + return ability.selectionName(); + } + + @Override + public void resize(int width, int height) { + calculateUIScale(); + updateButtonLayout(width, height); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + Gdx.input.setInputProcessor(null); + } + + @Override + public void dispose() { + if (batch != null) { + batch.dispose(); + batch = null; + } + if (shapeRenderer != null) { + shapeRenderer.dispose(); + shapeRenderer = null; + } + if (titleFont != null) { + titleFont.dispose(); + titleFont = null; + } + if (regularFont != null) { + regularFont.dispose(); + regularFont = null; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/AbilityShopScreen.java b/core/src/main/java/ru/project/tower/screens/AbilityShopScreen.java new file mode 100644 index 0000000..313e4c9 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/AbilityShopScreen.java @@ -0,0 +1,488 @@ +package ru.project.tower.screens; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.scenes.scene2d.InputEvent; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; +import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable; +import com.badlogic.gdx.utils.Align; +import com.badlogic.gdx.utils.viewport.ScreenViewport; +import ru.project.tower.DebugLog; +import ru.project.tower.GameContext; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.navigation.ScreenNavigation; +import ru.project.tower.navigation.ScreenNavigator; +import ru.project.tower.player.PlayerStats; +import ru.project.tower.screens.abilityshop.AbilityShopCardView; +import ru.project.tower.screens.abilityshop.AbilityShopDependencies; +import ru.project.tower.screens.abilityshop.AbilityShopPurchaseService; +import ru.project.tower.screens.abilityshop.AbilityShopResources; +import ru.project.tower.screens.abilityshop.AbilityShopScaleMetrics; +import ru.project.tower.support.RobotoFontFactory; +import ru.project.tower.support.ScreenPalette; + +public class AbilityShopScreen implements Screen { + private final ScreenNavigator navigator; + private SpriteBatch batch; + private ShapeRenderer shapeRenderer; + private Stage stage; + private final AbilityShopResources resources = new AbilityShopResources(); + + private final PlayerStats playerStats; + private final PlayerAbilities playerAbilities; + + private float uiScale; + private int adaptedFontSize; + private static final AbilityKind[] ABILITY_TYPES = AbilityKind.values(); + private float time; + + private final Color neonPink = ScreenPalette.PINK.create(); + private final Color gridBlue = ScreenPalette.BLUE.create(); + private final Color neonCyan = ScreenPalette.CYAN.create(); + private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create(); + private final Color neonGreen = ScreenPalette.GREEN.create(); + private final Color neonRed = ScreenPalette.RED.create(); + private final Color buttonDisabled = ScreenPalette.BUTTON_DISABLED.create(); + private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create(); + + private Color[] abilityColors = { + neonCyan, neonRed, neonGreen, neonPurple, neonCyan, neonPurple, neonGreen + }; + + public AbilityShopScreen(Game game, GameContext ctx) { + this(ScreenNavigation.forGame(game, ctx), AbilityShopDependencies.from(ctx)); + } + + public AbilityShopScreen(ScreenNavigator navigator, AbilityShopDependencies dependencies) { + this.navigator = navigator; + this.playerStats = dependencies.playerStats(); + this.playerAbilities = dependencies.playerAbilities(); + } + + + + + private void rebuildScreenAssets() { + resources.rebuild(); + createFont(); + } + + private void createFont() { + BitmapFont generatedFont = null; + BitmapFont generatedButtonFont = null; + try { + + if (RobotoFontFactory.exists()) { + DebugLog.log("AbilityShopScreen", "Найден файл шрифта roboto.ttf"); + + RobotoFontFactory.FontSpec spec = createFontSpec(); + generatedFont = RobotoFontFactory.generate(spec); + + generatedButtonFont = generateButtonFont(spec); + + installGeneratedFonts(generatedFont, generatedButtonFont); + generatedFont = null; + generatedButtonFont = null; + } else { + logMissingRobotoFont(); + installFallbackFonts(); + } + } catch (Exception e) { + + installFallbackFonts(); + Gdx.app.error("AbilityShopScreen", "Ошибка при создании шрифта: " + e.getMessage()); + } finally { + disposeGeneratedFont(generatedFont); + disposeGeneratedFont(generatedButtonFont); + } + } + + private RobotoFontFactory.FontSpec createFontSpec() { + return RobotoFontFactory.spec( + adaptedFontSize, + 1f * (uiScale < 1 ? 1 : uiScale), + neonPurple, + Color.WHITE, + 1, + 1, + fontShadowColor); + } + + private BitmapFont generateButtonFont(RobotoFontFactory.FontSpec spec) { + return RobotoFontFactory.generate(spec.withSize((int) (adaptedFontSize * 0.85f))); + } + + private void installGeneratedFonts(BitmapFont generatedFont, BitmapFont generatedButtonFont) { + resources.installFonts(generatedFont, generatedButtonFont); + } + + private void logMissingRobotoFont() { + Gdx.app.error( + "AbilityShopScreen", + "Файл шрифта roboto.ttf не найден, используем стандартный шрифт"); + } + + private void installFallbackFonts() { + resources.installFonts(RobotoFontFactory.fallback(), RobotoFontFactory.fallback(0.85f)); + } + + private void disposeGeneratedFont(BitmapFont generatedFont) { + if (generatedFont != null) generatedFont.dispose(); + } + + private void createUI() { + DebugLog.log("AbilityShopScreen", "Создание UI"); + + createButtonStyle(); + + createLabelStyles(); + + Table table = createAbilityShopTable(); + Label balanceLabel = addAbilityShopHeader(table); + addAbilityCards(table, balanceLabel); + addBackButton(table); + + Gdx.input.setInputProcessor(stage); + + DebugLog.log("AbilityShopScreen", "UI создан успешно"); + } + + private Table createAbilityShopTable() { + Table table = new Table(); + table.setFillParent(true); + stage.addActor(table); + return table; + } + + private Label addAbilityShopHeader(Table table) { + Label titleLabel = new Label("МАГАЗИН", resources.skin()); + table.add(titleLabel).padTop(14 * uiScale).padBottom(8 * uiScale); + table.row(); + + Label balanceLabel = new Label("Баланс: " + playerStats.getCurrency(), resources.skin()); + table.add(balanceLabel).padBottom(8 * uiScale); + table.row(); + return balanceLabel; + } + + private void addAbilityCards(Table table, Label balanceLabel) { + float screenWidth = Gdx.graphics.getWidth(); + Table abilitiesContainer = createAbilityCardsContainer(balanceLabel, screenWidth); + ScrollPane scrollPane = createAbilityScrollPane(abilitiesContainer); + + table.add(scrollPane).padLeft(10 * uiScale).padRight(10 * uiScale).expand().fill(); + table.row(); + } + + private Table createAbilityCardsContainer(Label balanceLabel, float screenWidth) { + float cardWidth = Math.min(screenWidth - 20f * uiScale, 420f * uiScale); + Table abilitiesContainer = new Table(); + + for (int i = 0; i < ABILITY_TYPES.length; i++) { + Table abilityCard = createAbilityCard(i, balanceLabel, cardWidth); + abilitiesContainer.add(abilityCard).pad(4 * uiScale).width(cardWidth).expandX(); + abilitiesContainer.row(); + } + return abilitiesContainer; + } + + private ScrollPane createAbilityScrollPane(Table abilitiesContainer) { + ScrollPane scrollPane = new ScrollPane(abilitiesContainer, resources.skin()); + scrollPane.setFadeScrollBars(false); + scrollPane.setScrollingDisabled(true, false); + return scrollPane; + } + + private void addBackButton(Table table) { + TextButton backButton = new TextButton("НАЗАД", resources.skin()); + backButton.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent event, float x, float y) { + handleBackClick(); + } + }); + + table.add(backButton).pad(10 * uiScale).size(150 * uiScale, 50 * uiScale); + } + + private void handleBackClick() { + try { + navigator.showMainMenu(); + } catch (Exception e) { + Gdx.app.error( + "AbilityShopScreen", "Ошибка при возврате в главное меню: " + e.getMessage()); + } + } + + private Table createAbilityCard(int index, Label balanceLabel, float cardWidth) { + AbilityKind ability = ABILITY_TYPES[index]; + AbilityShopCardView cardView = + AbilityShopCardView.forAbility( + ability, + playerAbilities.hasAbility(ability), + playerStats.getMaxWaveReached()); + + Table abilityCard = new Table(); + abilityCard.setBackground(createCardBackground(abilityColors[index])); + abilityCard.pad(8 * uiScale); + + Label nameLabel = new Label(cardView.name(), resources.skin()); + nameLabel.setAlignment(Align.left); + nameLabel.setWrap(true); + + Label detailsLabel = new Label(cardView.detailsText(), resources.skin()); + detailsLabel.setAlignment(Align.left); + detailsLabel.setWrap(true); + + final TextButton buyButton = new TextButton(cardView.buttonText(), resources.skin()); + + if (cardView.buttonDisabled()) { + buyButton.setDisabled(true); + } else { + buyButton.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent event, float x, float y) { + tryPurchaseAbility(index, buyButton, balanceLabel); + } + }); + } + + float buttonWidth = Math.min(94f * uiScale, cardWidth * 0.32f); + float contentWidth = cardWidth - buttonWidth - 34f * uiScale; + abilityCard.add(nameLabel).padRight(8 * uiScale).width(contentWidth).expandX().left(); + abilityCard.add(buyButton).size(buttonWidth, 36 * uiScale).right(); + abilityCard.row(); + abilityCard + .add(detailsLabel) + .padTop(4 * uiScale) + .colspan(2) + .width(cardWidth - 18f * uiScale) + .left(); + return abilityCard; + } + + private void createButtonStyle() { + TextButton.TextButtonStyle buttonStyle = createShopButtonStyle(); + Color[] uniqueCardColors = {neonCyan, neonRed, neonGreen, neonPurple}; + + installButtonDrawables(buttonStyle); + cacheCardBackgrounds(uniqueCardColors); + + resources.skin().add("default", buttonStyle); + } + + private TextButton.TextButtonStyle createShopButtonStyle() { + TextButton.TextButtonStyle buttonStyle = new TextButton.TextButtonStyle(); + buttonStyle.font = + resources.buttonFont() != null ? resources.buttonFont() : resources.font(); + buttonStyle.fontColor = Color.WHITE; + buttonStyle.overFontColor = neonCyan; + buttonStyle.downFontColor = neonPink; + return buttonStyle; + } + + private void installButtonDrawables(TextButton.TextButtonStyle buttonStyle) { + buttonStyle.up = createOwnedDrawable(buildBorderPixmap(neonPurple, 2), 3); + buttonStyle.over = createOwnedDrawable(buildBorderPixmap(neonCyan, 3), 3); + buttonStyle.down = createOwnedDrawable(buildBorderPixmap(neonPink, 3), 3); + buttonStyle.disabled = createOwnedDrawable(buildBorderPixmap(buttonDisabled, 2), 3); + } + + private NinePatchDrawable createOwnedDrawable(Pixmap pixmap, int split) { + return resources.screenAssets().ownNinePatch(pixmap, split); + } + + private void cacheCardBackgrounds(Color[] uniqueCardColors) { + int cardSplit = (int) (3 * uiScale); + for (Color c : uniqueCardColors) { + resources.putCardBackground(c, createOwnedDrawable(buildCardPixmap(c), cardSplit)); + } + } + + private void createLabelStyles() { + Label.LabelStyle labelStyle = new Label.LabelStyle(resources.font(), Color.WHITE); + resources.skin().add("default", labelStyle); + + ScrollPane.ScrollPaneStyle scrollPaneStyle = new ScrollPane.ScrollPaneStyle(); + resources.skin().add("default", scrollPaneStyle); + } + + private static Pixmap buildBorderPixmap(Color borderColor, int borderThickness) { + Pixmap px = new Pixmap(20, 20, Pixmap.Format.RGBA8888); + px.setColor(0.05f, 0.07f, 0.12f, 0.92f); + px.fill(); + px.setColor(borderColor); + for (int i = 0; i < borderThickness; i++) { + px.drawRectangle(i, i, 20 - i * 2, 20 - i * 2); + } + return px; + } + + private static Pixmap buildCardPixmap(Color borderColor) { + Pixmap px = new Pixmap(20, 20, Pixmap.Format.RGBA8888); + px.setColor(0.06f, 0.08f, 0.13f, 0.9f); + px.fill(); + px.setColor(borderColor); + for (int i = 0; i < 2; i++) { + px.drawRectangle(i, i, 20 - i * 2, 20 - i * 2); + } + return px; + } + + private NinePatchDrawable createCardBackground(Color color) { + NinePatchDrawable cached = resources.cardBackground(color); + if (cached != null) return cached; + + Gdx.app.error("AbilityShopScreen", "Unknown card color — no atlas region pre-packed"); + return resources.hasCardBackgrounds() ? resources.firstCardBackground() : null; + } + + private void calculateUIScale() { + AbilityShopScaleMetrics metrics = + AbilityShopScaleMetrics.calculate( + Gdx.graphics.getWidth(), Gdx.graphics.getHeight()); + uiScale = metrics.uiScale(); + adaptedFontSize = metrics.adaptedFontSize(); + } + + private void tryPurchaseAbility(int index, TextButton buyButton, Label balanceLabel) { + try { + AbilityKind abilityType = ABILITY_TYPES[index]; + + AbilityShopPurchaseService.Result result = + AbilityShopPurchaseService.tryPurchase( + abilityType, playerStats, playerAbilities); + if (result == AbilityShopPurchaseService.Result.PURCHASED) { + buyButton.setText("КУПЛЕНО"); + buyButton.setDisabled(true); + + balanceLabel.setText("Баланс: " + playerStats.getCurrency()); + logPurchasedAbility(abilityType); + } else if (result == AbilityShopPurchaseService.Result.INSUFFICIENT_FUNDS) { + DebugLog.log("AbilityShopScreen", "Недостаточно средств для покупки способности"); + } else if (result == AbilityShopPurchaseService.Result.LOCKED) { + DebugLog.log("AbilityShopScreen", "Способность еще не разблокирована прогрессом"); + } else { + DebugLog.log("AbilityShopScreen", "Способность уже куплена"); + } + } catch (Exception e) { + Gdx.app.error("AbilityShopScreen", "Ошибка при покупке способности: " + e.getMessage()); + } + } + + private void logPurchasedAbility(AbilityKind abilityType) { + if (DebugLog.DEBUG) { + DebugLog.log( + "AbilityShopScreen", "Куплена способность: " + abilityType.selectionName()); + } + } + + @Override + public void render(float delta) { + time += delta; + Gdx.gl.glClearColor(0.035f, 0.043f, 0.071f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + renderBackdrop(); + stage.act(delta); + stage.draw(); + } + + private void renderBackdrop() { + if (shapeRenderer == null) return; + shapeRenderer.begin(ShapeType.Line); + float spacing = 54f * uiScale; + float offset = (time * 6f) % spacing; + shapeRenderer.setColor(ScreenPalette.BLUE.set(gridBlue, 0.38f)); + for (float x = offset; x < Gdx.graphics.getWidth(); x += spacing) { + shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight()); + } + for (float y = offset; y < Gdx.graphics.getHeight(); y += spacing) { + shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y); + } + shapeRenderer.end(); + } + + @Override + public void show() { + dispose(); + DebugLog.log("AbilityShopScreen", "Создание экрана магазина способностей"); + calculateUIScale(); + batch = new SpriteBatch(); + shapeRenderer = new ShapeRenderer(); + stage = new Stage(new ScreenViewport()); + rebuildScreenAssets(); + createUI(); + } + + @Override + public void resize(int width, int height) { + logResize(width, height); + calculateUIScale(); + stage.getViewport().update(width, height, true); + + + stage.clear(); + disposeScreenAssets(); + rebuildScreenAssets(); + createUI(); + } + + private void logResize(int width, int height) { + if (DebugLog.DEBUG) { + DebugLog.log("AbilityShopScreen", "Изменение размера экрана: " + width + "x" + height); + } + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + Gdx.input.setInputProcessor(null); + } + + @Override + public void dispose() { + try { + if (stage != null) { + stage.dispose(); + stage = null; + } + if (batch != null) { + batch.dispose(); + batch = null; + } + if (shapeRenderer != null) { + shapeRenderer.dispose(); + shapeRenderer = null; + } + resources.dispose(); + } catch (Exception e) { + Gdx.app.error("AbilityShopScreen", "Ошибка в методе dispose(): " + e.getMessage()); + } + } + + private void disposeScreenAssets() { + resources.dispose(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/GameScreen.java b/core/src/main/java/ru/project/tower/screens/GameScreen.java new file mode 100644 index 0000000..b6a1c6f --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/GameScreen.java @@ -0,0 +1,1887 @@ +package ru.project.tower.screens; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.DebugLog; +import ru.project.tower.GameContext; +import ru.project.tower.GameState; +import ru.project.tower.abilities.AbilityHost; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.entities.bullets.BulletEffectsSink; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.BasicCircleData; +import ru.project.tower.entities.circles.BonusBallData; +import ru.project.tower.entities.circles.FastCircleData; +import ru.project.tower.entities.circles.HealerCircleData; +import ru.project.tower.entities.circles.StrongCircleData; +import ru.project.tower.entities.circles.bosses.SwarmQueenData; +import ru.project.tower.entities.particles.FloatingText; +import ru.project.tower.entities.particles.NeonParticle; +import ru.project.tower.navigation.ScreenNavigation; +import ru.project.tower.navigation.ScreenNavigator; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.player.PlayerRunCombatCalculator; +import ru.project.tower.player.PlayerStats; +import ru.project.tower.run.GameplayEvent; +import ru.project.tower.run.GameplayEventSink; +import ru.project.tower.run.GameplayEvents; +import ru.project.tower.run.GameplaySound; +import ru.project.tower.run.RunFrameEvents; +import ru.project.tower.run.RunFrameOrchestrator; +import ru.project.tower.run.RunRngStreams; +import ru.project.tower.run.RunSession; +import ru.project.tower.run.RunSessionConfig; +import ru.project.tower.run.RunSessionFactory; +import ru.project.tower.run.RunSnapshotCoordinator; +import ru.project.tower.run.RunStartMode; +import ru.project.tower.screens.gamescreen.GameScreenComposition; +import ru.project.tower.screens.gamescreen.GameScreenDependencies; +import ru.project.tower.screens.gamescreen.abilities.GameScreenAbilityHost; +import ru.project.tower.screens.gamescreen.actions.GameScreenActionRouter; +import ru.project.tower.screens.gamescreen.actions.GameScreenInputAction; +import ru.project.tower.screens.gamescreen.assets.GameScreenFontAssets; +import ru.project.tower.screens.gamescreen.assets.GameScreenMaterialAssets; +import ru.project.tower.screens.gamescreen.assets.GameScreenSoundAssets; +import ru.project.tower.screens.gamescreen.events.GameScreenBulletEffectsHostAdapter; +import ru.project.tower.screens.gamescreen.events.GameScreenBulletEffectsSink; +import ru.project.tower.screens.gamescreen.events.GameScreenEnemyKillEventHandler; +import ru.project.tower.screens.gamescreen.events.GameScreenEnemyKillHostAdapter; +import ru.project.tower.screens.gamescreen.events.GameScreenGameplayEventSink; +import ru.project.tower.screens.gamescreen.gameobjects.GameScreenGameObjectModel; +import ru.project.tower.screens.gamescreen.gameobjects.GameScreenGameObjectRenderer; +import ru.project.tower.screens.gamescreen.hud.GameScreenHudModel; +import ru.project.tower.screens.gamescreen.hud.GameScreenHudRenderer; +import ru.project.tower.screens.gamescreen.input.GameScreenInputController; +import ru.project.tower.screens.gamescreen.input.GameScreenInputHostAdapter; +import ru.project.tower.screens.gamescreen.input.GameScreenInputModel; +import ru.project.tower.screens.gamescreen.layout.GameScreenLayout; +import ru.project.tower.screens.gamescreen.layout.GameScreenScaleMetrics; +import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider; +import ru.project.tower.screens.gamescreen.model.GameScreenGameOverOverlayModel; +import ru.project.tower.screens.gamescreen.model.GameScreenPauseOverlayModel; +import ru.project.tower.screens.gamescreen.render.GameScreenGameOverOverlayRenderer; +import ru.project.tower.screens.gamescreen.render.GameScreenMenuButtonRenderer; +import ru.project.tower.screens.gamescreen.render.GameScreenPauseOverlayRenderer; +import ru.project.tower.screens.gamescreen.render.GameScreenRenderModelAssembler; +import ru.project.tower.screens.gamescreen.render.GameScreenUpgradePickOverlay; +import ru.project.tower.screens.gamescreen.render.PauseScreenshotOwner; +import ru.project.tower.screens.gamescreen.run.GameScreenRunFrameEvents; +import ru.project.tower.screens.gamescreen.run.GameScreenRunFrameHostAdapter; +import ru.project.tower.screens.gamescreen.run.GameScreenRunSnapshotHost; +import ru.project.tower.screens.gamescreen.run.GameScreenRunSnapshotHostAdapter; +import ru.project.tower.screens.gamescreen.shop.GameScreenShopController; +import ru.project.tower.screens.gamescreen.shop.GameScreenShopUpgradeHost; +import ru.project.tower.screens.gamescreen.shop.GameScreenShopUpgradeHostAdapter; +import ru.project.tower.screens.gamescreen.shop.ShopUpgradePreviewValues; +import ru.project.tower.screens.gamescreen.special.GameScreenBossEffectsHost; +import ru.project.tower.screens.gamescreen.special.GameScreenSpecialCircleHost; +import ru.project.tower.screens.gamescreen.special.GameScreenSpecialCircleHostAdapter; +import ru.project.tower.screens.gamescreen.upgrades.GameScreenUpgradeHost; +import ru.project.tower.screens.gamescreen.upgrades.GameScreenUpgradeHostAdapter; +import ru.project.tower.screens.gamescreen.vfx.GameScreenBackgroundRenderer; +import ru.project.tower.screens.gamescreen.vfx.GameScreenFloatingTextSystem; +import ru.project.tower.screens.gamescreen.vfx.GameScreenImpactEffects; +import ru.project.tower.screens.gamescreen.vfx.GameScreenParticleEffects; +import ru.project.tower.screens.gamescreen.vfx.GameScreenVfxModel; +import ru.project.tower.screens.gamescreen.vfx.GameScreenVfxRenderer; +import ru.project.tower.screens.gamescreen.waves.GameScreenWaveClearRewardHandler; +import ru.project.tower.screens.gamescreen.waves.GameScreenWaveClearRewardHostAdapter; +import ru.project.tower.screens.gamescreen.waves.GameScreenWaveRunEvents; +import ru.project.tower.screens.gamescreen.waves.GameScreenWaveRunHostAdapter; +import ru.project.tower.support.BossEffectsHost; +import ru.project.tower.support.GameClock; +import ru.project.tower.support.GdxViewportSize; +import ru.project.tower.support.MathUtilsRandomSource; +import ru.project.tower.support.PausableGameClock; +import ru.project.tower.support.RandomSource; +import ru.project.tower.support.ScreenPalette; +import ru.project.tower.support.ViewportSize; +import ru.project.tower.systems.BossPlayerCombatModifiers; +import ru.project.tower.systems.BonusBallSystem; +import ru.project.tower.systems.EnemyDeathHandler; +import ru.project.tower.systems.PlayerShootingSystem; +import ru.project.tower.systems.SpecialCircleSystem; +import ru.project.tower.talents.TalentData; +import ru.project.tower.talents.TalentTree; +import ru.project.tower.upgrades.UpgradeHost; +import ru.project.tower.upgrades.UpgradeSystem; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; +import ru.project.tower.waves.BossSpawnController; +import ru.project.tower.waves.SpawnController; +import ru.project.tower.waves.WaveController; +import ru.project.tower.waves.WaveRunEvents; +import ru.project.tower.waves.WaveRunOrchestrator; +import ru.project.tower.waves.WaveTuning; + +public class GameScreen + implements Screen, + GameScreenRunFrameHostAdapter.Port, + GameScreenWaveRunHostAdapter.Port, + GameScreenRunSnapshotHostAdapter.Port, + GameScreenUpgradeHostAdapter.Port, + GameScreenShopUpgradeHostAdapter.Port, + GameScreenWaveClearRewardHostAdapter.Port, + GameScreenBulletEffectsHostAdapter.Port, + GameScreenSpecialCircleHostAdapter.Port, + GameScreenEnemyKillHostAdapter.Port, + GameScreenInputHostAdapter.Port { + private SpriteBatch batch; + private final GameScreenFontAssets fontAssets = new GameScreenFontAssets(); + private final GameScreenMaterialAssets materialAssets = new GameScreenMaterialAssets(); + private ShapeRenderer shapeRenderer; + private final Rectangle square = new Rectangle(); + private Array circles; + private BulletSystem bulletSystem; + private EnemyDeathHandler enemyDeathHandler; + private GameScreenSoundAssets soundAssets; + private final GameplayEventSink gameplayEventSink = createGameplayEventSink(); + private final GameScreenWaveClearRewardHandler waveClearRewardHandler = + new GameScreenWaveClearRewardHandler( + new GameScreenWaveClearRewardHostAdapter(this), + gameplayEventSink, + new MathUtilsRandomSource()); + private final BulletEffectsSink bulletEffects = + new GameScreenBulletEffectsSink( + new GameScreenBulletEffectsHostAdapter(this), gameplayEventSink); + private Array bonusBalls = new Array<>(); + private BonusBallSystem bonusBallSystem; + private PlayerShootingSystem playerShootingSystem = new PlayerShootingSystem(); + + private long lastShotTime; + private final ScreenNavigator navigator; + private final PlayerCombatState playerCombatState = new PlayerCombatState(); + private boolean gameOver = false; + private boolean hudHelpVisible = false; + private final Rectangle restartButton = new Rectangle(); + private final Rectangle menuButton = new Rectangle(); + private final Rectangle gameMenuButton = new Rectangle(); + private static final float SQUARE_SIZE = 54f; + private static final float BULLET_RADIUS = 5f; + private static final float SHOOTING_RANGE = 150f; + + private final ShopUpgradeSystem shopUpgradeSystem = + new ShopUpgradeSystem( + new GameScreenShopUpgradeHost(new GameScreenShopUpgradeHostAdapter(this))); + private final GameScreenShopController shopController = + new GameScreenShopController(shopUpgradeSystem); + + private static final float WAVE_INDICATOR_HEIGHT = 15f; + private static final float WAVE_INDICATOR_PADDING = 10f; + private static final String SMOKE_OPEN_SHOP_PROPERTY = "tower.smoke.openShop"; + private static final String SMOKE_STATE_PROPERTY = "tower.smoke.state"; + private static final String SMOKE_VISUAL_FIXTURE_PROPERTY = "tower.smoke.visualFixture"; + private static final String SMOKE_REVIEW_STATE_PROPERTY = "tower.smoke.reviewState"; + private static final String SMOKE_WAVE_CLEAR_STATE = "waveClear"; + private static final String SMOKE_UPGRADE_PICK_STATE = "upgradePick"; + private static final String SMOKE_MATERIAL_SHOWCASE_STATE = "materialShowcase"; + private static final String SMOKE_SHOP_OPENING_STATE = "shopOpening"; + private static final String SMOKE_ABILITY_STATE_PREFIX = "ability-"; + private static final float SMOKE_SHOP_OPENING_PROGRESS = 0.56f; + private static final float PLAYER_CENTER_Y_RATIO = 0.52f; + private static final float SMOKE_REWARD_TEXT_HEIGHT = 15f; + + private final WaveController waveController = new WaveController(); + private WaveRunOrchestrator waveRunOrchestrator; + private SpawnController spawnController; + private BossSpawnController bossSpawnController; + private UpgradeSystem upgradeSystem; + private RunFrameOrchestrator runFrameOrchestrator; + private final PausableGameClock gameClock = new PausableGameClock(GameClock.SYSTEM); + + private float uiScale = 1.0f; + private float gameScale = 1.0f; + private float adaptedSquareSize; + private float adaptedCircleRadius; + private float adaptedBulletRadius; + private int adaptedFontSize; + private final Array smokeVisualCircles = new Array<>(); + private final Array smokeVisualBonusBalls = new Array<>(); + private final Array smokeVisualBackgroundParticles = new Array<>(); + private final Array smokeVisualBulletParticles = new Array<>(); + private final Array smokeVisualFloatingTexts = new Array<>(); + + + + + + private long getAbilityCooldownReduction() { + return (long) talentTree.getStatBonus(TalentData.TalentType.COOLDOWN); + } + + private void triggerDamageShake(int damage) { + impactEffects.applyDamageShake(damage); + } + + @Override + public boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs) { + long now = gameClock.nowMs(); + boolean damageBlocked = isPlayerDamageBlocked(now); + boolean accepted = + playerCombatState.damageIfVulnerable( + damage, damageCooldownMs, now, ignoredNow -> damageBlocked); + if (accepted && !damageBlocked) { + gameplayEventSink.emit( + GameplayEvents.playerDamaged( + damage, + playerCombatState.getHealth(), + square.x + square.width / 2f, + square.y + square.height / 2f)); + } + return accepted; + } + + private boolean isPlayerDamageBlocked(long now) { + return abilitySystem != null + && (abilitySystem.isShieldActive(now) || abilitySystem.isDashInvulnerable(now)); + } + + @Override + public void regeneratePlayer(long nowMs) { + playerCombatState.regenerateIfReady(nowMs); + } + + @Override + public long lastShotTime() { + return lastShotTime; + } + + @Override + public float effectiveShotCooldown() { + return getEffectiveShotCooldown(); + } + + @Override + public int playerHealth() { + return playerCombatState.getHealth(); + } + + @Override + public void handleEnemyKilled(BaseCircleData enemy) { + enemyDeathHandler.handleEnemyKilled( + enemy, gameScale, waveController.getCurrentEnemySpeedMultiplier()); + } + + @Override + public boolean spendMoney(int amount) { + return playerCombatState.spendMoney(amount); + } + + @Override + public void addMoney(int amount) { + playerCombatState.addMoney(amount); + } + + @Override + public void addCurrency(int amount) { + playerCombatState.addRunCurrencyEarned(amount); + playerStats.addCurrency(amount); + } + + @Override + public void addMaxHealth(int amount) { + playerCombatState.setMaxHealth(playerCombatState.getMaxHealth() + amount); + } + + @Override + public void healToFull() { + playerCombatState.setHealth(playerCombatState.getMaxHealth()); + } + + @Override + public void addBaseDamage(int amount) { + bulletSystem.increaseBaseDamage(amount); + } + + @Override + public void addBaseSpeed(float amount) { + bulletSystem.increaseBaseSpeed(amount); + } + + @Override + public void reduceShotCooldown(float amount, float minimum) { + playerCombatState.reduceShotCooldown(amount); + } + + @Override + public void addHealthRegen(int amount) { + playerCombatState.setHealthRegenRate(playerCombatState.getHealthRegenRate() + amount); + } + + @Override + public void spawnQueenMinion(SwarmQueenData queen) { + bossSpawnController.spawnQueenMinion(queen); + } + + private void triggerCritHitstop() { + impactEffects.applyCritHitstop(); + } + + @Override + public void triggerBossKillShake() { + impactEffects.applyBossKillShake(); + } + + @Override + public void triggerKillShake() { + impactEffects.applyKillShake(); + } + + private EnemyDeathHandler createEnemyDeathHandler() { + return createEnemyDeathHandler(new MathUtilsRandomSource()); + } + + private EnemyDeathHandler createEnemyDeathHandler(RandomSource random) { + return new EnemyDeathHandler( + circles, + (x, y, color) -> particleEffects.createExplosion(x, y, color), + gameplayEventSink, + random); + } + + private GameScreenEnemyKillEventHandler createEnemyKillEventHandler() { + return new GameScreenEnemyKillEventHandler( + new GameScreenEnemyKillHostAdapter(this), NEON_YELLOW); + } + + private GameplayEventSink createGameplayEventSink() { + return new GameScreenGameplayEventSink( + createGameplayEventHost(), createGameplayEventPalette()); + } + + private GameScreenGameplayEventSink.Host createGameplayEventHost() { + return new GameplayEventHostBridge(); + } + + private final class GameplayEventHostBridge implements GameScreenGameplayEventSink.Host { + @Override + public void spawnFloatingText(float x, float y, String text, Color color) { + GameScreen.this.spawnFloatingText(x, y, text, color); + } + + @Override + public void spawnFloatingText( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed) { + GameScreen.this.spawnFloatingText( + x, y, text, color, maxLifetime, initialScale, riseSpeed); + } + + @Override + public void applyShake(float intensity, float hitstopSeconds) { + impactEffects.applyShake(intensity, hitstopSeconds); + } + + @Override + public void triggerDamageShake(int damage) { + GameScreen.this.triggerDamageShake(damage); + } + + @Override + public void playSound(GameplaySound sound, float volume, float pitch, float pan) { + soundAssets.play(sound, volume, pitch, pan); + } + + @Override + public void createExplosion(float x, float y, Color color) { + particleEffects.createExplosion(x, y, color); + } + + @Override + public void createExplosion(float x, float y, Color color, int particleCount) { + particleEffects.createExplosion(x, y, color, particleCount); + } + + @Override + public void addCurrency(int amount) { + GameScreen.this.addCurrency(amount); + } + + @Override + public void updateMaxWave(int wave) { + playerStats.updateMaxWave(wave); + } + + @Override + public void handleEnemyKilled(GameplayEvent.EnemyKilled event) { + enemyKillEventHandler.handle(event); + } + } + + private GameScreenGameplayEventSink.Palette createGameplayEventPalette() { + return new GameScreenGameplayEventSink.Palette( + NEON_RED, NEON_YELLOW, NEON_GREEN, NEON_CYAN, NEON_BLUE, NEON_PURPLE); + } + + + + + + private boolean isPaused = false; + private boolean shouldPause = false; + private final PauseScreenshotOwner pauseScreenshotOwner = new PauseScreenshotOwner(); + private GameScreenPauseOverlayRenderer pauseOverlayRenderer; + private final GameScreenPauseOverlayModel pauseOverlayModel = new GameScreenPauseOverlayModel(); + private final Rectangle resumeButton = new Rectangle(); + private final Rectangle exitButton = new Rectangle(); + + + + private AbilitySystem abilitySystem; + + + + + + + private final GameScreenImpactEffects impactEffects = new GameScreenImpactEffects(); + + private static final Color NEON_RED = ScreenPalette.RED.create(); + private static final Color NEON_GREEN = ScreenPalette.GREEN.create(); + private static final Color NEON_BLUE = ScreenPalette.BLUE.create(); + private static final Color NEON_YELLOW = ScreenPalette.GOLD.create(); + private static final Color NEON_CYAN = ScreenPalette.CYAN.create(); + private static final Color NEON_PINK = ScreenPalette.PINK.create(); + private static final Color NEON_PURPLE = ScreenPalette.NEON_LIGHT_PURPLE.create(); + private static final Color PRICE_TEXT_COLOR = ScreenPalette.TEXT_PRICE.create(); + private static final Color SHADOW_WEAVER_COLOR = ScreenPalette.SHADOW_WEAVER.create(); + private static final Color GLACIAL_WARDEN_COLOR = ScreenPalette.GLACIAL_WARDEN.create(); + private static final Color BASIC_CIRCLE_COLOR = ScreenPalette.BASIC_ENEMY.create(); + private static final Color FAST_CIRCLE_COLOR = ScreenPalette.PINK.create(); + private static final Color SPLITTER_CIRCLE_COLOR = ScreenPalette.ORANGE.create(); + private static final Color SPAWNER_CIRCLE_COLOR = ScreenPalette.SUPPORT_ENEMY.create(); + private static final Color[] BACKGROUND_PARTICLE_COLORS = { + NEON_BLUE, NEON_PINK, NEON_CYAN, NEON_PURPLE + }; + private static final String UPGRADE_PICK_TITLE = "Выберите улучшение"; + private final SpecialCircleSystem specialCircleSystem = + new SpecialCircleSystem(NEON_RED, NEON_BLUE, NEON_YELLOW, NEON_CYAN, NEON_PURPLE); + private final GameScreenParticleEffects particleEffects = new GameScreenParticleEffects(); + private final GameScreenEnemyKillEventHandler enemyKillEventHandler = + createEnemyKillEventHandler(); + private final BossEffectsHost bossEffectsHost = new GameScreenBossEffectsHost(particleEffects); + private final SpecialCircleSystem.Host specialCircleHost = + new GameScreenSpecialCircleHost( + new GameScreenSpecialCircleHostAdapter(this), particleEffects); + + private static final float pulseSpeed = 2f; + + + + private final Color scratchColor = new Color(); + private final Color scratchColor2 = new Color(); + private final Color scratchColor3 = new Color(); + private final Color scratchColor4 = new Color(); + private final Color scratchColor5 = new Color(); + + private void setRect(Rectangle rect, float x, float y, float width, float height) { + rect.set(x, y, width, height); + } + + private void setRect(Rectangle rect, GameScreenLayout.Rect layoutRect) { + if (layoutRect == null) return; + setRect(rect, layoutRect.x(), layoutRect.y(), layoutRect.width(), layoutRect.height()); + } + + @Override + public void spawnFloatingText(float x, float y, String text, Color color) { + floatingTextSystem.spawn(x, y, text, color); + } + + @Override + public void playGameplaySound(GameplaySound sound, float volume, float pitch, float pan) { + soundAssets.play(sound, volume, pitch, pan); + } + + @Override + public void spawnFloatingText( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed) { + floatingTextSystem.spawn(x, y, text, color, maxLifetime, initialScale, riseSpeed); + } + + private float time = 0; + private static final Color BACKGROUND_COLOR = ScreenPalette.SCREEN.create(); + + private final PlayerStats playerStats; + private final PlayerAbilities playerAbilities; + private final GameScreenDependencies dependencies; + private final GameScreenComposition composition; + private final ViewportSize viewportSize; + private final SafeAreaInsetsProvider safeAreaInsetsProvider; + private final RunSessionFactory runSessionFactory; + private final GameState gameState; + private final TalentTree talentTree; + + private final GameScreenFloatingTextSystem floatingTextSystem = + new GameScreenFloatingTextSystem(); + + private static final Color BOSS_PROJECTILE_COLOR = new Color(0.8f, 0.2f, 0.8f, 1f); + + private Array spawnedBosses = new Array<>(); + + private Array selectedAbilities; + private RunSession runSession; + + private final GameScreenGameObjectModel gameObjectModel = new GameScreenGameObjectModel(); + private final GameScreenHudModel hudModel = new GameScreenHudModel(); + private final GameScreenVfxModel vfxModel = new GameScreenVfxModel(); + private final GameScreenGameOverOverlayModel gameOverOverlayModel = + new GameScreenGameOverOverlayModel(); + private final GameScreenRenderModelAssembler renderModelAssembler = + new GameScreenRenderModelAssembler( + gameObjectModel, hudModel, vfxModel, pauseOverlayModel, gameOverOverlayModel); + private final GameScreenGameObjectRenderer gameObjectRenderer = + new GameScreenGameObjectRenderer(renderModelAssembler::gameObjectModel); + private final GameScreenHudRenderer hudRenderer = + new GameScreenHudRenderer(renderModelAssembler::hudModel); + private final GameScreenVfxRenderer vfxRenderer = + new GameScreenVfxRenderer(renderModelAssembler::vfxModel); + private final GameScreenBackgroundRenderer backgroundRenderer = + new GameScreenBackgroundRenderer(); + private final GameScreenMenuButtonRenderer menuButtonRenderer = + new GameScreenMenuButtonRenderer(); + private final GameScreenUpgradePickOverlay upgradePickOverlay = + new GameScreenUpgradePickOverlay(NEON_CYAN, NEON_GREEN, NEON_PURPLE); + private RunSnapshotCoordinator runSnapshotCoordinator; + private final GameScreenGameOverOverlayRenderer gameOverOverlayRenderer = + new GameScreenGameOverOverlayRenderer(); + private final GameScreenInputModel inputModel = new GameScreenInputModel(); + + private final GameScreenActionRouter actionRouter = + new GameScreenActionRouter(new GameScreenActionHost()); + private final GameScreenInputController inputController = + new GameScreenInputController(new GameScreenInputHostAdapter(this)); + + @Override + public GameScreenInputModel inputModel() { + inputModel.fillScreenFields( + new GameScreenInputModel.ScreenFields( + upgradeSystem != null && upgradeSystem.isPickActive(), + upgradePickOverlay.canAcceptSelection(), + isPaused, + upgradePickOverlay.buttons(), + upgradePickOverlay.rerollButton(), + gameOver, + uiScale, + gameMenuButton, + restartButton, + menuButton, + resumeButton, + exitButton, + bonusBalls)); + shopController.fillInputModelShopFields(inputModel, playerAbilities); + return inputModel; + } + + @Override + public void performInputAction(GameScreenInputAction action) { + actionRouter.perform(action); + } + + @Override + public void scrollShopUpgrades(float amount) { + shopController.scrollBy(amount); + rebuildShopController(); + } + + @Override + public void applyPick(int index) { + upgradeSystem.applyPick(index); + } + + @Override + public void rerollPick() { + if (upgradeSystem.rerollsRemaining() <= 0) return; + float discount = shopUpgradeSystem.cardRerollDiscount(); + int rerollCost = upgradeSystem.rerollCost(discount); + UpgradeSystem.OfferCardView[] previousOffer = upgradeSystem.activeOfferViews(); + if (spendMoney(rerollCost) && upgradeSystem.rerollPick(discount)) { + upgradePickOverlay.rebuild(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), uiScale); + upgradePickOverlay.startReroll(previousOffer, upgradeSystem.activeOfferViews()); + } + } + + @Override + public void buyShopUpgrade(ShopUpgradeKind kind) { + shopController.buyUpgrade(kind); + } + + private void rebuildShopController() { + shopController.rebuild( + viewportSize.getWidth(), + viewportSize.getHeight(), + uiScale, + selectedAbilities, + playerAbilities, + safeAreaInsetsProvider.current()); + } + + private final class GameScreenActionHost implements GameScreenActionRouter.Host { + @Override + public void showUpgradePanel(GameScreenActionRouter.UpgradePanel panel) { + shopController.showPanel(panel); + rebuildShopController(); + } + + @Override + public void toggleShop() { + shopController.toggleShop(); + rebuildShopController(); + } + + @Override + public void toggleHudHelp() { + hudHelpVisible = !hudHelpVisible; + } + + @Override + public void scrollUpgrades(int direction) { + shopController.scroll(direction); + rebuildShopController(); + } + + @Override + public void buyUpgrade(GameScreenActionRouter.Upgrade upgrade) { + shopController.buyUpgrade(upgrade); + } + + @Override + public void activateAbility(GameScreenActionRouter.Ability ability) { + long now = gameClock.nowMs(); + switch (ability) { + case FREEZE: + abilitySystem.activateFreeze(0, 0, now); + break; + case EXPLOSION: + abilitySystem.activateExplosion(0, 0, now); + break; + case SHIELD: + abilitySystem.activateShield(now); + break; + case IMPULSE: + abilitySystem.activateImpulse(now); + break; + case DASH: + abilitySystem.activateDash(now); + break; + case PULL: + abilitySystem.activatePull(now); + break; + case TURRET: + abilitySystem.activateTurret(now); + break; + } + } + + @Override + public void requestPause() { + shouldPause = true; + } + + @Override + public void restartGame() { + GameScreen.this.restartGame(); + } + + @Override + public void returnToMainMenu() { + GameScreen.this.returnToMainMenu(); + } + + @Override + public void resumeGameFromPause() { + gameClock.resume(); + isPaused = false; + pauseScreenshotOwner.disposeAndClear(); + } + + @Override + public void exitPauseToMenu() { + pauseScreenshotOwner.disposeAndClear(); + returnToMainMenu(); + } + } + + @Override + public void collectBonusBall(int index) { + BonusBallSystem.CollectionEvent event = bonusBallSystem.collect(index); + applyBonusCollectionEffect(event.effect); + gameplayEventSink.emit( + GameplayEvents.bonusCollected(event.type.name(), event.x, event.y, event.text)); + } + + private void applyBonusCollectionEffect(BonusBallSystem.CollectionEffect effect) { + if (effect == null) return; + if (effect.healAmount() > 0) { + playerCombatState.setHealth( + Math.min( + playerCombatState.getMaxHealth(), + playerCombatState.getHealth() + effect.healAmount())); + } + if (effect.coinAmount() > 0) { + playerCombatState.addMoney(effect.coinAmount()); + } + if (effect.abilityCharge() > 0 && abilitySystem != null) { + abilitySystem.reduceAllCooldowns(effect.abilityCharge() * 1_500L); + } + if (effect.shieldPulse() > 0 && abilitySystem != null) { + abilitySystem.grantShieldPulse(gameClock.nowMs(), 1_200 + effect.shieldPulse() * 20); + } + } + + public GameScreen(Game game, GameContext ctx) { + this(navigatorFor(game, ctx), GameScreenDependencies.from(ctx), new GdxViewportSize()); + } + + GameScreen(Game game, GameContext ctx, ViewportSize viewportSize) { + this(navigatorFor(game, ctx), GameScreenDependencies.from(ctx), viewportSize); + } + + public GameScreen(ScreenNavigator navigator, GameContext ctx) { + this(navigator, GameScreenDependencies.from(ctx), new GdxViewportSize()); + } + + GameScreen(ScreenNavigator navigator, GameContext ctx, ViewportSize viewportSize) { + this(navigator, GameScreenDependencies.from(ctx), viewportSize); + } + + public GameScreen(ScreenNavigator navigator, GameScreenDependencies dependencies) { + this(navigator, dependencies, new GdxViewportSize()); + } + + GameScreen( + ScreenNavigator navigator, + GameScreenDependencies dependencies, + ViewportSize viewportSize) { + this(navigator, dependencies, viewportSize, new GameScreenComposition(dependencies)); + } + + GameScreen( + ScreenNavigator navigator, + GameScreenDependencies dependencies, + ViewportSize viewportSize, + GameScreenComposition composition) { + this( + navigator, + dependencies, + viewportSize, + composition, + composition.createRunSessionFactory(viewportSize)); + } + + GameScreen( + ScreenNavigator navigator, + GameScreenDependencies dependencies, + ViewportSize viewportSize, + RunSessionFactory runSessionFactory) { + this( + navigator, + dependencies, + viewportSize, + new GameScreenComposition(dependencies), + runSessionFactory); + } + + GameScreen( + ScreenNavigator navigator, + GameScreenDependencies dependencies, + ViewportSize viewportSize, + GameScreenComposition composition, + RunSessionFactory runSessionFactory) { + this.dependencies = dependencies; + this.composition = composition; + this.navigator = navigator; + this.viewportSize = viewportSize; + this.runSessionFactory = runSessionFactory; + this.safeAreaInsetsProvider = dependencies.safeAreaInsetsProvider(); + try { + DebugLog.log("GameScreen", "Constructing GameScreen"); + playerStats = dependencies.playerStats(); + playerAbilities = dependencies.playerAbilities(); + gameState = dependencies.gameState(); + talentTree = dependencies.talentTree(); + soundAssets = new GameScreenSoundAssets(dependencies.assetService()); + + if (gameState == null) { + Gdx.app.error("GameScreen", "GameState instance is null!"); + throw new RuntimeException("GameState instance is null"); + } + + selectedAbilities = gameState.getSelectedAbilities(); + + logSelectedAbilities("Got selected abilities, count: ", "Selected ability: "); + + DebugLog.log("GameScreen", "GameScreen constructed successfully"); + } catch (Exception e) { + Gdx.app.error("GameScreen", "Error in constructor: ", e); + throw e; + } + } + + private static ScreenNavigator navigatorFor(Game game, GameContext ctx) { + if (game == null) return null; + return ScreenNavigation.forGame(game, ctx); + } + + @Override + public void show() { + try { + DebugLog.log("GameScreen", "Showing GameScreen"); + + initializeRenderResources(); + initializeRunResources(); + + DebugLog.log("GameScreen", "Got GameState instance"); + + RunStartMode startMode = runSnapshotCoordinator.resolveStartMode(); + GameState.Snapshot restoredSnapshot = + runSnapshotCoordinator.restoreRunSnapshot(startMode); + + refreshRunSelections(); + + logSelectedAbilities("Received abilities count: ", "Received ability: "); + + calculateScaleFactor(); + DebugLog.log("GameScreen", "Scale factors calculated"); + + positionPlayerSquare(); + initializeRunSessionAndInput(); + initializeNewRunTimeline(); + initializeVisualEffectsAndOverlays(); + + runSnapshotCoordinator.applyRestoredRunSnapshot(startMode, restoredSnapshot); + } catch (Exception e) { + Gdx.app.error("GameScreen", "Error in show method: ", e); + throw e; + } + } + + private void initializeRenderResources() { + batch = new SpriteBatch(); + shapeRenderer = new ShapeRenderer(); + impactEffects.captureBaseProjection(shapeRenderer, batch); + } + + private void initializeRunResources() { + soundAssets.load(); + + circles = new Array(); + bulletSystem = composition.createBulletSystem(); + enemyDeathHandler = createEnemyDeathHandler(); + runSnapshotCoordinator = new RunSnapshotCoordinator(createRunSnapshotHost()); + + DebugLog.log("GameScreen", "Basic initialization complete"); + } + + private void refreshRunSelections() { + selectedAbilities = gameState.getSelectedAbilities(); + spawnedBosses = gameState.getSpawnedBosses(); + } + + private void positionPlayerSquare() { + square.set( + Gdx.graphics.getWidth() / 2f - adaptedSquareSize / 2f, + Gdx.graphics.getHeight() * PLAYER_CENTER_Y_RATIO - adaptedSquareSize / 2f, + adaptedSquareSize, + adaptedSquareSize); + } + + private void initializeRunSessionAndInput() { + createRunSession(); + rebuildShopController(); + if (smokeShopOpeningStateEnabled()) { + shopController.setShopTransitionProgressForReview(SMOKE_SHOP_OPENING_PROGRESS); + rebuildShopController(); + } else if (Boolean.getBoolean(SMOKE_OPEN_SHOP_PROPERTY)) { + shopController.setShopOpenImmediately(true); + rebuildShopController(); + } + if (smokeUpgradePickStateEnabled()) { + playerCombatState.setMoney(45); + upgradeSystem.triggerPick(); + upgradePickOverlay.rebuild(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), uiScale); + upgradePickOverlay.startReveal(upgradeSystem.activeOfferViews()); + } + inputController.install(); + fontAssets.rebuild(adaptedFontSize); + materialAssets.rebuild(); + } + + private void initializeNewRunTimeline() { + bulletSystem.applyTalentBaseStats(); + + gameClock.resetToWallNow(); + long now = gameClock.nowMs(); + resetCombatStateForNewRun(now); + lastShotTime = now; + + waveController.resetForNewRun(now); + activateSmokeAbilityShowcase(); + } + + private void activateSmokeAbilityShowcase() { + AbilityKind ability = smokeAbilityShowcaseKind(); + if (ability == null || abilitySystem == null) return; + actionRouter.perform(GameScreenInputAction.valueOf("ACTIVATE_" + ability.name())); + } + + private void initializeVisualEffectsAndOverlays() { + particleEffects.initBackgroundParticles( + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + BACKGROUND_PARTICLE_COLORS); + rebuildSmokeVisualFixture(); + + createGameMenuButton(); + rebuildPauseOverlayRenderer(); + } + + private boolean smokeVisualFixtureEnabled() { + return Boolean.getBoolean(SMOKE_VISUAL_FIXTURE_PROPERTY); + } + + private boolean smokeReviewStateEnabled() { + return Boolean.getBoolean(SMOKE_REVIEW_STATE_PROPERTY); + } + + private boolean smokeWaveClearStateEnabled() { + return SMOKE_WAVE_CLEAR_STATE.equals(System.getProperty(SMOKE_STATE_PROPERTY, "")); + } + + private boolean smokeUpgradePickStateEnabled() { + return SMOKE_UPGRADE_PICK_STATE.equals(System.getProperty(SMOKE_STATE_PROPERTY, "")); + } + + private boolean smokeMaterialShowcaseStateEnabled() { + return SMOKE_MATERIAL_SHOWCASE_STATE.equals(System.getProperty(SMOKE_STATE_PROPERTY, "")); + } + + private boolean smokeShopOpeningStateEnabled() { + return SMOKE_SHOP_OPENING_STATE.equals(System.getProperty(SMOKE_STATE_PROPERTY, "")); + } + + private boolean smokeDeterministicReviewStateEnabled() { + return smokeVisualFixtureEnabled() + || smokeReviewStateEnabled() + || smokeWaveClearStateEnabled() + || smokeUpgradePickStateEnabled() + || smokeMaterialShowcaseStateEnabled() + || smokeShopOpeningStateEnabled() + || smokeAbilityShowcaseKind() != null; + } + + private AbilityKind smokeAbilityShowcaseKind() { + String state = System.getProperty(SMOKE_STATE_PROPERTY, ""); + if (state == null || !state.startsWith(SMOKE_ABILITY_STATE_PREFIX)) { + return null; + } + String abilityId = state.substring(SMOKE_ABILITY_STATE_PREFIX.length()); + return AbilityKind.fromPersistedId(abilityId).orElse(null); + } + + private void rebuildSmokeVisualFixture() { + if (!smokeDeterministicReviewStateEnabled()) { + smokeVisualCircles.clear(); + smokeVisualBonusBalls.clear(); + smokeVisualBackgroundParticles.clear(); + smokeVisualBulletParticles.clear(); + smokeVisualFloatingTexts.clear(); + return; + } + + smokeVisualCircles.clear(); + smokeVisualBonusBalls.clear(); + smokeVisualBackgroundParticles.clear(); + smokeVisualBulletParticles.clear(); + smokeVisualFloatingTexts.clear(); + + float width = Gdx.graphics.getWidth(); + float height = Gdx.graphics.getHeight(); + float sx = width / 405f; + float sy = height / 682f; + + addSmokeCircle(new FastCircleData(), 85.5f, 181.5f, 5.5f, sx, sy, height); + addSmokeCircle(new BasicCircleData(), 151f, 252f, 6f, sx, sy, height); + addSmokeCircle(new BasicCircleData(), 224f, 221f, 6f, sx, sy, height); + addSmokeCircle(new StrongCircleData(), 115f, 294f, 8f, sx, sy, height); + addSmokeCircle(new HealerCircleData(), 307.5f, 539.5f, 6.5f, sx, sy, height); + addSmokeCircle(new FastCircleData(), 54.5f, 357.5f, 5.5f, sx, sy, height); + addSmokeCircle(new FastCircleData(), 98.5f, 390.5f, 5.5f, sx, sy, height); + addSmokeCircle(new BasicCircleData(), 345f, 264f, 6f, sx, sy, height); + addSmokeCircle(new HealerCircleData(), 282.5f, 340.5f, 6.5f, sx, sy, height); + addSmokeCircle(new BasicCircleData(), 190f, 176f, 6f, sx, sy, height); + + addSmokeRewardText(82f * sx, height - (158f + SMOKE_REWARD_TEXT_HEIGHT) * sy); + addSmokeRewardText(width - 82f * sx, 158f * sy); + if (smokeWaveClearStateEnabled()) { + addSmokeWaveClearText( + width * 0.5f, height - 4f, "Волна 12 очищена! +125", NEON_GREEN, 1.5f); + addSmokeWaveClearText(width * 0.5f, 4f, "+4 монет", NEON_YELLOW, 1.2f); + } + if (smokeMaterialShowcaseStateEnabled()) { + addSmokeBonusBall(332f, 452f, 15f, sx, sy, height, BonusBallData.BonusType.DAMAGE); + } + } + + private void addSmokeRewardText(float x, float y) { + FloatingText reward = new FloatingText(); + reward.configure(x, y, "+1", ScreenPalette.GREEN.create(), 3f, 0.55f, 0f); + smokeVisualFloatingTexts.add(reward); + } + + private void addSmokeWaveClearText( + float x, float y, String text, Color color, float initialScale) { + FloatingText floatingText = new FloatingText(); + floatingText.configure(x, y, text, color, 4f, initialScale, 0f); + smokeVisualFloatingTexts.add(floatingText); + } + + private void addSmokeBonusBall( + float referenceCenterX, + float referenceCenterYFromTop, + float referenceRadius, + float sx, + float sy, + float height, + BonusBallData.BonusType type) { + float scale = Math.min(sx, sy); + BonusBallData bonusBall = new BonusBallData(); + bonusBall.configure( + referenceCenterX * sx, + height - referenceCenterYFromTop * sy, + referenceRadius * scale, + 0f, + 0f, + type); + smokeVisualBonusBalls.add(bonusBall); + } + + private void addSmokeCircle( + BaseCircleData circle, + float referenceCenterX, + float referenceCenterYFromTop, + float referenceRadius, + float sx, + float sy, + float height) { + float scale = Math.min(sx, sy); + circle.configure( + referenceCenterX * sx, + height - referenceCenterYFromTop * sy, + referenceRadius * scale, + 0f, + 0f, + circle instanceof StrongCircleData ? 3 : 1, + 1); + smokeVisualCircles.add(circle); + } + + private void logSelectedAbilities(String countPrefix, String abilityPrefix) { + if (DebugLog.DEBUG) { + DebugLog.log("GameScreen", countPrefix + selectedAbilities.size); + for (AbilityKind ability : selectedAbilities) { + DebugLog.log("GameScreen", abilityPrefix + ability.persistedId()); + } + } + } + + private void createRunSession() { + runSession = + runSessionFactory.create( + new RunSessionConfig( + square, + circles, + bonusBalls, + bulletSystem, + playerAbilities, + selectedAbilities, + spawnedBosses, + gameClock, + bossEffectsHost, + createAbilityHost(), + createUpgradeHost())); + runSession.setRenderScale(gameScale, adaptedCircleRadius); + RunRngStreams rngStreams = runSession.rngStreams(); + if (rngStreams != null) { + playerShootingSystem = new PlayerShootingSystem(rngStreams.combat()); + enemyDeathHandler = createEnemyDeathHandler(rngStreams.waveComposition()); + } + spawnController = runSession.spawnController(); + bossSpawnController = runSession.bossSpawnController(); + bonusBallSystem = runSession.bonusBallSystem(); + abilitySystem = runSession.abilitySystem(); + upgradeSystem = runSession.upgradeSystem(); + waveRunOrchestrator = new WaveRunOrchestrator(waveController, createWaveRunEvents()); + runFrameOrchestrator = new RunFrameOrchestrator(createRunFrameConfig()); + } + + private RunFrameOrchestrator.Config createRunFrameConfig() { + return new RunFrameOrchestrator.Config( + circles, + square, + waveController, + waveRunOrchestrator, + bonusBallSystem, + spawnController, + abilitySystem, + bulletSystem, + bulletEffects, + specialCircleSystem, + specialCircleHost, + createRunFrameEvents()); + } + + private RunFrameEvents createRunFrameEvents() { + return new GameScreenRunFrameEvents(new GameScreenRunFrameHostAdapter(this)); + } + + private WaveRunEvents createWaveRunEvents() { + return new GameScreenWaveRunEvents(new GameScreenWaveRunHostAdapter(this)); + } + + @Override + public void applyWaveTuning(WaveTuning tuning) { + spawnController.setWaveTuning( + tuning.currentWave(), + tuning.strongCircleChance(), + tuning.fastCircleChance(), + tuning.splitterCircleChance(), + tuning.basicCircleHealth(), + tuning.strongCircleHealth(), + tuning.circleDamage(), + tuning.enemySpeedMultiplier()); + bossSpawnController.setCurrentWave(tuning.currentWave()); + } + + @Override + public void spawnWaveCircle() { + spawnController.spawnWaveCircle(); + } + + @Override + public void spawnRandomBoss() { + bossSpawnController.spawnRandomBoss(); + } + + @Override + public void handleWaveCleared(WaveController.WaveClearReward reward) { + waveClearRewardHandler.handle(reward); + } + + @Override + public void triggerUpgradePick() { + upgradeSystem.triggerPick(); + upgradePickOverlay.rebuild(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), uiScale); + upgradePickOverlay.startReveal(upgradeSystem.activeOfferViews()); + } + + @Override + public void endWave() { + bonusBallSystem.endWave(); + } + + @Override + public void addUpgradeFloatingText(float x, float y, String text, Color color) { + spawnFloatingText(x, y, text, color, 2.5f, 1.3f, 20f); + } + + @Override + public float playerCenterX() { + return square.x + square.width / 2f; + } + + @Override + public float playerCenterY() { + return square.y + square.height / 2f; + } + + @Override + public float viewportWidth() { + return Gdx.graphics.getWidth(); + } + + @Override + public float viewportHeight() { + return Gdx.graphics.getHeight(); + } + + private RunSnapshotCoordinator.Host createRunSnapshotHost() { + return new GameScreenRunSnapshotHost( + new GameScreenRunSnapshotHostAdapter(this), + gameState, + selectedAbilities, + spawnedBosses, + playerCombatState, + bulletSystem, + shopUpgradeSystem, + waveController); + } + + @Override + public RunRngStreams rngStreams() { + return runSession == null ? null : runSession.rngStreams(); + } + + private AbilityHost createAbilityHost() { + return new GameScreenAbilityHost( + createAbilityHostBridge(), bulletSystem, playerShootingSystem, gameplayEventSink); + } + + private GameScreenAbilityHost.Host createAbilityHostBridge() { + return new AbilityHostBridge(); + } + + private final class AbilityHostBridge implements GameScreenAbilityHost.Host { + @Override + public void addFloatingText(float x, float y, String text, Color color) { + GameScreen.this.spawnFloatingText(x, y, text, color); + } + + @Override + public void createExplosion(float x, float y, Color color) { + particleEffects.createExplosion(x, y, color); + } + + @Override + public void triggerKillShake() { + GameScreen.this.triggerKillShake(); + } + + @Override + public void triggerCritHitstop() { + GameScreen.this.triggerCritHitstop(); + } + + @Override + public float activeBonusDamageMultiplier() { + return getActiveBonusDamageMultiplier(); + } + + @Override + public float abilityScalingFactor() { + return getAbilityScalingFactor(); + } + + @Override + public float gameScale() { + return gameScale; + } + + @Override + public long abilityCooldownReductionMs() { + return getAbilityCooldownReduction(); + } + + @Override + public void addMoney(int amount) { + playerCombatState.addMoney(amount); + } + + @Override + public void killEnemy(BaseCircleData enemy) { + enemyDeathHandler.handleEnemyKilled( + enemy, gameScale, waveController.getCurrentEnemySpeedMultiplier()); + } + + @Override + public float activeBonusSpeedMultiplier() { + return getActiveBonusSpeedMultiplier(); + } + + @Override + public float adaptedBulletRadius() { + return adaptedBulletRadius; + } + } + + private UpgradeHost createUpgradeHost() { + return new GameScreenUpgradeHost( + new GameScreenUpgradeHostAdapter(this), bulletSystem, playerCombatState); + } + + private void calculateScaleFactor() { + float screenWidth = viewportSize.getWidth(); + float screenHeight = viewportSize.getHeight(); + GameScreenScaleMetrics metrics = + GameScreenScaleMetrics.calculate( + screenWidth, + screenHeight, + SQUARE_SIZE, + SpawnController.CIRCLE_RADIUS, + BULLET_RADIUS); + + uiScale = metrics.uiScale(); + gameScale = metrics.gameScale(); + adaptedSquareSize = metrics.adaptedSquareSize(); + adaptedCircleRadius = metrics.adaptedCircleRadius(); + adaptedBulletRadius = metrics.adaptedBulletRadius(); + adaptedFontSize = metrics.adaptedFontSize(); + + logScaleMetrics(screenWidth, screenHeight); + + if (spawnController != null) { + spawnController.setRenderScale(gameScale, adaptedCircleRadius); + } + if (bossSpawnController != null) { + bossSpawnController.setRenderScale(gameScale); + } + } + + private void logScaleMetrics(float screenWidth, float screenHeight) { + if (DebugLog.DEBUG) { + DebugLog.log( + "Scaling", + "UI Scale: " + + uiScale + + ", Game Scale: " + + gameScale + + ", Font Size: " + + adaptedFontSize + + ", Screen: " + + screenWidth + + "x" + + screenHeight); + } + } + + private void createGameMenuButton() { + int abilityCount = selectedAbilities == null ? 0 : selectedAbilities.size; + GameScreenLayout layout = + GameScreenLayout.calculate( + viewportSize.getWidth(), + viewportSize.getHeight(), + uiScale, + 0f, + GameScreenLayout.Panel.ATTACK, + abilityCount, + safeAreaInsetsProvider.current()); + setRect(gameMenuButton, layout.menuButton()); + } + + private void createRestartButton() { + GameScreenLayout.GameOverButtons buttons = + GameScreenLayout.gameOverButtons( + Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), uiScale); + setRect(restartButton, buttons.restartButton()); + setRect(menuButton, buttons.menuButton()); + } + + private void renderPauseMenu() { + if (pauseOverlayRenderer == null) { + rebuildPauseOverlayRenderer(); + } + pauseOverlayRenderer.render( + renderModelAssembler.pauseOverlayModel( + resumeButton, + exitButton, + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + NEON_GREEN, + NEON_RED, + NEON_CYAN, + NEON_PINK, + inputController.isPointerOver(resumeButton), + inputController.isPointerPressed(resumeButton), + inputController.isPointerOver(exitButton), + inputController.isPointerPressed(exitButton))); + } + + private void rebuildPauseOverlayRenderer() { + disposePauseOverlayRenderer(); + pauseOverlayRenderer = + new GameScreenPauseOverlayRenderer( + batch, + shapeRenderer, + fontAssets.font(), + pauseScreenshotOwner, + scratchColor); + pauseOverlayRenderer.initShader(); + } + + private void disposePauseOverlayRenderer() { + if (pauseOverlayRenderer != null) { + pauseOverlayRenderer.dispose(); + pauseOverlayRenderer = null; + } + } + + private void restartGame() { + startRun(RunStartMode.RESTART); + gameState.resetGameState(); + } + + private void startRun(RunStartMode startMode) { + if (startMode != RunStartMode.RESTART) { + return; + } + runSession.resetForNewRun(); + gameClock.resetToWallNow(); + long now = gameClock.nowMs(); + resetCombatStateForNewRun(now); + waveController.resetForNewRun(now); + gameOver = false; + + shopController.reset(); + } + + @Override + public void shootAtNearestCircle(long nowMs) { + boolean shot = + playerShootingSystem.shootAtNearestCircle( + circles, + square, + bulletSystem, + gameplayEventSink, + new PlayerShootingSystem.ShotConfig( + SHOOTING_RANGE, + gameScale, + adaptedBulletRadius, + getActiveBonusSpeedMultiplier(), + getActiveBonusDamageMultiplier(), + getEffectiveCritChance(), + getEffectiveCritMultiplier())); + if (shot) { + lastShotTime = nowMs; + } + } + + @Override + public void render(float delta) { + + + impactEffects.resetProjection(shapeRenderer, batch); + + if (renderPausedFrame()) return; + + delta = prepareActiveFrame(delta); + if (renderTerminalFrame()) return; + if (updateRunFrame(delta)) return; + + renderLiveFrame(); + capturePauseRequestIfNeeded(); + } + + private boolean renderPausedFrame() { + if (isPaused) { + renderPauseMenu(); + return true; + } + return false; + } + + private float prepareActiveFrame(float delta) { + float adjustedDelta = impactEffects.applyFrameEffects(delta, shapeRenderer, batch); + updateFrameVisualState(adjustedDelta); + renderBackgroundAndMenuButton(); + return adjustedDelta; + } + + private boolean renderTerminalFrame() { + if (gameOver) { + renderGameOver(); + return true; + } + return false; + } + + private boolean updateRunFrame(float delta) { + return runFrameOrchestrator.update( + delta, + gameClock.nowMs(), + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + gameScale, + upgradeSystem.isPickActive()); + } + + private void renderLiveFrame() { + populateLiveRenderModels(); + gameObjectRenderer.renderGameObjects(); + vfxRenderer.renderVfx(); + hudRenderer.renderHud(); + if (upgradeSystem.isPickActive()) { + upgradePickOverlay.render( + shapeRenderer, + batch, + fontAssets.upgradePickFont(), + upgradeSystem.activeOfferViews(), + upgradeSystem.rerollsRemaining(), + upgradeSystem.rerollCost(shopUpgradeSystem.cardRerollDiscount()), + uiScale, + UPGRADE_PICK_TITLE); + } + } + + private void populateLiveRenderModels() { + long now = gameClock.nowMs(); + boolean reviewState = smokeDeterministicReviewStateEnabled(); + Array renderCircles = reviewState ? smokeVisualCircles : circles; + Array renderBonusBalls = reviewState ? smokeVisualBonusBalls : bonusBalls; + Array renderBulletParticles = + reviewState ? smokeVisualBulletParticles : particleEffects.bulletParticles(); + Array renderFloatingTexts = + reviewState ? smokeVisualFloatingTexts : floatingTextSystem.items(); + boolean renderWaveActive = reviewState || waveController.isWaveActive(); + long renderEnemiesPerWave = reviewState ? 9L : waveController.getEnemiesPerWave(); + long renderEnemiesSpawned = reviewState ? 3L : waveController.getEnemiesSpawnedThisWave(); + int renderCurrentWave = reviewState ? 3 : waveController.getCurrentWave(); + int renderMoney = reviewState ? 8 : playerCombatState.getMoney(); + int renderRunCurrencyEarned = reviewState ? 15 : playerCombatState.getRunCurrencyEarned(); + int renderDamage = + reviewState + ? 12_400 + : bulletSystem.getEffectiveDamage(getActiveBonusDamageMultiplier()); + float renderSpeed = + reviewState + ? 450f + : bulletSystem.getEffectiveSpeed(getActiveBonusSpeedMultiplier()); + int renderHealthRegen = reviewState ? 1_200 : playerCombatState.getHealthRegenRate(); + float renderCooldown = reviewState ? 800f : getEffectiveShotCooldown(); + float renderCritChance = reviewState ? 0.05f : getEffectiveCritChance(); + float renderTime = reviewState ? 0f : time; + renderModelAssembler.populateLiveModels( + shapeRenderer, + batch, + fontAssets.font(), + fontAssets.floatingTextFont(), + fontAssets.hudFonts(), + materialAssets, + square, + renderCircles, + renderBonusBalls, + bulletSystem.getBullets(), + renderBulletParticles, + renderFloatingTexts, + abilitySystem, + now, + renderTime, + pulseSpeed, + uiScale, + gameScale, + adaptedSquareSize, + SHOOTING_RANGE, + reviewState, + waveController.getCurrentStrongCircleHealth(), + waveController.getCurrentBasicCircleHealth(), + viewportSize.getWidth(), + viewportSize.getHeight(), + renderWaveActive, + renderEnemiesPerWave, + renderEnemiesSpawned, + renderCurrentWave, + waveController.getWaveStartTime(), + waveController.getTimeBetweenWaves(), + playerCombatState.getHealth(), + playerCombatState.getMaxHealth(), + renderMoney, + renderRunCurrencyEarned, + renderDamage, + renderSpeed, + renderHealthRegen, + renderCooldown, + renderCritChance, + getEffectiveCritMultiplier(), + WAVE_INDICATOR_HEIGHT, + WAVE_INDICATOR_PADDING, + SHADOW_WEAVER_COLOR, + GLACIAL_WARDEN_COLOR, + BASIC_CIRCLE_COLOR, + FAST_CIRCLE_COLOR, + SPLITTER_CIRCLE_COLOR, + SPAWNER_CIRCLE_COLOR, + NEON_RED, + NEON_BLUE, + NEON_CYAN, + NEON_GREEN, + NEON_YELLOW, + NEON_PINK, + NEON_PURPLE, + PRICE_TEXT_COLOR, + BOSS_PROJECTILE_COLOR, + scratchColor, + scratchColor3, + scratchColor4, + scratchColor5); + shopController.setDeterministicReviewPricing(reviewState); + ShopUpgradePreviewValues shopPreviewValues = + ShopUpgradePreviewValues.fromEffectiveStats( + shopUpgradeSystem, + renderDamage, + renderSpeed, + renderCooldown, + renderCritChance, + getEffectiveCritMultiplier(), + playerCombatState.getMaxHealth(), + renderHealthRegen, + reviewState ? 1f : getActiveBonusDamageMultiplier(), + reviewState ? 1f : getActiveBonusSpeedMultiplier(), + reviewState + ? 1f + : upgradeSystem.getRunCooldownMult() + * bonusBallSystem.getCooldownMultiplier()); + shopController.fillHudPanelFields(hudModel, shopPreviewValues, abilitySystem, now); + hudModel.setHudHelpVisible(hudHelpVisible); + } + + private void capturePauseRequestIfNeeded() { + if (shouldPause) { + pauseScreenshotOwner.capture( + 0, 0, Gdx.graphics.getBackBufferWidth(), Gdx.graphics.getBackBufferHeight()); + gameClock.pause(); + isPaused = true; + shouldPause = false; + } + } + + private void updateFrameVisualState(float delta) { + time += delta; + if (!smokeShopOpeningStateEnabled()) { + shopController.updateTransition(delta); + } + upgradePickOverlay.update(delta); + particleEffects.update(delta, Gdx.graphics.getWidth(), Gdx.graphics.getHeight()); + particleEffects.emitBulletTrails( + delta, bulletSystem.getBullets(), NEON_GREEN, NEON_YELLOW, BOSS_PROJECTILE_COLOR); + floatingTextSystem.update(delta); + } + + private void renderBackgroundAndMenuButton() { + Gdx.gl.glClearColor( + BACKGROUND_COLOR.r, BACKGROUND_COLOR.g, BACKGROUND_COLOR.b, BACKGROUND_COLOR.a); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + float backgroundTime = smokeDeterministicReviewStateEnabled() ? 0f : time; + backgroundRenderer.render( + shapeRenderer, + smokeDeterministicReviewStateEnabled() + ? smokeVisualBackgroundParticles + : particleEffects.backgroundParticles(), + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + backgroundTime, + uiScale, + NEON_BLUE, + scratchColor); + float menuGlow = 0.3f + 0.3f * (float) Math.sin(time * pulseSpeed); + menuButtonRenderer.render( + shapeRenderer, + gameMenuButton, + NEON_RED, + NEON_CYAN, + NEON_PINK, + Color.WHITE, + scratchColor, + uiScale, + menuGlow, + inputController.isPointerOver(gameMenuButton), + inputController.isPointerPressed(gameMenuButton)); + } + + @Override + public void resize(int width, int height) { + float previousPlayerCenterX = playerCenterX(); + float previousPlayerCenterY = playerCenterY(); + + calculateScaleFactor(); + + updateProjectionForResize(width, height); + resizePlayerSquare(width, height); + createGameMenuButton(); + resizeSpawnedCircles(previousPlayerCenterX, previousPlayerCenterY); + rebuildSmokeVisualFixture(); + + bulletSystem.updateRadius(adaptedBulletRadius); + + rebuildShopController(); + + + + if (upgradeSystem != null && upgradeSystem.isPickActive()) { + upgradePickOverlay.rebuild(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), uiScale); + } + + inputController.install(); + + fontAssets.rebuild(adaptedFontSize); + materialAssets.rebuild(); + rebuildPauseOverlayRenderer(); + } + + private void updateProjectionForResize(int width, int height) { + if (shapeRenderer != null) { + shapeRenderer.getProjectionMatrix().setToOrtho2D(0, 0, width, height); + } + if (batch != null) { + batch.getProjectionMatrix().setToOrtho2D(0, 0, width, height); + } + impactEffects.captureBaseProjection(shapeRenderer, batch); + } + + private void resizePlayerSquare(int width, int height) { + square.width = adaptedSquareSize; + square.height = adaptedSquareSize; + square.x = width / 2f - adaptedSquareSize / 2f; + square.y = height * PLAYER_CENTER_Y_RATIO - adaptedSquareSize / 2f; + } + + private void resizeSpawnedCircles(float previousPlayerCenterX, float previousPlayerCenterY) { + runSession.resizeActiveCircles( + playerCenterX() - previousPlayerCenterX, + playerCenterY() - previousPlayerCenterY, + adaptedCircleRadius); + } + + @Override + public void pause() { + gameClock.pause(); + } + + @Override + public void resume() { + if (!isPaused) { + gameClock.resume(); + } + } + + @Override + public void hide() { + + inputController.uninstall(); + gameState.setSpawnedBosses(spawnedBosses); + } + + @Override + public void dispose() { + if (batch != null) { + batch.dispose(); + batch = null; + } + if (shapeRenderer != null) { + shapeRenderer.dispose(); + shapeRenderer = null; + } + fontAssets.dispose(); + materialAssets.dispose(); + soundAssets.dispose(); + if (runSession != null) { + runSession.dispose(); + runSession = null; + } + disposePauseOverlayRenderer(); + pauseScreenshotOwner.disposeAndClear(); + } + + @Override + public void gameOver() { + gameOver = true; + waveController.setWaveActive(false); + createRestartButton(); + + int wave = waveController.getCurrentWave(); + int currencyEarned = playerCombatState.awardGameOverCurrencyOnce(wave); + gameplayEventSink.emit(GameplayEvents.gameOver(wave, currencyEarned)); + } + + private void renderGameOver() { + gameOverOverlayRenderer.render( + renderModelAssembler.gameOverOverlayModel( + shapeRenderer, + batch, + fontAssets.font(), + fontAssets.gameOverTitleFont(), + restartButton, + menuButton, + uiScale, + time, + pulseSpeed, + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + waveController.getCurrentWave(), + playerCombatState.getMoney(), + inputController.isPointerOver(restartButton), + inputController.isPointerPressed(restartButton), + inputController.isPointerOver(menuButton), + inputController.isPointerPressed(menuButton), + NEON_RED, + NEON_BLUE, + NEON_CYAN, + NEON_PINK, + scratchColor, + scratchColor2)); + } + + private float getAbilityScalingFactor() { + return PlayerRunCombatCalculator.abilityScalingFactor(waveController.getCurrentWave()); + } + + private void returnToMainMenu() { + Gdx.app.postRunnable( + () -> { + if (navigator != null) { + navigator.showMainMenu(); + } + }); + } + + private void saveGameSnapshot() { + if (runSnapshotCoordinator == null) { + runSnapshotCoordinator = new RunSnapshotCoordinator(createRunSnapshotHost()); + } + runSnapshotCoordinator.saveGameSnapshot(); + } + + private void resetCombatStateForNewRun(long now) { + PlayerRunCombatCalculator.resetCombatStateForNewRun(playerCombatState, talentTree, now); + } + + private float getEffectiveShotCooldown() { + return PlayerRunCombatCalculator.effectiveShotCooldown( + playerCombatState, + upgradeSystem, + bonusBallSystem, + BossPlayerCombatModifiers.attackSpeedMultiplier(circles)); + } + + private float getActiveBonusSpeedMultiplier() { + return PlayerRunCombatCalculator.applyProjectileSpeedMultiplier( + bonusBallSystem.getSpeedMultiplier(), + BossPlayerCombatModifiers.projectileSpeedMultiplier(circles)); + } + + private float getActiveBonusDamageMultiplier() { + return bonusBallSystem.getDamageMultiplier(); + } + + private float getEffectiveCritChance() { + return PlayerRunCombatCalculator.effectiveCritChance( + playerCombatState, shopUpgradeSystem, upgradeSystem, talentTree); + } + + private float getEffectiveCritMultiplier() { + return PlayerRunCombatCalculator.effectiveCritMultiplier( + playerCombatState, shopUpgradeSystem); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/MainScreen.java b/core/src/main/java/ru/project/tower/screens/MainScreen.java new file mode 100644 index 0000000..eba6083 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/MainScreen.java @@ -0,0 +1,454 @@ +package ru.project.tower.screens; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.NinePatch; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.g2d.TextureAtlas; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.scenes.scene2d.InputEvent; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; +import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable; +import com.badlogic.gdx.utils.viewport.ScreenViewport; +import ru.project.tower.DebugLog; +import ru.project.tower.GameContext; +import ru.project.tower.navigation.ScreenNavigation; +import ru.project.tower.navigation.ScreenNavigator; +import ru.project.tower.screens.common.NeonMenuParticleSystem; +import ru.project.tower.screens.main.MainScreenScaleMetrics; +import ru.project.tower.support.RobotoFontFactory; +import ru.project.tower.support.ScreenAssets; +import ru.project.tower.support.ScreenPalette; +import ru.project.tower.support.UiAtlasBuilder; + +public class MainScreen implements Screen { + private Stage stage; + private Skin skin; + private TextureAtlas uiAtlas; + private ScreenAssets screenAssets; + private SpriteBatch batch; + private final ScreenNavigator navigator; + private BitmapFont font; + private BitmapFont titleFont; + private ShapeRenderer shapeRenderer; + + private final Color neonBlue = ScreenPalette.BLUE.create(); + private final Color neonPink = ScreenPalette.PINK.create(); + private final Color neonCyan = ScreenPalette.CYAN.create(); + private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create(); + private final Color backgroundColor = ScreenPalette.SCREEN.create(); + private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create(); + private final Color scratchColor = new Color(); + private final GlyphLayout titleLayout = new GlyphLayout(); + + private float uiScale; + private int adaptedFontSize; + + private final NeonMenuParticleSystem particles = new NeonMenuParticleSystem(); + private float time = 0; + private float pulseSpeed = 2.0f; + private static final String TITLE_TOP = "Square"; + private static final String TITLE_BOTTOM = "Defense"; + private static final float TITLE_FONT_SCALE = 1.32f; + private static final float TITLE_LINE_GAP_SCALE = 0.92f; + + public MainScreen(Game game, GameContext ctx) { + this(ScreenNavigation.forGame(game, ctx)); + } + + public MainScreen(ScreenNavigator navigator) { + this.navigator = navigator; + } + + @Override + public void show() { + dispose(); + initializeRenderResources(); + calculateUIScale(); + initializeStage(); + rebuildGeneratedMenuResources(); + createMenuTable(); + initializeParticles(); + } + + private void rebuildGeneratedMenuResources() { + disposeGeneratedMenuResources(); + createFont(); + screenAssets = new ScreenAssets(); + skin = screenAssets.skin(); + screenAssets.font("default", font); + createButtonStyle(); + } + + private void initializeRenderResources() { + batch = new SpriteBatch(); + shapeRenderer = new ShapeRenderer(); + } + + private void initializeStage() { + stage = new Stage(new ScreenViewport()); + Gdx.input.setInputProcessor(stage); + } + + private void createFont() { + font = + RobotoFontFactory.generate( + RobotoFontFactory.spec( + adaptedFontSize, + 2f, + neonPurple, + Color.WHITE, + 1, + 1, + fontShadowColor)); + titleFont = RobotoFontFactory.generate(createTitleFontSpec()); + } + + private RobotoFontFactory.FontSpec createTitleFontSpec() { + int titleSize = Math.round(adaptedFontSize * TITLE_FONT_SCALE * uiScale); + int titleShadowOffset = Math.max(1, Math.round(TITLE_FONT_SCALE * uiScale)); + return RobotoFontFactory.spec( + titleSize, + 2f * TITLE_FONT_SCALE * uiScale, + neonPurple, + Color.WHITE, + titleShadowOffset, + titleShadowOffset, + fontShadowColor); + } + + private TextButton.TextButtonStyle createButtonStyle() { + TextButton.TextButtonStyle buttonStyle = new TextButton.TextButtonStyle(); + buttonStyle.font = font; + buttonStyle.fontColor = Color.WHITE; + buttonStyle.overFontColor = neonCyan; + buttonStyle.downFontColor = neonPink; + + try (UiAtlasBuilder atlasBuilder = new UiAtlasBuilder(128, 128)) { + atlasBuilder.add("btn_up", buildBorderPixmap(neonCyan, 2)); + atlasBuilder.add("btn_over", buildBorderPixmap(neonCyan, 3)); + atlasBuilder.add("btn_down", buildBorderPixmap(neonPink, 3)); + uiAtlas = screenAssets.setAtlas(atlasBuilder.build()); + } + + buttonStyle.up = + new NinePatchDrawable(new NinePatch(uiAtlas.findRegion("btn_up"), 3, 3, 3, 3)); + buttonStyle.over = + new NinePatchDrawable(new NinePatch(uiAtlas.findRegion("btn_over"), 3, 3, 3, 3)); + buttonStyle.down = + new NinePatchDrawable(new NinePatch(uiAtlas.findRegion("btn_down"), 3, 3, 3, 3)); + + skin.add("default", buttonStyle); + return buttonStyle; + } + + private void createMenuTable() { + Table table = createMainMenuTable(); + MenuButtonLayout layout = calculateMenuButtonLayout(); + + TextButton gameButton = addMenuButton(table, "ИГРАТЬ", layout); + TextButton talentButton = addMenuButton(table, "ТАЛАНТЫ", layout); + TextButton abilityShopButton = addMenuButton(table, "МАГАЗИН", layout); + + installMenuButtonListeners(gameButton, talentButton, abilityShopButton); + } + + private Table createMainMenuTable() { + Table table = new Table(); + table.setFillParent(true); + table.padTop(Gdx.graphics.getHeight() * 0.22f); + stage.addActor(table); + return table; + } + + private MenuButtonLayout calculateMenuButtonLayout() { + float minButtonWidth = 180 * uiScale; + float maxButtonWidth = Gdx.graphics.getWidth() * 0.7f; + float buttonWidth = Math.min(maxButtonWidth, Math.max(minButtonWidth, 300 * uiScale)); + + float minButtonHeight = 50 * uiScale; + float maxButtonHeight = Gdx.graphics.getHeight() * 0.12f; + float buttonHeight = Math.min(maxButtonHeight, Math.max(minButtonHeight, 60 * uiScale)); + + return new MenuButtonLayout(buttonWidth, buttonHeight, Math.max(10, 20 * uiScale)); + } + + private TextButton addMenuButton(Table table, String text, MenuButtonLayout layout) { + TextButton button = new TextButton(text, skin); + table.add(button).size(layout.buttonWidth, layout.buttonHeight).pad(layout.buttonPadding); + table.row(); + return button; + } + + private void installMenuButtonListeners( + TextButton gameButton, TextButton talentButton, TextButton abilityShopButton) { + gameButton.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent event, float x, float y) { + navigator.showAbilitySelection(); + } + }); + + talentButton.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent event, float x, float y) { + handleTalentClick(); + } + }); + + abilityShopButton.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent event, float x, float y) { + handleAbilityShopClick(); + } + }); + } + + private void handleTalentClick() { + try { + DebugLog.log("MainScreen", "Attempting to open TalentTreeScreen..."); + navigator.showTalentTree(); + } catch (RuntimeException e) { + Gdx.app.error("MainScreen", "Error opening talents", e); + } + } + + private void handleAbilityShopClick() { + try { + navigator.showAbilityShop(); + } catch (Exception e) { + Gdx.app.error("MainScreen", "Error opening ability shop", e); + } + } + + private static final class MenuButtonLayout { + private final float buttonWidth; + private final float buttonHeight; + private final float buttonPadding; + + private MenuButtonLayout(float buttonWidth, float buttonHeight, float buttonPadding) { + this.buttonWidth = buttonWidth; + this.buttonHeight = buttonHeight; + this.buttonPadding = buttonPadding; + } + } + + private void initializeParticles() { + particles.init( + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + neonBlue, + neonPink, + neonCyan, + neonPurple); + } + + private void renderNeonTitleText() { + time += Gdx.graphics.getDeltaTime(); + + float glow = 0.5f + 0.5f * (float) Math.sin(time * pulseSpeed); + float scale = 1.0f + 0.1f * glow; + + batch.begin(); + + titleFont.getData().setScale(scale); + + float titleY = Gdx.graphics.getHeight() * 0.86f; + float lineGap = titleFont.getLineHeight() * TITLE_LINE_GAP_SCALE * scale; + + drawCenteredTitleLine(TITLE_TOP, titleY, glow); + drawCenteredTitleLine(TITLE_BOTTOM, titleY - lineGap, glow); + + titleFont.getData().setScale(1.0f); + + batch.end(); + } + + private void drawCenteredTitleLine(String text, float y, float glow) { + titleLayout.setText(titleFont, text); + float x = (Gdx.graphics.getWidth() - titleLayout.width) / 2f; + drawTitleLine(text, x, y, glow); + } + + private void drawTitleLine(String text, float x, float y, float glow) { + drawGlowLayer(batch, text, x, y, ScreenPalette.PINK, 0.15f + 0.18f * glow, 3f); + drawGlowLayer(batch, text, x, y, ScreenPalette.BLUE, 0.26f + 0.22f * glow, 2f); + drawGlowLayer(batch, text, x, y, ScreenPalette.CYAN, 0.45f + 0.26f * glow, 1f); + + titleFont.setColor(Color.WHITE); + titleFont.draw(batch, text, x, y); + } + + private void drawGlowLayer( + SpriteBatch batch, + String text, + float x, + float y, + ScreenPalette palette, + float alpha, + float offset) { + titleFont.setColor(palette.set(scratchColor, alpha)); + titleFont.draw(batch, text, x + offset, y + offset); + titleFont.draw(batch, text, x - offset, y - offset); + } + + private void calculateUIScale() { + float screenWidth = Gdx.graphics.getWidth(); + float screenHeight = Gdx.graphics.getHeight(); + + MainScreenScaleMetrics metrics = + MainScreenScaleMetrics.calculate(screenWidth, screenHeight); + uiScale = metrics.uiScale(); + adaptedFontSize = metrics.adaptedFontSize(); + + if (DebugLog.DEBUG) { + logScaleMetrics(screenWidth, screenHeight, metrics); + } + } + + private void logScaleMetrics( + float screenWidth, float screenHeight, MainScreenScaleMetrics metrics) { + DebugLog.log( + "MainScreen", + "UI Scale: " + + uiScale + + ", Font Size: " + + adaptedFontSize + + ", Portrait: " + + metrics.isPortrait() + + ", Screen: " + + screenWidth + + "x" + + screenHeight); + } + + private void renderNeonGrid() { + + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + + float alpha = 0.2f + 0.1f * (float) Math.sin(time * 0.5f); + shapeRenderer.setColor(ScreenPalette.BLUE.set(scratchColor, alpha)); + + float spacing = 50f * uiScale; + float offset = (time * 10f) % spacing; + + for (float y = offset; y < Gdx.graphics.getHeight(); y += spacing) { + shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y); + } + + for (float x = offset; x < Gdx.graphics.getWidth(); x += spacing) { + shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight()); + } + + shapeRenderer.end(); + } + + @Override + public void render(float delta) { + + time += delta; + particles.update( + delta, + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + neonBlue, + neonPink, + neonCyan, + neonPurple); + + Gdx.gl.glClearColor( + backgroundColor.r, backgroundColor.g, backgroundColor.b, backgroundColor.a); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + renderNeonGrid(); + + particles.render(shapeRenderer); + + renderNeonTitleText(); + + stage.act(delta); + stage.draw(); + } + + @Override + public void resize(int width, int height) { + + calculateUIScale(); + + stage.getViewport().update(width, height, true); + stage.clear(); + disposeGeneratedMenuResources(); + rebuildGeneratedMenuResources(); + createMenuTable(); + initializeParticles(); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + Gdx.input.setInputProcessor(null); + } + + @Override + public void dispose() { + if (stage != null) { + stage.dispose(); + stage = null; + } + disposeGeneratedMenuResources(); + if (batch != null) { + batch.dispose(); + batch = null; + } + if (shapeRenderer != null) { + shapeRenderer.dispose(); + shapeRenderer = null; + } + } + + private void disposeGeneratedMenuResources() { + if (screenAssets != null) { + screenAssets.dispose(); + } + screenAssets = null; + skin = null; + font = null; + if (titleFont != null) { + titleFont.dispose(); + } + titleFont = null; + uiAtlas = null; + } + + + private static Pixmap buildBorderPixmap(Color borderColor, int borderThickness) { + Pixmap px = new Pixmap(20, 20, Pixmap.Format.RGBA8888); + px.setColor(0.05f, 0.07f, 0.12f, 0.92f); + px.fill(); + px.setColor(borderColor); + for (int i = 0; i < borderThickness; i++) { + px.drawRectangle(i, i, 20 - i * 2, 20 - i * 2); + } + return px; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/TalentTreeScreen.java b/core/src/main/java/ru/project/tower/screens/TalentTreeScreen.java new file mode 100644 index 0000000..82ebd35 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/TalentTreeScreen.java @@ -0,0 +1,796 @@ +package ru.project.tower.screens; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.InputMultiplexer; +import com.badlogic.gdx.Screen; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.g2d.Batch; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.TextureAtlas; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.math.Vector3; +import com.badlogic.gdx.scenes.scene2d.Actor; +import com.badlogic.gdx.scenes.scene2d.InputEvent; +import com.badlogic.gdx.scenes.scene2d.Stage; +import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.TextButton; +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; +import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable; +import com.badlogic.gdx.utils.viewport.ScreenViewport; +import java.util.List; +import ru.project.tower.DebugLog; +import ru.project.tower.GameContext; +import ru.project.tower.navigation.ScreenNavigation; +import ru.project.tower.navigation.ScreenNavigator; +import ru.project.tower.player.PlayerStats; +import ru.project.tower.screens.common.NeonMenuParticleSystem; +import ru.project.tower.screens.talenttree.TalentTreeScaleMetrics; +import ru.project.tower.screens.talenttree.TalentTreeScreenDependencies; +import ru.project.tower.support.RobotoFontFactory; +import ru.project.tower.support.ScreenAssets; +import ru.project.tower.support.ScreenPalette; +import ru.project.tower.support.UiAtlasBuilder; +import ru.project.tower.talents.TalentData; +import ru.project.tower.talents.TalentTree; + +public class TalentTreeScreen implements Screen { + private static final String SMOKE_STATE_PROPERTY = "tower.smoke.state"; + private static final String SMOKE_TALENT_POPUP_STATE = "talentPopup"; + + private final ScreenNavigator navigator; + private Stage uiStage; + private Stage worldStage; + private ShapeRenderer shapeRenderer; + private final TalentTree talentTree; + private final PlayerStats playerStats; + private Skin skin; + private TextureAtlas uiAtlas; + private ScreenAssets screenAssets; + private BitmapFont font; + private BitmapFont smallFont; + private Table infoPopup; + private Label nameLabel, descLabel, costLabel, balanceLabel; + private TextButton upgradeButton; + private TalentNodeActor selectedNode; + private Vector3 lastTouch = new Vector3(); + private boolean isDragging = false; + private float time = 0; + + private final Color neonBlue = ScreenPalette.BLUE.create(); + private final Color neonCyan = ScreenPalette.CYAN.create(); + private final Color neonPurple = ScreenPalette.NEON_LIGHT_PURPLE.create(); + private final Color lockedGray = ScreenPalette.LOCKED_GRAY.create(); + private final Color purchasedColor = ScreenPalette.GREEN.create(); + private final Color backgroundColor = ScreenPalette.SCREEN.create(); + private final Color fontShadowColor = ScreenPalette.FONT_SHADOW.create(); + private final Color scratchColor = new Color(); + private final Color scratchColor2 = new Color(); + private final Vector2 treeCenter = new Vector2(); + + private float uiScale; + private int adaptedFontSize; + private final NeonMenuParticleSystem particles = + new NeonMenuParticleSystem(NeonMenuParticleSystem.Profile.DRIFTING); + + public TalentTreeScreen(Game game, GameContext ctx) { + this(ScreenNavigation.forGame(game, ctx), TalentTreeScreenDependencies.from(ctx)); + } + + public TalentTreeScreen(ScreenNavigator navigator, TalentTreeScreenDependencies dependencies) { + this.navigator = navigator; + this.talentTree = dependencies.talentTree(); + this.playerStats = dependencies.playerStats(); + } + + @Override + public void show() { + DebugLog.log("TalentTreeScreen", "Initializing..."); + try { + this.shapeRenderer = new ShapeRenderer(); + + calculateUIScale(); + + this.worldStage = new Stage(new ScreenViewport()); + this.uiStage = new Stage(new ScreenViewport()); + + rebuildScreenAssets(); + initTreeWorld(); + initUI(); + + centerCameraOnTree(); + applySmokeStateIfRequested(); + + particles.init( + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + neonBlue, + neonPurple, + neonBlue, + neonPurple); + + InputMultiplexer plexer = new InputMultiplexer(uiStage, worldStage); + Gdx.input.setInputProcessor(plexer); + + DebugLog.log("TalentTreeScreen", "Initialized successfully."); + } catch (RuntimeException t) { + Gdx.app.error("TalentTreeScreen", "FATAL CRASH DURING SHOW", t); + dispose(); + throw new IllegalStateException("TalentTreeScreen show failed", t); + } + } + + private void rebuildScreenAssets() { + screenAssets = new ScreenAssets(); + initFonts(); + initSkin(); + } + + private void initFonts() { + BitmapFont generatedFont = null; + BitmapFont generatedSmallFont = null; + if (RobotoFontFactory.exists()) { + try { + RobotoFontFactory.FontSpec spec = + RobotoFontFactory.spec( + adaptedFontSize, + 1.5f * (uiScale < 1 ? 1 : uiScale), + neonPurple, + Color.WHITE, + 1, + 1, + fontShadowColor) + .withCharacters(RobotoFontFactory.FONT_CHARACTERS_WITH_DEFAULTS); + generatedFont = RobotoFontFactory.generate(spec); + + generatedSmallFont = + RobotoFontFactory.generate( + spec.withSize((int) (adaptedFontSize * 0.7f)) + .withBorderWidth(1.0f * (uiScale < 1 ? 1 : uiScale))); + + font = screenAssets.font("default", generatedFont); + generatedFont = null; + smallFont = screenAssets.font("small", generatedSmallFont); + generatedSmallFont = null; + } finally { + if (generatedFont != null) generatedFont.dispose(); + if (generatedSmallFont != null) generatedSmallFont.dispose(); + } + } else { + font = screenAssets.font("default", RobotoFontFactory.fallback()); + smallFont = screenAssets.font("small", RobotoFontFactory.fallback(0.8f)); + } + } + + private void initSkin() { + skin = screenAssets.skin(); + + + int btnW = (int) (100 * uiScale); + int btnH = (int) (40 * uiScale); + + Pixmap pUp = new Pixmap(btnW, btnH, Pixmap.Format.RGBA8888); + pUp.setColor(0, 0, 0, 0.6f); + pUp.fill(); + pUp.setColor(neonCyan); + pUp.drawRectangle(0, 0, btnW, btnH); + pUp.drawRectangle(1, 1, btnW - 2, btnH - 2); + + Pixmap pDown = new Pixmap(btnW, btnH, Pixmap.Format.RGBA8888); + pDown.setColor(0.1f, 0.3f, 0.5f, 0.8f); + pDown.fill(); + pDown.setColor(neonCyan); + pDown.drawRectangle(0, 0, btnW, btnH); + + try (UiAtlasBuilder atlasBuilder = new UiAtlasBuilder(512, 256)) { + atlasBuilder.add("btn_up", pUp); + atlasBuilder.add("btn_down", pDown); + uiAtlas = screenAssets.setAtlas(atlasBuilder.build()); + } + + TextButton.TextButtonStyle style = new TextButton.TextButtonStyle(); + style.font = font; + style.up = new TextureRegionDrawable(uiAtlas.findRegion("btn_up")); + style.down = new TextureRegionDrawable(uiAtlas.findRegion("btn_down")); + style.overFontColor = neonCyan; + + skin.add("default", style); + skin.add("default", new Label.LabelStyle(font, Color.WHITE)); + skin.add("small", new Label.LabelStyle(smallFont, Color.WHITE)); + + Label.LabelStyle titleStyle = new Label.LabelStyle(font, neonCyan); + skin.add("title", titleStyle); + } + + + + + + + public static Vector2 computeTreeCenter(TalentTree tree) { + return computeTreeCenterInto(tree, new Vector2()); + } + + public static Vector2 computeTreeCenterInto(TalentTree tree, Vector2 out) { + if (out == null) { + throw new IllegalArgumentException("out must not be null"); + } + if (tree == null) return out.set(0f, 0f); + List all = tree.getAllTalents(); + if (all == null || all.isEmpty()) return out.set(0f, 0f); + float minX = Float.POSITIVE_INFINITY, maxX = Float.NEGATIVE_INFINITY; + float minY = Float.POSITIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY; + for (TalentData td : all) { + if (td == null) continue; + float x = td.getX(); + float y = td.getY(); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + if (minX == Float.POSITIVE_INFINITY) return out.set(0f, 0f); + return out.set((minX + maxX) * 0.5f, (minY + maxY) * 0.5f); + } + + private void centerCameraOnTree() { + if (worldStage == null) return; + computeTreeCenterInto(talentTree, treeCenter); + worldStage.getCamera().position.set(treeCenter.x * uiScale, treeCenter.y * uiScale, 0f); + worldStage.getCamera().update(); + } + + private void applySmokeStateIfRequested() { + if (SMOKE_TALENT_POPUP_STATE.equals(System.getProperty(SMOKE_STATE_PROPERTY, ""))) { + showTalentPopupForSmoke("keystone_splash"); + } + } + + private void showTalentPopupForSmoke(String talentId) { + if (worldStage == null || infoPopup == null) return; + com.badlogic.gdx.utils.Array actors = worldStage.getActors(); + for (int i = 0, n = actors.size; i < n; i++) { + if (!(actors.get(i) instanceof TalentNodeActor)) continue; + TalentNodeActor node = (TalentNodeActor) actors.get(i); + if (!node.talent.getId().equals(talentId)) continue; + selectedNode = node; + updatePopup(node.talent); + infoPopup.setPosition( + computePanelX(uiStage.getViewport().getWorldWidth(), infoPopup.getWidth()), + computePanelY(uiStage.getViewport().getWorldHeight(), infoPopup.getHeight())); + infoPopup.setVisible(true); + return; + } + } + + private void initTreeWorld() { + List talents = talentTree.getAllTalents(); + if (talents == null) return; + for (TalentData td : talents) { + if (td != null) worldStage.addActor(new TalentNodeActor(td)); + } + } + + private void initUI() { + createInfoPopup(); + addTopBar(); + } + + private void createInfoPopup() { + infoPopup = new Table(); + float popupW = 300 * uiScale; + float popupH = 240 * uiScale; + infoPopup.setBackground(getNeonWindowBg((int) popupW, (int) popupH)); + infoPopup.setVisible(false); + + createInfoPopupLabels(); + upgradeButton = createUpgradeButton(); + TextButton close = createInfoPopupCloseButton(); + populateInfoPopup(close, popupW, popupH); + } + + private void createInfoPopupLabels() { + nameLabel = new Label("", skin, "title"); + descLabel = new Label("", skin, "small"); + descLabel.setWrap(true); + costLabel = new Label("", skin); + } + + private TextButton createUpgradeButton() { + TextButton button = new TextButton("УЛУЧШИТЬ", skin); + button.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent event, float x, float y) { + if (selectedNode != null && !upgradeButton.isDisabled()) + purchase(selectedNode.talent); + } + }); + return button; + } + + private TextButton createInfoPopupCloseButton() { + TextButton close = new TextButton("X", skin); + close.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent e, float x, float y) { + infoPopup.setVisible(false); + selectedNode = null; + } + }); + return close; + } + + private void populateInfoPopup(TextButton close, float popupW, float popupH) { + Table inner = new Table(); + inner.add(nameLabel).pad(5 * uiScale).row(); + inner.add(descLabel).width(240 * uiScale).pad(10 * uiScale).row(); + inner.add(costLabel).pad(5 * uiScale).row(); + inner.add(upgradeButton).size(160 * uiScale, 45 * uiScale).pad(10 * uiScale); + + infoPopup.add(close).right().size(40 * uiScale, 40 * uiScale).pad(5 * uiScale).row(); + infoPopup.add(inner).expand().center(); + infoPopup.setSize(popupW, popupH); + infoPopup.setPosition( + computePanelX(uiStage.getViewport().getWorldWidth(), popupW), + computePanelY(uiStage.getViewport().getWorldHeight(), popupH)); + uiStage.addActor(infoPopup); + } + + private void addTopBar() { + Table top = new Table(); + top.setFillParent(true); + top.top(); + balanceLabel = new Label("Баланс: " + playerStats.getCurrency(), skin); + TextButton back = new TextButton("НАЗАД", skin); + back.addListener( + new ClickListener() { + @Override + public void clicked(InputEvent e, float x, float y) { + navigator.showMainMenu(); + } + }); + top.add(balanceLabel).expandX().left().pad(15 * uiScale); + top.add(back).right().pad(10 * uiScale).size(120 * uiScale, 40 * uiScale); + uiStage.addActor(top); + } + + private TextureRegionDrawable getNeonWindowBg(int w, int h) { + Pixmap pix = new Pixmap(w, h, Pixmap.Format.RGBA8888); + pix.setColor(0.05f, 0.05f, 0.1f, 0.9f); + pix.fill(); + pix.setColor(neonBlue); + pix.drawRectangle(0, 0, w, h); + pix.drawRectangle(1, 1, w - 2, h - 2); + + + pix.setColor(neonCyan); + pix.fillRectangle(0, 0, 10, 2); + pix.fillRectangle(0, 0, 2, 10); + pix.fillRectangle(w - 10, 0, 10, 2); + pix.fillRectangle(w - 2, 0, 2, 10); + pix.fillRectangle(0, h - 2, 10, 2); + pix.fillRectangle(0, h - 10, 2, 10); + pix.fillRectangle(w - 10, h - 2, 10, 2); + pix.fillRectangle(w - 2, h - 10, 2, 10); + + return screenAssets.own(pix); + } + + + + + + + + public static float computePanelX(float viewportW, float panelW) { + return Math.max(0f, (viewportW - panelW) / 2f); + } + + + + + + + public static float computePanelY(float viewportH, float panelH) { + return Math.max(0f, (viewportH - panelH) / 2f); + } + + private void purchase(TalentData t) { + int lvl = talentTree.getCurrentLevel(t.getId()); + if (talentTree.canUpgradeTalent(t.getId()) && playerStats.spendCurrency(t.getCost(lvl))) { + talentTree.upgradeTalent(t.getId()); + updatePopup(t); + balanceLabel.setText("Баланс: " + playerStats.getCurrency()); + } + } + + private void updatePopup(TalentData t) { + int lvl = talentTree.getCurrentLevel(t.getId()); + nameLabel.setText(t.getName() + " (" + lvl + "/" + t.getMaxLevel() + ")"); + descLabel.setText(t.getDescription() + "\n" + talentTree.boundedRatingText(t.getId())); + if (lvl >= t.getMaxLevel()) { + costLabel.setText("МАКС."); + upgradeButton.setDisabled(true); + } else { + int c = t.getCost(lvl); + costLabel.setText("Цена: " + c); + upgradeButton.setDisabled( + !talentTree.canUpgradeTalent(t.getId()) || playerStats.getCurrency() < c); + } + } + + @Override + public void render(float delta) { + Gdx.gl.glClearColor(backgroundColor.r, backgroundColor.g, backgroundColor.b, 1); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + + time += delta; + handleIn(); + particles.update( + delta, + Gdx.graphics.getWidth(), + Gdx.graphics.getHeight(), + uiScale, + neonBlue, + neonPurple, + neonBlue, + neonPurple); + + renderBackgroundGrid(); + shapeRenderer.setProjectionMatrix(uiStage.getCamera().combined); + particles.render(shapeRenderer); + + worldStage.act(delta); + worldStage.getCamera().update(); + shapeRenderer.setProjectionMatrix(worldStage.getCamera().combined); + + renderTalentConnections(); + renderTalentNodes(); + + worldStage.draw(); + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + uiStage.act(delta); + uiStage.draw(); + } + + private void renderTalentConnections() { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + + renderTalentConnectionGlowPass(); + renderTalentConnectionCorePass(); + + Gdx.gl.glLineWidth(1); + } + + private void renderTalentConnectionGlowPass() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + Gdx.gl.glLineWidth(5 * uiScale); + for (TalentData t : talentTree.getAllTalents()) { + for (String dep : t.getDependencyIds()) { + TalentData p = talentTree.getTalent(dep); + if (p != null) { + boolean active = talentConnectionActive(t, p); + Color baseCol = talentConnectionColor(active); + scratchColor.set(baseCol); + scratchColor.a = + (0.2f + 0.1f * MathUtils.sin(time * 2f)) * (active ? 1f : 0.5f); + shapeRenderer.setColor(scratchColor); + shapeRenderer.line( + t.getX() * uiScale, + t.getY() * uiScale, + p.getX() * uiScale, + p.getY() * uiScale); + } + } + } + shapeRenderer.end(); + } + + private void renderTalentConnectionCorePass() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + Gdx.gl.glLineWidth(1 * uiScale); + for (TalentData t : talentTree.getAllTalents()) { + for (String dep : t.getDependencyIds()) { + TalentData p = talentTree.getTalent(dep); + if (p != null) { + shapeRenderer.setColor(talentConnectionColor(talentConnectionActive(t, p))); + shapeRenderer.line( + t.getX() * uiScale, + t.getY() * uiScale, + p.getX() * uiScale, + p.getY() * uiScale); + } + } + } + shapeRenderer.end(); + } + + private boolean talentConnectionActive(TalentData child, TalentData parent) { + return talentTree.getCurrentLevel(child.getId()) > 0 + || talentTree.getCurrentLevel(parent.getId()) > 0; + } + + private Color talentConnectionColor(boolean active) { + return active ? neonBlue : lockedGray; + } + + private void renderTalentNodes() { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + shapeRenderer.setProjectionMatrix(worldStage.getCamera().combined); + + renderTalentNodeGlowPass(); + renderTalentNodeFillPass(); + renderTalentNodeBorderPass(); + + Gdx.gl.glLineWidth(1); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private void renderTalentNodeGlowPass() { + float glowScale = 1.0f + 0.1f * MathUtils.sin(time * 3f); + float alpha = 0.4f * (0.8f + 0.2f * MathUtils.sin(time * 5f)); + + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + Gdx.gl.glLineWidth(3 * uiScale); + com.badlogic.gdx.utils.Array actors = worldStage.getActors(); + for (int i = 0, n = actors.size; i < n; i++) { + if (actors.get(i) instanceof TalentNodeActor) { + TalentNodeActor node = (TalentNodeActor) actors.get(i); + scratchColor.set(talentNodeColor(node)); + scratchColor.a = alpha; + shapeRenderer.setColor(scratchColor); + node.drawHex(node.getX() + node.r, node.getY() + node.r, node.r * glowScale, false); + } + } + shapeRenderer.end(); + } + + private void renderTalentNodeFillPass() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + com.badlogic.gdx.utils.Array actors = worldStage.getActors(); + for (int i = 0, n = actors.size; i < n; i++) { + if (actors.get(i) instanceof TalentNodeActor) { + TalentNodeActor node = (TalentNodeActor) actors.get(i); + int level = talentTree.getCurrentLevel(node.talent.getId()); + boolean unlocked = talentTree.isUnlocked(node.talent.getId()); + scratchColor2.set(talentNodeColor(node)); + scratchColor2.a = (level > 0 || unlocked) ? 0.3f : 0.1f; + shapeRenderer.setColor(scratchColor2); + node.drawHex(node.getX() + node.r, node.getY() + node.r, node.r * 0.85f, true); + } + } + shapeRenderer.end(); + } + + private void renderTalentNodeBorderPass() { + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + Gdx.gl.glLineWidth(2 * uiScale); + com.badlogic.gdx.utils.Array actors = worldStage.getActors(); + for (int i = 0, n = actors.size; i < n; i++) { + if (actors.get(i) instanceof TalentNodeActor) { + TalentNodeActor node = (TalentNodeActor) actors.get(i); + shapeRenderer.setColor(talentNodeColor(node)); + node.drawHex(node.getX() + node.r, node.getY() + node.r, node.r * 0.85f, false); + } + } + shapeRenderer.end(); + } + + private Color talentNodeColor(TalentNodeActor node) { + int level = talentTree.getCurrentLevel(node.talent.getId()); + if (level > 0) return purchasedColor; + if (talentTree.isUnlocked(node.talent.getId())) return neonBlue; + return lockedGray; + } + + private void renderBackgroundGrid() { + shapeRenderer.setProjectionMatrix(uiStage.getCamera().combined); + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + float alpha = 0.1f + 0.05f * MathUtils.sin(time * 0.5f); + shapeRenderer.setColor(scratchColor.set(0.1f, 0.2f, 0.4f, alpha)); + + float cellSize = 60 * uiScale; + float offsetX = (time * 10f * uiScale) % cellSize; + float offsetY = (time * 5f * uiScale) % cellSize; + + for (float x = -offsetX; x < Gdx.graphics.getWidth(); x += cellSize) { + shapeRenderer.line(x, 0, x, Gdx.graphics.getHeight()); + } + for (float y = -offsetY; y < Gdx.graphics.getHeight(); y += cellSize) { + shapeRenderer.line(0, y, Gdx.graphics.getWidth(), y); + } + shapeRenderer.end(); + } + + private void calculateUIScale() { + TalentTreeScaleMetrics metrics = + TalentTreeScaleMetrics.calculate(Gdx.graphics.getWidth(), Gdx.graphics.getHeight()); + uiScale = metrics.uiScale(); + adaptedFontSize = metrics.adaptedFontSize(); + } + + private void handleIn() { + if (Gdx.input.isTouched()) { + if (!isDragging) { + isDragging = true; + lastTouch.set(Gdx.input.getX(), Gdx.input.getY(), 0); + } else { + float dx = lastTouch.x - Gdx.input.getX(); + float dy = Gdx.input.getY() - lastTouch.y; + worldStage.getCamera().translate(dx, dy, 0); + lastTouch.set(Gdx.input.getX(), Gdx.input.getY(), 0); + } + } else isDragging = false; + } + + @Override + public void resize(int w, int h) { + calculateUIScale(); + worldStage.getViewport().update(w, h, false); + uiStage.getViewport().update(w, h, true); + centerCameraOnTree(); + updateTalentNodeBounds(); + resizeInfoPopup(w, h); + + uiStage.clear(); + disposeScreenAssets(); + rebuildScreenAssets(); + initUI(); + applySmokeStateIfRequested(); + } + + private void updateTalentNodeBounds() { + if (worldStage == null) { + return; + } + com.badlogic.gdx.utils.Array actors = worldStage.getActors(); + for (int i = 0, n = actors.size; i < n; i++) { + if (actors.get(i) instanceof TalentNodeActor) { + ((TalentNodeActor) actors.get(i)).updateBounds(); + } + } + } + + private void resizeInfoPopup(int viewportWidth, int viewportHeight) { + if (infoPopup == null) { + return; + } + infoPopup.setSize(300 * uiScale, 240 * uiScale); + infoPopup.setPosition( + computePanelX(viewportWidth, infoPopup.getWidth()), + computePanelY(viewportHeight, infoPopup.getHeight())); + } + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void hide() { + Gdx.input.setInputProcessor(null); + } + + @Override + public void dispose() { + if (worldStage != null) { + worldStage.dispose(); + worldStage = null; + } + if (uiStage != null) { + uiStage.dispose(); + uiStage = null; + } + if (shapeRenderer != null) { + shapeRenderer.dispose(); + shapeRenderer = null; + } + disposeScreenAssets(); + } + + private void disposeScreenAssets() { + if (screenAssets != null) screenAssets.dispose(); + screenAssets = null; + skin = null; + uiAtlas = null; + font = null; + smallFont = null; + } + + private class TalentNodeActor extends Actor { + final TalentData talent; + float r = 40; + final GlyphLayout layout = new GlyphLayout(); + final String typeInitial; + final StringBuilder levelText = new StringBuilder(5); + private final float[] hexVertices = new float[12]; + + TalentNodeActor(TalentData t) { + this.talent = t; + this.typeInitial = t.getType().toString().substring(0, 1); + updateBounds(); + addListener( + new ClickListener() { + @Override + public void clicked(InputEvent e, float x, float y) { + selectedNode = TalentNodeActor.this; + updatePopup(talent); + infoPopup.setPosition( + computePanelX( + uiStage.getViewport().getWorldWidth(), + infoPopup.getWidth()), + computePanelY( + uiStage.getViewport().getWorldHeight(), + infoPopup.getHeight())); + infoPopup.setVisible(true); + } + }); + } + + void updateBounds() { + r = 40 * uiScale; + float tx = talent.getX() * uiScale; + float ty = talent.getY() * uiScale; + setBounds(tx - r, ty - r, r * 2, r * 2); + } + + @Override + public void draw(Batch batch, float alpha) { + int lvl = talentTree.getCurrentLevel(talent.getId()); + font.setColor(Color.WHITE); + layout.setText(font, typeInitial); + font.draw( + batch, + typeInitial, + getX() + r - layout.width / 2, + getY() + r + layout.height / 2); + + + levelText.setLength(0); + levelText.append(lvl).append('/').append(talent.getMaxLevel()); + smallFont.setColor(neonCyan); + layout.setText(smallFont, levelText); + smallFont.draw(batch, levelText, getX() + r - layout.width / 2, getY() - 5 * uiScale); + } + + private void drawHex(float x, float y, float radius, boolean filled) { + for (int i = 0; i < 6; i++) { + hexVertices[i * 2] = x + radius * MathUtils.cosDeg(i * 60 + 30); + hexVertices[i * 2 + 1] = y + radius * MathUtils.sinDeg(i * 60 + 30); + } + if (filled) { + for (int i = 0; i < 6; i++) { + shapeRenderer.triangle( + x, + y, + hexVertices[i * 2], + hexVertices[i * 2 + 1], + hexVertices[((i + 1) % 6) * 2], + hexVertices[((i + 1) % 6) * 2 + 1]); + } + } else { + for (int i = 0; i < 6; i++) { + shapeRenderer.line( + hexVertices[i * 2], + hexVertices[i * 2 + 1], + hexVertices[((i + 1) % 6) * 2], + hexVertices[((i + 1) % 6) * 2 + 1]); + } + } + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionDependencies.java b/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionDependencies.java new file mode 100644 index 0000000..1b9c2d8 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionDependencies.java @@ -0,0 +1,30 @@ +package ru.project.tower.screens.abilityselection; + +import java.util.Objects; +import ru.project.tower.GameContext; +import ru.project.tower.GameState; +import ru.project.tower.abilities.PlayerAbilities; + + +public final class AbilitySelectionDependencies { + private final PlayerAbilities playerAbilities; + private final GameState gameState; + + public AbilitySelectionDependencies(PlayerAbilities playerAbilities, GameState gameState) { + this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities"); + this.gameState = Objects.requireNonNull(gameState, "gameState"); + } + + public static AbilitySelectionDependencies from(GameContext ctx) { + Objects.requireNonNull(ctx, "ctx"); + return new AbilitySelectionDependencies(ctx.playerAbilities, ctx.gameState); + } + + public PlayerAbilities playerAbilities() { + return playerAbilities; + } + + public GameState gameState() { + return gameState; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionLayout.java b/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionLayout.java new file mode 100644 index 0000000..bdc3d1c --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionLayout.java @@ -0,0 +1,145 @@ +package ru.project.tower.screens.abilityselection; + +public final class AbilitySelectionLayout { + private static final int MAX_ABILITY_BUTTONS = 7; + + private final float buttonWidth; + private final float buttonHeight; + private final float buttonPadding; + private final Rect[] abilityButtons; + private final Rect startGameButton; + private final Rect backButton; + + private AbilitySelectionLayout( + float buttonWidth, + float buttonHeight, + float buttonPadding, + Rect[] abilityButtons, + Rect startGameButton, + Rect backButton) { + this.buttonWidth = buttonWidth; + this.buttonHeight = buttonHeight; + this.buttonPadding = buttonPadding; + this.abilityButtons = abilityButtons; + this.startGameButton = startGameButton; + this.backButton = backButton; + } + + public static AbilitySelectionLayout calculate( + int width, int height, float uiScale, int availableAbilityCount) { + float buttonWidth = Math.min(300 * uiScale, width * 0.6f); + float buttonHeight = Math.min(80 * uiScale, height * 0.12f); + float buttonPadding = 20 * uiScale; + int totalButtons = Math.min(MAX_ABILITY_BUTTONS, Math.max(0, availableAbilityCount)); + Rect[] abilityButtons = new Rect[totalButtons]; + + Rect startGameButton = + new Rect(width / 2f - buttonWidth / 2f, buttonPadding, buttonWidth, buttonHeight); + float abilityTop = height - Math.min(150 * uiScale, height * 0.22f); + float abilityBottom = startGameButton.y + startGameButton.height + buttonPadding; + float abilityStackSpace = Math.max(0f, abilityTop - abilityBottom); + float abilityStackHeight = + totalButtons == 0 + ? 0f + : totalButtons * buttonHeight + (totalButtons - 1) * buttonPadding; + + if (totalButtons > 0 && abilityStackHeight > abilityStackSpace) { + float compressedButtonHeight = + abilityStackSpace / (totalButtons + (totalButtons - 1) * 0.25f); + buttonHeight = Math.max(1f, compressedButtonHeight); + buttonPadding = Math.max(1f, buttonHeight * 0.25f); + abilityStackHeight = totalButtons * buttonHeight + (totalButtons - 1) * buttonPadding; + } + + float idealFirstAbilityY = height / 2f + abilityStackHeight / 2f - buttonHeight; + float minFirstAbilityY = + totalButtons == 0 + ? idealFirstAbilityY + : abilityBottom + (totalButtons - 1) * (buttonHeight + buttonPadding); + float maxFirstAbilityY = abilityTop - buttonHeight; + float firstAbilityY = clamp(idealFirstAbilityY, minFirstAbilityY, maxFirstAbilityY); + for (int i = 0; i < totalButtons; i++) { + abilityButtons[i] = + new Rect( + width / 2f - buttonWidth / 2f, + firstAbilityY - i * (buttonHeight + buttonPadding), + buttonWidth, + buttonHeight); + } + + float backSize = 50 * uiScale; + float backPadding = 30 * uiScale; + Rect backButton = + new Rect(backPadding, height - backSize - backPadding, backSize, backSize); + + return new AbilitySelectionLayout( + buttonWidth, + buttonHeight, + buttonPadding, + abilityButtons, + startGameButton, + backButton); + } + + private static float clamp(float value, float min, float max) { + return Math.min(Math.max(value, min), max); + } + + public float buttonWidth() { + return buttonWidth; + } + + public float buttonHeight() { + return buttonHeight; + } + + public float buttonPadding() { + return buttonPadding; + } + + public int abilityButtonCount() { + return abilityButtons.length; + } + + public Rect abilityButton(int index) { + return abilityButtons[index]; + } + + public Rect startGameButton() { + return startGameButton; + } + + public Rect backButton() { + return backButton; + } + + public static final class Rect { + private final float x; + private final float y; + private final float width; + private final float height; + + private Rect(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public float x() { + return x; + } + + public float y() { + return y; + } + + public float width() { + return width; + } + + public float height() { + return height; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionScaleMetrics.java b/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionScaleMetrics.java new file mode 100644 index 0000000..2f76afe --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityselection/AbilitySelectionScaleMetrics.java @@ -0,0 +1,45 @@ +package ru.project.tower.screens.abilityselection; + +public final class AbilitySelectionScaleMetrics { + private static final float BASE_WIDTH = 1280f; + private static final float BASE_HEIGHT = 720f; + + private final float uiScale; + private final int adaptedFontSize; + + private AbilitySelectionScaleMetrics(float uiScale, int adaptedFontSize) { + this.uiScale = uiScale; + this.adaptedFontSize = adaptedFontSize; + } + + public static AbilitySelectionScaleMetrics calculate( + float viewportWidth, float viewportHeight) { + float scaleX = viewportWidth / BASE_WIDTH; + float scaleY = viewportHeight / BASE_HEIGHT; + boolean isPortrait = viewportHeight > viewportWidth; + + float uiScale; + int adaptedFontSize; + if (isPortrait) { + uiScale = clamp(scaleY * 1.2f, 1.0f, 2.2f); + adaptedFontSize = (int) Math.min(32, Math.max(18, 22 * uiScale)); + } else { + uiScale = clamp(Math.min(scaleX, scaleY) * 1.2f, 0.8f, 1.8f); + adaptedFontSize = (int) Math.min(28, Math.max(16, 20 * uiScale)); + } + + return new AbilitySelectionScaleMetrics(uiScale, adaptedFontSize); + } + + private static float clamp(float value, float min, float max) { + return Math.min(Math.max(min, value), max); + } + + public float uiScale() { + return uiScale; + } + + public int adaptedFontSize() { + return adaptedFontSize; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopCardView.java b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopCardView.java new file mode 100644 index 0000000..8a16c52 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopCardView.java @@ -0,0 +1,70 @@ +package ru.project.tower.screens.abilityshop; + +import ru.project.tower.abilities.AbilityKind; + +public final class AbilityShopCardView { + private final String name; + private final String priceText; + private final String unlockText; + private final String detailsText; + private final String buttonText; + private final boolean buttonDisabled; + + private AbilityShopCardView( + String name, + String priceText, + String unlockText, + String detailsText, + String buttonText, + boolean buttonDisabled) { + this.name = name; + this.priceText = priceText; + this.unlockText = unlockText; + this.detailsText = detailsText; + this.buttonText = buttonText; + this.buttonDisabled = buttonDisabled; + } + + public static AbilityShopCardView forAbility(AbilityKind ability, boolean owned) { + return forAbility(ability, owned, ability.unlockWaveRequirement()); + } + + public static AbilityShopCardView forAbility( + AbilityKind ability, boolean owned, int maxWaveReached) { + boolean locked = !owned && maxWaveReached < ability.unlockWaveRequirement(); + String priceText = ability.shopPrice() + " монет"; + return new AbilityShopCardView( + ability.selectionName(), + priceText, + ability.unlockWaveRequirement() == 0 + ? "Доступно сразу" + : "Откроется: волна " + ability.unlockWaveRequirement(), + locked ? priceText + " / волна " + ability.unlockWaveRequirement() : priceText, + owned ? "КУПЛЕНО" : locked ? "ЗАКРЫТО" : "КУПИТЬ", + owned || locked); + } + + public String name() { + return name; + } + + public String priceText() { + return priceText; + } + + public String unlockText() { + return unlockText; + } + + public String detailsText() { + return detailsText; + } + + public String buttonText() { + return buttonText; + } + + public boolean buttonDisabled() { + return buttonDisabled; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopDependencies.java b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopDependencies.java new file mode 100644 index 0000000..6a39efb --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopDependencies.java @@ -0,0 +1,30 @@ +package ru.project.tower.screens.abilityshop; + +import java.util.Objects; +import ru.project.tower.GameContext; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.player.PlayerStats; + + +public final class AbilityShopDependencies { + private final PlayerStats playerStats; + private final PlayerAbilities playerAbilities; + + public AbilityShopDependencies(PlayerStats playerStats, PlayerAbilities playerAbilities) { + this.playerStats = Objects.requireNonNull(playerStats, "playerStats"); + this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities"); + } + + public static AbilityShopDependencies from(GameContext ctx) { + Objects.requireNonNull(ctx, "ctx"); + return new AbilityShopDependencies(ctx.playerStats, ctx.playerAbilities); + } + + public PlayerStats playerStats() { + return playerStats; + } + + public PlayerAbilities playerAbilities() { + return playerAbilities; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopPurchaseService.java b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopPurchaseService.java new file mode 100644 index 0000000..d54f488 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopPurchaseService.java @@ -0,0 +1,31 @@ +package ru.project.tower.screens.abilityshop; + +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.player.PlayerStats; + +public final class AbilityShopPurchaseService { + private AbilityShopPurchaseService() {} + + public static Result tryPurchase( + AbilityKind ability, PlayerStats playerStats, PlayerAbilities playerAbilities) { + if (playerAbilities.hasAbility(ability)) { + return Result.ALREADY_OWNED; + } + if (!playerAbilities.canUnlock(ability, playerStats.getMaxWaveReached())) { + return Result.LOCKED; + } + if (!playerStats.spendCurrency(ability.shopPrice())) { + return Result.INSUFFICIENT_FUNDS; + } + playerAbilities.purchaseAbility(ability); + return Result.PURCHASED; + } + + public enum Result { + PURCHASED, + ALREADY_OWNED, + LOCKED, + INSUFFICIENT_FUNDS + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopResources.java b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopResources.java new file mode 100644 index 0000000..5b5915a --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopResources.java @@ -0,0 +1,73 @@ +package ru.project.tower.screens.abilityshop; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable; +import com.badlogic.gdx.utils.Disposable; +import java.util.IdentityHashMap; +import ru.project.tower.support.ScreenAssets; + + +public final class AbilityShopResources implements Disposable { + private final IdentityHashMap cardBackgrounds = + new IdentityHashMap<>(); + + private ScreenAssets screenAssets; + private Skin skin; + private BitmapFont font; + private BitmapFont buttonFont; + + public void rebuild() { + dispose(); + screenAssets = new ScreenAssets(); + skin = screenAssets.skin(); + } + + public ScreenAssets screenAssets() { + return screenAssets; + } + + public Skin skin() { + return skin; + } + + public BitmapFont font() { + return font; + } + + public BitmapFont buttonFont() { + return buttonFont; + } + + public void installFonts(BitmapFont font, BitmapFont buttonFont) { + this.font = screenAssets.font("default", font); + this.buttonFont = screenAssets.font("button", buttonFont); + } + + public void putCardBackground(Color color, NinePatchDrawable drawable) { + cardBackgrounds.put(color, drawable); + } + + public NinePatchDrawable cardBackground(Color color) { + return cardBackgrounds.get(color); + } + + public boolean hasCardBackgrounds() { + return !cardBackgrounds.isEmpty(); + } + + public NinePatchDrawable firstCardBackground() { + return cardBackgrounds.values().iterator().next(); + } + + @Override + public void dispose() { + if (screenAssets != null) screenAssets.dispose(); + screenAssets = null; + skin = null; + font = null; + buttonFont = null; + cardBackgrounds.clear(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopScaleMetrics.java b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopScaleMetrics.java new file mode 100644 index 0000000..35fcf93 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/abilityshop/AbilityShopScaleMetrics.java @@ -0,0 +1,41 @@ +package ru.project.tower.screens.abilityshop; + +public final class AbilityShopScaleMetrics { + private static final float BASE_WIDTH = 1280f; + private static final float BASE_HEIGHT = 720f; + + private final float uiScale; + private final int adaptedFontSize; + + private AbilityShopScaleMetrics(float uiScale, int adaptedFontSize) { + this.uiScale = uiScale; + this.adaptedFontSize = adaptedFontSize; + } + + public static AbilityShopScaleMetrics calculate(float viewportWidth, float viewportHeight) { + boolean isPortrait = viewportHeight > viewportWidth; + float scaleX = viewportWidth / BASE_WIDTH; + float scaleY = viewportHeight / BASE_HEIGHT; + + if (isPortrait) { + float phoneScale = Math.min(viewportWidth / 360f, viewportHeight / 640f); + float uiScale = clamp(phoneScale, 0.88f, 1.25f); + return new AbilityShopScaleMetrics(uiScale, (int) clamp(18 * uiScale, 16, 22)); + } + + float uiScale = clamp(Math.min(scaleX, scaleY), 0.7f, 1.5f); + return new AbilityShopScaleMetrics(uiScale, (int) clamp(20 * uiScale, 18, 28)); + } + + public float uiScale() { + return uiScale; + } + + public int adaptedFontSize() { + return adaptedFontSize; + } + + private static float clamp(float value, float min, float max) { + return Math.min(max, Math.max(min, value)); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/common/NeonMenuParticleSystem.java b/core/src/main/java/ru/project/tower/screens/common/NeonMenuParticleSystem.java new file mode 100644 index 0000000..86accd1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/common/NeonMenuParticleSystem.java @@ -0,0 +1,171 @@ +package ru.project.tower.screens.common; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.utils.Array; + +public final class NeonMenuParticleSystem { + public enum Profile { + RISING(-30f, 30f, -10f, 50f, 1f, 4f, 1f, 3f, 1f, false, 0.1f), + DRIFTING(-20f, 20f, -20f, 20f, 1f, 3f, 2f, 5f, 0.5f, true, 0f); + + final float minVelocityX; + final float maxVelocityX; + final float minVelocityY; + final float maxVelocityY; + final float minSize; + final float maxSize; + final float minLifetime; + final float maxLifetime; + final float alphaMultiplier; + final boolean replaceExpired; + final float spawnChance; + + Profile( + float minVelocityX, + float maxVelocityX, + float minVelocityY, + float maxVelocityY, + float minSize, + float maxSize, + float minLifetime, + float maxLifetime, + float alphaMultiplier, + boolean replaceExpired, + float spawnChance) { + this.minVelocityX = minVelocityX; + this.maxVelocityX = maxVelocityX; + this.minVelocityY = minVelocityY; + this.maxVelocityY = maxVelocityY; + this.minSize = minSize; + this.maxSize = maxSize; + this.minLifetime = minLifetime; + this.maxLifetime = maxLifetime; + this.alphaMultiplier = alphaMultiplier; + this.replaceExpired = replaceExpired; + this.spawnChance = spawnChance; + } + } + + private final Profile profile; + private final Array particles = new Array<>(); + + public NeonMenuParticleSystem() { + this(Profile.RISING); + } + + public NeonMenuParticleSystem(Profile profile) { + this.profile = profile; + } + + public void init( + float width, + float height, + float uiScale, + Color colorA, + Color colorB, + Color colorC, + Color colorD) { + particles.clear(); + int particleCount = (int) (50 * uiScale); + for (int i = 0; i < particleCount; i++) { + spawn( + MathUtils.random(0, width), + MathUtils.random(0, height), + uiScale, + randomColor(colorA, colorB, colorC, colorD)); + } + } + + public void update( + float delta, + float width, + float height, + float uiScale, + Color colorA, + Color colorB, + Color colorC, + Color colorD) { + for (int i = particles.size - 1; i >= 0; i--) { + NeonParticle particle = particles.get(i); + particle.update(delta); + if (particle.lifetime <= 0f) { + particles.removeIndex(i); + if (profile.replaceExpired) { + spawn( + MathUtils.random(0, width), + MathUtils.random(0, height), + uiScale, + randomColor(colorA, colorB, colorC, colorD)); + } + } + } + + if (profile.spawnChance > 0f && MathUtils.random() < profile.spawnChance) { + spawn( + MathUtils.random(0, width), + 0f, + uiScale, + randomColor(colorA, colorB, colorC, colorD)); + } + } + + public void render(ShapeRenderer shapeRenderer) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + for (NeonParticle particle : particles) { + shapeRenderer.setColor(particle.color); + shapeRenderer.circle(particle.position.x, particle.position.y, particle.size); + } + shapeRenderer.end(); + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private void spawn(float x, float y, float uiScale, Color color) { + particles.add(new NeonParticle(x, y, uiScale, color, profile)); + } + + private static Color randomColor(Color colorA, Color colorB, Color colorC, Color colorD) { + float choice = MathUtils.random(); + if (choice < 0.25f) return colorA; + if (choice < 0.5f) return colorB; + if (choice < 0.75f) return colorC; + return colorD; + } + + private static final class NeonParticle { + final Vector2 position; + final Vector2 velocity; + final Color color; + final float size; + final float maxLifetime; + final float alphaMultiplier; + float lifetime; + + private NeonParticle(float x, float y, float uiScale, Color color, Profile profile) { + position = new Vector2(x, y); + velocity = + new Vector2( + MathUtils.random(profile.minVelocityX, profile.maxVelocityX), + MathUtils.random(profile.minVelocityY, profile.maxVelocityY)); + size = MathUtils.random(profile.minSize, profile.maxSize) * uiScale; + maxLifetime = MathUtils.random(profile.minLifetime, profile.maxLifetime); + lifetime = maxLifetime; + alphaMultiplier = profile.alphaMultiplier; + this.color = new Color(color); + } + + private void update(float delta) { + position.add(velocity.x * delta, velocity.y * delta); + lifetime -= delta; + color.a = (lifetime / maxLifetime) * alphaMultiplier; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/GameScreenComposition.java b/core/src/main/java/ru/project/tower/screens/gamescreen/GameScreenComposition.java new file mode 100644 index 0000000..a72c0d9 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/GameScreenComposition.java @@ -0,0 +1,32 @@ +package ru.project.tower.screens.gamescreen; + +import java.util.Objects; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.bullets.BulletTalentProvider; +import ru.project.tower.run.RunSessionFactory; +import ru.project.tower.support.MathUtilsRandomSource; +import ru.project.tower.support.RandomSource; +import ru.project.tower.support.ViewportSize; + + +public final class GameScreenComposition { + private final GameScreenDependencies dependencies; + private final RandomSource random; + + public GameScreenComposition(GameScreenDependencies dependencies) { + this(dependencies, new MathUtilsRandomSource()); + } + + GameScreenComposition(GameScreenDependencies dependencies, RandomSource random) { + this.dependencies = Objects.requireNonNull(dependencies, "dependencies"); + this.random = Objects.requireNonNull(random, "random"); + } + + public BulletSystem createBulletSystem() { + return new BulletSystem(BulletTalentProvider.fromTalentTree(dependencies.talentTree())); + } + + public RunSessionFactory createRunSessionFactory(ViewportSize viewportSize) { + return new RunSessionFactory(random, Objects.requireNonNull(viewportSize, "viewportSize")); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/GameScreenDependencies.java b/core/src/main/java/ru/project/tower/screens/gamescreen/GameScreenDependencies.java new file mode 100644 index 0000000..2313cbf --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/GameScreenDependencies.java @@ -0,0 +1,71 @@ +package ru.project.tower.screens.gamescreen; + +import java.util.Objects; +import ru.project.tower.GameContext; +import ru.project.tower.GameState; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.player.PlayerStats; +import ru.project.tower.screens.gamescreen.layout.SafeAreaInsetsProvider; +import ru.project.tower.support.GameAssetService; +import ru.project.tower.talents.TalentTree; + + +public final class GameScreenDependencies { + private final PlayerStats playerStats; + private final PlayerAbilities playerAbilities; + private final GameState gameState; + private final TalentTree talentTree; + private final GameAssetService assetService; + private final SafeAreaInsetsProvider safeAreaInsetsProvider; + + public GameScreenDependencies( + PlayerStats playerStats, + PlayerAbilities playerAbilities, + GameState gameState, + TalentTree talentTree, + GameAssetService assetService, + SafeAreaInsetsProvider safeAreaInsetsProvider) { + this.playerStats = Objects.requireNonNull(playerStats, "playerStats"); + this.playerAbilities = Objects.requireNonNull(playerAbilities, "playerAbilities"); + this.gameState = Objects.requireNonNull(gameState, "gameState"); + this.talentTree = Objects.requireNonNull(talentTree, "talentTree"); + this.assetService = Objects.requireNonNull(assetService, "assetService"); + this.safeAreaInsetsProvider = + Objects.requireNonNull(safeAreaInsetsProvider, "safeAreaInsetsProvider"); + } + + public static GameScreenDependencies from(GameContext ctx) { + Objects.requireNonNull(ctx, "ctx"); + return new GameScreenDependencies( + ctx.playerStats, + ctx.playerAbilities, + ctx.gameState, + ctx.talentTree, + ctx.assetService, + ctx.safeAreaInsetsProvider); + } + + public PlayerStats playerStats() { + return playerStats; + } + + public PlayerAbilities playerAbilities() { + return playerAbilities; + } + + public GameState gameState() { + return gameState; + } + + public TalentTree talentTree() { + return talentTree; + } + + public GameAssetService assetService() { + return assetService; + } + + public SafeAreaInsetsProvider safeAreaInsetsProvider() { + return safeAreaInsetsProvider; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/abilities/GameScreenAbilityHost.java b/core/src/main/java/ru/project/tower/screens/gamescreen/abilities/GameScreenAbilityHost.java new file mode 100644 index 0000000..e5c0c5e --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/abilities/GameScreenAbilityHost.java @@ -0,0 +1,122 @@ +package ru.project.tower.screens.gamescreen.abilities; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; +import ru.project.tower.abilities.AbilityHost; +import ru.project.tower.abilities.Turret; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.run.GameplayEventSink; +import ru.project.tower.systems.EnemyClassifier; +import ru.project.tower.systems.PlayerShootingSystem; + +public final class GameScreenAbilityHost implements AbilityHost { + private final Host host; + private final BulletSystem bulletSystem; + private final PlayerShootingSystem playerShootingSystem; + private final GameplayEventSink gameplayEventSink; + + public GameScreenAbilityHost( + Host host, + BulletSystem bulletSystem, + PlayerShootingSystem playerShootingSystem, + GameplayEventSink gameplayEventSink) { + this.host = Objects.requireNonNull(host, "host"); + this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem"); + this.playerShootingSystem = + Objects.requireNonNull(playerShootingSystem, "playerShootingSystem"); + this.gameplayEventSink = Objects.requireNonNull(gameplayEventSink, "gameplayEventSink"); + } + + @Override + public void addFloatingText(float x, float y, String text, Color color) { + host.addFloatingText(x, y, text, color); + } + + @Override + public void createExplosion(float x, float y, Color color) { + host.createExplosion(x, y, color); + } + + @Override + public void triggerKillShake() { + host.triggerKillShake(); + } + + @Override + public void triggerCritHitstop() { + host.triggerCritHitstop(); + } + + @Override + public int effectiveBulletDamage() { + return bulletSystem.getEffectiveDamage(host.activeBonusDamageMultiplier()); + } + + @Override + public float abilityScalingFactor() { + return host.abilityScalingFactor(); + } + + @Override + public float gameScale() { + return host.gameScale(); + } + + @Override + public long abilityCooldownReductionMs() { + return host.abilityCooldownReductionMs(); + } + + @Override + public boolean isBoss(BaseCircleData circle) { + return EnemyClassifier.isBoss(circle); + } + + @Override + public void addMoney(int amount) { + host.addMoney(amount); + } + + @Override + public void killEnemy(BaseCircleData enemy) { + host.killEnemy(enemy); + } + + @Override + public void onTurretShot(Turret turret, BaseCircleData target, long now) { + playerShootingSystem.shootTurretAtTarget( + turret, + target, + bulletSystem, + gameplayEventSink, + host.adaptedBulletRadius(), + host.gameScale() * host.activeBonusSpeedMultiplier()); + } + + public interface Host { + void addFloatingText(float x, float y, String text, Color color); + + void createExplosion(float x, float y, Color color); + + void triggerKillShake(); + + void triggerCritHitstop(); + + float activeBonusDamageMultiplier(); + + float abilityScalingFactor(); + + float gameScale(); + + long abilityCooldownReductionMs(); + + void addMoney(int amount); + + void killEnemy(BaseCircleData enemy); + + float activeBonusSpeedMultiplier(); + + float adaptedBulletRadius(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/actions/GameScreenActionRouter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/actions/GameScreenActionRouter.java new file mode 100644 index 0000000..7d78bb1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/actions/GameScreenActionRouter.java @@ -0,0 +1,180 @@ +package ru.project.tower.screens.gamescreen.actions; + +public final class GameScreenActionRouter { + public enum UpgradePanel { + ATTACK, + HEALTH, + ABILITIES + } + + public enum Upgrade { + DAMAGE, + SPEED, + COOLDOWN, + CRIT_CHANCE, + CRIT_MULTIPLIER, + HEALTH, + REGEN + } + + public enum Ability { + FREEZE, + EXPLOSION, + SHIELD, + IMPULSE, + DASH, + PULL, + TURRET + } + + public interface Host { + void showUpgradePanel(UpgradePanel panel); + + void toggleShop(); + + void toggleHudHelp(); + + void scrollUpgrades(int direction); + + void buyUpgrade(Upgrade upgrade); + + void activateAbility(Ability ability); + + void requestPause(); + + void restartGame(); + + void returnToMainMenu(); + + void resumeGameFromPause(); + + void exitPauseToMenu(); + } + + private final Host host; + + public GameScreenActionRouter(Host host) { + this.host = host; + } + + public void perform(GameScreenInputAction action) { + if (routePanelAction(action)) return; + if (routeScrollAction(action)) return; + if (routeUpgradeAction(action)) return; + if (routeAbilityAction(action)) return; + routeMenuAction(action); + } + + private boolean routePanelAction(GameScreenInputAction action) { + switch (action) { + case SHOW_ATTACK_UPGRADES: + host.showUpgradePanel(UpgradePanel.ATTACK); + return true; + case SHOW_HEALTH_UPGRADES: + host.showUpgradePanel(UpgradePanel.HEALTH); + return true; + case SHOW_ABILITY_BUTTONS: + host.showUpgradePanel(UpgradePanel.ABILITIES); + return true; + case TOGGLE_SHOP: + host.toggleShop(); + return true; + case TOGGLE_HUD_HELP: + host.toggleHudHelp(); + return true; + default: + return false; + } + } + + private boolean routeScrollAction(GameScreenInputAction action) { + switch (action) { + case SCROLL_UP: + host.scrollUpgrades(-1); + return true; + case SCROLL_DOWN: + host.scrollUpgrades(1); + return true; + default: + return false; + } + } + + private boolean routeUpgradeAction(GameScreenInputAction action) { + switch (action) { + case UPGRADE_DAMAGE: + host.buyUpgrade(Upgrade.DAMAGE); + return true; + case UPGRADE_SPEED: + host.buyUpgrade(Upgrade.SPEED); + return true; + case UPGRADE_COOLDOWN: + host.buyUpgrade(Upgrade.COOLDOWN); + return true; + case UPGRADE_CRIT_CHANCE: + host.buyUpgrade(Upgrade.CRIT_CHANCE); + return true; + case UPGRADE_CRIT_MULTIPLIER: + host.buyUpgrade(Upgrade.CRIT_MULTIPLIER); + return true; + case UPGRADE_HEALTH: + host.buyUpgrade(Upgrade.HEALTH); + return true; + case UPGRADE_REGEN: + host.buyUpgrade(Upgrade.REGEN); + return true; + default: + return false; + } + } + + private boolean routeAbilityAction(GameScreenInputAction action) { + switch (action) { + case ACTIVATE_FREEZE: + host.activateAbility(Ability.FREEZE); + return true; + case ACTIVATE_EXPLOSION: + host.activateAbility(Ability.EXPLOSION); + return true; + case ACTIVATE_SHIELD: + host.activateAbility(Ability.SHIELD); + return true; + case ACTIVATE_IMPULSE: + host.activateAbility(Ability.IMPULSE); + return true; + case ACTIVATE_DASH: + host.activateAbility(Ability.DASH); + return true; + case ACTIVATE_PULL: + host.activateAbility(Ability.PULL); + return true; + case ACTIVATE_TURRET: + host.activateAbility(Ability.TURRET); + return true; + default: + return false; + } + } + + private void routeMenuAction(GameScreenInputAction action) { + switch (action) { + case REQUEST_PAUSE: + host.requestPause(); + break; + case RESTART_GAME: + host.restartGame(); + break; + case RETURN_TO_MAIN_MENU: + host.returnToMainMenu(); + break; + case RESUME_GAME_FROM_PAUSE: + host.resumeGameFromPause(); + break; + case EXIT_PAUSE_TO_MENU: + host.exitPauseToMenu(); + break; + default: + break; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/actions/GameScreenInputAction.java b/core/src/main/java/ru/project/tower/screens/gamescreen/actions/GameScreenInputAction.java new file mode 100644 index 0000000..fa4e01f --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/actions/GameScreenInputAction.java @@ -0,0 +1,30 @@ +package ru.project.tower.screens.gamescreen.actions; + +public enum GameScreenInputAction { + SHOW_ATTACK_UPGRADES, + SHOW_HEALTH_UPGRADES, + SHOW_ABILITY_BUTTONS, + TOGGLE_SHOP, + TOGGLE_HUD_HELP, + SCROLL_UP, + SCROLL_DOWN, + UPGRADE_DAMAGE, + UPGRADE_SPEED, + UPGRADE_COOLDOWN, + UPGRADE_CRIT_CHANCE, + UPGRADE_CRIT_MULTIPLIER, + UPGRADE_HEALTH, + UPGRADE_REGEN, + ACTIVATE_FREEZE, + ACTIVATE_EXPLOSION, + ACTIVATE_SHIELD, + ACTIVATE_IMPULSE, + ACTIVATE_DASH, + ACTIVATE_PULL, + ACTIVATE_TURRET, + REQUEST_PAUSE, + RESTART_GAME, + RETURN_TO_MAIN_MENU, + RESUME_GAME_FROM_PAUSE, + EXIT_PAUSE_TO_MENU +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenFontAssets.java b/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenFontAssets.java new file mode 100644 index 0000000..18bdf55 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenFontAssets.java @@ -0,0 +1,212 @@ +package ru.project.tower.screens.gamescreen.assets; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.utils.Disposable; +import ru.project.tower.screens.gamescreen.visual.GameScreenVisualTokens; +import ru.project.tower.support.RobotoFontFactory; +import ru.project.tower.support.ScreenAssets; + +public final class GameScreenFontAssets implements Disposable { + private static final int REFERENCE_FONT_SIZE = 29; + private static final float FLOATING_TEXT_FONT_SCALE = 1.5f; + private static final float GAME_OVER_TITLE_FONT_SCALE = 1.5f; + private static final float UPGRADE_PICK_FONT_SCALE = 1.5f; + private static final int TOP_BAR_SIZE = 16; + private static final int STAT_SIZE = 16; + private static final int TAB_SIZE = 15; + private static final int CARD_TITLE_SIZE = GameScreenVisualTokens.Type.CARD_TITLE_SIZE; + private static final int CARD_VALUE_SIZE = GameScreenVisualTokens.Type.CARD_VALUE_SIZE; + private static final int CARD_ARROW_SIZE = GameScreenVisualTokens.Type.CARD_ARROW_SIZE; + private static final int PRICE_SIZE = 13; + private static final int DOCK_LABEL_SIZE = 13; + private static final int DOCK_COOLDOWN_SIZE = 15; + + private ScreenAssets screenAssets; + private BitmapFont font; + private BitmapFont floatingTextFont; + private BitmapFont gameOverTitleFont; + private BitmapFont upgradePickFont; + private HudFonts hudFonts; + + public void rebuild(int fontSize) { + dispose(); + + screenAssets = new ScreenAssets(); + font = + screenAssets.font( + "default", + RobotoFontFactory.generate( + RobotoFontFactory.spec( + fontSize, + 0f, + Color.CLEAR, + Color.WHITE, + 0, + 0, + Color.CLEAR))); + floatingTextFont = + screenAssets.font( + "floating-text", + RobotoFontFactory.generate( + RobotoFontFactory.spec( + Math.round(fontSize * FLOATING_TEXT_FONT_SCALE), + 0f, + Color.CLEAR, + Color.WHITE, + 0, + 0, + Color.CLEAR))); + gameOverTitleFont = + screenAssets.font( + "game-over-title", + RobotoFontFactory.generate( + RobotoFontFactory.spec( + Math.round(fontSize * GAME_OVER_TITLE_FONT_SCALE), + 0f, + Color.CLEAR, + Color.WHITE, + 0, + 0, + Color.CLEAR))); + upgradePickFont = + screenAssets.font( + "upgrade-pick", + RobotoFontFactory.generate( + RobotoFontFactory.spec( + Math.round(fontSize * UPGRADE_PICK_FONT_SCALE), + 0f, + Color.CLEAR, + Color.WHITE, + 0, + 0, + Color.CLEAR))); + hudFonts = new HudFonts(screenAssets, tokenScale(fontSize)); + } + + public BitmapFont font() { + return font; + } + + public BitmapFont floatingTextFont() { + return floatingTextFont; + } + + public BitmapFont gameOverTitleFont() { + return gameOverTitleFont; + } + + public BitmapFont upgradePickFont() { + return upgradePickFont; + } + + public HudFonts hudFonts() { + return hudFonts; + } + + @Override + public void dispose() { + if (screenAssets != null) { + screenAssets.dispose(); + } + screenAssets = null; + font = null; + floatingTextFont = null; + gameOverTitleFont = null; + upgradePickFont = null; + hudFonts = null; + } + + private static float tokenScale(int fontSize) { + return Math.max(0.72f, fontSize / (float) REFERENCE_FONT_SIZE); + } + + private static BitmapFont generateUiTokenFont( + ScreenAssets screenAssets, String name, int size) { + return screenAssets.font( + name, + RobotoFontFactory.generateUi( + RobotoFontFactory.spec( + size, 0f, Color.CLEAR, Color.WHITE, 0, 0, Color.CLEAR))); + } + + private static int scaledSize(int referenceSize, float scale) { + return Math.max(8, Math.round(referenceSize * scale)); + } + + public static final class HudFonts { + private final BitmapFont topBar; + private final BitmapFont stat; + private final BitmapFont tab; + private final BitmapFont cardTitle; + private final BitmapFont cardValue; + private final BitmapFont cardArrow; + private final BitmapFont price; + private final BitmapFont dockLabel; + private final BitmapFont dockCooldown; + + private HudFonts(ScreenAssets screenAssets, float scale) { + topBar = + generateUiTokenFont( + screenAssets, "hud-top-bar", scaledSize(TOP_BAR_SIZE, scale)); + stat = generateUiTokenFont(screenAssets, "hud-stat", scaledSize(STAT_SIZE, scale)); + tab = generateUiTokenFont(screenAssets, "hud-tab", scaledSize(TAB_SIZE, scale)); + cardTitle = + generateUiTokenFont( + screenAssets, "hud-card-title", scaledSize(CARD_TITLE_SIZE, scale)); + cardValue = + generateUiTokenFont( + screenAssets, "hud-card-value", scaledSize(CARD_VALUE_SIZE, scale)); + cardArrow = + generateUiTokenFont( + screenAssets, "hud-card-arrow", scaledSize(CARD_ARROW_SIZE, scale)); + price = + generateUiTokenFont( + screenAssets, "hud-price", scaledSize(PRICE_SIZE, scale)); + dockLabel = + generateUiTokenFont( + screenAssets, "hud-dock-label", scaledSize(DOCK_LABEL_SIZE, scale)); + dockCooldown = + generateUiTokenFont( + screenAssets, + "hud-dock-cooldown", + scaledSize(DOCK_COOLDOWN_SIZE, scale)); + } + + public BitmapFont topBar() { + return topBar; + } + + public BitmapFont stat() { + return stat; + } + + public BitmapFont tab() { + return tab; + } + + public BitmapFont cardTitle() { + return cardTitle; + } + + public BitmapFont cardValue() { + return cardValue; + } + + public BitmapFont cardArrow() { + return cardArrow; + } + + public BitmapFont price() { + return price; + } + + public BitmapFont dockLabel() { + return dockLabel; + } + + public BitmapFont dockCooldown() { + return dockCooldown; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenMaterialAssets.java b/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenMaterialAssets.java new file mode 100644 index 0000000..f7b873f --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenMaterialAssets.java @@ -0,0 +1,1056 @@ +package ru.project.tower.screens.gamescreen.assets; + +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.utils.Disposable; + +public final class GameScreenMaterialAssets implements Disposable { + private static final int RANGE_RING_SUPERSAMPLE = 4; + private static final int BONUS_ORB_SUPERSAMPLE = 4; + private static final int ENEMY_SUPERSAMPLE = 4; + private static final int HUD_ICON_SUPERSAMPLE = 4; + private static final int ABILITY_SUPERSAMPLE = 4; + private static final float FAST_ENEMY_CORE_ALPHA = 0.10f; + private static final float ARMORED_ENEMY_CUTOUT_ALPHA = 0.82f; + private static final float ARMORED_ENEMY_PLATE_ALPHA = 0.12f; + private static final int SPLITTER_ENEMY_SPOKES = 4; + private static final float RANGE_RING_HALF_THICKNESS = 0.34f; + private static final float RANGE_RING_FADE = 0.86f; + + private Texture radialGlow; + private Texture playerRangeGlow; + private Texture cooldownRing; + private Texture basicEnemy; + private Texture fastEnemy; + private Texture armoredEnemy; + private Texture supportEnemy; + private Texture splitterEnemy; + private Texture playerTower; + private Texture rangeRing; + private Texture bonusOrb; + private Texture hudAttackIcon; + private Texture hudRegenIcon; + private Texture hudCooldownIcon; + private Texture hudCritIcon; + private Texture hudShieldIcon; + private Texture hudBonusIcon; + private Texture abilityShieldShell; + private Texture abilityShockwave; + private Texture abilityTurretCore; + + public void rebuild() { + dispose(); + radialGlow = textureFrom(createRadialGlow(96)); + playerRangeGlow = textureFrom(createPlayerRangeGlow(192)); + cooldownRing = textureFrom(createCooldownRing(96)); + basicEnemy = textureFrom(createCircleEnemy(64, BasicEnemyStyle.BASIC)); + fastEnemy = textureFrom(createFastEnemy(64)); + armoredEnemy = textureFrom(createArmoredEnemy(64)); + supportEnemy = textureFrom(createCircleEnemy(64, BasicEnemyStyle.SUPPORT)); + splitterEnemy = textureFrom(createSplitterEnemy(64)); + playerTower = textureFrom(createPlayerTower(128)); + rangeRing = textureFrom(createRangeRing(384)); + bonusOrb = textureFrom(createBonusOrb(96)); + hudAttackIcon = hudIconTextureFrom(createHudIcon(96, HudIconStyle.SWORD)); + hudRegenIcon = hudIconTextureFrom(createHudIcon(96, HudIconStyle.PLUS)); + hudCooldownIcon = hudIconTextureFrom(createHudIcon(96, HudIconStyle.COOLDOWN)); + hudCritIcon = hudIconTextureFrom(createHudIcon(96, HudIconStyle.DIAMOND)); + hudShieldIcon = hudIconTextureFrom(createHudIcon(96, HudIconStyle.SHIELD)); + hudBonusIcon = hudIconTextureFrom(createHudIcon(96, HudIconStyle.HEX)); + abilityShieldShell = textureFrom(createAbilityShieldShell(192)); + abilityShockwave = textureFrom(createAbilityShockwave(192)); + abilityTurretCore = textureFrom(createAbilityTurretCore(96)); + } + + public Texture radialGlow() { + return radialGlow; + } + + public Texture playerRangeGlow() { + return playerRangeGlow; + } + + public Texture cooldownRing() { + return cooldownRing; + } + + public Texture basicEnemy() { + return basicEnemy; + } + + public Texture fastEnemy() { + return fastEnemy; + } + + public Texture armoredEnemy() { + return armoredEnemy; + } + + public Texture supportEnemy() { + return supportEnemy; + } + + public Texture splitterEnemy() { + return splitterEnemy; + } + + public Texture playerTower() { + return playerTower; + } + + public Texture rangeRing() { + return rangeRing; + } + + public Texture bonusOrb() { + return bonusOrb; + } + + public Texture hudAttackIcon() { + return hudAttackIcon; + } + + public Texture hudRegenIcon() { + return hudRegenIcon; + } + + public Texture hudCooldownIcon() { + return hudCooldownIcon; + } + + public Texture hudCritIcon() { + return hudCritIcon; + } + + public Texture hudShieldIcon() { + return hudShieldIcon; + } + + public Texture hudBonusIcon() { + return hudBonusIcon; + } + + public Texture abilityShieldShell() { + return abilityShieldShell; + } + + public Texture abilityShockwave() { + return abilityShockwave; + } + + public Texture abilityTurretCore() { + return abilityTurretCore; + } + + @Override + public void dispose() { + if (radialGlow != null) { + radialGlow.dispose(); + } + if (playerRangeGlow != null) { + playerRangeGlow.dispose(); + } + if (cooldownRing != null) { + cooldownRing.dispose(); + } + if (basicEnemy != null) { + basicEnemy.dispose(); + } + if (fastEnemy != null) { + fastEnemy.dispose(); + } + if (armoredEnemy != null) { + armoredEnemy.dispose(); + } + if (supportEnemy != null) { + supportEnemy.dispose(); + } + if (splitterEnemy != null) { + splitterEnemy.dispose(); + } + if (playerTower != null) { + playerTower.dispose(); + } + if (rangeRing != null) { + rangeRing.dispose(); + } + if (bonusOrb != null) { + bonusOrb.dispose(); + } + if (hudAttackIcon != null) { + hudAttackIcon.dispose(); + } + if (hudRegenIcon != null) { + hudRegenIcon.dispose(); + } + if (hudCooldownIcon != null) { + hudCooldownIcon.dispose(); + } + if (hudCritIcon != null) { + hudCritIcon.dispose(); + } + if (hudShieldIcon != null) { + hudShieldIcon.dispose(); + } + if (hudBonusIcon != null) { + hudBonusIcon.dispose(); + } + if (abilityShieldShell != null) { + abilityShieldShell.dispose(); + } + if (abilityShockwave != null) { + abilityShockwave.dispose(); + } + if (abilityTurretCore != null) { + abilityTurretCore.dispose(); + } + radialGlow = null; + playerRangeGlow = null; + cooldownRing = null; + basicEnemy = null; + fastEnemy = null; + armoredEnemy = null; + supportEnemy = null; + splitterEnemy = null; + playerTower = null; + rangeRing = null; + bonusOrb = null; + hudAttackIcon = null; + hudRegenIcon = null; + hudCooldownIcon = null; + hudCritIcon = null; + hudShieldIcon = null; + hudBonusIcon = null; + abilityShieldShell = null; + abilityShockwave = null; + abilityTurretCore = null; + } + + private static Texture textureFrom(Pixmap pixmap) { + Texture texture = new Texture(pixmap); + texture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear); + pixmap.dispose(); + return texture; + } + + private static Texture hudIconTextureFrom(Pixmap pixmap) { + Texture texture = new Texture(pixmap, true); + texture.setFilter(Texture.TextureFilter.MipMapLinearLinear, Texture.TextureFilter.Linear); + pixmap.dispose(); + return texture; + } + + private static Pixmap createRadialGlow(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float radius = center; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float dx = (x - center) / radius; + float dy = (y - center) / radius; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float alpha = smoothGlow(distance); + pixmap.drawPixel(x, y, rgba(255, 255, 255, alpha)); + } + } + return pixmap; + } + + private static Pixmap createPlayerRangeGlow(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float radius = center; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float dx = (x - center) / radius; + float dy = (y - center) / radius; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float alpha = playerRangeGlowAlpha(distance); + pixmap.drawPixel(x, y, rgba(255, 255, 255, alpha)); + } + } + return pixmap; + } + + private static Pixmap createCooldownRing(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float outer = center; + float inner = center * 0.74f; + float innerFade = center * 0.75f; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float dx = x - center; + float dy = y - center; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + if (distance > outer) continue; + float angle = (float) Math.toDegrees(Math.atan2(dy, dx)); + if (angle < 0f) angle += 360f; + boolean activeArc = angle >= 275f || angle <= 160f; + int conicR = activeArc ? 34 : 255; + int conicG = activeArc ? 217 : 255; + int conicB = activeArc ? 255 : 255; + float conicA = activeArc ? 0.95f : 0.12f; + int pixel = rgba(conicR, conicG, conicB, conicA); + if (distance < innerFade) { + float radialAlpha = + distance < inner + ? 0.96f + : 0.96f * (innerFade - distance) / (innerFade - inner); + pixel = mixRgba(conicR, conicG, conicB, conicA, 13, 17, 28, radialAlpha); + } + pixmap.drawPixel(x, y, pixel); + } + } + return pixmap; + } + + private static Pixmap createCircleEnemy(int size, BasicEnemyStyle style) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float outer = center * 0.76f; + float inner = outer * 0.67f; + int borderR = style == BasicEnemyStyle.BASIC ? 178 : 180; + int borderG = style == BasicEnemyStyle.BASIC ? 197 : 255; + int borderB = style == BasicEnemyStyle.BASIC ? 255 : 236; + int fillR = style == BasicEnemyStyle.BASIC ? 77 : 37; + int fillG = style == BasicEnemyStyle.BASIC ? 124 : 223; + int fillB = style == BasicEnemyStyle.BASIC ? 255 : 181; + float samples = ENEMY_SUPERSAMPLE * ENEMY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float outerCoverage = 0f; + float innerCoverage = 0f; + for (int sy = 0; sy < ENEMY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ENEMY_SUPERSAMPLE; sx++) { + float sampleX = enemySample(x, sx); + float sampleY = enemySample(y, sy); + outerCoverage += circleCoverage(sampleX, sampleY, center, outer); + innerCoverage += circleCoverage(sampleX, sampleY, center, inner); + } + } + outerCoverage /= samples; + innerCoverage /= samples; + if (outerCoverage <= 0f) continue; + float[] pixel = {0f, 0f, 0f, 0f}; + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + borderR, + borderG, + borderB, + 0.90f * outerCoverage); + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + fillR, + fillG, + fillB, + innerCoverage); + pixmap.drawPixel( + x, + y, + rgba( + Math.round(pixel[0]), + Math.round(pixel[1]), + Math.round(pixel[2]), + pixel[3])); + } + } + if (style == BasicEnemyStyle.SUPPORT) { + drawSupportMark(pixmap, center, size); + } + return pixmap; + } + + private static Pixmap createFastEnemy(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float outer = center * 0.76f; + float inner = outer * 0.62f; + float samples = ENEMY_SUPERSAMPLE * ENEMY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float outerCoverage = 0f; + float innerCoverage = 0f; + for (int sy = 0; sy < ENEMY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ENEMY_SUPERSAMPLE; sx++) { + float sampleX = enemySample(x, sx); + float sampleY = enemySample(y, sy); + outerCoverage += diamondCoverage(sampleX, sampleY, center, outer); + innerCoverage += diamondCoverage(sampleX, sampleY, center, inner); + } + } + outerCoverage /= samples; + innerCoverage /= samples; + if (outerCoverage <= 0f) continue; + float[] pixel = blend(0f, 0f, 0f, 0f, 255f, 115f, 223f, outerCoverage); + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + 255f, + 79f, + 216f, + FAST_ENEMY_CORE_ALPHA * innerCoverage); + pixmap.drawPixel( + x, + y, + rgba( + Math.round(pixel[0]), + Math.round(pixel[1]), + Math.round(pixel[2]), + pixel[3])); + } + } + return pixmap; + } + + private static Pixmap createArmoredEnemy(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float outerInset = size * 0.14f; + float border = size * 0.10f; + float innerInset = outerInset + border; + int inset = rgba(255, 155, 183, 0.22f); + float samples = ENEMY_SUPERSAMPLE * ENEMY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float outerCoverage = 0f; + float innerCoverage = 0f; + for (int sy = 0; sy < ENEMY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ENEMY_SUPERSAMPLE; sx++) { + float sampleX = enemySample(x, sx); + float sampleY = enemySample(y, sy); + outerCoverage += + boxCoverage( + sampleX, + sampleY, + outerInset, + size - outerInset, + outerInset, + size - outerInset); + innerCoverage += + boxCoverage( + sampleX, + sampleY, + innerInset, + size - innerInset, + innerInset, + size - innerInset); + } + } + outerCoverage /= samples; + innerCoverage /= samples; + if (outerCoverage <= 0f) continue; + float[] pixel = blend(0f, 0f, 0f, 0f, 255f, 155f, 183f, outerCoverage); + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + 11f, + 10f, + 18f, + ARMORED_ENEMY_CUTOUT_ALPHA * innerCoverage); + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + 255f, + 122f, + 160f, + ARMORED_ENEMY_PLATE_ALPHA * innerCoverage); + pixmap.drawPixel( + x, + y, + rgba( + Math.round(pixel[0]), + Math.round(pixel[1]), + Math.round(pixel[2]), + pixel[3])); + } + } + float insetBorder = border * 0.8f; + for (int y = Math.round(innerInset + insetBorder); + y <= Math.round(size - innerInset - insetBorder); + y++) { + for (int x = Math.round(innerInset + insetBorder); + x <= Math.round(size - innerInset - insetBorder); + x++) { + boolean edge = + x <= innerInset + insetBorder * 2f + || x >= size - innerInset - insetBorder * 2f + || y <= innerInset + insetBorder * 2f + || y >= size - innerInset - insetBorder * 2f; + if (edge) pixmap.drawPixel(x, y, inset); + } + } + return pixmap; + } + + private static Pixmap createSplitterEnemy(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float outer = center * 0.74f; + float inner = outer * 0.45f; + float spokeStart = inner * 0.54f; + float spokeEnd = outer * 0.88f; + float spokeThickness = size * 0.045f; + float samples = ENEMY_SUPERSAMPLE * ENEMY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float outerCoverage = 0f; + float innerCoverage = 0f; + float spokeCoverage = 0f; + for (int sy = 0; sy < ENEMY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ENEMY_SUPERSAMPLE; sx++) { + float sampleX = enemySample(x, sx); + float sampleY = enemySample(y, sy); + outerCoverage += circleCoverage(sampleX, sampleY, center, outer); + innerCoverage += circleCoverage(sampleX, sampleY, center, inner); + float sampleSpokeCoverage = 0f; + for (int spoke = 0; spoke < SPLITTER_ENEMY_SPOKES; spoke++) { + float angle = (float) (spoke * Math.PI * 2f / SPLITTER_ENEMY_SPOKES); + sampleSpokeCoverage = + Math.max( + sampleSpokeCoverage, + spokeCoverage( + sampleX, + sampleY, + center, + spokeStart, + spokeEnd, + spokeThickness, + angle)); + } + spokeCoverage += sampleSpokeCoverage; + } + } + outerCoverage /= samples; + innerCoverage /= samples; + spokeCoverage /= samples; + if (outerCoverage <= 0f && spokeCoverage <= 0f) continue; + float[] pixel = {0f, 0f, 0f, 0f}; + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + 190f, + 202f, + 255f, + 0.80f * outerCoverage); + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + 92f, + 78f, + 255f, + 0.44f * innerCoverage); + pixel = + blend( + pixel[0], + pixel[1], + pixel[2], + pixel[3], + 70f, + 235f, + 255f, + 0.92f * spokeCoverage); + pixmap.drawPixel( + x, + y, + rgba( + Math.round(pixel[0]), + Math.round(pixel[1]), + Math.round(pixel[2]), + pixel[3])); + } + } + return pixmap; + } + + private static Pixmap createPlayerTower(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float halfBody = size * (27f / 134f); + float border = size * (3f / 134f); + float spread = size * (7f / 134f); + float blur = size * (24f / 134f); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float dx = Math.abs(x - center) - halfBody; + float dy = Math.abs(y - center) - halfBody; + float outsideX = Math.max(dx, 0f); + float outsideY = Math.max(dy, 0f); + float outsideDistance = + (float) Math.sqrt(outsideX * outsideX + outsideY * outsideY); + float insideDistance = Math.max(dx, dy); + float centerDx = x - center; + float centerDy = y - center; + float radialDistance = (float) Math.sqrt(centerDx * centerDx + centerDy * centerDy); + float glowRadius = halfBody + blur * 1.45f; + + float red = 0f; + float green = 0f; + float blue = 0f; + float alpha = 0f; + + float glowAlpha = + playerTowerGlowAlpha(radialDistance, glowRadius, outsideDistance, spread); + if (outsideDistance > 0f || insideDistance <= 0f) { + float[] blended = blend(red, green, blue, alpha, 73f, 92f, 255f, glowAlpha); + red = blended[0]; + green = blended[1]; + blue = blended[2]; + alpha = blended[3]; + } + + if (insideDistance <= 0f) { + float[] filled = blend(red, green, blue, alpha, 83f, 97f, 255f, 1f); + red = filled[0]; + green = filled[1]; + blue = filled[2]; + alpha = filled[3]; + if (insideDistance >= -border) { + float[] bordered = blend(red, green, blue, alpha, 154f, 165f, 255f, 0.80f); + red = bordered[0]; + green = bordered[1]; + blue = bordered[2]; + alpha = bordered[3]; + } + } + + pixmap.drawPixel( + x, y, rgba(Math.round(red), Math.round(green), Math.round(blue), alpha)); + } + } + return pixmap; + } + + private static float playerTowerGlowAlpha( + float radialDistance, float glowRadius, float outsideDistance, float spread) { + float radialFalloff = Math.max(0f, 1f - radialDistance / glowRadius); + float alpha = radialFalloff * radialFalloff * 0.50f; + if (outsideDistance <= spread) { + alpha += 0.18f * (1f - outsideDistance / spread) * radialFalloff; + } + return alpha; + } + + private static Pixmap createRangeRing(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float radius = center - 1f; + float samples = RANGE_RING_SUPERSAMPLE * RANGE_RING_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float coverage = 0f; + for (int sy = 0; sy < RANGE_RING_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < RANGE_RING_SUPERSAMPLE; sx++) { + float sampleX = x + (sx + 0.5f) / RANGE_RING_SUPERSAMPLE - 0.5f; + float sampleY = y + (sy + 0.5f) / RANGE_RING_SUPERSAMPLE - 0.5f; + coverage += rangeRingCoverage(sampleX, sampleY, center, radius); + } + } + coverage /= samples; + if (coverage <= 0f) continue; + pixmap.drawPixel(x, y, rgba(255, 255, 255, coverage)); + } + } + return pixmap; + } + + private static float rangeRingCoverage(float x, float y, float center, float radius) { + float dx = x - center; + float dy = y - center; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float ringDistance = Math.abs(distance - radius); + return Math.max( + 0f, + Math.min( + 1f, + (RANGE_RING_HALF_THICKNESS + RANGE_RING_FADE - ringDistance) + / RANGE_RING_FADE)); + } + + private static Pixmap createBonusOrb(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1) / 2f; + float radius = center; + float samples = BONUS_ORB_SUPERSAMPLE * BONUS_ORB_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float coreAlpha = 0f; + float glowAlpha = 0f; + for (int sy = 0; sy < BONUS_ORB_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < BONUS_ORB_SUPERSAMPLE; sx++) { + float sampleX = x + (sx + 0.5f) / BONUS_ORB_SUPERSAMPLE - 0.5f; + float sampleY = y + (sy + 0.5f) / BONUS_ORB_SUPERSAMPLE - 0.5f; + float coverage = bonusOrbCoverage(sampleX, sampleY, center, radius); + float dx = (sampleX - center) / radius; + float dy = (sampleY - center) / radius; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + coreAlpha += distance <= 0.45f ? coverage : 0f; + glowAlpha += coverage * Math.max(0f, 1f - distance); + } + } + coreAlpha /= samples; + glowAlpha /= samples; + float alpha = Math.min(1f, coreAlpha * 0.92f + glowAlpha * 0.58f); + if (alpha <= 0f) continue; + pixmap.drawPixel(x, y, rgba(255, 255, 255, alpha)); + } + } + return pixmap; + } + + private static Pixmap createHudIcon(int size, HudIconStyle style) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float samples = HUD_ICON_SUPERSAMPLE * HUD_ICON_SUPERSAMPLE; + float max = size - 1f; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float coverage = 0f; + for (int sy = 0; sy < HUD_ICON_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < HUD_ICON_SUPERSAMPLE; sx++) { + float sampleX = + x + (sx + 0.5f) / HUD_ICON_SUPERSAMPLE - 0.5f; + float sampleY = + y + (sy + 0.5f) / HUD_ICON_SUPERSAMPLE - 0.5f; + float nx = sampleX / max * 2f - 1f; + float ny = 1f - sampleY / max * 2f; + coverage += hudIconCoverage(nx, ny, style); + } + } + coverage /= samples; + if (coverage > 0f) { + pixmap.drawPixel(x, y, rgba(255, 255, 255, coverage)); + } + } + } + return pixmap; + } + + private static Pixmap createAbilityShieldShell(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1f) / 2f; + float radius = center * 0.86f; + float samples = ABILITY_SUPERSAMPLE * ABILITY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float alpha = 0f; + for (int sy = 0; sy < ABILITY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ABILITY_SUPERSAMPLE; sx++) { + float sampleX = x + (sx + 0.5f) / ABILITY_SUPERSAMPLE - 0.5f; + float sampleY = y + (sy + 0.5f) / ABILITY_SUPERSAMPLE - 0.5f; + float dx = sampleX - center; + float dy = sampleY - center; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float ring = 1f - clamp01(Math.abs(distance - radius) / (size * 0.040f)); + float inner = 1f - clamp01(distance / (radius * 0.78f)); + float outerGlow = 1f - clamp01((distance - radius) / (size * 0.18f)); + alpha += Math.max(ring * 0.88f, Math.max(inner * 0.13f, outerGlow * 0.16f)); + } + } + alpha = clamp01(alpha / samples); + if (alpha > 0f) pixmap.drawPixel(x, y, rgba(255, 255, 255, alpha)); + } + } + return pixmap; + } + + private static Pixmap createAbilityShockwave(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1f) / 2f; + float radius = center * 0.72f; + float samples = ABILITY_SUPERSAMPLE * ABILITY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float alpha = 0f; + for (int sy = 0; sy < ABILITY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ABILITY_SUPERSAMPLE; sx++) { + float sampleX = x + (sx + 0.5f) / ABILITY_SUPERSAMPLE - 0.5f; + float sampleY = y + (sy + 0.5f) / ABILITY_SUPERSAMPLE - 0.5f; + float dx = sampleX - center; + float dy = sampleY - center; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float ring = 1f - clamp01(Math.abs(distance - radius) / (size * 0.06f)); + float core = 1f - clamp01(distance / (radius * 0.38f)); + alpha += Math.max(ring * 0.72f, core * 0.26f); + } + } + alpha = clamp01(alpha / samples); + if (alpha > 0f) pixmap.drawPixel(x, y, rgba(255, 255, 255, alpha)); + } + } + return pixmap; + } + + private static Pixmap createAbilityTurretCore(int size) { + Pixmap pixmap = new Pixmap(size, size, Pixmap.Format.RGBA8888); + float center = (size - 1f) / 2f; + float radius = center * 0.58f; + float samples = ABILITY_SUPERSAMPLE * ABILITY_SUPERSAMPLE; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float alpha = 0f; + for (int sy = 0; sy < ABILITY_SUPERSAMPLE; sy++) { + for (int sx = 0; sx < ABILITY_SUPERSAMPLE; sx++) { + float sampleX = x + (sx + 0.5f) / ABILITY_SUPERSAMPLE - 0.5f; + float sampleY = y + (sy + 0.5f) / ABILITY_SUPERSAMPLE - 0.5f; + float dx = sampleX - center; + float dy = sampleY - center; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float body = circleCoverage(sampleX, sampleY, center, radius); + float rim = 1f - clamp01(Math.abs(distance - radius) / (size * 0.05f)); + float barrel = boxCoverage( + sampleX, + sampleY, + center + radius * 0.10f, + center + radius * 1.06f, + center - radius * 0.12f, + center + radius * 0.12f); + alpha += Math.max(Math.max(body * 0.55f, rim), barrel); + } + } + alpha = clamp01(alpha / samples); + if (alpha > 0f) pixmap.drawPixel(x, y, rgba(255, 255, 255, alpha)); + } + } + return pixmap; + } + + private static float hudIconCoverage(float x, float y, HudIconStyle style) { + switch (style) { + case SWORD: + return swordCoverage(x, y); + case PLUS: + return Math.max( + unitBoxCoverage(x, y, -0.12f, 0.12f, -0.72f, 0.72f), + unitBoxCoverage(x, y, -0.72f, 0.72f, -0.12f, 0.12f)); + case COOLDOWN: + float distance = (float) Math.sqrt(x * x + y * y); + float ring = Math.abs(distance - 0.56f) <= 0.12f ? 1f : 0f; + return Math.max(ring, segmentCoverage(x, y, 0f, 0f, 0.38f, 0.38f, 0.10f)); + case DIAMOND: + return Math.abs(Math.abs(x) + Math.abs(y) - 0.70f) <= 0.10f ? 1f : 0f; + case SHIELD: + return shieldCoverage(x, y); + case HEX: + float hexDistance = + Math.max(Math.abs(y), Math.abs(x) * 0.86f + Math.abs(y) * 0.5f); + return Math.abs(hexDistance - 0.68f) <= 0.09f ? 1f : 0f; + default: + return 0f; + } + } + + private static float swordCoverage(float x, float y) { + float axisX = 0.30f; + float axisY = 0.954f; + float crossX = -axisY; + float crossY = axisX; + float u = x * axisX + y * axisY; + float v = x * crossX + y * crossY; + + float blade = 0f; + if (u >= -0.16f && u <= 0.78f) { + float tip = clamp01((0.78f - u) / 0.22f); + float halfWidth = u > 0.56f ? 0.04f + 0.11f * tip : 0.15f; + blade = Math.abs(v) <= halfWidth ? 1f : 0f; + } + float guard = Math.abs(u + 0.24f) <= 0.07f && Math.abs(v) <= 0.48f ? 1f : 0f; + float handle = u >= -0.78f && u <= -0.28f && Math.abs(v) <= 0.085f ? 1f : 0f; + float pommelDistance = (float) Math.sqrt((u + 0.84f) * (u + 0.84f) + v * v); + float pommel = pommelDistance <= 0.13f ? 1f : 0f; + return Math.max(Math.max(blade, guard), Math.max(handle, pommel)); + } + + private static float shieldCoverage(float x, float y) { + float topToBottom = (1f - y) * 0.5f; + float halfWidth; + if (topToBottom < 0.16f) { + halfWidth = 0.82f * topToBottom / 0.16f; + } else if (topToBottom < 0.68f) { + halfWidth = 0.82f - (topToBottom - 0.16f) * 0.14f / 0.52f; + } else { + halfWidth = 0.68f * (1f - topToBottom) / 0.32f; + } + return Math.abs(x) <= halfWidth ? 1f : 0f; + } + + private static float segmentCoverage( + float x, float y, float x1, float y1, float x2, float y2, float width) { + float dx = x2 - x1; + float dy = y2 - y1; + float length2 = dx * dx + dy * dy; + if (length2 <= 0f) return 0f; + float t = ((x - x1) * dx + (y - y1) * dy) / length2; + t = clamp01(t); + float closestX = x1 + dx * t; + float closestY = y1 + dy * t; + float distance = + (float) + Math.sqrt( + (x - closestX) * (x - closestX) + + (y - closestY) * (y - closestY)); + return distance <= width * 0.5f ? 1f : 0f; + } + + private static float unitBoxCoverage( + float x, float y, float left, float right, float bottom, float top) { + return x >= left && x <= right && y >= bottom && y <= top ? 1f : 0f; + } + + private static float bonusOrbCoverage(float x, float y, float center, float radius) { + float dx = (x - center) / radius; + float dy = (y - center) / radius; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + float coreEdge = 0.45f; + if (distance <= coreEdge) { + return 1f; + } + return Math.max(0f, 1f - (distance - coreEdge) / (1f - coreEdge)); + } + + private enum HudIconStyle { + SWORD, + PLUS, + COOLDOWN, + DIAMOND, + SHIELD, + HEX + } + + private static float enemySample(int pixel, int sample) { + return pixel + (sample + 0.5f) / ENEMY_SUPERSAMPLE - 0.5f; + } + + private static float circleCoverage(float x, float y, float center, float radius) { + float dx = x - center; + float dy = y - center; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + return clamp01(radius + 0.5f - distance); + } + + private static float diamondCoverage(float x, float y, float center, float radius) { + float distance = Math.abs(x - center) + Math.abs(y - center); + return clamp01(radius + 0.5f - distance); + } + + private static float boxCoverage( + float x, float y, float left, float right, float bottom, float top) { + float horizontal = Math.min(x - left + 0.5f, right - x + 0.5f); + float vertical = Math.min(y - bottom + 0.5f, top - y + 0.5f); + return clamp01(Math.min(horizontal, vertical)); + } + + private static float spokeCoverage( + float x, + float y, + float center, + float innerRadius, + float outerRadius, + float thickness, + float angle) { + float dx = x - center; + float dy = y - center; + float along = Math.abs(dx * (float) Math.cos(angle) + dy * (float) Math.sin(angle)); + if (along < innerRadius || along > outerRadius) return 0f; + float across = Math.abs(-dx * (float) Math.sin(angle) + dy * (float) Math.cos(angle)); + return clamp01(thickness + 0.5f - across); + } + + private static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + private static void drawSupportMark(Pixmap pixmap, float center, int size) { + int mark = rgba(7, 17, 15, 1f); + int halfWidth = Math.round(size * 0.21f); + int halfThickness = Math.max(1, Math.round(size * 0.03f)); + for (int y = Math.round(center - halfThickness); + y <= Math.round(center + halfThickness); + y++) { + for (int x = Math.round(center - halfWidth); x <= Math.round(center + halfWidth); x++) { + pixmap.drawPixel(x, y, mark); + } + } + for (int y = Math.round(center - halfWidth); y <= Math.round(center + halfWidth); y++) { + for (int x = Math.round(center - halfThickness); + x <= Math.round(center + halfThickness); + x++) { + pixmap.drawPixel(x, y, mark); + } + } + } + + private static float smoothGlow(float distance) { + float clamped = Math.max(0f, Math.min(1f, distance)); + float falloff = 1f - clamped; + return falloff * falloff * falloff; + } + + private static float playerRangeGlowAlpha(float distance) { + float falloff = clamp01(1f - distance); + return (float) Math.pow(falloff, 0.72f); + } + + private static float[] blend( + float baseR, + float baseG, + float baseB, + float baseA, + float sourceR, + float sourceG, + float sourceB, + float sourceA) { + float outA = sourceA + baseA * (1f - sourceA); + if (outA <= 0f) { + return new float[] {0f, 0f, 0f, 0f}; + } + float outR = (sourceR * sourceA + baseR * baseA * (1f - sourceA)) / outA; + float outG = (sourceG * sourceA + baseG * baseA * (1f - sourceA)) / outA; + float outB = (sourceB * sourceA + baseB * baseA * (1f - sourceA)) / outA; + return new float[] {outR, outG, outB, outA}; + } + + private static int rgba(int r, int g, int b, float alpha) { + int a = Math.round(Math.max(0f, Math.min(1f, alpha)) * 255f); + return (r << 24) | (g << 16) | (b << 8) | a; + } + + private static int mixRgba( + int baseR, + int baseG, + int baseB, + float baseA, + int sourceR, + int sourceG, + int sourceB, + float sourceA) { + float[] mixed = blend(baseR, baseG, baseB, baseA, sourceR, sourceG, sourceB, sourceA); + return rgba(Math.round(mixed[0]), Math.round(mixed[1]), Math.round(mixed[2]), mixed[3]); + } + + private enum BasicEnemyStyle { + BASIC, + SUPPORT + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenSoundAssets.java b/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenSoundAssets.java new file mode 100644 index 0000000..75cda24 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/assets/GameScreenSoundAssets.java @@ -0,0 +1,27 @@ +package ru.project.tower.screens.gamescreen.assets; + +import com.badlogic.gdx.utils.Disposable; +import ru.project.tower.run.GameplaySound; +import ru.project.tower.support.GameAssetService; +import ru.project.tower.support.GameplaySoundAssets; + +public final class GameScreenSoundAssets implements Disposable { + private final GameplaySoundAssets sounds; + + public GameScreenSoundAssets(GameAssetService assetService) { + sounds = new GameplaySoundAssets(assetService, "GameScreen"); + } + + public void load() { + sounds.load(); + } + + public void play(GameplaySound sound, float volume, float pitch, float pan) { + sounds.play(sound, volume, pitch, pan); + } + + @Override + public void dispose() { + sounds.dispose(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenBulletEffectsHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenBulletEffectsHostAdapter.java new file mode 100644 index 0000000..2ada756 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenBulletEffectsHostAdapter.java @@ -0,0 +1,42 @@ +package ru.project.tower.screens.gamescreen.events; + +import java.util.Objects; +import ru.project.tower.entities.circles.BaseCircleData; + +public final class GameScreenBulletEffectsHostAdapter implements GameScreenBulletEffectsSink.Host { + private final Port port; + + public GameScreenBulletEffectsHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void handleEnemyKilled(BaseCircleData enemy) { + port.handleEnemyKilled(enemy); + } + + @Override + public boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs) { + return port.damagePlayerIfVulnerable(damage, damageCooldownMs); + } + + @Override + public int playerHealth() { + return port.playerHealth(); + } + + @Override + public void gameOver() { + port.gameOver(); + } + + public interface Port { + void handleEnemyKilled(BaseCircleData enemy); + + boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs); + + int playerHealth(); + + void gameOver(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenBulletEffectsSink.java b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenBulletEffectsSink.java new file mode 100644 index 0000000..cc04ce5 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenBulletEffectsSink.java @@ -0,0 +1,72 @@ +package ru.project.tower.screens.gamescreen.events; + +import java.util.Objects; +import ru.project.tower.DebugLog; +import ru.project.tower.entities.bullets.BulletData; +import ru.project.tower.entities.bullets.BulletEffectsSink; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.run.GameplayColor; +import ru.project.tower.run.GameplayEventSink; +import ru.project.tower.run.GameplayEvents; + +public final class GameScreenBulletEffectsSink implements BulletEffectsSink { + private static final long ENEMY_BULLET_DAMAGE_COOLDOWN_MS = 500L; + + private final Host host; + private final GameplayEventSink gameplayEventSink; + + public GameScreenBulletEffectsSink(Host host, GameplayEventSink gameplayEventSink) { + this.host = Objects.requireNonNull(host, "host"); + this.gameplayEventSink = Objects.requireNonNull(gameplayEventSink, "gameplayEventSink"); + } + + @Override + public void onEnemyHit(BaseCircleData enemy, int damage, boolean isCrit) { + if (DebugLog.DEBUG) { + DebugLog.log( + "Collision", + "Bullet hit circle! Circle health: " + + enemy.health + + ", Bullet damage: " + + damage); + } + } + + @Override + public void onCrit(BaseCircleData enemy, int damage) { + gameplayEventSink.emit( + GameplayEvents.floatingText( + enemy.getX(), + enemy.getY(), + "CRIT! " + damage, + GameplayColor.NEON_RED, + 1f, + 1f, + 50f)); + gameplayEventSink.emit(GameplayEvents.shake(4f, 0.05f)); + } + + @Override + public void onEnemyKilled(BaseCircleData enemy) { + DebugLog.log("Collision", "Circle destroyed!"); + host.handleEnemyKilled(enemy); + } + + @Override + public void onEnemyBulletHitPlayer(BulletData bullet) { + if (host.damagePlayerIfVulnerable(bullet.damage, ENEMY_BULLET_DAMAGE_COOLDOWN_MS) + && host.playerHealth() <= 0) { + host.gameOver(); + } + } + + public interface Host { + void handleEnemyKilled(BaseCircleData enemy); + + boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs); + + int playerHealth(); + + void gameOver(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenEnemyKillEventHandler.java b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenEnemyKillEventHandler.java new file mode 100644 index 0000000..225b1c6 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenEnemyKillEventHandler.java @@ -0,0 +1,78 @@ +package ru.project.tower.screens.gamescreen.events; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; +import ru.project.tower.entities.circles.FastCircleData; +import ru.project.tower.entities.circles.StrongCircleData; +import ru.project.tower.entities.circles.bosses.SwarmQueenData; +import ru.project.tower.run.GameplayEvent; +import ru.project.tower.run.GameplaySound; + +public final class GameScreenEnemyKillEventHandler { + private static final float KILL_REWARD_LIFETIME_SECONDS = 1.0f; + private static final float KILL_REWARD_INITIAL_SCALE = 0.65f; + private static final float KILL_REWARD_RISE_SPEED = 50f; + + public interface Host { + void triggerBossKillShake(); + + void triggerKillShake(); + + void spawnFloatingText(float x, float y, String text, Color color); + + void spawnFloatingText( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed); + + void playGameplaySound(GameplaySound sound, float volume, float pitch, float pan); + + void addMoney(int amount); + } + + private final Host host; + private final Color rewardColor; + + public GameScreenEnemyKillEventHandler(Host host, Color rewardColor) { + this.host = Objects.requireNonNull(host, "host"); + this.rewardColor = Objects.requireNonNull(rewardColor, "rewardColor"); + } + + public void handle(GameplayEvent.EnemyKilled event) { + Objects.requireNonNull(event, "event"); + + if (event.boss) { + host.triggerBossKillShake(); + } else { + host.triggerKillShake(); + } + host.spawnFloatingText( + event.position.x, + event.position.y + 10f, + "+" + event.reward, + rewardColor, + KILL_REWARD_LIFETIME_SECONDS, + KILL_REWARD_INITIAL_SCALE, + KILL_REWARD_RISE_SPEED); + playEnemyKilledSound(event.enemyKind); + host.addMoney(event.reward); + } + + private void playEnemyKilledSound(String enemyKind) { + float volume = 0.5f; + float pitch = 1.0f; + if (SwarmQueenData.class.getSimpleName().equals(enemyKind)) { + pitch = 0.5f; + volume = 0.8f; + } else if (StrongCircleData.class.getSimpleName().equals(enemyKind)) { + pitch = 0.8f; + } else if (FastCircleData.class.getSimpleName().equals(enemyKind)) { + pitch = 1.2f; + } + host.playGameplaySound(GameplaySound.POP, volume, pitch, 0f); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenEnemyKillHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenEnemyKillHostAdapter.java new file mode 100644 index 0000000..e78d43a --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenEnemyKillHostAdapter.java @@ -0,0 +1,71 @@ +package ru.project.tower.screens.gamescreen.events; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; +import ru.project.tower.run.GameplaySound; + +public final class GameScreenEnemyKillHostAdapter implements GameScreenEnemyKillEventHandler.Host { + private final Port port; + + public GameScreenEnemyKillHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void triggerBossKillShake() { + port.triggerBossKillShake(); + } + + @Override + public void triggerKillShake() { + port.triggerKillShake(); + } + + @Override + public void spawnFloatingText(float x, float y, String text, Color color) { + port.spawnFloatingText(x, y, text, color); + } + + @Override + public void spawnFloatingText( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed) { + port.spawnFloatingText(x, y, text, color, maxLifetime, initialScale, riseSpeed); + } + + @Override + public void playGameplaySound(GameplaySound sound, float volume, float pitch, float pan) { + port.playGameplaySound(sound, volume, pitch, pan); + } + + @Override + public void addMoney(int amount) { + port.addMoney(amount); + } + + public interface Port { + void triggerBossKillShake(); + + void triggerKillShake(); + + void spawnFloatingText(float x, float y, String text, Color color); + + void spawnFloatingText( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed); + + void playGameplaySound(GameplaySound sound, float volume, float pitch, float pan); + + void addMoney(int amount); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenGameplayEventSink.java b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenGameplayEventSink.java new file mode 100644 index 0000000..4ddbaec --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/events/GameScreenGameplayEventSink.java @@ -0,0 +1,153 @@ +package ru.project.tower.screens.gamescreen.events; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; +import ru.project.tower.DebugLog; +import ru.project.tower.run.GameplayColor; +import ru.project.tower.run.GameplayEvent; +import ru.project.tower.run.GameplayEventDispatcher; +import ru.project.tower.run.GameplaySound; + +public final class GameScreenGameplayEventSink extends GameplayEventDispatcher { + private final Host host; + private final Palette palette; + + public GameScreenGameplayEventSink(Host host, Palette palette) { + this.host = Objects.requireNonNull(host, "host"); + this.palette = Objects.requireNonNull(palette, "palette"); + } + + @Override + protected void onFloatingTextRequested(GameplayEvent.FloatingTextRequested event) { + host.spawnFloatingText( + event.position.x, + event.position.y, + event.text, + colorFor(event.color), + event.durationSeconds, + event.scale, + event.riseDistance); + } + + @Override + protected void onShakeRequested(GameplayEvent.ShakeRequested event) { + host.applyShake(event.intensity, event.hitstopSeconds); + } + + @Override + protected void onSoundRequested(GameplayEvent.SoundRequested event) { + host.playSound(event.sound, event.volume, event.pitch, event.pan); + } + + @Override + protected void onExplosionRequested(GameplayEvent.ExplosionRequested event) { + host.createExplosion( + event.position.x, event.position.y, colorFor(event.color), event.particleCount); + } + + @Override + protected void onBonusCollected(GameplayEvent.BonusCollected event) { + host.spawnFloatingText( + event.position.x, event.position.y, event.displayText, palette.neonYellow); + host.createExplosion(event.position.x, event.position.y, palette.neonYellow); + } + + @Override + protected void onPlayerDamaged(GameplayEvent.PlayerDamaged event) { + host.triggerDamageShake(event.damage); + } + + @Override + protected void onGameOver(GameplayEvent.GameOver event) { + if (event.currencyEarned > 0) { + host.addCurrency(event.currencyEarned); + host.updateMaxWave(event.reachedWave); + DebugLog.log( + "GameOver", + "Начислено " + + event.currencyEarned + + " монет за прохождение " + + event.reachedWave + + " волн"); + } + } + + @Override + protected void onEnemyKilled(GameplayEvent.EnemyKilled event) { + host.handleEnemyKilled(event); + } + + private Color colorFor(GameplayColor color) { + switch (color) { + case NEON_RED: + return palette.neonRed; + case NEON_YELLOW: + return palette.neonYellow; + case NEON_GREEN: + return palette.neonGreen; + case NEON_CYAN: + return palette.neonCyan; + case NEON_BLUE: + return palette.neonBlue; + case NEON_PURPLE: + return palette.neonPurple; + case WHITE: + return Color.WHITE; + default: + throw new IllegalArgumentException("Unsupported gameplay color: " + color); + } + } + + public interface Host { + void spawnFloatingText(float x, float y, String text, Color color); + + void spawnFloatingText( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed); + + void applyShake(float intensity, float hitstopSeconds); + + void triggerDamageShake(int damage); + + void playSound(GameplaySound sound, float volume, float pitch, float pan); + + void createExplosion(float x, float y, Color color); + + void createExplosion(float x, float y, Color color, int particleCount); + + void addCurrency(int amount); + + void updateMaxWave(int wave); + + void handleEnemyKilled(GameplayEvent.EnemyKilled event); + } + + public static final class Palette { + private final Color neonRed; + private final Color neonYellow; + private final Color neonGreen; + private final Color neonCyan; + private final Color neonBlue; + private final Color neonPurple; + + public Palette( + Color neonRed, + Color neonYellow, + Color neonGreen, + Color neonCyan, + Color neonBlue, + Color neonPurple) { + this.neonRed = Objects.requireNonNull(neonRed, "neonRed"); + this.neonYellow = Objects.requireNonNull(neonYellow, "neonYellow"); + this.neonGreen = Objects.requireNonNull(neonGreen, "neonGreen"); + this.neonCyan = Objects.requireNonNull(neonCyan, "neonCyan"); + this.neonBlue = Objects.requireNonNull(neonBlue, "neonBlue"); + this.neonPurple = Objects.requireNonNull(neonPurple, "neonPurple"); + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/gameobjects/GameScreenGameObjectModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/gameobjects/GameScreenGameObjectModel.java new file mode 100644 index 0000000..05e49e5 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/gameobjects/GameScreenGameObjectModel.java @@ -0,0 +1,418 @@ +package ru.project.tower.screens.gamescreen.gameobjects; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.entities.bullets.BulletData; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.BonusBallData; +import ru.project.tower.screens.gamescreen.assets.GameScreenMaterialAssets; + +public final class GameScreenGameObjectModel { + private final GameScreenGameObjectRenderContext renderContext = + new GameScreenGameObjectRenderContext(); + private final GameScreenGameObjectViewModel viewModel = new GameScreenGameObjectViewModel(); + + public GameScreenGameObjectModel() {} + + GameScreenGameObjectRenderContext renderContext() { + return renderContext; + } + + GameScreenGameObjectViewModel viewModel() { + return viewModel; + } + + public void populateRenderContext(RenderContextData data) { + populateRenderContext( + data.shapeRenderer(), + data.batch(), + data.materialAssets(), + data.scratchColor(), + data.scratchColor3(), + data.scratchColor4(), + data.scratchColor5()); + } + + public void populateRenderContext( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + GameScreenMaterialAssets materialAssets, + Color scratchColor, + Color scratchColor3, + Color scratchColor4, + Color scratchColor5) { + renderContext.shapeRenderer = shapeRenderer; + renderContext.batch = batch; + renderContext.materialAssets = materialAssets; + renderContext.scratchColor = scratchColor; + renderContext.scratchColor3 = scratchColor3; + renderContext.scratchColor4 = scratchColor4; + renderContext.scratchColor5 = scratchColor5; + } + + public void populateView(ViewState state) { + populateView( + state.square(), + state.circles(), + state.bonusBalls(), + state.bullets(), + state.abilitySystem(), + state.nowMs(), + state.time(), + state.pulseSpeed(), + state.uiScale(), + state.gameScale(), + state.shootingRange(), + state.reviewVisuals(), + state.currentStrongCircleHealth(), + state.currentBasicCircleHealth(), + state.shadowWeaverColor(), + state.glacialWardenColor(), + state.basicCircleColor(), + state.fastCircleColor(), + state.splitterCircleColor(), + state.spawnerCircleColor(), + state.neonBlue(), + state.neonCyan(), + state.neonGreen(), + state.neonYellow(), + state.bossProjectileColor()); + } + + public void populateView( + Rectangle square, + Array circles, + Array bonusBalls, + Array bullets, + AbilitySystem abilitySystem, + long nowMs, + float time, + float pulseSpeed, + float uiScale, + float gameScale, + float shootingRange, + boolean reviewVisuals, + int currentStrongCircleHealth, + int currentBasicCircleHealth, + Color shadowWeaverColor, + Color glacialWardenColor, + Color basicCircleColor, + Color fastCircleColor, + Color splitterCircleColor, + Color spawnerCircleColor, + Color neonBlue, + Color neonCyan, + Color neonGreen, + Color neonYellow, + Color bossProjectileColor) { + viewModel.square = square; + viewModel.circles = circles; + viewModel.bonusBalls = bonusBalls; + viewModel.bullets = bullets; + viewModel.abilitySystem = abilitySystem; + viewModel.nowMs = nowMs; + viewModel.time = time; + viewModel.pulseSpeed = pulseSpeed; + viewModel.uiScale = uiScale; + viewModel.gameScale = gameScale; + viewModel.shootingRange = shootingRange; + viewModel.reviewVisuals = reviewVisuals; + viewModel.currentStrongCircleHealth = currentStrongCircleHealth; + viewModel.currentBasicCircleHealth = currentBasicCircleHealth; + viewModel.shadowWeaverColor = shadowWeaverColor; + viewModel.glacialWardenColor = glacialWardenColor; + viewModel.basicCircleColor = basicCircleColor; + viewModel.fastCircleColor = fastCircleColor; + viewModel.splitterCircleColor = splitterCircleColor; + viewModel.spawnerCircleColor = spawnerCircleColor; + viewModel.neonBlue = neonBlue; + viewModel.neonCyan = neonCyan; + viewModel.neonGreen = neonGreen; + viewModel.neonYellow = neonYellow; + viewModel.bossProjectileColor = bossProjectileColor; + } + + public static final class RenderContextData { + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final GameScreenMaterialAssets materialAssets; + private final Color scratchColor; + private final Color scratchColor3; + private final Color scratchColor4; + private final Color scratchColor5; + + public RenderContextData( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + GameScreenMaterialAssets materialAssets, + Color scratchColor, + Color scratchColor3, + Color scratchColor4, + Color scratchColor5) { + this.shapeRenderer = shapeRenderer; + this.batch = batch; + this.materialAssets = materialAssets; + this.scratchColor = scratchColor; + this.scratchColor3 = scratchColor3; + this.scratchColor4 = scratchColor4; + this.scratchColor5 = scratchColor5; + } + + public ShapeRenderer shapeRenderer() { + return shapeRenderer; + } + + public SpriteBatch batch() { + return batch; + } + + public GameScreenMaterialAssets materialAssets() { + return materialAssets; + } + + public Color scratchColor() { + return scratchColor; + } + + public Color scratchColor3() { + return scratchColor3; + } + + public Color scratchColor4() { + return scratchColor4; + } + + public Color scratchColor5() { + return scratchColor5; + } + } + + public static final class ViewState { + private final Rectangle square; + private final Array circles; + private final Array bonusBalls; + private final Array bullets; + private final AbilitySystem abilitySystem; + private final long nowMs; + private final float time; + private final float pulseSpeed; + private final float uiScale; + private final float gameScale; + private final float shootingRange; + private final boolean reviewVisuals; + private final int currentStrongCircleHealth; + private final int currentBasicCircleHealth; + private final Color shadowWeaverColor; + private final Color glacialWardenColor; + private final Color basicCircleColor; + private final Color fastCircleColor; + private final Color splitterCircleColor; + private final Color spawnerCircleColor; + private final Color neonBlue; + private final Color neonCyan; + private final Color neonGreen; + private final Color neonYellow; + private final Color bossProjectileColor; + + public ViewState( + Rectangle square, + Array circles, + Array bonusBalls, + Array bullets, + AbilitySystem abilitySystem, + long nowMs, + float time, + float pulseSpeed, + float uiScale, + float gameScale, + float shootingRange, + boolean reviewVisuals, + int currentStrongCircleHealth, + int currentBasicCircleHealth, + Color shadowWeaverColor, + Color glacialWardenColor, + Color basicCircleColor, + Color fastCircleColor, + Color splitterCircleColor, + Color spawnerCircleColor, + Color neonBlue, + Color neonCyan, + Color neonGreen, + Color neonYellow, + Color bossProjectileColor) { + this.square = square; + this.circles = circles; + this.bonusBalls = bonusBalls; + this.bullets = bullets; + this.abilitySystem = abilitySystem; + this.nowMs = nowMs; + this.time = time; + this.pulseSpeed = pulseSpeed; + this.uiScale = uiScale; + this.gameScale = gameScale; + this.shootingRange = shootingRange; + this.reviewVisuals = reviewVisuals; + this.currentStrongCircleHealth = currentStrongCircleHealth; + this.currentBasicCircleHealth = currentBasicCircleHealth; + this.shadowWeaverColor = shadowWeaverColor; + this.glacialWardenColor = glacialWardenColor; + this.basicCircleColor = basicCircleColor; + this.fastCircleColor = fastCircleColor; + this.splitterCircleColor = splitterCircleColor; + this.spawnerCircleColor = spawnerCircleColor; + this.neonBlue = neonBlue; + this.neonCyan = neonCyan; + this.neonGreen = neonGreen; + this.neonYellow = neonYellow; + this.bossProjectileColor = bossProjectileColor; + } + + public Rectangle square() { + return square; + } + + public Array circles() { + return circles; + } + + public Array bonusBalls() { + return bonusBalls; + } + + public Array bullets() { + return bullets; + } + + public AbilitySystem abilitySystem() { + return abilitySystem; + } + + public long nowMs() { + return nowMs; + } + + public float time() { + return time; + } + + public float pulseSpeed() { + return pulseSpeed; + } + + public float uiScale() { + return uiScale; + } + + public float gameScale() { + return gameScale; + } + + public float shootingRange() { + return shootingRange; + } + + public boolean reviewVisuals() { + return reviewVisuals; + } + + public int currentStrongCircleHealth() { + return currentStrongCircleHealth; + } + + public int currentBasicCircleHealth() { + return currentBasicCircleHealth; + } + + public Color shadowWeaverColor() { + return shadowWeaverColor; + } + + public Color glacialWardenColor() { + return glacialWardenColor; + } + + public Color basicCircleColor() { + return basicCircleColor; + } + + public Color fastCircleColor() { + return fastCircleColor; + } + + public Color splitterCircleColor() { + return splitterCircleColor; + } + + public Color spawnerCircleColor() { + return spawnerCircleColor; + } + + public Color neonBlue() { + return neonBlue; + } + + public Color neonCyan() { + return neonCyan; + } + + public Color neonGreen() { + return neonGreen; + } + + public Color neonYellow() { + return neonYellow; + } + + public Color bossProjectileColor() { + return bossProjectileColor; + } + } +} + +final class GameScreenGameObjectRenderContext { + ShapeRenderer shapeRenderer; + SpriteBatch batch; + GameScreenMaterialAssets materialAssets; + Color scratchColor; + Color scratchColor3; + Color scratchColor4; + Color scratchColor5; + + Color withAlpha(Color base, float alpha) { + scratchColor.set(base); + scratchColor.a = alpha; + return scratchColor; + } +} + +final class GameScreenGameObjectViewModel { + Rectangle square; + Array circles; + Array bonusBalls; + Array bullets; + AbilitySystem abilitySystem; + long nowMs; + float time; + float pulseSpeed; + float uiScale; + float gameScale; + float shootingRange; + boolean reviewVisuals; + int currentStrongCircleHealth; + int currentBasicCircleHealth; + Color shadowWeaverColor; + Color glacialWardenColor; + Color basicCircleColor; + Color fastCircleColor; + Color splitterCircleColor; + Color spawnerCircleColor; + Color neonBlue; + Color neonCyan; + Color neonGreen; + Color neonYellow; + Color bossProjectileColor; +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/gameobjects/GameScreenGameObjectRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/gameobjects/GameScreenGameObjectRenderer.java new file mode 100644 index 0000000..d9bbf3a --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/gameobjects/GameScreenGameObjectRenderer.java @@ -0,0 +1,1105 @@ +package ru.project.tower.screens.gamescreen.gameobjects; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Vector2; +import ru.project.tower.entities.bullets.BulletData; +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.entities.circles.FastCircleData; +import ru.project.tower.entities.circles.HealerCircleData; +import ru.project.tower.entities.circles.SpawnerCircleData; +import ru.project.tower.entities.circles.SplitterCircleData; +import ru.project.tower.entities.circles.StrongCircleData; +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.ScreenPalette; + +public final class GameScreenGameObjectRenderer { + private static final float MOCK_RANGE_RADIUS_TO_SQUARE_SIZE = 126f / 54f; + private static final float MIN_ENEMY_VISUAL_RADIUS = 6.25f; + private static final int RANGE_RING_SEGMENTS = 192; + + public interface Host { + public GameScreenGameObjectModel gameObjectModel(); + } + + private final Host host; + + public GameScreenGameObjectRenderer(Host host) { + this.host = host; + } + + public void renderGameObjects() { + Gdx.gl.glEnable(GL20.GL_BLEND); + renderShootingRangeIndicator(); + renderNeonSquare(); + renderNeonCircles(); + renderEliteAura(); + renderBonusBalls(); + renderNeonBullets(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private GameScreenGameObjectModel model() { + return host.gameObjectModel(); + } + + private GameScreenGameObjectRenderContext context() { + return model().renderContext(); + } + + private GameScreenGameObjectViewModel view() { + return model().viewModel(); + } + + void renderNeonSquare() { + GameScreenGameObjectRenderContext context = context(); + GameScreenGameObjectViewModel view = view(); + if (renderPlayerTowerMaterial(context, view)) { + return; + } + ShapeRenderer shapeRenderer = context.shapeRenderer; + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + shapeRenderer.begin(ShapeType.Filled); + + boolean shieldOn = view.abilitySystem.isShieldActive(view.nowMs); + Color playerColor = shieldOn ? view.neonCyan : view.neonBlue; + shapeRenderer.setColor(context.withAlpha(playerColor, 0.08f)); + shapeRenderer.rect( + view.square.x - 13f * view.uiScale, + view.square.y - 13f * view.uiScale, + view.square.width + 26f * view.uiScale, + view.square.height + 26f * view.uiScale); + shapeRenderer.setColor(context.withAlpha(playerColor, 0.18f)); + shapeRenderer.rect( + view.square.x - 7f * view.uiScale, + view.square.y - 7f * view.uiScale, + view.square.width + 14f * view.uiScale, + view.square.height + 14f * view.uiScale); + + Color squareColor = context.scratchColor3.set(playerColor); + squareColor.a = 0.9f; + shapeRenderer.setColor(squareColor); + shapeRenderer.rect(view.square.x, view.square.y, view.square.width, view.square.height); + drawPlayerBorder(shapeRenderer, view); + + shapeRenderer.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private boolean renderPlayerTowerMaterial( + GameScreenGameObjectRenderContext context, GameScreenGameObjectViewModel view) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.playerTower() == null) { + return false; + } + float centerX = view.square.x + view.square.width / 2f; + float centerY = view.square.y + view.square.height / 2f; + float size = view.square.width + 80f * view.uiScale; + SpriteBatch batch = context.batch; + batch.begin(); + batch.setColor(Color.WHITE); + batch.draw( + context.materialAssets.playerTower(), + centerX - size / 2f, + centerY - size / 2f, + size, + size); + batch.end(); + return true; + } + + private void drawPlayerBorder(ShapeRenderer shapeRenderer, GameScreenGameObjectViewModel view) { + float border = 3f * view.uiScale; + shapeRenderer.setColor(0.604f, 0.647f, 1f, 0.42f); + shapeRenderer.rect(view.square.x, view.square.y, view.square.width, border); + shapeRenderer.rect( + view.square.x, + view.square.y + view.square.height - border, + view.square.width, + border); + shapeRenderer.rect(view.square.x, view.square.y, border, view.square.height); + shapeRenderer.rect( + view.square.x + view.square.width - border, + view.square.y, + border, + view.square.height); + } + + void renderEliteAura() { + GameScreenGameObjectRenderContext context = context(); + GameScreenGameObjectViewModel view = view(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + float pulse = 0.5f + 0.5f * (float) Math.sin(view.time * view.pulseSpeed * 2f); + if (renderEliteAuraMaterial(context, view, pulse)) { + return; + } + shapeRenderer.begin(ShapeType.Filled); + for (BaseCircleData circle : view.circles) { + if (!circle.isElite()) continue; + float radius = circle.getRadius(); + for (int i = 3; i >= 1; i--) { + float alpha = 0.15f * pulse * (4 - i) / 3f; + shapeRenderer.setColor(ScreenPalette.GOLD.set(context.scratchColor, alpha)); + shapeRenderer.circle(circle.getX(), circle.getY(), radius + 4f * i * view.uiScale); + } + } + shapeRenderer.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private boolean renderEliteAuraMaterial( + GameScreenGameObjectRenderContext context, + GameScreenGameObjectViewModel view, + float pulse) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.radialGlow() == null) { + return false; + } + SpriteBatch batch = context.batch; + Texture glow = context.materialAssets.radialGlow(); + batch.begin(); + for (BaseCircleData circle : view.circles) { + if (!circle.isElite()) continue; + float radius = circle.getRadius() + 14f * view.uiScale; + Color auraColor = ScreenPalette.GOLD.set(context.scratchColor, 0.24f * pulse); + batch.setColor(auraColor); + batch.draw( + glow, circle.getX() - radius, circle.getY() - radius, radius * 2f, radius * 2f); + } + batch.setColor(Color.WHITE); + batch.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + return true; + } + + void renderBonusBalls() { + GameScreenGameObjectRenderContext context = context(); + GameScreenGameObjectViewModel view = view(); + if (context.batch != null + && context.materialAssets != null + && context.materialAssets.bonusOrb() != null + && renderBonusOrbMaterial(context, view)) { + return; + } + ShapeRenderer shapeRenderer = context.shapeRenderer; + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + shapeRenderer.begin(ShapeType.Filled); + + for (BonusBallData ball : view.bonusBalls) { + float glow = ball.getGlowAmount(); + + for (int i = 3; i > 0; i--) { + shapeRenderer.setColor( + context.withAlpha(view.neonYellow, 0.2f * glow * (4 - i) / 3f)); + shapeRenderer.circle( + ball.getX(), ball.getY(), ball.getRadius() * (1f + i * 0.4f * glow)); + } + + shapeRenderer.setColor(view.neonYellow); + shapeRenderer.circle(ball.getX(), ball.getY(), ball.getRadius()); + } + + shapeRenderer.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private boolean renderBonusOrbMaterial( + GameScreenGameObjectRenderContext context, GameScreenGameObjectViewModel view) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.bonusOrb() == null) { + return false; + } + SpriteBatch batch = context.batch; + Texture orb = context.materialAssets.bonusOrb(); + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + batch.begin(); + for (BonusBallData ball : view.bonusBalls) { + float glow = ball.getGlowAmount(); + float size = ball.getRadius() * (2.6f + 1.8f * glow); + batch.setColor( + view.neonYellow.r, view.neonYellow.g, view.neonYellow.b, 0.62f + 0.38f * glow); + batch.draw(orb, ball.getX() - size / 2f, ball.getY() - size / 2f, size, size); + } + batch.setColor(Color.WHITE); + batch.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + return true; + } + + void renderNeonBullets() { + GameScreenGameObjectRenderContext context = context(); + GameScreenGameObjectViewModel view = view(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + shapeRenderer.begin(ShapeType.Filled); + + for (BulletData bullet : view.bullets) { + float glow = + 0.5f + + 0.5f + * (float) + Math.sin( + view.time * view.pulseSpeed * 1.5f + + bullet.getX() * 0.02f); + float critBoost = bullet.isCrit() ? 2.0f : 1.0f; + float extraRadius = 3f * glow * view.uiScale * critBoost; + + Color bulletColor; + if (bullet.isEnemyBullet()) { + bulletColor = view.bossProjectileColor; + } else if (bullet.isCrit()) { + bulletColor = view.neonYellow; + } else { + bulletColor = view.neonGreen; + } + Color glowColor = context.scratchColor4.set(bulletColor); + + for (int i = 2; i > 0; i--) { + float alpha = 0.3f * (3 - i) / 2 * glow; + float extraSize = extraRadius * i; + glowColor.a = alpha; + shapeRenderer.setColor(glowColor); + shapeRenderer.circle(bullet.getX(), bullet.getY(), bullet.getRadius() + extraSize); + } + + shapeRenderer.setColor(bulletColor); + shapeRenderer.circle(bullet.getX(), bullet.getY(), bullet.getRadius() * critBoost); + } + + shapeRenderer.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + void renderShootingRangeIndicator() { + GameScreenGameObjectRenderContext context = context(); + GameScreenGameObjectViewModel view = view(); + float squareCenterX = view.square.x + view.square.width / 2; + float squareCenterY = view.square.y + view.square.height / 2; + float adjustedRange = + Math.min( + view.shootingRange * view.gameScale, + view.square.width * MOCK_RANGE_RADIUS_TO_SQUARE_SIZE); + renderPlayerRangeGlowMaterial(context, view, squareCenterX, squareCenterY, adjustedRange); + if (renderRangeRingMaterial(context, view, squareCenterX, squareCenterY, adjustedRange)) { + return; + } + ShapeRenderer shapeRenderer = context.shapeRenderer; + shapeRenderer.begin(ShapeType.Line); + Gdx.gl.glLineWidth(1f); + shapeRenderer.setColor(context.withAlpha(view.neonYellow, 0.42f)); + shapeRenderer.circle(squareCenterX, squareCenterY, adjustedRange, RANGE_RING_SEGMENTS); + shapeRenderer.end(); + Gdx.gl.glLineWidth(1f); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private void renderPlayerRangeGlowMaterial( + GameScreenGameObjectRenderContext context, + GameScreenGameObjectViewModel view, + float centerX, + float centerY, + float radius) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.playerRangeGlow() == null) { + return; + } + float size = radius * 2f; + SpriteBatch batch = context.batch; + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + batch.begin(); + batch.setColor(view.neonBlue.r, view.neonBlue.g, view.neonBlue.b, 0.20f); + batch.draw( + context.materialAssets.playerRangeGlow(), + centerX - size / 2f, + centerY - size / 2f, + size, + size); + batch.setColor(Color.WHITE); + batch.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private boolean renderRangeRingMaterial( + GameScreenGameObjectRenderContext context, + GameScreenGameObjectViewModel view, + float centerX, + float centerY, + float radius) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.rangeRing() == null) { + return false; + } + float size = radius * 2f; + SpriteBatch batch = context.batch; + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + batch.begin(); + batch.setColor(view.neonYellow.r, view.neonYellow.g, view.neonYellow.b, 0.36f); + batch.draw( + context.materialAssets.rangeRing(), + centerX - size / 2f, + centerY - size / 2f, + size, + size); + batch.setColor(Color.WHITE); + batch.end(); + return true; + } + + void renderNeonCircles() { + GameScreenGameObjectRenderContext context = context(); + GameScreenGameObjectViewModel view = view(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + renderMaterialEnemySprites(context(), view); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + renderLegacySpecialEnemies(shapeRenderer, view); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private void renderLegacySpecialEnemies( + ShapeRenderer shapeRenderer, GameScreenGameObjectViewModel view) { + shapeRenderer.begin(ShapeType.Filled); + + for (BaseCircleData circle : view.circles) { + if (usesMaterialEnemySprite(circle)) continue; + float time = view.time; + float pulseSpeed = view.pulseSpeed; + float uiScale = view.uiScale; + float glow = 0.5f + 0.5f * (float) Math.sin(time * pulseSpeed + circle.getX() * 0.02f); + float extraRadius = 5f * glow * uiScale; + + Color baseColor = + renderTypeSpecificDecorations(circle, shapeRenderer, glow, extraRadius); + renderSharedCircleDecorations(circle, shapeRenderer, baseColor); + } + + shapeRenderer.end(); + } + + private void renderMaterialEnemySprites( + GameScreenGameObjectRenderContext context, GameScreenGameObjectViewModel view) { + renderMaterialEnemyGlows(context(), view); + if (context.batch == null || context.materialAssets == null) { + renderMaterialEnemyCirclesFallback(context.shapeRenderer, view); + return; + } + SpriteBatch batch = context.batch; + batch.begin(); + for (BaseCircleData circle : view.circles) { + if (!usesMaterialEnemySprite(circle)) continue; + Texture texture = mockEnemyTexture(context, circle); + if (texture == null) continue; + float size = mockEnemyTextureSize(circle); + batch.setColor(Color.WHITE); + batch.draw(texture, circle.getX() - size / 2f, circle.getY() - size / 2f, size, size); + batch.setColor(1f, 1f, 1f, 0.42f); + batch.draw(texture, circle.getX() - size / 2f, circle.getY() - size / 2f, size, size); + } + batch.setColor(Color.WHITE); + batch.end(); + } + + private boolean usesMaterialEnemySprite(BaseCircleData circle) { + return circle instanceof BasicCircleData + || circle instanceof FastCircleData + || circle instanceof StrongCircleData + || circle instanceof HealerCircleData + || circle instanceof SpawnerCircleData + || circle instanceof SplitterCircleData; + } + + private Texture mockEnemyTexture( + GameScreenGameObjectRenderContext context, BaseCircleData circle) { + if (circle instanceof FastCircleData) { + return context.materialAssets.fastEnemy(); + } + if (circle instanceof StrongCircleData) { + return context.materialAssets.armoredEnemy(); + } + if (circle instanceof HealerCircleData || circle instanceof SpawnerCircleData) { + return context.materialAssets.supportEnemy(); + } + if (circle instanceof SplitterCircleData) { + return context.materialAssets.splitterEnemy(); + } + return context.materialAssets.basicEnemy(); + } + + private float mockEnemyTextureSize(BaseCircleData circle) { + float visualRadius = + view().reviewVisuals ? circle.getRadius() : runtimeEnemyVisualRadius(circle); + float size = visualRadius * 2f / 0.76f; + if (circle instanceof FastCircleData) { + return size * 1.36f; + } + return size; + } + + private float runtimeEnemyVisualRadius(BaseCircleData circle) { + return Math.min(circle.getRadius(), mockEnemyReferenceRadius(circle) * view().uiScale); + } + + private float mockEnemyReferenceRadius(BaseCircleData circle) { + if (circle instanceof StrongCircleData) { + return 8f; + } + if (circle instanceof HealerCircleData || circle instanceof SpawnerCircleData) { + return 6.5f; + } + if (circle instanceof SplitterCircleData) { + return 6.3f; + } + if (circle instanceof FastCircleData) { + return 5.5f; + } + return 6f; + } + + private void renderMaterialEnemyCirclesFallback( + ShapeRenderer shapeRenderer, GameScreenGameObjectViewModel view) { + shapeRenderer.begin(ShapeType.Filled); + for (BaseCircleData circle : view.circles) { + if (!usesMaterialEnemySprite(circle)) continue; + if (circle instanceof FastCircleData) { + renderMockFastEnemy(shapeRenderer, circle); + } else if (circle instanceof StrongCircleData) { + renderMockArmoredEnemy(shapeRenderer, circle); + } else if (circle instanceof HealerCircleData || circle instanceof SpawnerCircleData) { + renderMockSupportEnemy(shapeRenderer, circle); + } else if (circle instanceof SplitterCircleData) { + renderMockSplitterEnemy(shapeRenderer, circle); + } else { + renderMockBasicEnemy(shapeRenderer, circle); + } + } + shapeRenderer.end(); + } + + private void renderMaterialEnemyGlows( + GameScreenGameObjectRenderContext context, GameScreenGameObjectViewModel view) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.radialGlow() == null) { + return; + } + SpriteBatch batch = context.batch; + Texture glow = context.materialAssets.radialGlow(); + batch.begin(); + for (BaseCircleData circle : view.circles) { + if (!usesMaterialEnemySprite(circle)) continue; + setMockGlowColor(batch, circle); + float visualRadius = + view.reviewVisuals ? circle.getRadius() : runtimeEnemyVisualRadius(circle); + float radius = visualRadius + 14f * view.uiScale; + batch.draw( + glow, circle.getX() - radius, circle.getY() - radius, radius * 2f, radius * 2f); + } + batch.setColor(Color.WHITE); + batch.end(); + } + + private void setMockGlowColor(SpriteBatch batch, BaseCircleData circle) { + if (circle instanceof FastCircleData) { + batch.setColor(1f, 0.31f, 0.847f, 0.76f); + } else if (circle instanceof StrongCircleData) { + batch.setColor(1f, 0.478f, 0.627f, 0.66f); + } else if (circle instanceof HealerCircleData || circle instanceof SpawnerCircleData) { + batch.setColor(0.145f, 0.875f, 0.710f, 0.74f); + } else { + batch.setColor(0.302f, 0.486f, 1f, 0.82f); + } + } + + private void renderMockBasicEnemy(ShapeRenderer shapeRenderer, BaseCircleData circle) { + float radius = circle.getRadius(); + float scale = view().uiScale; + shapeRenderer.setColor(0.302f, 0.486f, 1f, 0.10f); + shapeRenderer.circle(circle.getX(), circle.getY(), radius + 3f * scale); + shapeRenderer.setColor(0.698f, 0.773f, 1f, 0.82f); + shapeRenderer.circle(circle.getX(), circle.getY(), radius); + shapeRenderer.setColor(0.302f, 0.486f, 1f, 0.94f); + shapeRenderer.circle(circle.getX(), circle.getY(), Math.max(1f, radius - 2f * scale)); + } + + private void renderMockFastEnemy(ShapeRenderer shapeRenderer, BaseCircleData circle) { + float radius = circle.getRadius(); + float scale = view().uiScale; + shapeRenderer.setColor(1f, 0.31f, 0.847f, 0.10f); + renderDiamond(shapeRenderer, circle.getX(), circle.getY(), radius + 3f * scale); + shapeRenderer.setColor(1f, 0.451f, 0.875f, 0.90f); + renderDiamond(shapeRenderer, circle.getX(), circle.getY(), radius); + shapeRenderer.setColor(1f, 0.31f, 0.847f, 0.16f); + renderDiamond( + shapeRenderer, circle.getX(), circle.getY(), Math.max(1f, radius - 2f * scale)); + } + + private void renderMockArmoredEnemy(ShapeRenderer shapeRenderer, BaseCircleData circle) { + float radius = circle.getRadius(); + float scale = view().uiScale; + float size = radius * 2f; + float x = circle.getX() - radius; + float y = circle.getY() - radius; + shapeRenderer.setColor(1f, 0.478f, 0.627f, 0.08f); + shapeRenderer.rect(x - 3f * scale, y - 3f * scale, size + 6f * scale, size + 6f * scale); + shapeRenderer.setColor(1f, 0.608f, 0.718f, 0.84f); + shapeRenderer.rect(x, y, size, size); + shapeRenderer.setColor(0.07f, 0.05f, 0.08f, 0.92f); + shapeRenderer.rect(x + 2f * scale, y + 2f * scale, size - 4f * scale, size - 4f * scale); + shapeRenderer.setColor(1f, 0.478f, 0.627f, 0.08f); + shapeRenderer.rect(x + 2f * scale, y + 2f * scale, size - 4f * scale, size - 4f * scale); + shapeRenderer.setColor(1f, 0.608f, 0.718f, 0.16f); + shapeRenderer.rect(x + 4f * scale, y + 4f * scale, size - 8f * scale, size - 8f * scale); + } + + private void renderMockSupportEnemy(ShapeRenderer shapeRenderer, BaseCircleData circle) { + float radius = circle.getRadius(); + float scale = view().uiScale; + shapeRenderer.setColor(0.145f, 0.875f, 0.710f, 0.10f); + shapeRenderer.circle(circle.getX(), circle.getY(), radius + 3f * scale); + shapeRenderer.setColor(0.706f, 1f, 0.925f, 0.82f); + shapeRenderer.circle(circle.getX(), circle.getY(), radius); + shapeRenderer.setColor(0.145f, 0.875f, 0.710f, 0.94f); + shapeRenderer.circle(circle.getX(), circle.getY(), Math.max(1f, radius - 2f * scale)); + shapeRenderer.setColor(0.027f, 0.067f, 0.059f, 0.95f); + shapeRenderer.rect( + circle.getX() - 3.5f * scale, circle.getY() - 1f * scale, 7f * scale, 2f * scale); + shapeRenderer.rect( + circle.getX() - 1f * scale, circle.getY() - 3.5f * scale, 2f * scale, 7f * scale); + } + + private void renderMockSplitterEnemy(ShapeRenderer shapeRenderer, BaseCircleData circle) { + float radius = circle.getRadius(); + float scale = view().uiScale; + shapeRenderer.setColor(0.27f, 0.32f, 1f, 0.10f); + shapeRenderer.circle(circle.getX(), circle.getY(), radius + 3f * scale); + shapeRenderer.setColor(0.745f, 0.792f, 1f, 0.78f); + shapeRenderer.circle(circle.getX(), circle.getY(), radius); + shapeRenderer.setColor(0.275f, 0.922f, 1f, 0.86f); + float spoke = Math.max(2f, radius * 0.28f); + shapeRenderer.rectLine( + circle.getX() - radius * 0.72f, + circle.getY(), + circle.getX() + radius * 0.72f, + circle.getY(), + spoke); + shapeRenderer.rectLine( + circle.getX(), + circle.getY() - radius * 0.72f, + circle.getX(), + circle.getY() + radius * 0.72f, + spoke); + } + + private Color renderTypeSpecificDecorations( + BaseCircleData circle, ShapeRenderer shapeRenderer, float glow, float extraRadius) { + if (circle instanceof VolatileReactorData) { + return renderVolatileReactor((VolatileReactorData) circle, shapeRenderer, glow); + } else if (circle instanceof ShadowWeaverData) { + renderShadowWeaver((ShadowWeaverData) circle, shapeRenderer, glow, extraRadius); + return view().shadowWeaverColor; + } else if (circle instanceof JuggernautData) { + renderJuggernaut((JuggernautData) circle, shapeRenderer); + return view().shadowWeaverColor; + } else if (circle instanceof SwarmQueenData) { + renderSwarmQueen((SwarmQueenData) circle, shapeRenderer); + return view().shadowWeaverColor; + } else if (circle instanceof GlacialWardenData) { + renderGlacialWarden((GlacialWardenData) circle, shapeRenderer, glow); + return view().glacialWardenColor; + } else if (circle instanceof IllusionMasterData) { + return renderIllusionMaster((IllusionMasterData) circle, shapeRenderer); + } else if (circle instanceof BasicCircleData) { + return view().basicCircleColor; + } else if (circle instanceof StrongCircleData) { + return context().scratchColor3.set(1f, 0.61f, 0.72f, 1f); + } else if (circle instanceof FastCircleData) { + return view().fastCircleColor; + } else if (circle instanceof HealerCircleData) { + return view().spawnerCircleColor; + } else if (circle instanceof SplitterCircleData) { + return view().splitterCircleColor; + } else if (circle instanceof SpawnerCircleData) { + return view().spawnerCircleColor; + } + return view().neonBlue; + } + + private Color renderVolatileReactor( + VolatileReactorData reactor, ShapeRenderer shapeRenderer, float glow) { + float[] color = reactor.getColor(); + Color baseColor = context().scratchColor3.set(color[0], color[1], color[2], color[3]); + + for (VolatileReactorData.FireZone zone : reactor.getFireZones()) { + float[] zoneColor = zone.getColor(); + Color fireColor = + context() + .scratchColor4 + .set(zoneColor[0], zoneColor[1], zoneColor[2], zoneColor[3]); + + for (int i = 2; i > 0; i--) { + float alpha = fireColor.a * 0.5f * (3 - i) / 2; + Color glowColor = context().scratchColor5.set(fireColor); + glowColor.a = alpha; + shapeRenderer.setColor(glowColor); + shapeRenderer.circle(zone.x, zone.y, zone.radius * (1f + 0.3f * i)); + } + + shapeRenderer.setColor(fireColor); + shapeRenderer.circle(zone.x, zone.y, zone.radius); + } + + float heatGlow = reactor.getHeatLevel() * glow; + Color ventColor = context().scratchColor4.set(1f, 0.3f + heatGlow * 0.7f, 0.1f, 0.7f); + for (int i = 0; i < 4; i++) { + float angle = (float) (i * Math.PI / 2); + float ventX = reactor.getX() + (float) Math.cos(angle) * reactor.getRadius() * 0.7f; + float ventY = reactor.getY() + (float) Math.sin(angle) * reactor.getRadius() * 0.7f; + shapeRenderer.setColor(ventColor); + shapeRenderer.circle(ventX, ventY, reactor.getRadius() * 0.2f); + } + return baseColor; + } + + private void renderShadowWeaver( + ShadowWeaverData weaver, ShapeRenderer shapeRenderer, float glow, float extraRadius) { + if (!weaver.isTeleporting()) { + for (int i = 1; i <= 3; i++) { + float alpha = 0.3f * (1f - i / 4f); + Color trailColor = context().scratchColor4.set(0.8f, 0.2f, 0.8f, alpha); + shapeRenderer.setColor(trailColor); + shapeRenderer.circle( + weaver.getX(), + weaver.getY() - i * 10f, + weaver.getRadius() * (1f - i * 0.1f)); + } + } + + for (ShadowWeaverData.ShadowCloneData clone : weaver.getClones()) { + float[] cloneColor = clone.getColor(); + Color cloneBaseColor = + context() + .scratchColor4 + .set(cloneColor[0], cloneColor[1], cloneColor[2], cloneColor[3]); + + for (int i = 2; i > 0; i--) { + float alpha = 0.2f * (3 - i) / 2 * glow; + Color glowColor = context().scratchColor5.set(cloneBaseColor); + glowColor.a = alpha; + shapeRenderer.setColor(glowColor); + shapeRenderer.circle( + clone.getX(), clone.getY(), clone.getRadius() + extraRadius * i); + } + + shapeRenderer.setColor(cloneBaseColor); + shapeRenderer.circle(clone.getX(), clone.getY(), clone.getRadius()); + } + } + + private void renderJuggernaut(JuggernautData juggernaut, ShapeRenderer shapeRenderer) { + float time = view().time; + float pulseSpeed = view().pulseSpeed; + + for (int i = 0; i < juggernaut.getCurrentLayer(); i++) { + float layerGlow = 0.3f + 0.2f * (float) Math.sin(time * pulseSpeed * (1 + i * 0.5f)); + Color armorColor = + context() + .scratchColor4 + .set( + 0.7f + layerGlow * 0.3f, + 0.7f + layerGlow * 0.3f, + 0.7f + layerGlow * 0.3f, + 0.5f); + shapeRenderer.setColor(armorColor); + shapeRenderer.circle( + juggernaut.getX(), juggernaut.getY(), juggernaut.getRadius() * (1f + 0.2f * i)); + } + + if (juggernaut.isPreparingCharge()) { + float chargeProgress = juggernaut.getChargeProgress(); + Color chargeColor = + context().scratchColor4.set(1f, 0.2f, 0.2f, 0.3f + chargeProgress * 0.4f); + shapeRenderer.setColor(chargeColor); + shapeRenderer.circle( + juggernaut.getX(), + juggernaut.getY(), + juggernaut.getRadius() * (1f + chargeProgress * 0.5f)); + } + } + + private void renderSwarmQueen(SwarmQueenData queen, ShapeRenderer shapeRenderer) { + if (queen.isCharging()) { + float chargeProgress = queen.getChargeProgress(); + Color chargeColor = + context().scratchColor4.set(1f, 0.5f, 0.8f, 0.3f + chargeProgress * 0.4f); + shapeRenderer.setColor(chargeColor); + shapeRenderer.circle( + queen.getX(), queen.getY(), queen.getRadius() * (1f + chargeProgress * 0.5f)); + } + + Color orbColor = queen.getOrbitalParticleColor(); + shapeRenderer.setColor(orbColor); + for (Vector2 particle : queen.getOrbitalParticles()) { + shapeRenderer.circle(particle.x, particle.y, queen.getRadius() * 0.15f); + } + } + + private void renderGlacialWarden( + GlacialWardenData warden, ShapeRenderer shapeRenderer, float glow) { + float time = view().time; + float uiScale = view().uiScale; + + renderGlacialWardenAura(warden, shapeRenderer, glow); + renderGlacialWardenSpikes(warden, shapeRenderer, glow, time, uiScale); + renderGlacialWardenShards(warden, shapeRenderer); + renderGlacialWardenPrisonCharge(warden, shapeRenderer, time, uiScale); + renderGlacialWardenActivePrison(warden, shapeRenderer, glow, time, uiScale); + } + + private void renderGlacialWardenAura( + GlacialWardenData warden, ShapeRenderer shapeRenderer, float glow) { + Color auraColor = context().scratchColor4.set(0.7f, 0.8f, 1f, 0.2f + glow * 0.1f); + shapeRenderer.setColor(auraColor); + shapeRenderer.circle(warden.getX(), warden.getY(), warden.getAuraRadius()); + } + + private void renderGlacialWardenSpikes( + GlacialWardenData warden, + ShapeRenderer shapeRenderer, + float glow, + float time, + float uiScale) { + int spikes = 8; + float spikeLength = warden.getRadius() * 0.4f; + for (int i = 0; i < spikes; i++) { + float angle = (float) (i * Math.PI * 2 / spikes + time * 0.5f); + float x1 = warden.getX() + (float) Math.cos(angle) * warden.getRadius(); + float y1 = warden.getY() + (float) Math.sin(angle) * warden.getRadius(); + float x2 = warden.getX() + (float) Math.cos(angle) * (warden.getRadius() + spikeLength); + float y2 = warden.getY() + (float) Math.sin(angle) * (warden.getRadius() + spikeLength); + + Color spikeColor = context().scratchColor4.set(0.8f, 0.9f, 1f, 0.7f + glow * 0.3f); + shapeRenderer.setColor(spikeColor); + shapeRenderer.rectLine(x1, y1, x2, y2, 3f * uiScale); + } + } + + private void renderGlacialWardenShards(GlacialWardenData warden, ShapeRenderer shapeRenderer) { + for (GlacialWardenData.IceShard shard : warden.getActiveShards()) { + float alpha = shard.getAlpha(); + Color shardColor = context().scratchColor4.set(0.8f, 0.9f, 1f, alpha); + + for (int i = 1; i <= 3; i++) { + float trailAlpha = alpha * (1f - i / 4f); + Color trailColor = context().scratchColor5.set(shardColor); + trailColor.a = trailAlpha; + shapeRenderer.setColor(trailColor); + float trailX = shard.x - shard.velocity.x * 0.02f * i; + float trailY = shard.y - shard.velocity.y * 0.02f * i; + shapeRenderer.circle( + trailX, trailY, GlacialWardenData.getShardRadius() * (1f - i * 0.2f)); + } + + shapeRenderer.setColor(shardColor); + shapeRenderer.circle(shard.x, shard.y, GlacialWardenData.getShardRadius()); + } + } + + private void renderGlacialWardenPrisonCharge( + GlacialWardenData warden, ShapeRenderer shapeRenderer, float time, float uiScale) { + if (!warden.isCastingPrison()) return; + + float progress = warden.getPrisonCastProgress(); + Vector2 pos = warden.getPrisonChargePosition(); + + Color chargeColor = context().scratchColor4.set(0.7f, 0.8f, 1f, 0.3f + progress * 0.4f); + shapeRenderer.setColor(chargeColor); + shapeRenderer.circle(pos.x, pos.y, GlacialWardenData.getPrisonRadius() * progress); + + int crystals = 6; + for (int i = 0; i < crystals; i++) { + float angle = (float) (i * Math.PI * 2 / crystals + time * 2f); + float dist = GlacialWardenData.getPrisonRadius() * 0.5f * progress; + float x = pos.x + (float) Math.cos(angle) * dist; + float y = pos.y + (float) Math.sin(angle) * dist; + + Color crystalColor = + context().scratchColor4.set(0.8f, 0.9f, 1f, 0.5f + progress * 0.5f); + shapeRenderer.setColor(crystalColor); + shapeRenderer.circle(x, y, 5f * uiScale); + } + } + + private void renderGlacialWardenActivePrison( + GlacialWardenData warden, + ShapeRenderer shapeRenderer, + float glow, + float time, + float uiScale) { + if (!warden.isPrisonActive()) return; + + Vector2 pos = warden.getPrisonChargePosition(); + Color prisonColor = context().scratchColor4.set(0.7f, 0.8f, 1f, 0.4f + glow * 0.2f); + shapeRenderer.setColor(prisonColor); + shapeRenderer.circle(pos.x, pos.y, GlacialWardenData.getPrisonRadius()); + + int crystals = 8; + for (int i = 0; i < crystals; i++) { + float angle = (float) (i * Math.PI * 2 / crystals + time); + float x = pos.x + (float) Math.cos(angle) * GlacialWardenData.getPrisonRadius(); + float y = pos.y + (float) Math.sin(angle) * GlacialWardenData.getPrisonRadius(); + + Color crystalColor = context().scratchColor4.set(0.8f, 0.9f, 1f, 0.6f + glow * 0.4f); + shapeRenderer.setColor(crystalColor); + shapeRenderer.circle(x, y, 8f * uiScale); + } + } + + private Color renderIllusionMaster(IllusionMasterData master, ShapeRenderer shapeRenderer) { + float[] color = master.getColor(); + Color baseColor = context().scratchColor3.set(color[0], color[1], color[2], color[3]); + + renderIllusionMasterIllusions(master, shapeRenderer); + if (master.isDistortionActive()) { + float intensity = master.getDistortionIntensity(); + shapeRenderer.setColor(context().scratchColor4.set(1f, 1f, 1f, intensity * 0.3f)); + shapeRenderer.circle( + master.getX(), master.getY(), master.getRadius() * (1f + intensity)); + } + return baseColor; + } + + private void renderIllusionMasterIllusions( + IllusionMasterData master, ShapeRenderer shapeRenderer) { + for (IllusionMasterData.IllusionData illusion : master.getIllusions()) { + float[] color = illusion.getColor(); + shapeRenderer.setColor(color[0], color[1], color[2], color[3]); + shapeRenderer.circle(illusion.getX(), illusion.getY(), illusion.getRadius()); + } + } + + private void renderSharedCircleDecorations( + BaseCircleData circle, ShapeRenderer shapeRenderer, Color baseColor) { + float time = view().time; + float pulseSpeed = view().pulseSpeed; + float uiScale = view().uiScale; + float healthRatio = + (float) circle.health + / (circle instanceof StrongCircleData + ? view().currentStrongCircleHealth + : view().currentBasicCircleHealth); + + float uniquePhase = (circle.getX() * 0.03f + circle.getY() * 0.02f) % (2 * MathUtils.PI); + float pulseIntensity = 0.6f + 0.4f * (float) Math.sin(time * pulseSpeed + uniquePhase); + + if (healthRatio < 0.3f) { + pulseIntensity = 0.4f + 0.6f * (float) Math.sin(time * pulseSpeed * 2 + uniquePhase); + } + + Color glowColor = context().scratchColor4.set(baseColor); + glowColor.r = MathUtils.clamp(glowColor.r * pulseIntensity, 0, 1); + glowColor.g = MathUtils.clamp(glowColor.g * pulseIntensity, 0, 1); + glowColor.b = MathUtils.clamp(glowColor.b * pulseIntensity, 0, 1); + + renderStrongCircleDecorations(circle, shapeRenderer, glowColor, pulseIntensity, uiScale); + renderFastCircleDecorations(circle, shapeRenderer, glowColor, pulseIntensity, uiScale); + renderSplitterCircleDecorations( + circle, shapeRenderer, glowColor, time, pulseSpeed, uiScale); + renderSpawnerCircleDecorations(circle, shapeRenderer, glowColor, time, pulseSpeed, uiScale); + renderFrozenCircleOverlay(circle, shapeRenderer, time, uiScale); + renderCircleBody(circle, shapeRenderer, glowColor); + } + + private void renderStrongCircleDecorations( + BaseCircleData circle, + ShapeRenderer shapeRenderer, + Color glowColor, + float pulseIntensity, + float uiScale) { + if (!(circle instanceof StrongCircleData)) return; + for (int i = 3; i > 0; i--) { + float alpha = 0.2f * (4 - i) / 3 * pulseIntensity; + float extraSize = 5f * i * uiScale; + Color armorColor = context().scratchColor5.set(glowColor); + armorColor.a = alpha; + shapeRenderer.setColor(armorColor); + shapeRenderer.circle(circle.getX(), circle.getY(), circle.getRadius() + extraSize); + } + } + + private void renderFastCircleDecorations( + BaseCircleData circle, + ShapeRenderer shapeRenderer, + Color glowColor, + float pulseIntensity, + float uiScale) { + if (!(circle instanceof FastCircleData)) return; + + float speedAlpha = 0.3f * pulseIntensity; + Color speedColor = context().scratchColor5.set(glowColor); + speedColor.a = speedAlpha; + + float velocityLength = circle.velocity.len(); + float normalizedVelocityX = velocityLength == 0f ? 0f : circle.velocity.x / velocityLength; + float normalizedVelocityY = velocityLength == 0f ? 0f : circle.velocity.y / velocityLength; + + shapeRenderer.setColor(speedColor); + for (int i = 0; i < 3; i++) { + float offset = i * 5f * uiScale; + shapeRenderer.circle( + circle.getX() - normalizedVelocityX * offset, + circle.getY() - normalizedVelocityY * offset, + circle.getRadius() * (1 - i * 0.2f)); + } + } + + private void renderSplitterCircleDecorations( + BaseCircleData circle, + ShapeRenderer shapeRenderer, + Color glowColor, + float time, + float pulseSpeed, + float uiScale) { + if (!(circle instanceof SplitterCircleData)) return; + + float splitAlpha = 0.25f + 0.15f * (float) Math.sin(time * pulseSpeed * 2); + Color splitColor = context().scratchColor5.set(glowColor); + splitColor.a = splitAlpha; + + shapeRenderer.setColor(splitColor); + shapeRenderer.circle(circle.getX(), circle.getY(), circle.getRadius() * 1.5f); + + for (int i = 0; i < 4; i++) { + float angle = MathUtils.PI / 2 * i + time * 0.5f; + float lineLength = circle.getRadius() * 1.3f; + shapeRenderer.rectLine( + circle.getX(), + circle.getY(), + circle.getX() + MathUtils.cos(angle) * lineLength, + circle.getY() + MathUtils.sin(angle) * lineLength, + 1.5f * uiScale); + } + } + + private void renderSpawnerCircleDecorations( + BaseCircleData circle, + ShapeRenderer shapeRenderer, + Color glowColor, + float time, + float pulseSpeed, + float uiScale) { + if (!(circle instanceof SpawnerCircleData)) return; + + for (int i = 0; i < 2; i++) { + float spawnPulse = + 0.3f + 0.2f * (float) Math.sin(time * pulseSpeed * 1.5f + i * MathUtils.PI); + float waveRadius = + circle.getRadius() + * (1.5f + i * 0.5f) + * (1 + 0.3f * (float) Math.sin(time * 3 + i * MathUtils.PI)); + + Color waveColor = context().scratchColor5.set(glowColor); + waveColor.a = spawnPulse * 0.3f; + + shapeRenderer.setColor(waveColor); + shapeRenderer.circle(circle.getX(), circle.getY(), waveRadius); + } + + float orbitSpeed = time * 2; + int pointCount = 3; + float orbitRadius = circle.getRadius() * 0.6f; + shapeRenderer.setColor(context().withAlpha(glowColor, 0.7f)); + for (int i = 0; i < pointCount; i++) { + float angle = orbitSpeed + i * (MathUtils.PI * 2 / pointCount); + shapeRenderer.circle( + circle.getX() + MathUtils.cos(angle) * orbitRadius, + circle.getY() + MathUtils.sin(angle) * orbitRadius, + 2f * uiScale); + } + } + + private void renderFrozenCircleOverlay( + BaseCircleData circle, ShapeRenderer shapeRenderer, float time, float uiScale) { + if (!circle.isFrozen()) return; + + shapeRenderer.setColor( + context().withAlpha(view().neonCyan, 0.4f + 0.3f * (float) Math.sin(time * 5f))); + shapeRenderer.circle(circle.getX(), circle.getY(), circle.getRadius() * 1.3f); + + for (int i = 0; i < 6; i++) { + float angle = i * MathUtils.PI / 3; + float spikeLength = circle.getRadius() * 0.7f; + shapeRenderer.rectLine( + circle.getX() + MathUtils.cos(angle) * circle.getRadius() * 0.9f, + circle.getY() + MathUtils.sin(angle) * circle.getRadius() * 0.9f, + circle.getX() + MathUtils.cos(angle) * (circle.getRadius() + spikeLength), + circle.getY() + MathUtils.sin(angle) * (circle.getRadius() + spikeLength), + 2f * uiScale); + } + } + + private void renderCircleBody( + BaseCircleData circle, ShapeRenderer shapeRenderer, Color glowColor) { + Color circleColor = context().scratchColor5.set(glowColor); + circleColor.a = 0.9f; + shapeRenderer.setColor(circleColor); + float radius = Math.max(circle.getRadius(), MIN_ENEMY_VISUAL_RADIUS * view().uiScale); + shapeRenderer.setColor(context().withAlpha(circleColor, 0.12f)); + shapeRenderer.circle(circle.getX(), circle.getY(), radius + 4f * view().uiScale); + circleColor.a = 0.94f; + shapeRenderer.setColor(circleColor); + if (circle instanceof FastCircleData) { + renderDiamond(shapeRenderer, circle.getX(), circle.getY(), radius * 1.05f); + } else if (circle instanceof StrongCircleData) { + shapeRenderer.rect( + circle.getX() - radius, circle.getY() - radius, radius * 2f, radius * 2f); + } else { + shapeRenderer.circle(circle.getX(), circle.getY(), radius); + } + renderSupportMark(circle, shapeRenderer, radius); + } + + private void renderDiamond(ShapeRenderer shapeRenderer, float x, float y, float radius) { + shapeRenderer.triangle(x, y + radius, x + radius, y, x, y - radius); + shapeRenderer.triangle(x, y + radius, x - radius, y, x, y - radius); + } + + private void renderSupportMark( + BaseCircleData circle, ShapeRenderer shapeRenderer, float radius) { + if (!(circle instanceof SpawnerCircleData) && !(circle instanceof HealerCircleData)) return; + shapeRenderer.setColor(0.02f, 0.07f, 0.06f, 0.92f); + float mark = Math.max(2f, radius * 0.25f); + shapeRenderer.rect( + circle.getX() - mark * 1.8f, + circle.getY() - mark * 0.45f, + mark * 3.6f, + mark * 0.9f); + shapeRenderer.rect( + circle.getX() - mark * 0.45f, + circle.getY() - mark * 1.8f, + mark * 0.9f, + mark * 3.6f); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudModel.java new file mode 100644 index 0000000..80b5a19 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudModel.java @@ -0,0 +1,661 @@ +package ru.project.tower.screens.gamescreen.hud; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.screens.gamescreen.assets.GameScreenFontAssets.HudFonts; +import ru.project.tower.screens.gamescreen.assets.GameScreenMaterialAssets; +import ru.project.tower.screens.gamescreen.shop.ShopUpgradePreviewValues; + +public final class GameScreenHudModel { + private final GameScreenHudRenderContext renderContext = new GameScreenHudRenderContext(); + private final GameScreenHudViewModel viewModel = new GameScreenHudViewModel(); + + public GameScreenHudModel() {} + + GameScreenHudRenderContext renderContext() { + return renderContext; + } + + GameScreenHudViewModel viewModel() { + return viewModel; + } + + public void populateRenderContext(RenderContextData data) { + populateRenderContext( + data.shapeRenderer(), + data.batch(), + data.font(), + data.hudFonts(), + data.materialAssets(), + data.neonRed(), + data.neonCyan(), + data.neonBlue(), + data.neonPink(), + data.priceTextColor(), + data.scratchColor()); + } + + public void populateRenderContext( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + HudFonts hudFonts, + GameScreenMaterialAssets materialAssets, + Color neonRed, + Color neonCyan, + Color neonBlue, + Color neonPink, + Color priceTextColor, + Color scratchColor) { + renderContext.shapeRenderer = shapeRenderer; + renderContext.batch = batch; + renderContext.font = font; + renderContext.hudFonts = hudFonts; + renderContext.materialAssets = materialAssets; + renderContext.neonRed = neonRed; + renderContext.neonCyan = neonCyan; + renderContext.neonBlue = neonBlue; + renderContext.neonPink = neonPink; + renderContext.priceTextColor = priceTextColor; + renderContext.scratchColor = scratchColor; + } + + public void populateWaveAndStats(WaveAndStatsState state) { + populateWaveAndStats( + state.viewportWidth(), + state.viewportHeight(), + state.uiScale(), + state.time(), + state.pulseSpeed(), + state.waveIndicatorHeight(), + state.waveIndicatorPadding(), + state.waveActive(), + state.enemiesPerWave(), + state.enemiesSpawnedThisWave(), + state.currentWave(), + state.nowMs(), + state.waveStartTime(), + state.timeBetweenWaves(), + state.squareHealth(), + state.maxSquareHealth(), + state.money(), + state.runCurrencyEarned()); + } + + public void populateWaveAndStats( + int viewportWidth, + int viewportHeight, + float uiScale, + float time, + float pulseSpeed, + float waveIndicatorHeight, + float waveIndicatorPadding, + boolean waveActive, + long enemiesPerWave, + long enemiesSpawnedThisWave, + int currentWave, + long nowMs, + long waveStartTime, + long timeBetweenWaves, + int squareHealth, + int maxSquareHealth, + int money, + int runCurrencyEarned) { + viewModel.viewportWidth = viewportWidth; + viewModel.viewportHeight = viewportHeight; + viewModel.uiScale = uiScale; + viewModel.time = time; + viewModel.pulseSpeed = pulseSpeed; + viewModel.waveIndicatorHeight = waveIndicatorHeight; + viewModel.waveIndicatorPadding = waveIndicatorPadding; + viewModel.waveActive = waveActive; + viewModel.enemiesPerWave = enemiesPerWave; + viewModel.enemiesSpawnedThisWave = enemiesSpawnedThisWave; + viewModel.currentWave = currentWave; + viewModel.nowMs = nowMs; + viewModel.waveStartTime = waveStartTime; + viewModel.timeBetweenWaves = timeBetweenWaves; + viewModel.squareHealth = squareHealth; + viewModel.maxSquareHealth = maxSquareHealth; + viewModel.money = money; + viewModel.runCurrencyEarned = runCurrencyEarned; + } + + public void fillShopPanelFields(ShopPanelFields fields) { + viewModel.departmentButtonsY = fields.departmentButtonsY(); + viewModel.showingAttackUpgrades = fields.showingAttackUpgrades(); + viewModel.showingAbilities = fields.showingAbilities(); + viewModel.shopExpanded = fields.shopExpanded(); + viewModel.shopTransitionProgress = fields.shopTransitionProgress(); + viewModel.attackDepartmentButton = fields.attackDepartmentButton(); + viewModel.healthDepartmentButton = fields.healthDepartmentButton(); + viewModel.abilitiesDepartmentButton = fields.abilitiesDepartmentButton(); + viewModel.shopToggleButton = fields.shopToggleButton(); + viewModel.topBar = fields.topBar(); + viewModel.waveBar = fields.waveBar(); + viewModel.statStrip = fields.statStrip(); + viewModel.shopSheet = fields.shopSheet(); + viewModel.shopHandle = fields.shopHandle(); + viewModel.abilityDock = fields.abilityDock(); + viewModel.dockLabel = fields.dockLabel(); + } + + public void populateCombatStats( + int effectiveDamage, + float effectiveSpeed, + int healthRegenRate, + float effectiveCooldownMs, + float effectiveCritChance, + float effectiveCritMultiplier) { + viewModel.effectiveDamage = effectiveDamage; + viewModel.effectiveSpeed = effectiveSpeed; + viewModel.healthRegenRate = healthRegenRate; + viewModel.effectiveCooldownMs = effectiveCooldownMs; + viewModel.effectiveCritChance = effectiveCritChance; + viewModel.effectiveCritMultiplier = effectiveCritMultiplier; + } + + public void setHudHelpVisible(boolean visible) { + viewModel.hudHelpVisible = visible; + } + + public void setAttackButton( + int index, Rectangle bounds, Color color, CharSequence label, int price) { + setAttackButton(index, bounds, color, label, price, 0f); + } + + public void setAttackButton( + int index, + Rectangle bounds, + Color color, + CharSequence label, + int price, + float statDelta) { + setAttackButton(index, bounds, color, label, price, statDelta, null, null); + } + + public void setAttackButton( + int index, + Rectangle bounds, + Color color, + CharSequence label, + int price, + float statDelta, + CharSequence currentValue, + CharSequence nextValue) { + setHudButton( + viewModel.attackButtons[index], + bounds, + color, + label, + price, + statDelta, + currentValue, + nextValue); + } + + public void setAttackButton( + int index, Rectangle bounds, Color color, ShopUpgradePreviewValues.PreviewRow row) { + setHudButton(viewModel.attackButtons[index], bounds, color, row); + } + + public void setHealthButton( + int index, Rectangle bounds, Color color, CharSequence label, int price) { + setHudButton(viewModel.healthButtons[index], bounds, color, label, price, 0f); + } + + public void setHealthButton( + int index, Rectangle bounds, Color color, ShopUpgradePreviewValues.PreviewRow row) { + setHudButton(viewModel.healthButtons[index], bounds, color, row); + } + + public void setBonusButton( + int index, Rectangle bounds, Color color, CharSequence label, int price) { + setBonusButton(index, bounds, color, label, price, 0f); + } + + public void setBonusButton( + int index, + Rectangle bounds, + Color color, + CharSequence label, + int price, + float statDelta) { + setBonusButton(index, bounds, color, label, price, statDelta, null, null); + } + + public void setBonusButton( + int index, + Rectangle bounds, + Color color, + CharSequence label, + int price, + float statDelta, + CharSequence currentValue, + CharSequence nextValue) { + setHudButton( + viewModel.bonusButtons[index], + bounds, + color, + label, + price, + statDelta, + currentValue, + nextValue); + } + + public void setBonusButton( + int index, Rectangle bounds, Color color, ShopUpgradePreviewValues.PreviewRow row) { + setHudButton(viewModel.bonusButtons[index], bounds, color, row); + } + + public void clearAbilityButtons() { + viewModel.hasAnyAbilityButton = false; + for (GameScreenHudViewModel.AbilityButton button : viewModel.abilityButtons) { + button.bounds = null; + button.cooldownMs = 0L; + } + } + + public void setAbilityButton( + AbilityKind ability, Rectangle bounds, Color color, long cooldownMs) { + GameScreenHudViewModel.AbilityButton button = viewModel.abilityButton(ability); + button.bounds = bounds; + button.color = color; + if (bounds != null) { + viewModel.hasAnyAbilityButton = true; + button.cooldownMs = cooldownMs; + } + } + + private void setHudButton( + GameScreenHudViewModel.HudButton button, + Rectangle bounds, + Color color, + CharSequence label, + int price, + float statDelta) { + setHudButton(button, bounds, color, label, price, statDelta, null, null); + } + + private void setHudButton( + GameScreenHudViewModel.HudButton button, + Rectangle bounds, + Color color, + CharSequence label, + int price, + float statDelta, + CharSequence currentValue, + CharSequence nextValue) { + button.bounds = bounds; + button.color = color; + button.label = label; + button.price = price; + button.statDelta = statDelta; + button.currentValue = currentValue; + button.nextValue = nextValue; + button.description = null; + button.disabledReason = null; + button.kind = null; + button.iconToken = null; + button.colorToken = null; + button.maxed = false; + } + + private void setHudButton( + GameScreenHudViewModel.HudButton button, + Rectangle bounds, + Color color, + ShopUpgradePreviewValues.PreviewRow row) { + button.bounds = bounds; + button.color = color; + button.label = row.gameName(); + button.price = row.price(); + button.statDelta = 0f; + button.currentValue = row.currentValue(); + button.nextValue = row.nextValue(); + button.description = row.description(); + button.disabledReason = null; + button.kind = row.kind(); + button.iconToken = row.iconToken(); + button.colorToken = row.colorToken(); + button.maxed = row.maxed(); + } + + public static final class RenderContextData { + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final BitmapFont font; + private final HudFonts hudFonts; + private final GameScreenMaterialAssets materialAssets; + private final Color neonRed; + private final Color neonCyan; + private final Color neonBlue; + private final Color neonPink; + private final Color priceTextColor; + private final Color scratchColor; + + public RenderContextData( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + HudFonts hudFonts, + GameScreenMaterialAssets materialAssets, + Color neonRed, + Color neonCyan, + Color neonBlue, + Color neonPink, + Color priceTextColor, + Color scratchColor) { + this.shapeRenderer = shapeRenderer; + this.batch = batch; + this.font = font; + this.hudFonts = hudFonts; + this.materialAssets = materialAssets; + this.neonRed = neonRed; + this.neonCyan = neonCyan; + this.neonBlue = neonBlue; + this.neonPink = neonPink; + this.priceTextColor = priceTextColor; + this.scratchColor = scratchColor; + } + + public ShapeRenderer shapeRenderer() { + return shapeRenderer; + } + + public SpriteBatch batch() { + return batch; + } + + public BitmapFont font() { + return font; + } + + public HudFonts hudFonts() { + return hudFonts; + } + + public GameScreenMaterialAssets materialAssets() { + return materialAssets; + } + + public Color neonRed() { + return neonRed; + } + + public Color neonCyan() { + return neonCyan; + } + + public Color neonBlue() { + return neonBlue; + } + + public Color neonPink() { + return neonPink; + } + + public Color priceTextColor() { + return priceTextColor; + } + + public Color scratchColor() { + return scratchColor; + } + } + + public static final class WaveAndStatsState { + private final int viewportWidth; + private final int viewportHeight; + private final float uiScale; + private final float time; + private final float pulseSpeed; + private final float waveIndicatorHeight; + private final float waveIndicatorPadding; + private final boolean waveActive; + private final long enemiesPerWave; + private final long enemiesSpawnedThisWave; + private final int currentWave; + private final long nowMs; + private final long waveStartTime; + private final long timeBetweenWaves; + private final int squareHealth; + private final int maxSquareHealth; + private final int money; + private final int runCurrencyEarned; + + public WaveAndStatsState( + int viewportWidth, + int viewportHeight, + float uiScale, + float time, + float pulseSpeed, + float waveIndicatorHeight, + float waveIndicatorPadding, + boolean waveActive, + long enemiesPerWave, + long enemiesSpawnedThisWave, + int currentWave, + long nowMs, + long waveStartTime, + long timeBetweenWaves, + int squareHealth, + int maxSquareHealth, + int money, + int runCurrencyEarned) { + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.uiScale = uiScale; + this.time = time; + this.pulseSpeed = pulseSpeed; + this.waveIndicatorHeight = waveIndicatorHeight; + this.waveIndicatorPadding = waveIndicatorPadding; + this.waveActive = waveActive; + this.enemiesPerWave = enemiesPerWave; + this.enemiesSpawnedThisWave = enemiesSpawnedThisWave; + this.currentWave = currentWave; + this.nowMs = nowMs; + this.waveStartTime = waveStartTime; + this.timeBetweenWaves = timeBetweenWaves; + this.squareHealth = squareHealth; + this.maxSquareHealth = maxSquareHealth; + this.money = money; + this.runCurrencyEarned = runCurrencyEarned; + } + + public int viewportWidth() { + return viewportWidth; + } + + public int viewportHeight() { + return viewportHeight; + } + + public float uiScale() { + return uiScale; + } + + public float time() { + return time; + } + + public float pulseSpeed() { + return pulseSpeed; + } + + public float waveIndicatorHeight() { + return waveIndicatorHeight; + } + + public float waveIndicatorPadding() { + return waveIndicatorPadding; + } + + public boolean waveActive() { + return waveActive; + } + + public long enemiesPerWave() { + return enemiesPerWave; + } + + public long enemiesSpawnedThisWave() { + return enemiesSpawnedThisWave; + } + + public int currentWave() { + return currentWave; + } + + public long nowMs() { + return nowMs; + } + + public long waveStartTime() { + return waveStartTime; + } + + public long timeBetweenWaves() { + return timeBetweenWaves; + } + + public int squareHealth() { + return squareHealth; + } + + public int maxSquareHealth() { + return maxSquareHealth; + } + + public int money() { + return money; + } + + public int runCurrencyEarned() { + return runCurrencyEarned; + } + } + + public static final class ShopPanelFields { + private final float departmentButtonsY; + private final boolean showingAttackUpgrades; + private final boolean showingAbilities; + private final boolean shopExpanded; + private final float shopTransitionProgress; + private final Rectangle attackDepartmentButton; + private final Rectangle healthDepartmentButton; + private final Rectangle abilitiesDepartmentButton; + private final Rectangle shopToggleButton; + private final Rectangle topBar; + private final Rectangle waveBar; + private final Rectangle statStrip; + private final Rectangle shopSheet; + private final Rectangle shopHandle; + private final Rectangle abilityDock; + private final Rectangle dockLabel; + + public ShopPanelFields( + float departmentButtonsY, + boolean showingAttackUpgrades, + boolean showingAbilities, + boolean shopExpanded, + float shopTransitionProgress, + Rectangle attackDepartmentButton, + Rectangle healthDepartmentButton, + Rectangle abilitiesDepartmentButton, + Rectangle shopToggleButton, + Rectangle topBar, + Rectangle waveBar, + Rectangle statStrip, + Rectangle shopSheet, + Rectangle shopHandle, + Rectangle abilityDock, + Rectangle dockLabel) { + this.departmentButtonsY = departmentButtonsY; + this.showingAttackUpgrades = showingAttackUpgrades; + this.showingAbilities = showingAbilities; + this.shopExpanded = shopExpanded; + this.shopTransitionProgress = shopTransitionProgress; + this.attackDepartmentButton = attackDepartmentButton; + this.healthDepartmentButton = healthDepartmentButton; + this.abilitiesDepartmentButton = abilitiesDepartmentButton; + this.shopToggleButton = shopToggleButton; + this.topBar = topBar; + this.waveBar = waveBar; + this.statStrip = statStrip; + this.shopSheet = shopSheet; + this.shopHandle = shopHandle; + this.abilityDock = abilityDock; + this.dockLabel = dockLabel; + } + + public float departmentButtonsY() { + return departmentButtonsY; + } + + public boolean showingAttackUpgrades() { + return showingAttackUpgrades; + } + + public boolean showingAbilities() { + return showingAbilities; + } + + public boolean shopExpanded() { + return shopExpanded; + } + + public float shopTransitionProgress() { + return shopTransitionProgress; + } + + public Rectangle attackDepartmentButton() { + return attackDepartmentButton; + } + + public Rectangle healthDepartmentButton() { + return healthDepartmentButton; + } + + public Rectangle abilitiesDepartmentButton() { + return abilitiesDepartmentButton; + } + + public Rectangle shopToggleButton() { + return shopToggleButton; + } + + public Rectangle topBar() { + return topBar; + } + + public Rectangle waveBar() { + return waveBar; + } + + public Rectangle statStrip() { + return statStrip; + } + + public Rectangle shopSheet() { + return shopSheet; + } + + public Rectangle shopHandle() { + return shopHandle; + } + + public Rectangle abilityDock() { + return abilityDock; + } + + public Rectangle dockLabel() { + return dockLabel; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudRenderContext.java b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudRenderContext.java new file mode 100644 index 0000000..2ef5788 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudRenderContext.java @@ -0,0 +1,22 @@ +package ru.project.tower.screens.gamescreen.hud; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import ru.project.tower.screens.gamescreen.assets.GameScreenFontAssets.HudFonts; +import ru.project.tower.screens.gamescreen.assets.GameScreenMaterialAssets; + +final class GameScreenHudRenderContext { + ShapeRenderer shapeRenderer; + SpriteBatch batch; + BitmapFont font; + HudFonts hudFonts; + GameScreenMaterialAssets materialAssets; + Color neonRed; + Color neonCyan; + Color neonBlue; + Color neonPink; + Color priceTextColor; + Color scratchColor; +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudRenderer.java new file mode 100644 index 0000000..5134792 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudRenderer.java @@ -0,0 +1,1909 @@ +package ru.project.tower.screens.gamescreen.hud; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.HdpiUtils; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.screens.gamescreen.assets.GameScreenFontAssets.HudFonts; +import ru.project.tower.screens.gamescreen.ui.NeonTextRenderer; +import ru.project.tower.screens.gamescreen.visual.GameScreenVisualTokens; +import ru.project.tower.support.CompactNumberFormatter; +import ru.project.tower.support.ScreenPalette; + +public final class GameScreenHudRenderer { + public interface Host { + public GameScreenHudModel hudModel(); + } + + private static final String ATTACK_DEPARTMENT_TEXT = "Атака"; + private static final String HEALTH_DEPARTMENT_TEXT = "Здоровье"; + private static final String ABILITIES_DEPARTMENT_TEXT = "Бонусы"; + private static final String WAVE_LABEL = "Волна: "; + private static final String HEALTH_LABEL = "Здоровье: "; + private static final String MONEY_LABEL = "Деньги: ₽"; + private static final String EMPTY_ABILITIES_MESSAGE = "Нет выбранных способностей"; + private static final String UPGRADE_ARROW_TEXT = "→"; + private static final GameScreenHudViewModel.HudButton[] EMPTY_BUTTONS = + new GameScreenHudViewModel.HudButton[0]; + private static final float REFERENCE_WIDTH = 405f; + private static final float REFERENCE_HEIGHT = 682f; + private static final float DOCK_COOLDOWN_SPRITE_OFFSET_X = -2f; + private static final float DOCK_COOLDOWN_SPRITE_OFFSET_Y = -2f; + private static final float DOCK_COOLDOWN_STROKE_WIDTH = 5f; + private static final float DOCK_COOLDOWN_ARC_SEGMENT_DEGREES = 6f; + private static final float STAT_ICON_OFFSET_X = 23f; + private static final float STAT_TEXT_OFFSET_X = 34f; + private static final float STAT_CRIT_ICON_OFFSET_X = 0f; + private static final float STAT_CRIT_ICON_OFFSET_Y = 0f; + private static final float STAT_ICON_SPRITE_SIZE = 16f; + private static final float SHOP_CARD_TITLE_PAD_X = 13f; + private static final float SHOP_CARD_VALUE_OFFSET_Y = 6f; + private static final float SHOP_CARD_CLIP_BLEED = 3f; + private static final float SHOP_CLOSED_TRANSLATE_RATIO = 1f; + private static final float WAVEBAR_COLOR_FILL_TRIM_TOP = 2f; + private static final float WAVEBAR_CYAN_FILL_SHARE = 0.58f; + private static final float TOPBAR_RUBLE_OFFSET_X = -5f; + private static final float TOPBAR_RUBLE_OFFSET_Y = -2f; + private static final float TOPBAR_RUBLE_SCALE = 0.82f; + private static final float TOPBAR_MONEY_SLOT_SHARE = 0.52f; + private static final float TOPBAR_COIN_ICON_RADIUS = 4.3f; + private static final float TOPBAR_COIN_ICON_GAP = 8f; + private static final float TOPBAR_COIN_TEXT_INSET_X = 14f; + private static final float TOPBAR_HEART_OFFSET_X = 8f; + private static final float TOPBAR_HEART_OFFSET_Y = -2f; + private static final float TOPBAR_HEART_GEOMETRY_SCALE = 0.62f; + private static final float TOPBAR_WAVE_GROUP_OFFSET_X = 6f; + + private final Host host; + private final NeonTextRenderer textRenderer = new NeonTextRenderer(); + private final GlyphLayout emptyAbilitiesLayout = new GlyphLayout(); + private final GlyphLayout rubleLayout = new GlyphLayout(); + private final StringBuilder waveText = new StringBuilder(16); + private final StringBuilder healthText = new StringBuilder(24); + private final StringBuilder moneyText = new StringBuilder(16); + private final StringBuilder runCoinText = new StringBuilder(16); + private final StringBuilder priceText = new StringBuilder(16); + private final StringBuilder cooldownText = new StringBuilder(10); + private final StringBuilder statText = new StringBuilder(16); + private final StringBuilder currentValue = new StringBuilder(16); + private final StringBuilder nextValue = new StringBuilder(16); + private final Rectangle hudHelpRect = new Rectangle(); + private final Rectangle scratchRect = new Rectangle(); + private final Rectangle shopCardClipRect = new Rectangle(); + + public GameScreenHudRenderer(Host host) { + this.host = host; + } + + public void renderHud() { + renderHudShapes(); + renderHudText(); + } + + private void renderHudShapes() { + GameScreenHudModel model = host.hudModel(); + GameScreenHudRenderContext context = model.renderContext(); + GameScreenHudViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + float glow = 0.3f + 0.3f * (float) Math.sin(view.time * view.pulseSpeed); + float shopTranslateY = shopSheetTranslateY(view); + boolean shopVisible = shopAnimationVisible(view); + + Gdx.gl.glEnable(GL20.GL_BLEND); + shapeRenderer.begin(ShapeType.Filled); + drawPanelFill(shapeRenderer, view.topBar, 0.08f, 0.10f, 0.16f, 0.90f); + drawTopBarIconFills(model, shapeRenderer, view); + drawStatCardFills(shapeRenderer, view); + drawStatIconGlows(shapeRenderer, view); + drawWaveBarFill(model, shapeRenderer, view); + if (view.hudHelpVisible) { + drawHudHelpPanel(shapeRenderer, view); + } + if (shopVisible) { + translateShopGeometry(view, shopTranslateY); + drawPanelFill(shapeRenderer, view.shopSheet, 0.06f, 0.08f, 0.13f, 0.98f); + drawTabFills(shapeRenderer, view); + drawClippedShopButtonFills(model, shapeRenderer, view, glow); + translateShopGeometry(view, -shopTranslateY); + } + if (shopVisible) { + drawPanelFill( + shapeRenderer, + translatedShopHandleBounds(view, scratchRect), + 0.75f, + 0.78f, + 0.86f, + 0.28f); + } + if (dockChromeVisible(view)) { + drawPanelFill(shapeRenderer, view.abilityDock, 0.05f, 0.07f, 0.12f, 0.92f); + } + drawPanelFill( + shapeRenderer, + view.shopToggleButton, + GameScreenVisualTokens.Dock.TOGGLE_FILL_RED, + GameScreenVisualTokens.Dock.TOGGLE_FILL_GREEN, + GameScreenVisualTokens.Dock.TOGGLE_FILL_BLUE, + GameScreenVisualTokens.Dock.TOGGLE_FILL_ALPHA); + drawToggleIcon(shapeRenderer, view); + drawAbilityButtonFills(model, shapeRenderer, glow); + shapeRenderer.end(); + + shapeRenderer.begin(ShapeType.Line); + Gdx.gl.glLineWidth(1f); + drawPanelBorder(model, shapeRenderer, view.topBar, context.neonBlue, 0.24f); + drawPanelBorder(model, shapeRenderer, view.waveBar, context.neonCyan, 0.30f); + drawStatCardBorders(model, shapeRenderer, view, glow); + if (view.hudHelpVisible) { + drawPanelBorder( + model, shapeRenderer, hudHelpBounds(view), context.neonCyan, 0.42f + glow); + } + if (shopVisible) { + translateShopGeometry(view, shopTranslateY); + drawPanelBorder(model, shapeRenderer, view.shopSheet, context.neonBlue, 0.28f); + drawDepartmentTabBorders(model, shapeRenderer, view, glow); + drawClippedShopButtonBorders(model, shapeRenderer, view, glow); + translateShopGeometry(view, -shopTranslateY); + } + if (dockChromeVisible(view)) { + drawPanelBorder(model, shapeRenderer, view.abilityDock, context.neonBlue, 0.22f); + } + drawPanelBorder(model, shapeRenderer, view.shopToggleButton, context.neonCyan, 0.45f); + drawAbilityCooldownProgressArcs(model, shapeRenderer); + drawAbilityButtonBorders(model, shapeRenderer, glow); + shapeRenderer.end(); + Gdx.gl.glLineWidth(1f); + Gdx.gl.glDisable(GL20.GL_BLEND); + } + + private void renderHudText() { + GameScreenHudModel model = host.hudModel(); + GameScreenHudRenderContext context = model.renderContext(); + GameScreenHudViewModel view = model.viewModel(); + SpriteBatch batch = context.batch; + + batch.begin(); + drawStatIconSprites(model, batch, view); + drawTopBarText(model, batch, topBarFont(context), view); + drawStatStripText(model, batch, statFont(context), view); + if (view.hudHelpVisible) { + drawHudHelpText(model, batch, tabFont(context), view); + } + float shopTranslateY = shopSheetTranslateY(view); + if (shopAnimationVisible(view)) { + translateShopGeometry(view, shopTranslateY); + drawTabIconSprites(model, batch, view); + drawTabText( + batch, + tabFont(context), + view, + "Атака", + view.attackDepartmentButton, + view.showingAttackUpgrades); + drawTabText( + batch, + tabFont(context), + view, + "Защита", + view.healthDepartmentButton, + !view.showingAttackUpgrades && !view.showingAbilities); + drawTabText( + batch, + tabFont(context), + view, + "Бонусы", + view.abilitiesDepartmentButton, + view.showingAbilities); + drawClippedShopCardTexts(model, batch, view); + translateShopGeometry(view, -shopTranslateY); + } + drawDockText(model, batch, view); + batch.end(); + } + + static float shopAnimationProgress(GameScreenHudViewModel view) { + float progress = Math.max(0f, Math.min(1f, view.shopTransitionProgress)); + return progress * progress * (3f - 2f * progress); + } + + private boolean shopAnimationVisible(GameScreenHudViewModel view) { + return shopAnimationProgress(view) > 0f; + } + + private static float shopSheetTranslateY(GameScreenHudViewModel view) { + Rectangle sheet = view.shopSheet; + if (sheet == null) return 0f; + return sheet.height * SHOP_CLOSED_TRANSLATE_RATIO * (1f - shopAnimationProgress(view)); + } + + static Rectangle translatedShopHandleBounds(GameScreenHudViewModel view, Rectangle target) { + if (target == null) { + throw new IllegalArgumentException("Target rectangle is required"); + } + Rectangle handle = view.shopHandle; + if (handle == null) return target.set(0f, 0f, 0f, 0f); + return target.set( + handle.x, handle.y + shopSheetTranslateY(view), handle.width, handle.height); + } + + private boolean dockChromeVisible(GameScreenHudViewModel view) { + return view.shopExpanded || view.hasAnyAbilityButton; + } + + private void translateShopGeometry(GameScreenHudViewModel view, float offsetY) { + if (offsetY == 0f) return; + translateRect(view.shopSheet, offsetY); + translateRect(view.shopHandle, offsetY); + translateRect(view.attackDepartmentButton, offsetY); + translateRect(view.healthDepartmentButton, offsetY); + translateRect(view.abilitiesDepartmentButton, offsetY); + translateButtons(visibleUpgradeButtons(view), offsetY); + } + + private void translateButtons(GameScreenHudViewModel.HudButton[] buttons, float offsetY) { + for (GameScreenHudViewModel.HudButton button : buttons) { + translateRect(button.bounds, offsetY); + } + } + + private void translateRect(Rectangle rect, float offsetY) { + if (rect != null) { + rect.y += offsetY; + } + } + + private GameScreenHudViewModel.HudButton[] visibleUpgradeButtons(GameScreenHudViewModel view) { + if (view.showingAttackUpgrades) return view.attackButtons; + if (view.showingAbilities) return view.bonusButtons; + return view.healthButtons; + } + + private void drawPanelFill( + ShapeRenderer shapeRenderer, Rectangle rect, float r, float g, float b, float a) { + if (rect == null || rect.width <= 0f || rect.height <= 0f) return; + shapeRenderer.setColor(r, g, b, a); + shapeRenderer.rect(rect.x, rect.y, rect.width, rect.height); + } + + private void drawHudHelpPanel(ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + Rectangle help = hudHelpBounds(view); + drawPanelFill(shapeRenderer, help, 0.05f, 0.07f, 0.12f, 0.94f); + } + + private void drawStatCardFills(ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + Rectangle strip = view.statStrip; + if (strip == null) return; + float gap = 5f * visualScale(view); + float slotWidth = (strip.width - gap * 3f) / 4f; + for (int i = 0; i < 4; i++) { + shapeRenderer.setColor(0.05f, 0.07f, 0.12f, 0.78f); + shapeRenderer.rect(strip.x + i * (slotWidth + gap), strip.y, slotWidth, strip.height); + } + } + + private void drawTopBarIconFills( + GameScreenHudModel model, ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + Rectangle top = view.topBar; + if (top == null) return; + float scale = visualScale(view); + float y = top.y + top.height * 0.5f; + float leftX = top.x + 18f * scale + TOPBAR_WAVE_GROUP_OFFSET_X * scale; + shapeRenderer.setColor( + ScreenPalette.MUTED.r, ScreenPalette.MUTED.g, ScreenPalette.MUTED.b, 0.95f); + shapeRenderer.triangle(leftX, y + 6f * scale, leftX + 6f * scale, y, leftX, y - 6f * scale); + shapeRenderer.triangle(leftX, y + 6f * scale, leftX - 6f * scale, y, leftX, y - 6f * scale); + + float heartX = top.x + top.width * 0.5f - 38f * scale + TOPBAR_HEART_OFFSET_X * scale; + float heartY = y + TOPBAR_HEART_OFFSET_Y * scale; + shapeRenderer.setColor( + ScreenPalette.GREEN.r, ScreenPalette.GREEN.g, ScreenPalette.GREEN.b, 0.95f); + float heartScale = scale * TOPBAR_HEART_GEOMETRY_SCALE; + shapeRenderer.circle( + heartX - 2.4f * heartScale, heartY + 2f * heartScale, 2.6f * heartScale); + shapeRenderer.circle( + heartX + 2.4f * heartScale, heartY + 2f * heartScale, 2.6f * heartScale); + shapeRenderer.triangle( + heartX - 5.2f * heartScale, + heartY + 1f * heartScale, + heartX + 5.2f * heartScale, + heartY + 1f * heartScale, + heartX, + heartY - 6f * heartScale); + + runCoinText.setLength(0); + runCoinText.append(CompactNumberFormatter.format(view.runCurrencyEarned)); + scratchRect.set(runCoinRect(view)); + float coinTextX = runCoinTextX(model, runCoinText, scratchRect, 1.0f); + float coinX = coinTextX - TOPBAR_COIN_ICON_GAP * scale; + shapeRenderer.setColor( + ScreenPalette.GOLD.r, ScreenPalette.GOLD.g, ScreenPalette.GOLD.b, 0.95f); + shapeRenderer.circle(coinX, y, TOPBAR_COIN_ICON_RADIUS * scale); + shapeRenderer.setColor(1f, 1f, 1f, 0.32f); + shapeRenderer.circle(coinX - 1.3f * scale, y + 1.3f * scale, 1.2f * scale); + } + + private void drawStatIconGlows(ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + Rectangle strip = view.statStrip; + if (strip == null) return; + float scale = visualScale(view); + float gap = 5f * scale; + float slotWidth = (strip.width - gap * 3f) / 4f; + for (int i = 0; i < 4; i++) { + float x = strip.x + i * (slotWidth + gap) + STAT_ICON_OFFSET_X * scale; + float y = strip.y + strip.height * 0.5f; + if (i == 3) { + x += STAT_CRIT_ICON_OFFSET_X * scale; + y += STAT_CRIT_ICON_OFFSET_Y * scale; + } + drawStatIconGlow(shapeRenderer, i, x, y, scale); + } + } + + private void drawStatIconGlow( + ShapeRenderer shapeRenderer, int index, float centerX, float centerY, float scale) { + ScreenPalette color = + index == 1 + ? ScreenPalette.GREEN + : index == 3 ? ScreenPalette.GOLD : ScreenPalette.CYAN; + shapeRenderer.setColor(color.r, color.g, color.b, 0.11f); + shapeRenderer.circle(centerX, centerY, 9f * scale); + shapeRenderer.setColor(color.r, color.g, color.b, 0.08f); + shapeRenderer.circle(centerX, centerY, 6f * scale); + } + + private void drawStatIconSprites( + GameScreenHudModel model, SpriteBatch batch, GameScreenHudViewModel view) { + GameScreenHudRenderContext context = model.renderContext(); + if (context.materialAssets == null || view.statStrip == null) return; + float scale = visualScale(view); + float gap = 5f * scale; + float slotWidth = (view.statStrip.width - gap * 3f) / 4f; + for (int i = 0; i < 4; i++) { + float x = view.statStrip.x + i * (slotWidth + gap) + STAT_ICON_OFFSET_X * scale; + float y = view.statStrip.y + view.statStrip.height * 0.5f; + if (i == 3) { + x += STAT_CRIT_ICON_OFFSET_X * scale; + y += STAT_CRIT_ICON_OFFSET_Y * scale; + } + drawMaterialIcon( + batch, + statIconTexture(context, i), + statIconPalette(i), + STAT_ICON_SPRITE_SIZE * scale, + x, + y, + 0.95f); + } + batch.setColor(Color.WHITE); + } + + private Texture statIconTexture(GameScreenHudRenderContext context, int index) { + if (index == 0) return context.materialAssets.hudAttackIcon(); + if (index == 1) return context.materialAssets.hudRegenIcon(); + if (index == 2) return context.materialAssets.hudCooldownIcon(); + return context.materialAssets.hudCritIcon(); + } + + private ScreenPalette statIconPalette(int index) { + if (index == 1) return ScreenPalette.GREEN; + if (index == 3) return ScreenPalette.GOLD; + return ScreenPalette.CYAN; + } + + private void drawStatCardBorders( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel view, + float glow) { + Rectangle strip = view.statStrip; + if (strip == null) return; + float gap = 5f * visualScale(view); + float slotWidth = (strip.width - gap * 3f) / 4f; + for (int i = 0; i < 4; i++) { + scratchRect.set(strip.x + i * (slotWidth + gap), strip.y, slotWidth, strip.height); + drawPanelBorder( + model, shapeRenderer, scratchRect, model.renderContext().neonBlue, 0.17f); + } + } + + private void drawTabFills(ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + drawTabFill(shapeRenderer, view.attackDepartmentButton, view.showingAttackUpgrades); + drawTabFill( + shapeRenderer, + view.healthDepartmentButton, + !view.showingAttackUpgrades && !view.showingAbilities); + drawTabFill(shapeRenderer, view.abilitiesDepartmentButton, view.showingAbilities); + } + + private void drawTabFill(ShapeRenderer shapeRenderer, Rectangle tab, boolean active) { + if (tab == null) return; + if (active) { + shapeRenderer.setColor( + GameScreenVisualTokens.Shop.ACTIVE_TAB_FILL_RED, + GameScreenVisualTokens.Shop.ACTIVE_TAB_FILL_GREEN, + GameScreenVisualTokens.Shop.ACTIVE_TAB_FILL_BLUE, + GameScreenVisualTokens.Shop.ACTIVE_TAB_FILL_ALPHA); + } else { + shapeRenderer.setColor(1f, 1f, 1f, 0.045f); + } + shapeRenderer.rect(tab.x, tab.y, tab.width, tab.height); + } + + private void drawTabIconSprites( + GameScreenHudModel model, SpriteBatch batch, GameScreenHudViewModel view) { + GameScreenHudRenderContext context = model.renderContext(); + if (context.materialAssets == null) return; + drawTabIconSprite( + batch, + context.materialAssets.hudAttackIcon(), + view.attackDepartmentButton, + view.showingAttackUpgrades, + view); + drawTabIconSprite( + batch, + context.materialAssets.hudShieldIcon(), + view.healthDepartmentButton, + !view.showingAttackUpgrades && !view.showingAbilities, + view); + drawTabIconSprite( + batch, + context.materialAssets.hudBonusIcon(), + view.abilitiesDepartmentButton, + view.showingAbilities, + view); + batch.setColor(Color.WHITE); + } + + private void drawTabIconSprite( + SpriteBatch batch, + Texture texture, + Rectangle tab, + boolean active, + GameScreenHudViewModel view) { + if (tab == null) return; + float scale = visualScale(view); + float centerX = tab.x + 31f * scale; + float centerY = tab.y + tab.height * 0.53f; + ScreenPalette palette = active ? ScreenPalette.CYAN : ScreenPalette.MUTED; + drawMaterialIcon( + batch, texture, palette, 17f * scale, centerX, centerY, active ? 0.96f : 0.72f); + } + + private void drawMaterialIcon( + SpriteBatch batch, + Texture texture, + ScreenPalette palette, + float size, + float centerX, + float centerY, + float alpha) { + if (texture == null) return; + batch.setColor(palette.r, palette.g, palette.b, alpha); + batch.draw(texture, centerX - size * 0.5f, centerY - size * 0.5f, size, size); + } + + private void drawToggleIcon(ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + Rectangle rect = view.shopToggleButton; + if (rect == null) return; + float scale = visualScale(view); + float centerX = rect.x + rect.width * 0.5f; + float centerY = rect.y + rect.height * 0.52f; + float half = 7f * scale; + float rise = 5f * scale; + shapeRenderer.setColor( + ScreenPalette.CYAN.r, ScreenPalette.CYAN.g, ScreenPalette.CYAN.b, 0.96f); + if (view.shopExpanded) { + shapeRenderer.rectLine( + centerX - half, centerY + rise * 0.45f, centerX, centerY - rise, 2.2f * scale); + shapeRenderer.rectLine( + centerX, centerY - rise, centerX + half, centerY + rise * 0.45f, 2.2f * scale); + } else { + shapeRenderer.rectLine( + centerX - half, centerY - rise * 0.45f, centerX, centerY + rise, 2.2f * scale); + shapeRenderer.rectLine( + centerX, centerY + rise, centerX + half, centerY - rise * 0.45f, 2.2f * scale); + } + } + + private Rectangle hudHelpBounds(GameScreenHudViewModel view) { + Rectangle strip = view.statStrip; + if (strip == null) return null; + return hudHelpRect.set( + strip.x, strip.y - 36f * view.uiScale, strip.width, 30f * view.uiScale); + } + + private void drawPanelBorder( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + Rectangle rect, + Color color, + float alpha) { + if (rect == null || rect.width <= 0f || rect.height <= 0f) return; + shapeRenderer.setColor(withAlpha(model.renderContext(), color, Math.min(1f, alpha))); + shapeRenderer.rect(rect.x, rect.y, rect.width, rect.height); + } + + private void drawWaveBarFill( + GameScreenHudModel model, ShapeRenderer shapeRenderer, GameScreenHudViewModel view) { + Rectangle bar = view.waveBar; + if (bar == null) return; + shapeRenderer.setColor(1f, 1f, 1f, 0.07f); + shapeRenderer.rect(bar.x, bar.y, bar.width, bar.height); + float progress = waveProgress(view); + float filledWidth = bar.width * progress; + float cyanWidth = filledWidth * WAVEBAR_CYAN_FILL_SHARE; + float visualScale = visualScale(view); + float fillY = bar.y; + float fillHeight = bar.height - WAVEBAR_COLOR_FILL_TRIM_TOP * visualScale; + shapeRenderer.setColor(model.renderContext().neonCyan); + shapeRenderer.rect(bar.x, fillY, cyanWidth, fillHeight); + shapeRenderer.setColor(model.renderContext().neonBlue); + shapeRenderer.rect(bar.x + cyanWidth, fillY, filledWidth - cyanWidth, fillHeight); + } + + private float waveProgress(GameScreenHudViewModel view) { + if (view.waveActive && view.enemiesPerWave > 0L) { + return Math.min( + 1f, Math.max(0f, (float) view.enemiesSpawnedThisWave / view.enemiesPerWave)); + } + if (!view.waveActive && view.currentWave > 0 && view.timeBetweenWaves > 0L) { + return Math.min( + 1f, + Math.max( + 0f, (float) (view.nowMs - view.waveStartTime) / view.timeBetweenWaves)); + } + return 0f; + } + + private void drawDepartmentTabBorders( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel view, + float glow) { + drawPanelBorder( + model, + shapeRenderer, + view.attackDepartmentButton, + tabColor(model, view.showingAttackUpgrades), + tabAlpha(view.showingAttackUpgrades, glow)); + drawPanelBorder( + model, + shapeRenderer, + view.healthDepartmentButton, + tabColor(model, !view.showingAttackUpgrades && !view.showingAbilities), + tabAlpha(!view.showingAttackUpgrades && !view.showingAbilities, glow)); + drawPanelBorder( + model, + shapeRenderer, + view.abilitiesDepartmentButton, + tabColor(model, view.showingAbilities), + tabAlpha(view.showingAbilities, glow)); + } + + private Color tabColor(GameScreenHudModel model, boolean active) { + return active ? model.renderContext().neonCyan : Color.WHITE; + } + + private float tabAlpha(boolean active, float glow) { + return active ? 0.90f : 0.08f; + } + + private void drawTopBarText( + GameScreenHudModel model, + SpriteBatch batch, + BitmapFont font, + GameScreenHudViewModel view) { + Rectangle top = view.topBar; + if (top == null) return; + float scale = 1.0f; + float inset = 16f * visualScale(view); + float slotWidth = (top.width - inset * 2f) / 3f; + + waveText.setLength(0); + waveText.append("Волна ").append(view.currentWave); + scratchRect.set( + top.x + + inset + + 14f * visualScale(view) + + TOPBAR_WAVE_GROUP_OFFSET_X * visualScale(view), + top.y, + slotWidth, + top.height); + drawFittedLeft( + font, + batch, + waveText, + scratchRect, + scale, + ScreenPalette.TEXT.set(model.renderContext().scratchColor)); + + healthText.setLength(0); + healthText + .append(CompactNumberFormatter.format(view.squareHealth)) + .append('/') + .append(CompactNumberFormatter.format(view.maxSquareHealth)); + scratchRect.set( + top.x + inset + slotWidth + 8f * visualScale(view), top.y, slotWidth, top.height); + drawFittedCenter( + font, + batch, + healthText, + scratchRect, + scale, + ScreenPalette.TEXT.set(model.renderContext().scratchColor)); + + moneyText.setLength(0); + moneyText.append(CompactNumberFormatter.format(view.money)); + float moneySlotWidth = slotWidth * TOPBAR_MONEY_SLOT_SHARE; + scratchRect.set(top.x + inset + slotWidth * 2f, top.y, moneySlotWidth, top.height); + drawTopBarMoneyText(model, batch, font, moneyText, scratchRect, scale); + + runCoinText.setLength(0); + runCoinText.append(CompactNumberFormatter.format(view.runCurrencyEarned)); + scratchRect.set(runCoinRect(view)); + drawTopBarRunCoinText(model, batch, font, runCoinText, scratchRect, scale); + font.setColor(Color.WHITE); + } + + private void drawTopBarMoneyText( + GameScreenHudModel model, + SpriteBatch batch, + BitmapFont font, + CharSequence amount, + Rectangle rect, + float scale) { + if (rect == null) return; + float oldScale = setFontScale(font, scale); + rubleLayout.setText(font, "₽ "); + emptyAbilitiesLayout.setText(font, amount); + float x = rect.x + rect.width - rubleLayout.width - emptyAbilitiesLayout.width; + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + float rubleX = x + TOPBAR_RUBLE_OFFSET_X * visualScale(model.viewModel()); + float rubleY = y + TOPBAR_RUBLE_OFFSET_Y * visualScale(model.viewModel()); + font.setColor(ScreenPalette.GOLD.set(model.renderContext().scratchColor)); + font.getData().setScale(scale * TOPBAR_RUBLE_SCALE); + font.draw(batch, "₽ ", rubleX, rubleY); + font.getData().setScale(scale); + font.setColor(ScreenPalette.TEXT.set(model.renderContext().scratchColor)); + font.draw(batch, amount, x + rubleLayout.width, y); + restoreFontScale(font, oldScale); + } + + private void drawTopBarRunCoinText( + GameScreenHudModel model, + SpriteBatch batch, + BitmapFont font, + CharSequence amount, + Rectangle rect, + float scale) { + if (rect == null) return; + float x = runCoinTextX(model, amount, rect, scale); + float oldScale = setFontScale(font, scale); + emptyAbilitiesLayout.setText(font, amount); + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + font.setColor(ScreenPalette.GOLD.set(model.renderContext().scratchColor)); + font.draw(batch, amount, x, y); + restoreFontScale(font, oldScale); + } + + private Rectangle runCoinRect(GameScreenHudViewModel view) { + Rectangle top = view.topBar; + if (top == null) return scratchRect.set(0f, 0f, 0f, 0f); + float scale = visualScale(view); + float inset = 16f * scale; + float slotWidth = (top.width - inset * 2f) / 3f; + float moneySlotWidth = slotWidth * TOPBAR_MONEY_SLOT_SHARE; + return scratchRect.set( + top.x + inset + slotWidth * 2f + moneySlotWidth, + top.y, + slotWidth - moneySlotWidth, + top.height); + } + + private float runCoinTextX( + GameScreenHudModel model, CharSequence amount, Rectangle rect, float scale) { + if (rect == null) return 0f; + BitmapFont font = topBarFont(model.renderContext()); + float oldScale = setFontScale(font, scale); + emptyAbilitiesLayout.setText(font, amount); + float x = rect.x + rect.width - emptyAbilitiesLayout.width; + x = Math.max(x, rect.x + TOPBAR_COIN_TEXT_INSET_X * visualScale(model.viewModel())); + restoreFontScale(font, oldScale); + return x; + } + + private void drawStatStripText( + GameScreenHudModel model, + SpriteBatch batch, + BitmapFont font, + GameScreenHudViewModel view) { + Rectangle strip = view.statStrip; + if (strip == null) return; + float gap = 5f * visualScale(view); + float slotWidth = (strip.width - gap * 3f) / 4f; + drawStat( + batch, + font, + model, + CompactNumberFormatter.format(view.effectiveDamage), + strip.x, + strip.y, + strip.height, + slotWidth); + drawStat( + batch, + font, + model, + hudPerSecond(view.healthRegenRate), + strip.x + slotWidth + gap, + strip.y, + strip.height, + slotWidth); + drawStat( + batch, + font, + model, + hudSeconds(view.effectiveCooldownMs), + strip.x + (slotWidth + gap) * 2f, + strip.y, + strip.height, + slotWidth); + drawStat( + batch, + font, + model, + percentText(view.effectiveCritChance), + strip.x + (slotWidth + gap) * 3f, + strip.y, + strip.height, + slotWidth); + } + + private void drawStat( + SpriteBatch batch, + BitmapFont font, + GameScreenHudModel model, + String value, + float x, + float y, + float height, + float width) { + statText.setLength(0); + statText.append(value); + float iconSpace = STAT_TEXT_OFFSET_X * visualScale(model.viewModel()); + scratchRect.set(x + iconSpace, y, width - iconSpace - 2f, height); + drawFittedLeft( + font, + batch, + statText, + scratchRect, + 1.0f, + ScreenPalette.TEXT.set(model.renderContext().scratchColor)); + } + + private void drawHudHelpText( + GameScreenHudModel model, + SpriteBatch batch, + BitmapFont font, + GameScreenHudViewModel view) { + Rectangle help = hudHelpBounds(view); + if (help == null) return; + font.setColor(ScreenPalette.TEXT.set(model.renderContext().scratchColor)); + textRenderer.drawCenteredText( + font, batch, "Урон Регенерация Частота выстрела Шанс крита", help); + } + + private void drawTabText( + SpriteBatch batch, + BitmapFont font, + GameScreenHudViewModel view, + String text, + Rectangle rect, + boolean active) { + if (rect == null) return; + float iconSpace = 24f * visualScale(view); + scratchRect.set( + rect.x + iconSpace, + rect.y, + rect.width - iconSpace - 6f * visualScale(view), + rect.height); + Color textColor = + active + ? ScreenPalette.TEXT.set( + host.hudModel().renderContext().scratchColor, 0.96f) + : ScreenPalette.MUTED.set( + host.hudModel().renderContext().scratchColor, 0.82f); + if (active) { + drawFittedCenterEmphasis(font, batch, text, scratchRect, 1.0f, textColor); + } else { + drawFittedCenter(font, batch, text, scratchRect, 1.0f, textColor); + } + font.setColor(Color.WHITE); + } + + private void drawShopCardTexts( + GameScreenHudModel model, SpriteBatch batch, GameScreenHudViewModel view) { + GameScreenHudViewModel.HudButton[] buttons = visibleUpgradeButtons(view); + for (int i = 0; i < buttons.length; i++) { + GameScreenHudViewModel.HudButton button = buttons[i]; + if (button.bounds == null) continue; + fillCardValues(view, button, view.showingAttackUpgrades, view.showingAbilities, i); + drawCardText(model, batch, button, i, currentValue, nextValue); + } + } + + private void drawClippedShopCardTexts( + GameScreenHudModel model, SpriteBatch batch, GameScreenHudViewModel view) { + batch.flush(); + boolean clipped = beginShopCardClip(view); + drawShopCardTexts(model, batch, view); + batch.flush(); + if (clipped) { + endShopCardClip(); + } + } + + private boolean beginShopCardClip(GameScreenHudViewModel view) { + Rectangle clip = shopCardClipBounds(view); + if (clip == null || clip.width <= 0f || clip.height <= 0f) return false; + Gdx.gl.glEnable(GL20.GL_SCISSOR_TEST); + HdpiUtils.glScissor( + (int) clip.x, + (int) clip.y, + (int) Math.ceil(clip.width), + (int) Math.ceil(clip.height)); + return true; + } + + private void endShopCardClip() { + Gdx.gl.glDisable(GL20.GL_SCISSOR_TEST); + } + + private Rectangle shopCardClipBounds(GameScreenHudViewModel view) { + if (view.shopSheet == null) return null; + float scale = visualScale(view); + float top = + view.departmentButtonsY + - GameScreenVisualTokens.Layout.CARD_GAP * scale + + SHOP_CARD_CLIP_BLEED * scale; + float bottom = view.shopSheet.y - SHOP_CARD_CLIP_BLEED * scale; + return shopCardClipRect.set(view.shopSheet.x, bottom, view.shopSheet.width, top - bottom); + } + + private void fillCardValues( + GameScreenHudViewModel view, + GameScreenHudViewModel.HudButton button, + boolean attack, + boolean bonuses, + int index) { + currentValue.setLength(0); + nextValue.setLength(0); + if (button.currentValue != null && button.nextValue != null) { + currentValue.append(button.currentValue); + nextValue.append(button.nextValue); + } else if (bonuses) { + appendMultiplier(currentValue, view.effectiveCritMultiplier); + appendMultiplier(nextValue, view.effectiveCritMultiplier + button.statDelta); + } else if (attack) { + fillAttackCardValues(view, button, index); + } else { + fillHealthCardValues(view, index); + } + } + + private void fillAttackCardValues( + GameScreenHudViewModel view, GameScreenHudViewModel.HudButton button, int index) { + switch (index) { + case 0: + currentValue.append(CompactNumberFormatter.format(view.effectiveDamage)); + nextValue.append( + CompactNumberFormatter.format( + view.effectiveDamage + Math.round(button.statDelta))); + break; + case 1: + currentValue.append(CompactNumberFormatter.format(view.effectiveSpeed)); + nextValue.append( + CompactNumberFormatter.format(view.effectiveSpeed + button.statDelta)); + break; + case 2: + currentValue.append(hudSeconds(view.effectiveCooldownMs)); + nextValue.append( + hudSeconds(Math.max(100f, view.effectiveCooldownMs - button.statDelta))); + break; + case 3: + appendPercent(currentValue, view.effectiveCritChance); + appendPercent(nextValue, view.effectiveCritChance + button.statDelta); + break; + default: + appendMultiplier(currentValue, view.effectiveCritMultiplier); + appendMultiplier(nextValue, view.effectiveCritMultiplier + button.statDelta); + break; + } + } + + private String percentText(float fraction) { + return Math.round(fraction * 100f) + "%"; + } + + private void appendPercent(StringBuilder builder, float fraction) { + builder.append(Math.round(fraction * 100f)).append('%'); + } + + private void appendMultiplier(StringBuilder builder, float value) { + int tenths = Math.round(value * 10f); + builder.append(tenths / 10).append('.').append(Math.abs(tenths % 10)).append('x'); + } + + private void fillHealthCardValues(GameScreenHudViewModel view, int index) { + if (index == 0) { + currentValue.append(CompactNumberFormatter.format(view.maxSquareHealth)); + nextValue.append(CompactNumberFormatter.format(view.maxSquareHealth + 20L)); + } else { + currentValue.append(hudPerSecond(view.healthRegenRate)); + nextValue.append(hudPerSecond(view.healthRegenRate + 1L)); + } + } + + private String hudSeconds(float milliseconds) { + return CompactNumberFormatter.seconds(milliseconds).replace('s', 'с'); + } + + private String hudPerSecond(long value) { + return CompactNumberFormatter.signedPerSecond(value).replace("/s", "/с"); + } + + private void drawCardText( + GameScreenHudModel model, + SpriteBatch batch, + GameScreenHudViewModel.HudButton button, + int index, + CharSequence oldValue, + CharSequence newValue) { + GameScreenHudRenderContext context = model.renderContext(); + GameScreenHudViewModel view = model.viewModel(); + Rectangle rect = button.bounds; + BitmapFont titleFont = cardTitleFont(context); + BitmapFont valueFont = cardValueFont(context); + BitmapFont arrowFont = cardArrowFont(context); + BitmapFont priceFont = priceFont(context); + float scale = visualScale(view); + float pad = 8f * scale; + float priceWidth = 48f * scale; + CharSequence title = displayCardTitle(button.label); + CharSequence current = oldValue; + CharSequence next = newValue; + boolean affordable = cardAffordable(view, button); + boolean showUpgradeArrow = !sameUpgradeValue(current, next); + float titlePadX = SHOP_CARD_TITLE_PAD_X * scale; + float valueOffsetY = SHOP_CARD_VALUE_OFFSET_Y * scale; + float valuePadY = 8f * scale; + + scratchRect.set( + rect.x + titlePadX, + rect.y + rect.height * 0.46f, + rect.width - titlePadX - pad - priceWidth, + rect.height * 0.48f); + drawFittedLeftEmphasis( + titleFont, + batch, + title, + scratchRect, + 1.0f, + affordable + ? ScreenPalette.TEXT.set(context.scratchColor) + : ScreenPalette.MUTED.set(context.scratchColor, 0.68f)); + + statText.setLength(0); + statText.append(current); + if (showUpgradeArrow) { + statText.append(' ').append(UPGRADE_ARROW_TEXT).append(' ').append(next); + } + float valueScale = + fittedScale(valueFont, statText, 1.0f, rect.width - priceWidth - titlePadX - pad); + float oldFontScale = setFontScale(valueFont, valueScale); + float oldArrowFontScale = setFontScale(arrowFont, valueScale); + emptyAbilitiesLayout.setText(valueFont, current); + float valuesY = rect.y + valuePadY + emptyAbilitiesLayout.height + valueOffsetY; + float valueX = rect.x + titlePadX; + valueFont.setColor( + ScreenPalette.MUTED.set(context.scratchColor, affordable ? 0.88f : 0.48f)); + valueFont.draw(batch, current, valueX, valuesY); + if (showUpgradeArrow) { + valueX += emptyAbilitiesLayout.width + 5f * scale; + emptyAbilitiesLayout.setText(arrowFont, UPGRADE_ARROW_TEXT); + arrowFont.setColor( + ScreenPalette.TEXT.set(context.scratchColor, affordable ? 0.52f : 0.36f)); + arrowFont.draw(batch, UPGRADE_ARROW_TEXT, valueX, valuesY); + valueX += emptyAbilitiesLayout.width + 5f * scale; + valueFont.setColor( + ScreenPalette.ORANGE.set(context.scratchColor, affordable ? 0.96f : 0.62f)); + valueFont.draw(batch, next, valueX, valuesY); + } + restoreFontScale(arrowFont, oldArrowFontScale); + restoreFontScale(valueFont, oldFontScale); + + priceText.setLength(0); + if (button.maxed) { + priceText.append("MAX"); + } else { + priceText.append('₽').append(CompactNumberFormatter.format(button.price)); + } + setPricePillBounds(scratchRect, rect, view); + drawFittedCenter( + priceFont, + batch, + priceText, + scratchRect, + 1.0f, + affordable + ? ScreenPalette.GOLD.set(context.scratchColor) + : ScreenPalette.RED.set(context.scratchColor)); + titleFont.setColor(Color.WHITE); + valueFont.setColor(Color.WHITE); + arrowFont.setColor(Color.WHITE); + priceFont.setColor(Color.WHITE); + } + + private boolean sameUpgradeValue(CharSequence current, CharSequence next) { + if (current == null || next == null) return current == next; + return current.toString().contentEquals(next); + } + + private CharSequence displayCardTitle(CharSequence rawLabel) { + String label = rawLabel == null ? "" : rawLabel.toString().toUpperCase(); + if (label.equals("УРОН")) return "Урон"; + if (label.equals("СКОР")) return "Скорость"; + if (label.equals("ЧАСТ")) return "Частота"; + if (label.equals("ШАНС")) return "Крит. шанс"; + if (label.startsWith("ЗДОР") || label.startsWith("ЗДР")) return "Здоровье"; + if (label.startsWith("РЕГ")) return "Реген"; + if (label.equals("КРИТ")) return "Крит"; + return rawLabel; + } + + private void setPricePillBounds(Rectangle target, Rectangle card, GameScreenHudViewModel view) { + float scale = visualScale(view); + float width = 44f * scale; + float height = 23f * scale; + target.set( + card.x + card.width - 9f * scale - width, + card.y + (card.height - height) / 2f, + width, + height); + } + + private void drawDockText( + GameScreenHudModel model, SpriteBatch batch, GameScreenHudViewModel view) { + GameScreenHudRenderContext context = model.renderContext(); + BitmapFont labelFont = dockLabelFont(context); + BitmapFont cooldownFont = dockCooldownFont(context); + if (dockChromeVisible(view) && view.dockLabel != null) { + scratchRect.set( + view.dockLabel.x + 4f * visualScale(view), + view.dockLabel.y, + view.dockLabel.width - 8f * visualScale(view), + view.dockLabel.height); + drawFittedLeft( + labelFont, + batch, + view.shopExpanded ? "Свернуть" : "Апгрейды", + scratchRect, + 1.0f, + ScreenPalette.MUTED.set(context.scratchColor)); + } + drawToggleGlyph(batch, labelFont, model, view); + for (GameScreenHudViewModel.AbilityButton button : view.abilityButtons) { + if (button.bounds == null) continue; + if (button.cooldownMs > 0L) { + setDockCooldownText(cooldownText, button.cooldownMs); + Rectangle cooldownBounds = dockCooldownVisualBounds(view, button.bounds); + drawFittedCenterEmphasis( + cooldownFont, + batch, + cooldownText, + cooldownBounds, + 1.0f, + ScreenPalette.CYAN.set(context.scratchColor)); + } + } + labelFont.setColor(Color.WHITE); + cooldownFont.setColor(Color.WHITE); + } + + private Rectangle dockCooldownVisualBounds(GameScreenHudViewModel view, Rectangle rect) { + float visualScale = visualScale(view); + return scratchRect.set( + rect.x + DOCK_COOLDOWN_SPRITE_OFFSET_X * visualScale, + rect.y + DOCK_COOLDOWN_SPRITE_OFFSET_Y * visualScale, + rect.width, + rect.height); + } + + private void drawToggleGlyph( + SpriteBatch batch, + BitmapFont font, + GameScreenHudModel model, + GameScreenHudViewModel view) { + + + } + + void renderWaveIndicator() { + GameScreenHudModel model = host.hudModel(); + GameScreenHudRenderContext context = model.renderContext(); + GameScreenHudViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + float uiScale = view.uiScale; + + shapeRenderer.begin(ShapeType.Filled); + + float indicatorWidth = view.viewportWidth - view.waveIndicatorPadding * 2 * uiScale; + float indicatorHeight = view.waveIndicatorHeight * uiScale; + float indicatorX = view.waveIndicatorPadding * uiScale; + float indicatorY = waveIndicatorY(view); + + shapeRenderer.setColor(0f, 0f, 0f, 1f); + shapeRenderer.rect(indicatorX, indicatorY, indicatorWidth, indicatorHeight); + + if (view.waveActive && view.enemiesPerWave > 0) { + float progress = (float) view.enemiesSpawnedThisWave / view.enemiesPerWave; + shapeRenderer.setColor(context.neonRed); + shapeRenderer.rect(indicatorX, indicatorY, indicatorWidth * progress, indicatorHeight); + } else if (!view.waveActive && view.currentWave > 0) { + float timeProgress = (float) (view.nowMs - view.waveStartTime) / view.timeBetweenWaves; + timeProgress = Math.min(1, Math.max(0, timeProgress)); + + shapeRenderer.setColor(context.neonCyan); + shapeRenderer.rect( + indicatorX, indicatorY, indicatorWidth * timeProgress, indicatorHeight); + } + + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Line); + + float glowPulse = 0.5f + 0.5f * (float) Math.sin(view.time * view.pulseSpeed); + Gdx.gl.glLineWidth(2f); + + Color borderBase; + if (view.waveActive) { + borderBase = context.neonRed; + } else if (!view.waveActive && view.currentWave > 0) { + borderBase = context.neonCyan; + } else { + borderBase = context.neonBlue; + } + shapeRenderer.setColor(withAlpha(context, borderBase, 0.7f + 0.3f * glowPulse)); + shapeRenderer.rect(indicatorX, indicatorY, indicatorWidth, indicatorHeight); + + Gdx.gl.glLineWidth(1f); + shapeRenderer.end(); + } + + void renderDepartmentTabs() { + GameScreenHudModel model = host.hudModel(); + GameScreenHudViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = model.renderContext().shapeRenderer; + + shapeRenderer.begin(ShapeType.Filled); + + Rectangle attackButton = view.attackDepartmentButton; + Rectangle healthButton = view.healthDepartmentButton; + Rectangle abilitiesButton = view.abilitiesDepartmentButton; + + shapeRenderer.setColor(ScreenPalette.QUIET.set(model.renderContext().scratchColor, 0.42f)); + shapeRenderer.rect(attackButton.x, attackButton.y, attackButton.width, attackButton.height); + shapeRenderer.rect(healthButton.x, healthButton.y, healthButton.width, healthButton.height); + shapeRenderer.rect( + abilitiesButton.x, + abilitiesButton.y, + abilitiesButton.width, + abilitiesButton.height); + + if (view.showingAttackUpgrades) { + shapeRenderer.setColor( + ScreenPalette.CYAN.set(model.renderContext().scratchColor, 0.16f)); + shapeRenderer.rect( + attackButton.x, attackButton.y, attackButton.width, attackButton.height); + } else if (view.showingAbilities) { + shapeRenderer.setColor( + ScreenPalette.BLUE.set(model.renderContext().scratchColor, 0.18f)); + shapeRenderer.rect( + abilitiesButton.x, + abilitiesButton.y, + abilitiesButton.width, + abilitiesButton.height); + } else { + shapeRenderer.setColor( + ScreenPalette.RED.set(model.renderContext().scratchColor, 0.16f)); + shapeRenderer.rect( + healthButton.x, healthButton.y, healthButton.width, healthButton.height); + } + + shapeRenderer.setColor(ScreenPalette.MUTED.set(model.renderContext().scratchColor, 0.70f)); + shapeRenderer.end(); + } + + void renderUpgradeButtons() { + GameScreenHudModel model = host.hudModel(); + GameScreenHudViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = model.renderContext().shapeRenderer; + shapeRenderer.begin(ShapeType.Filled); + shapeRenderer.flush(); + Gdx.gl.glEnable(GL20.GL_SCISSOR_TEST); + HdpiUtils.glScissor(0, 0, view.viewportWidth, (int) view.departmentButtonsY); + drawUpgradeButtonFills(shapeRenderer); + shapeRenderer.flush(); + shapeRenderer.end(); + + shapeRenderer.begin(ShapeType.Line); + drawUpgradeButtonBorders(shapeRenderer); + shapeRenderer.flush(); + shapeRenderer.end(); + + Gdx.gl.glDisable(GL20.GL_SCISSOR_TEST); + } + + private void drawUpgradeButtonFills(ShapeRenderer shapeRenderer) { + GameScreenHudModel model = host.hudModel(); + GameScreenHudViewModel view = model.viewModel(); + float glow = 0.3f + 0.3f * (float) Math.sin(view.time * view.pulseSpeed); + + if (view.showingAttackUpgrades) { + drawButtonFills(model, shapeRenderer, view.attackButtons, glow); + } else if (view.showingAbilities) { + drawButtonFills(model, shapeRenderer, view.bonusButtons, glow); + } else { + drawButtonFills(model, shapeRenderer, view.healthButtons, glow); + } + } + + private void drawUpgradeButtonBorders(ShapeRenderer shapeRenderer) { + GameScreenHudModel model = host.hudModel(); + GameScreenHudViewModel view = model.viewModel(); + float glow = 0.3f + 0.3f * (float) Math.sin(view.time * view.pulseSpeed); + + if (view.showingAttackUpgrades) { + drawButtonBorders(model, shapeRenderer, view.attackButtons, glow); + } else if (view.showingAbilities) { + drawButtonBorders(model, shapeRenderer, view.bonusButtons, glow); + } else { + drawButtonBorders(model, shapeRenderer, view.healthButtons, glow); + } + } + + void renderUpgradeText() { + GameScreenHudModel model = host.hudModel(); + GameScreenHudRenderContext context = model.renderContext(); + GameScreenHudViewModel view = model.viewModel(); + BitmapFont font = context.font; + SpriteBatch batch = context.batch; + float uiScale = view.uiScale; + Rectangle attackButton = view.attackDepartmentButton; + Rectangle healthButton = view.healthDepartmentButton; + Rectangle abilitiesButton = view.abilitiesDepartmentButton; + + float indicatorY = waveIndicatorY(view); + + batch.begin(); + + waveText.setLength(0); + waveText.append(WAVE_LABEL).append(view.currentWave); + font.draw(batch, waveText, 10 * uiScale, indicatorY - 10 * uiScale); + + healthText.setLength(0); + healthText + .append(HEALTH_LABEL) + .append(view.squareHealth) + .append('/') + .append(view.maxSquareHealth); + font.draw(batch, healthText, 10 * uiScale, indicatorY - 40 * uiScale); + + moneyText.setLength(0); + moneyText.append(MONEY_LABEL).append(view.money); + font.draw(batch, moneyText, 10 * uiScale, indicatorY - 70 * uiScale); + + drawDepartmentText(batch, font, ATTACK_DEPARTMENT_TEXT, attackButton); + drawDepartmentText(batch, font, HEALTH_DEPARTMENT_TEXT, healthButton); + drawDepartmentText(batch, font, ABILITIES_DEPARTMENT_TEXT, abilitiesButton); + + batch.flush(); + Gdx.gl.glEnable(GL20.GL_SCISSOR_TEST); + HdpiUtils.glScissor(0, 0, view.viewportWidth, (int) view.departmentButtonsY); + + drawButtonTexts(model, batch); + + batch.flush(); + Gdx.gl.glDisable(GL20.GL_SCISSOR_TEST); + + batch.end(); + } + + private void drawButtonFills( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.HudButton[] buttons, + float glow) { + GameScreenHudViewModel view = model.viewModel(); + for (int i = 0; i < buttons.length; i++) { + GameScreenHudViewModel.HudButton button = buttons[i]; + if (button.bounds != null) { + drawUpgradeCardFill(model, shapeRenderer, button, cardAffordable(view, button)); + } + } + } + + private void drawClippedShopButtonFills( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel view, + float glow) { + shapeRenderer.flush(); + boolean clipped = beginShopCardClip(view); + drawButtonFills(model, shapeRenderer, visibleUpgradeButtons(view), glow); + shapeRenderer.flush(); + if (clipped) { + endShopCardClip(); + } + } + + private void drawAbilityButtonFills( + GameScreenHudModel model, ShapeRenderer shapeRenderer, float glow) { + for (GameScreenHudViewModel.AbilityButton button : model.viewModel().abilityButtons) { + if (button.bounds != null) { + drawDockAbilityFill(model, shapeRenderer, button, glow); + } + } + } + + private void drawButtonBorders( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.HudButton[] buttons, + float glow) { + GameScreenHudViewModel view = model.viewModel(); + for (int i = 0; i < buttons.length; i++) { + GameScreenHudViewModel.HudButton button = buttons[i]; + if (button.bounds != null) { + drawUpgradeCardBorder( + model, shapeRenderer, button, cardAffordable(view, button), glow); + } + } + } + + private void drawClippedShopButtonBorders( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel view, + float glow) { + shapeRenderer.flush(); + boolean clipped = beginShopCardClip(view); + drawButtonBorders(model, shapeRenderer, visibleUpgradeButtons(view), glow); + shapeRenderer.flush(); + if (clipped) { + endShopCardClip(); + } + } + + private boolean cardAffordable( + GameScreenHudViewModel view, GameScreenHudViewModel.HudButton button) { + return !button.maxed && button.price <= view.money; + } + + private void drawAbilityButtonBorders( + GameScreenHudModel model, ShapeRenderer shapeRenderer, float glow) { + for (GameScreenHudViewModel.AbilityButton button : model.viewModel().abilityButtons) { + if (button.bounds != null) { + drawDockAbilityBorder(model, shapeRenderer, button, glow); + } + } + } + + private void drawUpgradeCardFill( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.HudButton button, + boolean affordable) { + Rectangle rect = button.bounds; + if (rect == null) return; + GameScreenHudRenderContext context = model.renderContext(); + shapeRenderer.setColor(1f, 1f, 1f, affordable ? 0.06f : 0.037f); + shapeRenderer.rect(rect.x, rect.y, rect.width, rect.height); + if (affordable) { + shapeRenderer.setColor(0.133f, 0.851f, 1f, 0.08f); + shapeRenderer.rect(rect.x, rect.y, rect.width, rect.height); + } + setPricePillBounds(scratchRect, rect, model.viewModel()); + if (affordable) { + shapeRenderer.setColor( + ScreenPalette.GOLD.set(context.scratchColor, affordable ? 0.12f : 0f)); + } else { + shapeRenderer.setColor(ScreenPalette.RED.set(context.scratchColor, 0.10f)); + } + shapeRenderer.rect(scratchRect.x, scratchRect.y, scratchRect.width, scratchRect.height); + } + + private void drawUpgradeCardBorder( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.HudButton button, + boolean affordable, + float glow) { + Rectangle rect = button.bounds; + if (rect == null) return; + Color color = affordable ? model.renderContext().neonCyan : Color.WHITE; + float alpha = affordable ? 0.38f : 0.12f; + if (isButtonHovered(rect)) { + alpha = Math.min(1f, alpha + 0.22f); + } + drawPanelBorder(model, shapeRenderer, rect, color, alpha); + setPricePillBounds(scratchRect, rect, model.viewModel()); + drawPanelBorder( + model, + shapeRenderer, + scratchRect, + affordable ? model.renderContext().priceTextColor : model.renderContext().neonRed, + affordable ? 0.22f : 0.30f); + } + + private void drawDockAbilityFill( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.AbilityButton button, + float glow) { + Rectangle rect = button.bounds; + if (rect == null) return; + if (button.cooldownMs > 0L) { + rect = dockCooldownVisualBounds(model.viewModel(), rect); + } + shapeRenderer.setColor(1f, 1f, 1f, 0.07f); + shapeRenderer.rect(rect.x, rect.y, rect.width, rect.height); + if (button.cooldownMs > 0L) { + drawDockCooldownFill(model, shapeRenderer, button); + } else { + drawDockReadyIcon(model, shapeRenderer, button); + } + } + + private void drawDockCooldownFill( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.AbilityButton button) { + Rectangle rect = dockCooldownVisualBounds(model.viewModel(), button.bounds); + float scale = visualScale(model.viewModel()); + float centerX = rect.x + rect.width * 0.5f; + float centerY = rect.y + rect.height * 0.5f; + float radius = 20f * scale; + float sweepDegrees = dockCooldownSweepDegrees(button); + shapeRenderer.setColor(1f, 1f, 1f, 0.12f); + shapeRenderer.circle(centerX, centerY, radius); + shapeRenderer.setColor(0.05f, 0.067f, 0.11f, 0.96f); + shapeRenderer.circle(centerX, centerY, 11f * scale); + } + + private void drawAbilityCooldownProgressArcs( + GameScreenHudModel model, ShapeRenderer shapeRenderer) { + for (GameScreenHudViewModel.AbilityButton button : model.viewModel().abilityButtons) { + if (button.bounds != null && button.cooldownMs > 0L) { + drawDockCooldownProgressArc(model, shapeRenderer, button); + } + } + } + + private void drawDockCooldownProgressArc( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.AbilityButton button) { + Rectangle rect = dockCooldownVisualBounds(model.viewModel(), button.bounds); + float scale = visualScale(model.viewModel()); + float centerX = rect.x + rect.width * 0.5f; + float centerY = rect.y + rect.height * 0.5f; + float radius = 20f * scale; + float sweepDegrees = dockCooldownSweepDegrees(button); + if (sweepDegrees > 0f) { + shapeRenderer.flush(); + Gdx.gl.glLineWidth(DOCK_COOLDOWN_STROKE_WIDTH * scale); + shapeRenderer.setColor( + ScreenPalette.CYAN.r, ScreenPalette.CYAN.g, ScreenPalette.CYAN.b, 0.95f); + drawDockCooldownStrokeArc(shapeRenderer, centerX, centerY, radius, sweepDegrees); + shapeRenderer.flush(); + Gdx.gl.glLineWidth(1f); + } + } + + private void drawDockCooldownStrokeArc( + ShapeRenderer shapeRenderer, + float centerX, + float centerY, + float radius, + float sweepDegrees) { + float previousAngle = 90f; + float previousX = centerX + MathUtils.cosDeg(previousAngle) * radius; + float previousY = centerY + MathUtils.sinDeg(previousAngle) * radius; + float coveredDegrees = 0f; + while (coveredDegrees < sweepDegrees) { + float nextCoveredDegrees = + Math.min(coveredDegrees + DOCK_COOLDOWN_ARC_SEGMENT_DEGREES, sweepDegrees); + float angle = 90f - nextCoveredDegrees; + float x = centerX + MathUtils.cosDeg(angle) * radius; + float y = centerY + MathUtils.sinDeg(angle) * radius; + shapeRenderer.line(previousX, previousY, x, y); + previousX = x; + previousY = y; + coveredDegrees = nextCoveredDegrees; + } + } + + private float dockCooldownSweepDegrees(GameScreenHudViewModel.AbilityButton button) { + long totalCooldownMs = button.ability.baseCooldownMs(); + if (totalCooldownMs <= 0L) return 0f; + float remainingShare = clamp(button.cooldownMs / (float) totalCooldownMs, 0f, 1f); + return remainingShare * 360f; + } + + private void drawDockReadyIcon( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.AbilityButton button) { + Rectangle rect = button.bounds; + float scale = visualScale(model.viewModel()); + float centerX = rect.x + rect.width * 0.5f; + float centerY = rect.y + rect.height * 0.5f; + switch (button.ability) { + case FREEZE: + drawFreezeDockIcon(shapeRenderer, centerX, centerY, scale); + break; + case EXPLOSION: + drawExplosionDockIcon(shapeRenderer, centerX, centerY, scale); + break; + case SHIELD: + drawShieldDockIcon(shapeRenderer, centerX, centerY, scale, button.color); + break; + default: + drawGenericDockIcon(shapeRenderer, centerX, centerY, scale, button.color); + break; + } + } + + private void drawFreezeDockIcon( + ShapeRenderer shapeRenderer, float centerX, float centerY, float scale) { + shapeRenderer.setColor(0.01f, 0.014f, 0.023f, 0.90f); + shapeRenderer.rectLine( + centerX - 9f * scale, centerY, centerX + 9f * scale, centerY, 1.35f * scale); + shapeRenderer.rectLine( + centerX - 6.5f * scale, + centerY - 6.5f * scale, + centerX + 6.5f * scale, + centerY + 6.5f * scale, + 1.35f * scale); + shapeRenderer.rectLine( + centerX - 6.5f * scale, + centerY + 6.5f * scale, + centerX + 6.5f * scale, + centerY - 6.5f * scale, + 1.35f * scale); + } + + private void drawExplosionDockIcon( + ShapeRenderer shapeRenderer, float centerX, float centerY, float scale) { + shapeRenderer.setColor(0.01f, 0.014f, 0.023f, 0.96f); + shapeRenderer.circle(centerX, centerY, 5.5f * scale); + } + + private void drawShieldDockIcon( + ShapeRenderer shapeRenderer, float centerX, float centerY, float scale, Color color) { + shapeRenderer.setColor(color.r, color.g, color.b, 0.88f); + shapeRenderer.rect(centerX - 7f * scale, centerY + 1f * scale, 14f * scale, 6f * scale); + shapeRenderer.triangle( + centerX - 7f * scale, + centerY + 1f * scale, + centerX + 7f * scale, + centerY + 1f * scale, + centerX, + centerY - 10f * scale); + } + + private void drawGenericDockIcon( + ShapeRenderer shapeRenderer, float centerX, float centerY, float scale, Color color) { + shapeRenderer.setColor(color.r, color.g, color.b, 0.86f); + shapeRenderer.circle(centerX, centerY, 8f * scale); + } + + private void drawDockAbilityBorder( + GameScreenHudModel model, + ShapeRenderer shapeRenderer, + GameScreenHudViewModel.AbilityButton button, + float glow) { + Rectangle rect = button.bounds; + if (rect == null) return; + if (button.cooldownMs > 0L) { + rect = dockCooldownVisualBounds(model.viewModel(), rect); + } + Color color = button.cooldownMs > 0L ? model.renderContext().neonCyan : button.color; + float alpha = button.cooldownMs > 0L ? 0.48f : 0.28f + glow * 0.14f; + drawPanelBorder(model, shapeRenderer, rect, color, alpha); + if (button.cooldownMs <= 0L) { + float inset = 4f * visualScale(model.viewModel()); + scratchRect.set( + rect.x + inset, + rect.y + inset, + rect.width - inset * 2f, + rect.height - inset * 2f); + drawPanelBorder(model, shapeRenderer, scratchRect, Color.WHITE, 0.10f); + } + } + + private void drawButtonTexts(GameScreenHudModel model, SpriteBatch batch) { + GameScreenHudViewModel view = model.viewModel(); + if (view.showingAttackUpgrades) { + for (GameScreenHudViewModel.HudButton button : view.attackButtons) { + if (button.bounds != null) { + drawButtonText(model, batch, button.label, button.price, button.bounds); + } + } + } else if (view.showingAbilities) { + for (GameScreenHudViewModel.HudButton button : view.bonusButtons) { + if (button.bounds != null) { + drawButtonText(model, batch, button.label, button.price, button.bounds); + } + } + } else { + for (GameScreenHudViewModel.HudButton button : view.healthButtons) { + if (button.bounds != null) { + drawButtonText(model, batch, button.label, button.price, button.bounds); + } + } + } + } + + private void drawDepartmentText( + SpriteBatch batch, BitmapFont font, String text, Rectangle button) { + textRenderer.drawCenteredText(font, batch, text, button); + } + + private void drawButtonText( + GameScreenHudModel model, + SpriteBatch batch, + CharSequence mainText, + int price, + Rectangle button) { + priceText.setLength(0); + priceText.append(" (").append(price).append("₽)"); + + GameScreenHudRenderContext context = model.renderContext(); + textRenderer.drawPricedButtonText( + context.font, batch, mainText, priceText, button, context.priceTextColor); + } + + private void drawEmptyAbilitiesPlaceholder(GameScreenHudModel model, SpriteBatch batch) { + GameScreenHudViewModel view = model.viewModel(); + BitmapFont font = model.renderContext().font; + emptyAbilitiesLayout.setText(font, EMPTY_ABILITIES_MESSAGE); + float x = (view.viewportWidth - emptyAbilitiesLayout.width) / 2f; + float y = view.departmentButtonsY / 2f + emptyAbilitiesLayout.height / 2f; + font.setColor(0.7f, 0.7f, 0.7f, 1f); + font.draw(batch, EMPTY_ABILITIES_MESSAGE, x, y); + font.setColor(1f, 1f, 1f, 1f); + } + + private void drawNeonButtonText( + GameScreenHudModel model, + SpriteBatch batch, + CharSequence mainText, + CharSequence cooldownText, + Rectangle button) { + GameScreenHudRenderContext context = model.renderContext(); + GameScreenHudViewModel view = model.viewModel(); + float glow = 0.8f + 0.2f * (float) Math.sin(view.time * view.pulseSpeed); + textRenderer.drawNeonButtonText( + context.font, + batch, + mainText, + cooldownText, + button, + ScreenPalette.TEXT_COOLDOWN.set(context.scratchColor, glow)); + } + + private boolean isButtonHovered(Rectangle button) { + float mouseX = Gdx.input.getX(); + float mouseY = Gdx.graphics.getHeight() - Gdx.input.getY(); + return button.contains(mouseX, mouseY); + } + + private Color withAlpha(GameScreenHudRenderContext context, Color base, float alpha) { + context.scratchColor.set(base); + context.scratchColor.a = alpha; + return context.scratchColor; + } + + private float visualScale(GameScreenHudViewModel view) { + float widthScale = view.viewportWidth / REFERENCE_WIDTH; + float heightScale = view.viewportHeight / REFERENCE_HEIGHT; + return clamp(Math.min(widthScale, heightScale), 0.72f, 1.35f); + } + + private float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + private float setFontScale(BitmapFont font, float scale) { + float oldScale = font.getData().scaleX; + font.getData().setScale(scale); + return oldScale; + } + + private void restoreFontScale(BitmapFont font, float oldScale) { + font.getData().setScale(oldScale); + } + + private BitmapFont topBarFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.topBar(); + } + + private BitmapFont statFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.stat(); + } + + private BitmapFont tabFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.tab(); + } + + private BitmapFont cardTitleFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.cardTitle(); + } + + private BitmapFont cardValueFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.cardValue(); + } + + private BitmapFont cardArrowFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.cardArrow(); + } + + private BitmapFont priceFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.price(); + } + + private BitmapFont dockLabelFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.dockLabel(); + } + + private BitmapFont dockCooldownFont(GameScreenHudRenderContext context) { + HudFonts fonts = context.hudFonts; + return fonts == null ? context.font : fonts.dockCooldown(); + } + + private float fittedScale(BitmapFont font, CharSequence text, float baseScale, float maxWidth) { + float oldScale = setFontScale(font, baseScale); + emptyAbilitiesLayout.setText(font, text); + float scale = baseScale; + if (emptyAbilitiesLayout.width > maxWidth && emptyAbilitiesLayout.width > 0f) { + scale *= maxWidth / emptyAbilitiesLayout.width; + font.getData().setScale(scale); + emptyAbilitiesLayout.setText(font, text); + } + restoreFontScale(font, oldScale); + return scale; + } + + private void drawFittedLeft( + BitmapFont font, + SpriteBatch batch, + CharSequence text, + Rectangle rect, + float baseScale, + Color color) { + if (rect == null) return; + float oldScale = setFontScale(font, fittedScale(font, text, baseScale, rect.width)); + emptyAbilitiesLayout.setText(font, text); + font.setColor(color); + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + font.draw(batch, text, rect.x, y); + restoreFontScale(font, oldScale); + } + + private void drawFittedCenter( + BitmapFont font, + SpriteBatch batch, + CharSequence text, + Rectangle rect, + float baseScale, + Color color) { + if (rect == null) return; + float oldScale = setFontScale(font, fittedScale(font, text, baseScale, rect.width)); + emptyAbilitiesLayout.setText(font, text); + font.setColor(color); + float x = rect.x + (rect.width - emptyAbilitiesLayout.width) / 2f; + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + font.draw(batch, text, x, y); + restoreFontScale(font, oldScale); + } + + private void drawFittedLeftEmphasis( + BitmapFont font, + SpriteBatch batch, + CharSequence text, + Rectangle rect, + float baseScale, + Color color) { + if (rect == null) return; + float oldScale = setFontScale(font, fittedScale(font, text, baseScale, rect.width)); + emptyAbilitiesLayout.setText(font, text); + font.setColor(color); + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + font.draw(batch, text, rect.x, y); + font.draw(batch, text, rect.x + 0.45f * font.getData().scaleX, y); + restoreFontScale(font, oldScale); + } + + private void drawFittedCenterEmphasis( + BitmapFont font, + SpriteBatch batch, + CharSequence text, + Rectangle rect, + float baseScale, + Color color) { + if (rect == null) return; + float oldScale = setFontScale(font, fittedScale(font, text, baseScale, rect.width)); + emptyAbilitiesLayout.setText(font, text); + font.setColor(color); + float x = rect.x + (rect.width - emptyAbilitiesLayout.width) / 2f; + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + font.draw(batch, text, x, y); + font.draw(batch, text, x + 0.45f * font.getData().scaleX, y); + restoreFontScale(font, oldScale); + } + + private void drawFittedRight( + BitmapFont font, + SpriteBatch batch, + CharSequence text, + Rectangle rect, + float baseScale, + Color color) { + if (rect == null) return; + float oldScale = setFontScale(font, fittedScale(font, text, baseScale, rect.width)); + emptyAbilitiesLayout.setText(font, text); + font.setColor(color); + float x = rect.x + rect.width - emptyAbilitiesLayout.width; + float y = rect.y + (rect.height + emptyAbilitiesLayout.height) / 2f; + font.draw(batch, text, x, y); + restoreFontScale(font, oldScale); + } + + private void setCooldownText(StringBuilder builder, long cooldown) { + builder.setLength(0); + if (cooldown <= 0) return; + + long tenths = Math.round(cooldown / 100f); + builder.append('(').append(tenths / 10).append('.').append(tenths % 10).append(" с)"); + } + + private void setDockCooldownText(StringBuilder builder, long cooldown) { + builder.setLength(0); + if (cooldown <= 0) return; + + long tenths = Math.round(cooldown / 100f); + builder.append(tenths / 10).append('.').append(tenths % 10); + } + + private float waveIndicatorY(GameScreenHudViewModel view) { + float indicatorHeight = view.waveIndicatorHeight * view.uiScale; + return view.viewportHeight - view.waveIndicatorPadding * view.uiScale - indicatorHeight; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudViewModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudViewModel.java new file mode 100644 index 0000000..5a43201 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/hud/GameScreenHudViewModel.java @@ -0,0 +1,102 @@ +package ru.project.tower.screens.gamescreen.hud; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; + +final class GameScreenHudViewModel { + private static final AbilityKind[] ABILITY_KINDS = AbilityKind.values(); + + final HudButton[] attackButtons = createUpgradeButtons(10); + final HudButton[] healthButtons = createUpgradeButtons(10); + final HudButton[] bonusButtons = createUpgradeButtons(10); + final AbilityButton[] abilityButtons = new AbilityButton[ABILITY_KINDS.length]; + + int viewportWidth; + int viewportHeight; + float uiScale; + float time; + float pulseSpeed; + float waveIndicatorHeight; + float waveIndicatorPadding; + float departmentButtonsY; + boolean waveActive; + long enemiesPerWave; + long enemiesSpawnedThisWave; + int currentWave; + long nowMs; + long waveStartTime; + long timeBetweenWaves; + int squareHealth; + int maxSquareHealth; + int money; + int runCurrencyEarned; + int effectiveDamage; + float effectiveSpeed; + int healthRegenRate; + float effectiveCooldownMs; + float effectiveCritChance; + float effectiveCritMultiplier; + boolean showingAttackUpgrades; + boolean showingAbilities; + boolean shopExpanded; + float shopTransitionProgress; + Rectangle attackDepartmentButton; + Rectangle healthDepartmentButton; + Rectangle abilitiesDepartmentButton; + Rectangle shopToggleButton; + Rectangle topBar; + Rectangle waveBar; + Rectangle statStrip; + Rectangle shopSheet; + Rectangle shopHandle; + Rectangle abilityDock; + Rectangle dockLabel; + boolean hudHelpVisible; + boolean hasAnyAbilityButton; + + GameScreenHudViewModel() { + for (AbilityKind ability : ABILITY_KINDS) { + abilityButtons[ability.ordinal()] = new AbilityButton(ability); + } + } + + private static HudButton[] createUpgradeButtons(int count) { + HudButton[] buttons = new HudButton[count]; + for (int i = 0; i < buttons.length; i++) { + buttons[i] = new HudButton(); + } + return buttons; + } + + AbilityButton abilityButton(AbilityKind ability) { + return abilityButtons[ability.ordinal()]; + } + + static class HudButton { + Rectangle bounds; + Color color; + CharSequence label; + int price; + float statDelta; + CharSequence currentValue; + CharSequence nextValue; + CharSequence description; + CharSequence disabledReason; + ShopUpgradeKind kind; + String iconToken; + String colorToken; + boolean maxed; + } + + static final class AbilityButton extends HudButton { + final AbilityKind ability; + long cooldownMs; + + private AbilityButton(AbilityKind ability) { + this.ability = ability; + this.label = ability.hudLabel(); + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenGestureProcessorFactory.java b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenGestureProcessorFactory.java new file mode 100644 index 0000000..8a4d2b2 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenGestureProcessorFactory.java @@ -0,0 +1,13 @@ +package ru.project.tower.screens.gamescreen.input; + +import com.badlogic.gdx.InputProcessor; +import com.badlogic.gdx.input.GestureDetector; +import com.badlogic.gdx.input.GestureDetector.GestureAdapter; + +public interface GameScreenGestureProcessorFactory { + InputProcessor create(GestureAdapter listener); + + static GameScreenGestureProcessorFactory libGdx() { + return listener -> new GestureDetector(20, 0.5f, 2, 0.15f, listener); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputController.java b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputController.java new file mode 100644 index 0000000..51d808a --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputController.java @@ -0,0 +1,512 @@ +package ru.project.tower.screens.gamescreen.input; + +import com.badlogic.gdx.Input.Keys; +import com.badlogic.gdx.InputAdapter; +import com.badlogic.gdx.InputMultiplexer; +import com.badlogic.gdx.InputProcessor; +import com.badlogic.gdx.input.GestureDetector.GestureAdapter; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.entities.circles.BonusBallData; +import ru.project.tower.screens.gamescreen.actions.GameScreenInputAction; +import ru.project.tower.support.GdxInputProcessorInstaller; +import ru.project.tower.support.GdxViewportSize; +import ru.project.tower.support.InputProcessorInstaller; +import ru.project.tower.support.ViewportSize; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; + +public final class GameScreenInputController { + public interface Host { + GameScreenInputModel inputModel(); + + void perform(GameScreenInputAction action); + + void scrollShopUpgrades(float amount); + + void applyPick(int index); + + void rerollPick(); + + void buyShopUpgrade(ShopUpgradeKind kind); + + void collectBonusBall(int index); + } + + private final Host host; + private final ViewportSize viewportSize; + private final InputProcessorInstaller inputProcessorInstaller; + private final GameScreenGestureProcessorFactory gestureProcessorFactory; + private final Rectangle paddedHitBounds = new Rectangle(); + private static final float WHEEL_SCROLL_PIXELS = 42f; + private float pointerX = -1f; + private float pointerY = -1f; + private boolean pointerDown; + + public GameScreenInputController(Host host) { + this(host, new GdxViewportSize(), new GdxInputProcessorInstaller()); + } + + public GameScreenInputController( + Host host, ViewportSize viewportSize, InputProcessorInstaller inputProcessorInstaller) { + this( + host, + viewportSize, + inputProcessorInstaller, + GameScreenGestureProcessorFactory.libGdx()); + } + + public GameScreenInputController( + Host host, + ViewportSize viewportSize, + InputProcessorInstaller inputProcessorInstaller, + GameScreenGestureProcessorFactory gestureProcessorFactory) { + this.host = host; + this.viewportSize = viewportSize; + this.inputProcessorInstaller = inputProcessorInstaller; + this.gestureProcessorFactory = gestureProcessorFactory; + } + + public void install() { + InputProcessor gestureProcessor = createGestureProcessor(); + InputAdapter pointerTracker = createPointerTracker(); + installInputProcessors(pointerTracker, gestureProcessor); + } + + public void uninstall() { + inputProcessorInstaller.setInputProcessor(null); + pointerDown = false; + pointerX = -1f; + pointerY = -1f; + } + + private InputProcessor createGestureProcessor() { + return gestureProcessorFactory.create(createGestureListener()); + } + + private GestureAdapter createGestureListener() { + return new GestureAdapter() { + @Override + public boolean tap(float x, float y, int count, int button) { + return handleGestureTap(x, y); + } + + @Override + public boolean pan(float x, float y, float deltaX, float deltaY) { + return handleGesturePan(y, deltaY); + } + }; + } + + private boolean handleGestureTap(float x, float y) { + return handleTouch(x, viewportSize.getHeight() - y); + } + + private boolean handleGesturePan(float y, float deltaY) { + GameScreenInputModel model = host.inputModel(); + float touchY = viewportSize.getHeight() - y; + if (isShopScrollTouch(model, touchY) && deltaY != 0f) { + host.scrollShopUpgrades(-deltaY); + return true; + } + return false; + } + + private boolean handleWheelScroll(float amountY) { + GameScreenInputModel model = host.inputModel(); + if (isShopScrollTouch(model, pointerY) && amountY != 0f) { + host.scrollShopUpgrades(amountY * WHEEL_SCROLL_PIXELS * model.uiScale); + return true; + } + return false; + } + + private boolean isShopScrollTouch(GameScreenInputModel model, float touchY) { + return model.shopExpanded + && touchY >= 0f + && touchY < model.departmentButtonsY + 50f * model.uiScale; + } + + private InputAdapter createPointerTracker() { + return new InputAdapter() { + @Override + public boolean mouseMoved(int screenX, int screenY) { + updatePointer(screenX, screenY); + return false; + } + + @Override + public boolean keyDown(int keycode) { + return handleKeyDown(keycode); + } + + @Override + public boolean touchDown(int screenX, int screenY, int pointer, int button) { + updatePointer(screenX, screenY); + pointerDown = true; + return false; + } + + @Override + public boolean touchDragged(int screenX, int screenY, int pointer) { + updatePointer(screenX, screenY); + return false; + } + + @Override + public boolean touchUp(int screenX, int screenY, int pointer, int button) { + updatePointer(screenX, screenY); + pointerDown = false; + return false; + } + + @Override + public boolean scrolled(float amountX, float amountY) { + return handleWheelScroll(amountY); + } + }; + } + + private void installInputProcessors( + InputAdapter pointerTracker, InputProcessor gestureProcessor) { + InputMultiplexer multiplexer = new InputMultiplexer(); + multiplexer.addProcessor(pointerTracker); + multiplexer.addProcessor(gestureProcessor); + inputProcessorInstaller.setInputProcessor(multiplexer); + } + + public boolean isPointerOver(Rectangle button) { + return button != null && button.contains(pointerX, pointerY); + } + + public boolean isPointerPressed(Rectangle button) { + return pointerDown && isPointerOver(button); + } + + private void updatePointer(int screenX, int screenY) { + pointerX = screenX; + pointerY = viewportSize.getHeight() - screenY; + } + + void showAttackUpgrades() { + host.perform(GameScreenInputAction.SHOW_ATTACK_UPGRADES); + } + + void showHealthUpgrades() { + host.perform(GameScreenInputAction.SHOW_HEALTH_UPGRADES); + } + + void showAbilityButtons() { + host.perform(GameScreenInputAction.SHOW_ABILITY_BUTTONS); + } + + public boolean handleTouch(float touchX, float touchY) { + GameScreenInputModel model = host.inputModel(); + if (model.paused) { + handlePauseMenuInput(model, touchX, touchY); + return true; + } + if (handleUpgradePickTouch(model, touchX, touchY)) return true; + if (model.gameOver) { + handleRestartButton(model, touchX, touchY); + return true; + } + if (handleUpgradeButtons(model, touchX, touchY)) return true; + if (handleGameMenuButton(model, touchX, touchY)) return true; + return handleBonusBallClicks(model, touchX, touchY); + } + + public boolean handleKeyDown(int keycode) { + GameScreenInputModel model = host.inputModel(); + if (!model.upgradePickActive) return false; + if (!model.upgradePickInteractive) return true; + int pickIndex = upgradePickIndexForKey(keycode); + if (pickIndex >= 0) { + host.applyPick(pickIndex); + return true; + } + if (keycode == Keys.R) { + host.rerollPick(); + return true; + } + return false; + } + + private int upgradePickIndexForKey(int keycode) { + if (keycode == Keys.NUM_1 || keycode == Keys.NUMPAD_1) return 0; + if (keycode == Keys.NUM_2 || keycode == Keys.NUMPAD_2) return 1; + if (keycode == Keys.NUM_3 || keycode == Keys.NUMPAD_3) return 2; + return -1; + } + + private boolean handleUpgradePickTouch(GameScreenInputModel model, float touchX, float touchY) { + if (!model.upgradePickActive) return false; + if (!model.upgradePickInteractive) return true; + Rectangle rerollButton = model.upgradePickRerollButton(); + if (rerollButton != null && rerollButton.contains(touchX, touchY)) { + host.rerollPick(); + return true; + } + for (int i = 0; i < 3; i++) { + Rectangle button = model.upgradePickButton(i); + if (button != null && button.contains(touchX, touchY)) { + host.applyPick(i); + return true; + } + } + return true; + } + + private boolean handleUpgradeButtons(GameScreenInputModel model, float touchX, float touchY) { + float touchPadding = model.touchPadding; + + if (checkButtonClick( + model.shopToggleButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.TOGGLE_SHOP)) return true; + if (checkButtonClick( + model.statStrip, touchX, touchY, 0f, GameScreenInputAction.TOGGLE_HUD_HELP)) + return true; + if (handleAbilityActivationButtons(model, touchX, touchY, touchPadding)) return true; + if (!model.shopExpanded) return false; + if (handleDepartmentButtons(model, touchX, touchY, touchPadding)) return true; + if (handleScrollButtons(model, touchX, touchY)) return true; + if (handleDynamicShopUpgradeButtons(model, touchX, touchY, touchPadding)) return true; + + if (model.showingAttackUpgrades) { + return handleAttackUpgradeButtons(model, touchX, touchY, touchPadding); + } + if (!model.showingAbilities) { + return handleHealthUpgradeButtons(model, touchX, touchY, touchPadding); + } + return checkButtonClick( + model.critMultiplierUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_CRIT_MULTIPLIER); + } + + private boolean handleDepartmentButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + if (paddedContains(model.attackDepartmentButton, touchX, touchY, touchPadding)) { + showAttackUpgrades(); + return true; + } + + if (paddedContains(model.healthDepartmentButton, touchX, touchY, touchPadding)) { + showHealthUpgrades(); + return true; + } + + if (paddedContains(model.abilitiesDepartmentButton, touchX, touchY, touchPadding)) { + showAbilityButtons(); + return true; + } + return false; + } + + private boolean handleScrollButtons(GameScreenInputModel model, float touchX, float touchY) { + if (model.scrollUpButton != null && model.scrollUpButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.SCROLL_UP); + return true; + } + + if (model.scrollDownButton != null && model.scrollDownButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.SCROLL_DOWN); + return true; + } + return false; + } + + private boolean handleDynamicShopUpgradeButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + for (int i = 0; i < model.shopUpgradeButtons.length; i++) { + Rectangle button = model.shopUpgradeButtons[i]; + ShopUpgradeKind kind = model.shopUpgradeKinds[i]; + if (kind != null && paddedContains(button, touchX, touchY, touchPadding)) { + host.buyShopUpgrade(kind); + return true; + } + } + return false; + } + + private boolean handleAttackUpgradeButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + if (checkButtonClick( + model.damageUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_DAMAGE)) return true; + if (checkButtonClick( + model.speedUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_SPEED)) return true; + if (checkButtonClick( + model.cooldownUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_COOLDOWN)) return true; + if (checkButtonClick( + model.critChanceUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_CRIT_CHANCE)) return true; + if (checkButtonClick( + model.critMultiplierUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_CRIT_MULTIPLIER)) return true; + return false; + } + + private boolean handleHealthUpgradeButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + if (checkButtonClick( + model.healthUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_HEALTH)) return true; + if (checkButtonClick( + model.regenUpgradeButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.UPGRADE_REGEN)) return true; + return false; + } + + private boolean handleAbilityActivationButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + if (handleCombatAbilityButtons(model, touchX, touchY, touchPadding)) return true; + return handleMobilityAbilityButtons(model, touchX, touchY, touchPadding); + } + + private boolean handleCombatAbilityButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + if (checkButtonClick( + model.freezeAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_FREEZE)) return true; + if (checkButtonClick( + model.explosionAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_EXPLOSION)) return true; + if (checkButtonClick( + model.shieldAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_SHIELD)) return true; + if (checkButtonClick( + model.impulseAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_IMPULSE)) return true; + return false; + } + + private boolean handleMobilityAbilityButtons( + GameScreenInputModel model, float touchX, float touchY, float touchPadding) { + if (checkButtonClick( + model.dashAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_DASH)) return true; + if (checkButtonClick( + model.pullAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_PULL)) return true; + if (checkButtonClick( + model.turretAbilityButton, + touchX, + touchY, + touchPadding, + GameScreenInputAction.ACTIVATE_TURRET)) return true; + return false; + } + + private boolean handleGameMenuButton(GameScreenInputModel model, float touchX, float touchY) { + Rectangle gameMenuButton = model.gameMenuButton; + if (gameMenuButton != null && gameMenuButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.REQUEST_PAUSE); + return true; + } + return false; + } + + private boolean handleRestartButton(GameScreenInputModel model, float touchX, float touchY) { + if (model.restartButton != null && model.restartButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.RESTART_GAME); + return true; + } + + if (model.menuButton != null && model.menuButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.RETURN_TO_MAIN_MENU); + return true; + } + return false; + } + + private boolean handlePauseMenuInput(GameScreenInputModel model, float touchX, float touchY) { + if (model.resumeButton != null && model.resumeButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.RESUME_GAME_FROM_PAUSE); + return true; + } else if (model.exitButton != null && model.exitButton.contains(touchX, touchY)) { + host.perform(GameScreenInputAction.EXIT_PAUSE_TO_MENU); + return true; + } + return false; + } + + private boolean handleBonusBallClicks(GameScreenInputModel model, float touchX, float touchY) { + for (int i = model.bonusBalls.size - 1; i >= 0; i--) { + BonusBallData ball = model.bonusBalls.get(i); + if (ball.circle.contains(touchX, touchY)) { + host.collectBonusBall(i); + return true; + } + } + return false; + } + + boolean checkButtonClick( + Rectangle button, + float touchX, + float touchY, + float touchPadding, + GameScreenInputAction action) { + if (paddedContains(button, touchX, touchY, touchPadding)) { + host.perform(action); + return true; + } + return false; + } + + private boolean paddedContains(Rectangle button, float x, float y, float padding) { + return button != null + && paddedHitBounds + .set( + button.x - padding, + button.y - padding, + button.width + padding * 2, + button.height + padding * 2) + .contains(x, y); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputHostAdapter.java new file mode 100644 index 0000000..916fdfa --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputHostAdapter.java @@ -0,0 +1,64 @@ +package ru.project.tower.screens.gamescreen.input; + +import java.util.Objects; +import ru.project.tower.screens.gamescreen.actions.GameScreenInputAction; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; + +public final class GameScreenInputHostAdapter implements GameScreenInputController.Host { + private final Port port; + + public GameScreenInputHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public GameScreenInputModel inputModel() { + return port.inputModel(); + } + + @Override + public void perform(GameScreenInputAction action) { + port.performInputAction(action); + } + + @Override + public void scrollShopUpgrades(float amount) { + port.scrollShopUpgrades(amount); + } + + @Override + public void applyPick(int index) { + port.applyPick(index); + } + + @Override + public void rerollPick() { + port.rerollPick(); + } + + @Override + public void buyShopUpgrade(ShopUpgradeKind kind) { + port.buyShopUpgrade(kind); + } + + @Override + public void collectBonusBall(int index) { + port.collectBonusBall(index); + } + + public interface Port { + GameScreenInputModel inputModel(); + + void performInputAction(GameScreenInputAction action); + + void scrollShopUpgrades(float amount); + + void applyPick(int index); + + void rerollPick(); + + void buyShopUpgrade(ShopUpgradeKind kind); + + void collectBonusBall(int index); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputModel.java new file mode 100644 index 0000000..beb3137 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/input/GameScreenInputModel.java @@ -0,0 +1,420 @@ +package ru.project.tower.screens.gamescreen.input; + +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.entities.circles.BonusBallData; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; + +public final class GameScreenInputModel { + final Rectangle[] upgradePickButtons = new Rectangle[3]; + Rectangle upgradePickRerollButton; + + boolean upgradePickActive; + boolean upgradePickInteractive; + boolean paused; + boolean showingAttackUpgrades; + boolean showingAbilities; + boolean shopExpanded; + boolean gameOver; + int purchasedAbilityCount; + float touchPadding; + float departmentButtonsY; + float uiScale; + Rectangle attackDepartmentButton; + Rectangle healthDepartmentButton; + Rectangle abilitiesDepartmentButton; + Rectangle scrollUpButton; + Rectangle scrollDownButton; + Rectangle shopToggleButton; + Rectangle statStrip; + Rectangle damageUpgradeButton; + Rectangle speedUpgradeButton; + Rectangle cooldownUpgradeButton; + Rectangle critChanceUpgradeButton; + Rectangle critMultiplierUpgradeButton; + Rectangle healthUpgradeButton; + Rectangle regenUpgradeButton; + final Rectangle[] shopUpgradeButtons = new Rectangle[10]; + final ShopUpgradeKind[] shopUpgradeKinds = new ShopUpgradeKind[10]; + Rectangle freezeAbilityButton; + Rectangle explosionAbilityButton; + Rectangle shieldAbilityButton; + Rectangle impulseAbilityButton; + Rectangle dashAbilityButton; + Rectangle pullAbilityButton; + Rectangle turretAbilityButton; + Rectangle gameMenuButton; + Rectangle restartButton; + Rectangle menuButton; + Rectangle resumeButton; + Rectangle exitButton; + Array bonusBalls; + + public GameScreenInputModel() {} + + public void fillScreenFields(ScreenFields fields) { + this.upgradePickActive = fields.upgradePickActive(); + this.upgradePickInteractive = fields.upgradePickInteractive(); + this.paused = fields.paused(); + for (int i = 0; i < upgradePickButtons.length; i++) { + upgradePickButtons[i] = fields.upgradeButton(i); + } + this.upgradePickRerollButton = fields.upgradeRerollButton(); + this.gameOver = fields.gameOver(); + this.uiScale = fields.uiScale(); + this.gameMenuButton = fields.gameMenuButton(); + this.restartButton = fields.restartButton(); + this.menuButton = fields.menuButton(); + this.resumeButton = fields.resumeButton(); + this.exitButton = fields.exitButton(); + this.bonusBalls = fields.bonusBalls(); + } + + public void fillShopFields(ShopFields fields) { + this.showingAttackUpgrades = fields.showingAttackUpgrades(); + this.showingAbilities = fields.showingAbilities(); + this.shopExpanded = fields.shopExpanded(); + this.purchasedAbilityCount = fields.purchasedAbilityCount(); + this.touchPadding = fields.touchPadding(); + this.departmentButtonsY = fields.departmentButtonsY(); + this.attackDepartmentButton = fields.attackDepartmentButton(); + this.healthDepartmentButton = fields.healthDepartmentButton(); + this.abilitiesDepartmentButton = fields.abilitiesDepartmentButton(); + this.scrollUpButton = fields.scrollUpButton(); + this.scrollDownButton = fields.scrollDownButton(); + this.shopToggleButton = fields.shopToggleButton(); + this.statStrip = fields.statStrip(); + this.damageUpgradeButton = fields.damageUpgradeButton(); + this.speedUpgradeButton = fields.speedUpgradeButton(); + this.cooldownUpgradeButton = fields.cooldownUpgradeButton(); + this.critChanceUpgradeButton = fields.critChanceUpgradeButton(); + this.critMultiplierUpgradeButton = fields.critMultiplierUpgradeButton(); + this.healthUpgradeButton = fields.healthUpgradeButton(); + this.regenUpgradeButton = fields.regenUpgradeButton(); + for (int i = 0; i < shopUpgradeButtons.length; i++) { + this.shopUpgradeButtons[i] = fields.shopUpgradeButton(i); + this.shopUpgradeKinds[i] = fields.shopUpgradeKind(i); + } + this.freezeAbilityButton = fields.freezeAbilityButton(); + this.explosionAbilityButton = fields.explosionAbilityButton(); + this.shieldAbilityButton = fields.shieldAbilityButton(); + this.impulseAbilityButton = fields.impulseAbilityButton(); + this.dashAbilityButton = fields.dashAbilityButton(); + this.pullAbilityButton = fields.pullAbilityButton(); + this.turretAbilityButton = fields.turretAbilityButton(); + } + + Rectangle upgradePickButton(int index) { + return upgradePickButtons[index]; + } + + Rectangle upgradePickRerollButton() { + return upgradePickRerollButton; + } + + public static final class ScreenFields { + private final boolean upgradePickActive; + private final boolean upgradePickInteractive; + private final boolean paused; + private final Rectangle[] upgradeButtons; + private final Rectangle upgradeRerollButton; + private final boolean gameOver; + private final float uiScale; + private final Rectangle gameMenuButton; + private final Rectangle restartButton; + private final Rectangle menuButton; + private final Rectangle resumeButton; + private final Rectangle exitButton; + private final Array bonusBalls; + + public ScreenFields( + boolean upgradePickActive, + boolean upgradePickInteractive, + boolean paused, + Rectangle[] upgradeButtons, + Rectangle upgradeRerollButton, + boolean gameOver, + float uiScale, + Rectangle gameMenuButton, + Rectangle restartButton, + Rectangle menuButton, + Rectangle resumeButton, + Rectangle exitButton, + Array bonusBalls) { + this.upgradePickActive = upgradePickActive; + this.upgradePickInteractive = upgradePickInteractive; + this.paused = paused; + this.upgradeButtons = upgradeButtons; + this.upgradeRerollButton = upgradeRerollButton; + this.gameOver = gameOver; + this.uiScale = uiScale; + this.gameMenuButton = gameMenuButton; + this.restartButton = restartButton; + this.menuButton = menuButton; + this.resumeButton = resumeButton; + this.exitButton = exitButton; + this.bonusBalls = bonusBalls; + } + + public boolean upgradePickActive() { + return upgradePickActive; + } + + public boolean upgradePickInteractive() { + return upgradePickInteractive; + } + + public boolean paused() { + return paused; + } + + public Rectangle upgradeButton(int index) { + return upgradeButtons[index]; + } + + public Rectangle upgradeRerollButton() { + return upgradeRerollButton; + } + + public boolean gameOver() { + return gameOver; + } + + public float uiScale() { + return uiScale; + } + + public Rectangle gameMenuButton() { + return gameMenuButton; + } + + public Rectangle restartButton() { + return restartButton; + } + + public Rectangle menuButton() { + return menuButton; + } + + public Rectangle resumeButton() { + return resumeButton; + } + + public Rectangle exitButton() { + return exitButton; + } + + public Array bonusBalls() { + return bonusBalls; + } + } + + public static final class ShopFields { + private final boolean showingAttackUpgrades; + private final boolean showingAbilities; + private final boolean shopExpanded; + private final int purchasedAbilityCount; + private final float touchPadding; + private final float departmentButtonsY; + private final Rectangle attackDepartmentButton; + private final Rectangle healthDepartmentButton; + private final Rectangle abilitiesDepartmentButton; + private final Rectangle scrollUpButton; + private final Rectangle scrollDownButton; + private final Rectangle shopToggleButton; + private final Rectangle statStrip; + private final Rectangle damageUpgradeButton; + private final Rectangle speedUpgradeButton; + private final Rectangle cooldownUpgradeButton; + private final Rectangle critChanceUpgradeButton; + private final Rectangle critMultiplierUpgradeButton; + private final Rectangle healthUpgradeButton; + private final Rectangle regenUpgradeButton; + private final Rectangle[] shopUpgradeButtons; + private final ShopUpgradeKind[] shopUpgradeKinds; + private final Rectangle freezeAbilityButton; + private final Rectangle explosionAbilityButton; + private final Rectangle shieldAbilityButton; + private final Rectangle impulseAbilityButton; + private final Rectangle dashAbilityButton; + private final Rectangle pullAbilityButton; + private final Rectangle turretAbilityButton; + + public ShopFields( + boolean showingAttackUpgrades, + boolean showingAbilities, + boolean shopExpanded, + int purchasedAbilityCount, + float touchPadding, + float departmentButtonsY, + Rectangle attackDepartmentButton, + Rectangle healthDepartmentButton, + Rectangle abilitiesDepartmentButton, + Rectangle scrollUpButton, + Rectangle scrollDownButton, + Rectangle shopToggleButton, + Rectangle statStrip, + Rectangle damageUpgradeButton, + Rectangle speedUpgradeButton, + Rectangle cooldownUpgradeButton, + Rectangle critChanceUpgradeButton, + Rectangle critMultiplierUpgradeButton, + Rectangle healthUpgradeButton, + Rectangle regenUpgradeButton, + Rectangle[] shopUpgradeButtons, + ShopUpgradeKind[] shopUpgradeKinds, + Rectangle freezeAbilityButton, + Rectangle explosionAbilityButton, + Rectangle shieldAbilityButton, + Rectangle impulseAbilityButton, + Rectangle dashAbilityButton, + Rectangle pullAbilityButton, + Rectangle turretAbilityButton) { + this.showingAttackUpgrades = showingAttackUpgrades; + this.showingAbilities = showingAbilities; + this.shopExpanded = shopExpanded; + this.purchasedAbilityCount = purchasedAbilityCount; + this.touchPadding = touchPadding; + this.departmentButtonsY = departmentButtonsY; + this.attackDepartmentButton = attackDepartmentButton; + this.healthDepartmentButton = healthDepartmentButton; + this.abilitiesDepartmentButton = abilitiesDepartmentButton; + this.scrollUpButton = scrollUpButton; + this.scrollDownButton = scrollDownButton; + this.shopToggleButton = shopToggleButton; + this.statStrip = statStrip; + this.damageUpgradeButton = damageUpgradeButton; + this.speedUpgradeButton = speedUpgradeButton; + this.cooldownUpgradeButton = cooldownUpgradeButton; + this.critChanceUpgradeButton = critChanceUpgradeButton; + this.critMultiplierUpgradeButton = critMultiplierUpgradeButton; + this.healthUpgradeButton = healthUpgradeButton; + this.regenUpgradeButton = regenUpgradeButton; + this.shopUpgradeButtons = shopUpgradeButtons; + this.shopUpgradeKinds = shopUpgradeKinds; + this.freezeAbilityButton = freezeAbilityButton; + this.explosionAbilityButton = explosionAbilityButton; + this.shieldAbilityButton = shieldAbilityButton; + this.impulseAbilityButton = impulseAbilityButton; + this.dashAbilityButton = dashAbilityButton; + this.pullAbilityButton = pullAbilityButton; + this.turretAbilityButton = turretAbilityButton; + } + + public boolean showingAttackUpgrades() { + return showingAttackUpgrades; + } + + public boolean showingAbilities() { + return showingAbilities; + } + + public boolean shopExpanded() { + return shopExpanded; + } + + public int purchasedAbilityCount() { + return purchasedAbilityCount; + } + + public float touchPadding() { + return touchPadding; + } + + public float departmentButtonsY() { + return departmentButtonsY; + } + + public Rectangle attackDepartmentButton() { + return attackDepartmentButton; + } + + public Rectangle healthDepartmentButton() { + return healthDepartmentButton; + } + + public Rectangle abilitiesDepartmentButton() { + return abilitiesDepartmentButton; + } + + public Rectangle scrollUpButton() { + return scrollUpButton; + } + + public Rectangle scrollDownButton() { + return scrollDownButton; + } + + public Rectangle shopToggleButton() { + return shopToggleButton; + } + + public Rectangle statStrip() { + return statStrip; + } + + public Rectangle damageUpgradeButton() { + return damageUpgradeButton; + } + + public Rectangle speedUpgradeButton() { + return speedUpgradeButton; + } + + public Rectangle cooldownUpgradeButton() { + return cooldownUpgradeButton; + } + + public Rectangle critChanceUpgradeButton() { + return critChanceUpgradeButton; + } + + public Rectangle critMultiplierUpgradeButton() { + return critMultiplierUpgradeButton; + } + + public Rectangle healthUpgradeButton() { + return healthUpgradeButton; + } + + public Rectangle regenUpgradeButton() { + return regenUpgradeButton; + } + + public Rectangle shopUpgradeButton(int index) { + return shopUpgradeButtons[index]; + } + + public ShopUpgradeKind shopUpgradeKind(int index) { + return shopUpgradeKinds[index]; + } + + public Rectangle freezeAbilityButton() { + return freezeAbilityButton; + } + + public Rectangle explosionAbilityButton() { + return explosionAbilityButton; + } + + public Rectangle shieldAbilityButton() { + return shieldAbilityButton; + } + + public Rectangle impulseAbilityButton() { + return impulseAbilityButton; + } + + public Rectangle dashAbilityButton() { + return dashAbilityButton; + } + + public Rectangle pullAbilityButton() { + return pullAbilityButton; + } + + public Rectangle turretAbilityButton() { + return turretAbilityButton; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/layout/GameScreenLayout.java b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/GameScreenLayout.java new file mode 100644 index 0000000..41437bc --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/GameScreenLayout.java @@ -0,0 +1,1069 @@ +package ru.project.tower.screens.gamescreen.layout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import ru.project.tower.screens.gamescreen.visual.GameScreenVisualTokens; + +public final class GameScreenLayout { + public static final float MIN_TAPPABLE_TARGET = 48f; + + private static final float BASE_WIDTH = 1280f; + private static final float BASE_HEIGHT = 720f; + private static final float MIN_PADDING = 8f; + private static final float MAX_BUTTON_WIDTH = 150f; + private static final float REFERENCE_WIDTH = GameScreenVisualTokens.REFERENCE_WIDTH; + private static final float REFERENCE_HEIGHT = GameScreenVisualTokens.REFERENCE_HEIGHT; + private static final float MARGIN = GameScreenVisualTokens.Layout.MARGIN; + private static final float TOP_MARGIN = GameScreenVisualTokens.Layout.TOP_MARGIN; + private static final float MENU_RIGHT = GameScreenVisualTokens.Layout.MENU_RIGHT; + private static final float TOPBAR_HEIGHT = GameScreenVisualTokens.Hud.TOPBAR_HEIGHT; + private static final float WAVEBAR_HEIGHT = GameScreenVisualTokens.Hud.WAVEBAR_HEIGHT; + private static final float STAT_STRIP_HEIGHT = + GameScreenVisualTokens.Hud.STAT_STRIP_HEIGHT; + private static final float MENU_SIZE = GameScreenVisualTokens.Menu.SIZE; + private static final float DOCK_HEIGHT = GameScreenVisualTokens.Dock.HEIGHT; + private static final float DOCK_BUTTON = GameScreenVisualTokens.Dock.BUTTON_SIZE; + private static final float SHOP_HEIGHT = GameScreenVisualTokens.Shop.SHEET_HEIGHT; + private static final float SHOP_BOTTOM = GameScreenVisualTokens.Layout.SHOP_BOTTOM; + private static final float SHOP_PADDING_X = GameScreenVisualTokens.Layout.SHOP_PADDING_X; + private static final float HANDLE_WIDTH = GameScreenVisualTokens.Layout.HANDLE_WIDTH; + private static final float HANDLE_HEIGHT = GameScreenVisualTokens.Layout.HANDLE_HEIGHT; + private static final float TAB_HEIGHT = GameScreenVisualTokens.Shop.TAB_HEIGHT; + private static final float CARD_HEIGHT = GameScreenVisualTokens.Shop.CARD_HEIGHT; + private static final float CARD_GAP = GameScreenVisualTokens.Layout.CARD_GAP; + private static final int DOCK_ABILITY_COUNT = 3; + private static final int SHOP_CATEGORY_BUTTON_COUNT = 10; + private static final int SHOP_MIN_COLUMN_COUNT = 2; + private static final int SHOP_MAX_COLUMN_COUNT = 5; + private static final float SHOP_MAX_CARD_WIDTH = 420f; + + public enum Panel { + ATTACK, + HEALTH, + ABILITIES + } + + private final int viewportWidth; + private final int viewportHeight; + private final float uiScale; + private final float buttonPadding; + private final float buttonWidth; + private final float minButtonWidth; + private final float buttonHeight; + private final float rowAdvance; + private final float departmentButtonsY; + private final float visibleAreaHeight; + private final float minScrollOffset; + private final float maxScrollOffset; + private final float touchPadding; + private final Rect scissorBounds; + private final Rect attackDepartmentButton; + private final Rect healthDepartmentButton; + private final Rect abilitiesDepartmentButton; + private final Rect[] attackButtons; + private final Rect[] healthButtons; + private final Rect[] abilityButtons; + private final List contentButtons; + private final Rect scrollUpButton; + private final Rect scrollDownButton; + private final Rect topBar; + private final Rect waveBar; + private final Rect statStrip; + private final Rect menuButton; + private final Rect shopSheet; + private final Rect shopHandle; + private final Rect abilityDock; + private final Rect shopToggleButton; + private final Rect dockLabel; + private final Rect[] dockAbilityButtons; + + private GameScreenLayout( + int viewportWidth, + int viewportHeight, + float uiScale, + float buttonPadding, + float buttonWidth, + float minButtonWidth, + float buttonHeight, + float rowAdvance, + float departmentButtonsY, + float minScrollOffset, + float maxScrollOffset, + float touchPadding, + Rect scissorBounds, + Rect attackDepartmentButton, + Rect healthDepartmentButton, + Rect abilitiesDepartmentButton, + Rect[] attackButtons, + Rect[] healthButtons, + Rect[] abilityButtons, + List contentButtons, + Rect scrollUpButton, + Rect scrollDownButton, + Chrome chrome) { + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.uiScale = uiScale; + this.buttonPadding = buttonPadding; + this.buttonWidth = buttonWidth; + this.minButtonWidth = minButtonWidth; + this.buttonHeight = buttonHeight; + this.rowAdvance = rowAdvance; + this.departmentButtonsY = departmentButtonsY; + this.visibleAreaHeight = departmentButtonsY; + this.minScrollOffset = minScrollOffset; + this.maxScrollOffset = maxScrollOffset; + this.touchPadding = touchPadding; + this.scissorBounds = scissorBounds; + this.attackDepartmentButton = attackDepartmentButton; + this.healthDepartmentButton = healthDepartmentButton; + this.abilitiesDepartmentButton = abilitiesDepartmentButton; + this.attackButtons = attackButtons; + this.healthButtons = healthButtons; + this.abilityButtons = abilityButtons; + this.contentButtons = contentButtons; + this.scrollUpButton = scrollUpButton; + this.scrollDownButton = scrollDownButton; + this.topBar = chrome.topBar; + this.waveBar = chrome.waveBar; + this.statStrip = chrome.statStrip; + this.menuButton = chrome.menuButton; + this.shopSheet = chrome.shopSheet; + this.shopHandle = chrome.shopHandle; + this.abilityDock = chrome.abilityDock; + this.shopToggleButton = chrome.shopToggleButton; + this.dockLabel = chrome.dockLabel; + this.dockAbilityButtons = chrome.dockAbilityButtons; + } + + public static float uiScaleFor(int viewportWidth, int viewportHeight) { + float scaleX = viewportWidth / BASE_WIDTH; + float scaleY = viewportHeight / BASE_HEIGHT; + boolean portrait = viewportHeight > viewportWidth; + if (portrait) { + return clamp(scaleY * 1.2f, 1.0f, 2.5f); + } + return clamp(Math.min(scaleX, scaleY) * 1.2f, 0.8f, 1.8f); + } + + public static GameScreenLayout calculate( + int viewportWidth, + int viewportHeight, + float uiScale, + float requestedScrollOffset, + Panel panel, + int abilityCount) { + return calculate( + viewportWidth, + viewportHeight, + uiScale, + requestedScrollOffset, + panel, + abilityCount, + SafeAreaInsets.zero()); + } + + public static GameScreenLayout calculate( + int viewportWidth, + int viewportHeight, + float uiScale, + float requestedScrollOffset, + Panel panel, + int abilityCount, + SafeAreaInsets safeArea) { + validateCalculateInputs(viewportWidth, viewportHeight, panel, safeArea); + int safeWidth = viewportWidth - safeArea.left() - safeArea.right(); + int safeHeight = viewportHeight - safeArea.top() - safeArea.bottom(); + + Chrome chrome = createChrome(safeArea, safeWidth, safeHeight, uiScale); + LayoutMetrics metrics = + calculateMetrics( + safeWidth, + safeHeight, + uiScale, + requestedScrollOffset, + panel, + abilityCount, + chrome); + DepartmentButtons departmentButtons = + createDepartmentButtons( + safeArea.left(), + safeArea.bottom(), + safeWidth, + metrics.departmentButtonsY, + metrics); + PanelButtons panelButtons = + createPanelButtons( + safeArea.left(), + safeArea.bottom(), + safeWidth, + panel, + abilityCount, + metrics); + ScrollButtons scrollButtons = + createScrollButtons(safeArea.left(), safeArea.bottom(), safeWidth, metrics); + return createLayout( + viewportWidth, + viewportHeight, + uiScale, + safeArea, + safeWidth, + metrics, + departmentButtons, + panelButtons, + scrollButtons, + chrome); + } + + private static GameScreenLayout createLayout( + int viewportWidth, + int viewportHeight, + float uiScale, + SafeAreaInsets safeArea, + int safeWidth, + LayoutMetrics metrics, + DepartmentButtons departmentButtons, + PanelButtons panelButtons, + ScrollButtons scrollButtons, + Chrome chrome) { + return new GameScreenLayout( + viewportWidth, + viewportHeight, + uiScale, + metrics.padding, + metrics.buttonWidth, + metrics.minButtonWidth, + metrics.buttonHeight, + metrics.rowAdvance, + metrics.departmentButtonsY, + 0f, + metrics.maxScrollOffset, + metrics.touchPadding, + new Rect(safeArea.left(), safeArea.bottom(), safeWidth, metrics.departmentButtonsY), + departmentButtons.attackDepartment, + departmentButtons.healthDepartment, + departmentButtons.abilitiesDepartment, + panelButtons.attackButtons, + panelButtons.healthButtons, + panelButtons.abilityButtons, + panelButtons.contentButtons, + scrollButtons.scrollUp, + scrollButtons.scrollDown, + chrome); + } + + public static Rect[] upgradePickCards(int viewportWidth, int viewportHeight, float uiScale) { + validateViewport(viewportWidth, viewportHeight); + + float cardWidth = Math.min(360f * uiScale, viewportWidth * 0.72f); + float rerollHeight = rerollButtonHeight(uiScale); + float gap = Math.max(10f * uiScale, viewportHeight * 0.018f); + float cardHeight = + Math.min(150f * uiScale, (viewportHeight - rerollHeight - gap * 5f) / 3f); + cardHeight = Math.max(72f * uiScale, cardHeight); + float totalHeight = cardHeight * 3f + gap * 2f + rerollHeight + gap * 1.25f; + float titleReserve = 34f * uiScale; + float bottomY = Math.max(20f * uiScale, (viewportHeight - totalHeight) / 2f - titleReserve); + float startX = (viewportWidth - cardWidth) / 2f; + float firstCardY = bottomY + rerollHeight + gap * 1.25f + (cardHeight + gap) * 2f; + + Rect[] cards = new Rect[3]; + for (int i = 0; i < cards.length; i++) { + cards[i] = new Rect(startX, firstCardY - i * (cardHeight + gap), cardWidth, cardHeight); + } + return cards; + } + + public static Rect upgradePickRerollButton( + int viewportWidth, int viewportHeight, float uiScale) { + validateViewport(viewportWidth, viewportHeight); + + Rect[] cards = upgradePickCards(viewportWidth, viewportHeight, uiScale); + float height = rerollButtonHeight(uiScale); + float width = Math.min(cards[0].width(), 240f * uiScale); + float x = cards[0].x() + (cards[0].width() - width) / 2f; + float y = + Math.max( + 20f * uiScale, + cards[2].y() - height - Math.max(10f * uiScale, viewportHeight * 0.018f)); + return new Rect(x, y, width, height); + } + + public static float upgradePickRevealDelay(int cardIndex) { + return Math.max(0, cardIndex) * 0.08f; + } + + private static float rerollButtonHeight(float uiScale) { + return Math.max(42f * uiScale, 48f * uiScale); + } + + public static GameOverButtons gameOverButtons( + int viewportWidth, int viewportHeight, float uiScale) { + validateViewport(viewportWidth, viewportHeight); + + float buttonWidth = Math.max(150f * uiScale, viewportWidth * 0.3f); + float buttonHeight = Math.max(50f * uiScale, viewportHeight * 0.08f); + float x = (viewportWidth - buttonWidth) / 2f; + return new GameOverButtons( + new Rect(x, viewportHeight / 2f - 80f * uiScale, buttonWidth, buttonHeight), + new Rect(x, viewportHeight / 2f - 150f * uiScale, buttonWidth, buttonHeight)); + } + + public static PauseButtons pauseButtons(int viewportWidth, int viewportHeight, float uiScale) { + validateViewport(viewportWidth, viewportHeight); + + float width = 300f * uiScale; + float height = 60f * uiScale; + float spacing = 20f * uiScale; + float startY = viewportHeight / 2f + spacing / 2f; + float x = (viewportWidth - width) / 2f; + return new PauseButtons( + new Rect(x, startY, width, height), + new Rect(x, startY - height - spacing, width, height)); + } + + public int viewportWidth() { + return viewportWidth; + } + + public int viewportHeight() { + return viewportHeight; + } + + public float uiScale() { + return uiScale; + } + + public float buttonPadding() { + return buttonPadding; + } + + public float buttonWidth() { + return buttonWidth; + } + + public float minButtonWidth() { + return minButtonWidth; + } + + public float buttonHeight() { + return buttonHeight; + } + + public float rowAdvance() { + return rowAdvance; + } + + public float departmentButtonsY() { + return departmentButtonsY; + } + + public float visibleAreaHeight() { + return visibleAreaHeight; + } + + public float minScrollOffset() { + return minScrollOffset; + } + + public float maxScrollOffset() { + return maxScrollOffset; + } + + public float touchPadding() { + return touchPadding; + } + + public Rect scissorBounds() { + return scissorBounds; + } + + public Rect attackDepartmentButton() { + return attackDepartmentButton; + } + + public Rect healthDepartmentButton() { + return healthDepartmentButton; + } + + public Rect abilitiesDepartmentButton() { + return abilitiesDepartmentButton; + } + + public Rect attackButton(int index) { + return buttonAt(attackButtons, index); + } + + public Rect healthButton(int index) { + return buttonAt(healthButtons, index); + } + + public Rect abilityButton(int index) { + return buttonAt(abilityButtons, index); + } + + public Rect scrollUpButton() { + return scrollUpButton; + } + + public Rect scrollDownButton() { + return scrollDownButton; + } + + public Rect topBar() { + return topBar; + } + + public Rect waveBar() { + return waveBar; + } + + public Rect statStrip() { + return statStrip; + } + + public Rect menuButton() { + return menuButton; + } + + public Rect shopSheet() { + return shopSheet; + } + + public Rect shopHandle() { + return shopHandle; + } + + public Rect abilityDock() { + return abilityDock; + } + + public Rect shopToggleButton() { + return shopToggleButton; + } + + public Rect dockLabel() { + return dockLabel; + } + + public Rect dockAbilityButton(int index) { + return buttonAt(dockAbilityButtons, index); + } + + public List contentButtons() { + return contentButtons; + } + + private static Rect buttonAt(Rect[] buttons, int index) { + if (index < 0 || index >= buttons.length) return null; + return buttons[index]; + } + + private static void validateCalculateInputs( + int viewportWidth, int viewportHeight, Panel panel, SafeAreaInsets safeArea) { + if (viewportWidth <= 0 || viewportHeight <= 0) { + throw new IllegalArgumentException("Viewport must be positive"); + } + if (panel == null) { + throw new IllegalArgumentException("Panel is required"); + } + if (safeArea == null) { + throw new IllegalArgumentException("Safe area is required"); + } + if (safeArea.left() + safeArea.right() >= viewportWidth + || safeArea.top() + safeArea.bottom() >= viewportHeight) { + throw new IllegalArgumentException("Safe area must leave positive viewport space"); + } + } + + private static LayoutMetrics calculateMetrics( + int viewportWidth, + int viewportHeight, + float uiScale, + float requestedScrollOffset, + Panel panel, + int abilityCount, + Chrome chrome) { + float visualScale = visualScale(viewportWidth, viewportHeight); + float padding = buttonPadding(visualScale); + float buttonHeight = buttonHeight(visualScale); + float minButtonWidth = minButtonWidth(visualScale); + int columnCount = shopColumnCount(viewportWidth, visualScale, padding); + float buttonWidth = + buttonWidth(viewportWidth, visualScale, padding, minButtonWidth, columnCount); + float buttonStartX = buttonStartX(viewportWidth, padding, buttonWidth, columnCount); + float rowAdvance = buttonHeight + padding; + + float departmentPadding = departmentPadding(visualScale); + float departmentWidth = departmentWidth(viewportWidth, visualScale, departmentPadding); + float departmentStartX = departmentStartX(visualScale); + + int rowCount = rowCount(panel, abilityCount, columnCount); + float contentHeight = contentHeight(rowCount, buttonHeight, padding); + float departmentButtonsY = departmentButtonsY(chrome, visualScale); + float departmentHeight = departmentHeight(visualScale); + float cardContentTop = departmentButtonsY - CARD_GAP * visualScale; + float contentBottom = chrome.shopSheet.y(); + float visibleContentHeight = Math.max(0f, cardContentTop - contentBottom); + float maxScrollOffset = Math.max(0f, contentHeight - visibleContentHeight); + float scrollOffset = clamp(requestedScrollOffset, 0f, maxScrollOffset); + float touchPadding = touchPadding(buttonHeight, departmentHeight); + + return new LayoutMetrics( + padding, + buttonWidth, + minButtonWidth, + buttonHeight, + rowAdvance, + columnCount, + buttonStartX, + departmentPadding, + departmentWidth, + departmentStartX, + departmentButtonsY, + departmentHeight, + cardContentTop, + contentBottom, + maxScrollOffset, + scrollOffset, + touchPadding); + } + + private static float visualScale(int viewportWidth, int viewportHeight) { + float widthScale = viewportWidth / REFERENCE_WIDTH; + float heightScale = viewportHeight / REFERENCE_HEIGHT; + return clamp(Math.min(widthScale, heightScale), 0.72f, 1.35f); + } + + private static float buttonPadding(float visualScale) { + return CARD_GAP * visualScale; + } + + private static float buttonHeight(float visualScale) { + return CARD_HEIGHT * visualScale; + } + + private static float minButtonWidth(float visualScale) { + return Math.max(MIN_TAPPABLE_TARGET, 84f * visualScale); + } + + private static float buttonWidth( + int viewportWidth, + float visualScale, + float padding, + float minButtonWidth, + int columnCount) { + float availableWidth = viewportWidth - SHOP_PADDING_X * 2f * visualScale; + float gridGapWidth = padding * (columnCount - 1); + return Math.max( + minButtonWidth, + (availableWidth - gridGapWidth) / columnCount); + } + + private static int shopColumnCount(int viewportWidth, float visualScale, float padding) { + float availableWidth = viewportWidth - SHOP_PADDING_X * 2f * visualScale; + float maxCardWidth = SHOP_MAX_CARD_WIDTH * visualScale; + int columns = (int) Math.ceil((availableWidth + padding) / (maxCardWidth + padding)); + return (int) clamp(columns, SHOP_MIN_COLUMN_COUNT, SHOP_MAX_COLUMN_COUNT); + } + + private static float buttonStartX( + int viewportWidth, float padding, float buttonWidth, int columnCount) { + float contentWidth = buttonWidth * columnCount + padding * (columnCount - 1); + return Math.max(0f, (viewportWidth - contentWidth) / 2f); + } + + private static float departmentPadding(float visualScale) { + return 6f * visualScale; + } + + private static float departmentWidth( + int viewportWidth, float visualScale, float departmentPadding) { + float availableDepartmentWidth = + (viewportWidth - SHOP_PADDING_X * 2f * visualScale - departmentPadding * 2f) + / 3f; + return Math.max(MIN_TAPPABLE_TARGET, availableDepartmentWidth); + } + + private static float departmentStartX(float visualScale) { + return SHOP_PADDING_X * visualScale; + } + + private static float departmentButtonsY(Chrome chrome, float visualScale) { + return chrome.shopSheet.top() - 54f * visualScale; + } + + private static float departmentHeight(float visualScale) { + return TAB_HEIGHT * visualScale; + } + + private static float touchPadding(float buttonHeight, float departmentHeight) { + float shortestVisualTarget = Math.min(buttonHeight, departmentHeight); + return Math.max(0f, (MIN_TAPPABLE_TARGET - shortestVisualTarget) / 2f); + } + + private static DepartmentButtons createDepartmentButtons( + int originX, + int originY, + int safeWidth, + float departmentButtonsY, + LayoutMetrics metrics) { + Rect attackDepartment = + new Rect( + originX + metrics.departmentStartX, + originY + departmentButtonsY, + metrics.departmentWidth, + metrics.departmentHeight); + Rect healthDepartment = + new Rect( + originX + + metrics.departmentStartX + + metrics.departmentWidth + + metrics.departmentPadding, + originY + departmentButtonsY, + metrics.departmentWidth, + metrics.departmentHeight); + float abilitiesDepartmentX = + Math.min( + metrics.departmentStartX + + (metrics.departmentWidth + metrics.departmentPadding) * 2f, + safeWidth - metrics.departmentWidth); + Rect abilitiesDepartment = + new Rect( + originX + abilitiesDepartmentX, + originY + departmentButtonsY, + metrics.departmentWidth, + metrics.departmentHeight); + return new DepartmentButtons(attackDepartment, healthDepartment, abilitiesDepartment); + } + + private static PanelButtons createPanelButtons( + int originX, + int originY, + int safeWidth, + Panel panel, + int abilityCount, + LayoutMetrics metrics) { + Rect[] attackButtons = new Rect[SHOP_CATEGORY_BUTTON_COUNT]; + Rect[] healthButtons = new Rect[SHOP_CATEGORY_BUTTON_COUNT]; + Rect[] abilityButtons = new Rect[SHOP_CATEGORY_BUTTON_COUNT]; + + if (panel == Panel.ATTACK) { + fillButtonGrid( + attackButtons, originX, originY, safeWidth, metrics, metrics.cardContentTop); + } else if (panel == Panel.HEALTH) { + fillButtonGrid( + healthButtons, originX, originY, safeWidth, metrics, metrics.cardContentTop); + } else { + fillButtonGrid( + abilityButtons, originX, originY, safeWidth, metrics, metrics.cardContentTop); + } + + List contentButtons = + collectContentButtons(attackButtons, healthButtons, abilityButtons); + return new PanelButtons(attackButtons, healthButtons, abilityButtons, contentButtons); + } + + private static ScrollButtons createScrollButtons( + int originX, int originY, int safeWidth, LayoutMetrics metrics) { + Rect scrollUp = null; + Rect scrollDown = null; + if (metrics.maxScrollOffset > 0f) { + float scrollSize = MIN_TAPPABLE_TARGET; + float scrollX = originX + safeWidth - metrics.padding - scrollSize; + scrollUp = + new Rect( + scrollX, + originY + metrics.departmentButtonsY - scrollSize, + scrollSize, + scrollSize); + scrollDown = new Rect(scrollX, metrics.contentBottom, scrollSize, scrollSize); + } + return new ScrollButtons(scrollUp, scrollDown); + } + + private static Chrome createChrome( + SafeAreaInsets safeArea, int safeWidth, int safeHeight, float uiScale) { + float visualScale = visualScale(safeWidth, safeHeight); + float margin = MARGIN * visualScale; + float topMargin = TOP_MARGIN * visualScale; + float topbarHeight = TOPBAR_HEIGHT * visualScale; + float topbarX = safeArea.left() + margin; + float topbarY = safeArea.bottom() + safeHeight - topMargin - topbarHeight; + float topbarWidth = Math.max(MIN_TAPPABLE_TARGET, safeWidth - margin * 2f); + Rect topBar = new Rect(topbarX, topbarY, topbarWidth, topbarHeight); + + float wavebarHeight = Math.max(4f, WAVEBAR_HEIGHT * visualScale); + Rect waveBar = + new Rect( + topbarX, + topbarY - 5f * visualScale - wavebarHeight, + topbarWidth, + wavebarHeight); + + float menuSize = MENU_SIZE * visualScale; + float statY = waveBar.y() - 9f * visualScale - STAT_STRIP_HEIGHT * visualScale; + Rect menuButton = + new Rect( + safeArea.left() + safeWidth - MENU_RIGHT * visualScale - menuSize, + statY + STAT_STRIP_HEIGHT * visualScale - menuSize, + menuSize, + menuSize); + Rect statStrip = + new Rect( + topbarX, + statY, + Math.max(MIN_TAPPABLE_TARGET, menuButton.x() - topbarX - 8f * visualScale), + STAT_STRIP_HEIGHT * visualScale); + + float dockHeight = DOCK_HEIGHT * visualScale; + float dockY = safeArea.bottom() + margin; + Rect abilityDock = new Rect(topbarX, dockY, topbarWidth, dockHeight); + float dockPadding = 8f * visualScale; + float dockButton = DOCK_BUTTON * visualScale; + Rect shopToggle = + new Rect( + abilityDock.x() + dockPadding, + abilityDock.y() + (abilityDock.height() - dockButton) / 2f, + dockButton, + dockButton); + Rect[] dockAbilities = new Rect[DOCK_ABILITY_COUNT]; + float abilityX = abilityDock.right() - dockPadding - dockButton; + for (int i = dockAbilities.length - 1; i >= 0; i--) { + dockAbilities[i] = + new Rect( + abilityX, + abilityDock.y() + (abilityDock.height() - dockButton) / 2f, + dockButton, + dockButton); + abilityX -= dockButton + dockPadding; + } + Rect dockLabel = + new Rect( + shopToggle.right() + dockPadding, + abilityDock.y() + dockPadding, + Math.max(0f, abilityX - shopToggle.right()), + abilityDock.height() - dockPadding * 2f); + + float shopHeight = SHOP_HEIGHT * visualScale; + float shopY = Math.max(abilityDock.top() + 8f * visualScale, SHOP_BOTTOM * visualScale); + Rect shopSheet = + new Rect( + safeArea.left(), + safeArea.bottom() + shopY, + safeWidth, + Math.min( + shopHeight, + Math.max(0f, statStrip.y() - safeArea.bottom() - shopY))); + Rect shopHandle = + new Rect( + safeArea.left() + (safeWidth - HANDLE_WIDTH * visualScale) / 2f, + shopSheet.top() - 12f * visualScale, + HANDLE_WIDTH * visualScale, + HANDLE_HEIGHT * visualScale); + return new Chrome( + topBar, + waveBar, + statStrip, + menuButton, + shopSheet, + shopHandle, + abilityDock, + shopToggle, + dockLabel, + dockAbilities); + } + + private static void addAllVisible(List out, Rect[] buttons) { + for (Rect button : buttons) { + if (button != null) out.add(button); + } + } + + private static List collectContentButtons( + Rect[] attackButtons, Rect[] healthButtons, Rect[] abilityButtons) { + List buttons = new ArrayList<>(); + addAllVisible(buttons, attackButtons); + addAllVisible(buttons, healthButtons); + addAllVisible(buttons, abilityButtons); + return Collections.unmodifiableList(buttons); + } + + private static int rowCount(Panel panel, int abilityCount, int columnCount) { + return (int) Math.ceil((float) SHOP_CATEGORY_BUTTON_COUNT / columnCount); + } + + private static float contentHeight(int rowCount, float buttonHeight, float padding) { + if (rowCount <= 0) return 0f; + return rowCount * buttonHeight + (rowCount - 1) * padding; + } + + private static void fillButtonGrid( + Rect[] buttons, + int originX, + int originY, + int safeWidth, + LayoutMetrics metrics, + float contentTop) { + fillButtonGrid( + buttons, + originX, + originY, + safeWidth, + originY + contentTop, + metrics.contentBottom, + metrics.columnCount, + metrics.buttonStartX, + metrics.buttonWidth, + metrics.buttonHeight, + metrics.padding, + metrics.scrollOffset); + } + + private static void fillButtonGrid( + Rect[] buttons, + int originX, + int originY, + int safeWidth, + float contentTop, + float contentBottom, + int columnCount, + float buttonStartX, + float buttonWidth, + float buttonHeight, + float padding, + float scrollOffset) { + for (int i = 0; i < buttons.length; i++) { + int row = i / columnCount; + int col = i % columnCount; + float x = originX + buttonStartX + col * (buttonWidth + padding); + float y = contentTop - (row + 1) * buttonHeight - row * padding + scrollOffset; + Rect candidate = new Rect(x, y, buttonWidth, buttonHeight); + if (candidate.x >= originX + && candidate.top() > contentBottom + && candidate.right() <= originX + safeWidth + && candidate.y < contentTop) { + buttons[i] = candidate; + } + } + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + private static void validateViewport(int viewportWidth, int viewportHeight) { + if (viewportWidth <= 0 || viewportHeight <= 0) { + throw new IllegalArgumentException("Viewport must be positive"); + } + } + + private static final class LayoutMetrics { + private final float padding; + private final float buttonWidth; + private final float minButtonWidth; + private final float buttonHeight; + private final float rowAdvance; + private final int columnCount; + private final float buttonStartX; + private final float departmentPadding; + private final float departmentWidth; + private final float departmentStartX; + private final float departmentButtonsY; + private final float departmentHeight; + private final float cardContentTop; + private final float contentBottom; + private final float maxScrollOffset; + private final float scrollOffset; + private final float touchPadding; + + private LayoutMetrics( + float padding, + float buttonWidth, + float minButtonWidth, + float buttonHeight, + float rowAdvance, + int columnCount, + float buttonStartX, + float departmentPadding, + float departmentWidth, + float departmentStartX, + float departmentButtonsY, + float departmentHeight, + float cardContentTop, + float contentBottom, + float maxScrollOffset, + float scrollOffset, + float touchPadding) { + this.padding = padding; + this.buttonWidth = buttonWidth; + this.minButtonWidth = minButtonWidth; + this.buttonHeight = buttonHeight; + this.rowAdvance = rowAdvance; + this.columnCount = columnCount; + this.buttonStartX = buttonStartX; + this.departmentPadding = departmentPadding; + this.departmentWidth = departmentWidth; + this.departmentStartX = departmentStartX; + this.departmentButtonsY = departmentButtonsY; + this.departmentHeight = departmentHeight; + this.cardContentTop = cardContentTop; + this.contentBottom = contentBottom; + this.maxScrollOffset = maxScrollOffset; + this.scrollOffset = scrollOffset; + this.touchPadding = touchPadding; + } + } + + private static final class DepartmentButtons { + private final Rect attackDepartment; + private final Rect healthDepartment; + private final Rect abilitiesDepartment; + + private DepartmentButtons( + Rect attackDepartment, Rect healthDepartment, Rect abilitiesDepartment) { + this.attackDepartment = attackDepartment; + this.healthDepartment = healthDepartment; + this.abilitiesDepartment = abilitiesDepartment; + } + } + + private static final class PanelButtons { + private final Rect[] attackButtons; + private final Rect[] healthButtons; + private final Rect[] abilityButtons; + private final List contentButtons; + + private PanelButtons( + Rect[] attackButtons, + Rect[] healthButtons, + Rect[] abilityButtons, + List contentButtons) { + this.attackButtons = attackButtons; + this.healthButtons = healthButtons; + this.abilityButtons = abilityButtons; + this.contentButtons = contentButtons; + } + } + + private static final class ScrollButtons { + private final Rect scrollUp; + private final Rect scrollDown; + + private ScrollButtons(Rect scrollUp, Rect scrollDown) { + this.scrollUp = scrollUp; + this.scrollDown = scrollDown; + } + } + + private static final class Chrome { + private final Rect topBar; + private final Rect waveBar; + private final Rect statStrip; + private final Rect menuButton; + private final Rect shopSheet; + private final Rect shopHandle; + private final Rect abilityDock; + private final Rect shopToggleButton; + private final Rect dockLabel; + private final Rect[] dockAbilityButtons; + + private Chrome( + Rect topBar, + Rect waveBar, + Rect statStrip, + Rect menuButton, + Rect shopSheet, + Rect shopHandle, + Rect abilityDock, + Rect shopToggleButton, + Rect dockLabel, + Rect[] dockAbilityButtons) { + this.topBar = topBar; + this.waveBar = waveBar; + this.statStrip = statStrip; + this.menuButton = menuButton; + this.shopSheet = shopSheet; + this.shopHandle = shopHandle; + this.abilityDock = abilityDock; + this.shopToggleButton = shopToggleButton; + this.dockLabel = dockLabel; + this.dockAbilityButtons = dockAbilityButtons; + } + } + + public static final class GameOverButtons { + private final Rect restartButton; + private final Rect menuButton; + + private GameOverButtons(Rect restartButton, Rect menuButton) { + this.restartButton = restartButton; + this.menuButton = menuButton; + } + + public Rect restartButton() { + return restartButton; + } + + public Rect menuButton() { + return menuButton; + } + } + + public static final class PauseButtons { + private final Rect resumeButton; + private final Rect exitButton; + + private PauseButtons(Rect resumeButton, Rect exitButton) { + this.resumeButton = resumeButton; + this.exitButton = exitButton; + } + + public Rect resumeButton() { + return resumeButton; + } + + public Rect exitButton() { + return exitButton; + } + } + + public static final class Rect { + private final float x; + private final float y; + private final float width; + private final float height; + + Rect(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public float x() { + return x; + } + + public float y() { + return y; + } + + public float width() { + return width; + } + + public float height() { + return height; + } + + public float right() { + return x + width; + } + + public float top() { + return y + height; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/layout/GameScreenScaleMetrics.java b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/GameScreenScaleMetrics.java new file mode 100644 index 0000000..f4563d3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/GameScreenScaleMetrics.java @@ -0,0 +1,87 @@ +package ru.project.tower.screens.gamescreen.layout; + +public final class GameScreenScaleMetrics { + private static final float BASE_WIDTH = 1280f; + private static final float BASE_HEIGHT = 720f; + private static final float STABLE_GAME_SCALE = 1f; + + private final float uiScale; + private final float gameScale; + private final float adaptedSquareSize; + private final float adaptedCircleRadius; + private final float adaptedBulletRadius; + private final int adaptedFontSize; + + private GameScreenScaleMetrics( + float uiScale, + float gameScale, + float adaptedSquareSize, + float adaptedCircleRadius, + float adaptedBulletRadius, + int adaptedFontSize) { + this.uiScale = uiScale; + this.gameScale = gameScale; + this.adaptedSquareSize = adaptedSquareSize; + this.adaptedCircleRadius = adaptedCircleRadius; + this.adaptedBulletRadius = adaptedBulletRadius; + this.adaptedFontSize = adaptedFontSize; + } + + public static GameScreenScaleMetrics calculate( + float viewportWidth, + float viewportHeight, + float squareSize, + float circleRadius, + float bulletRadius) { + float scaleX = viewportWidth / BASE_WIDTH; + float scaleY = viewportHeight / BASE_HEIGHT; + boolean isPortrait = viewportHeight > viewportWidth; + + float uiScale; + int adaptedFontSize; + if (isPortrait) { + uiScale = clamp(scaleY * 1.2f, 1.0f, 2.5f); + adaptedFontSize = (int) Math.min(38, Math.max(22, 26 * uiScale)); + } else { + float viewportScale = Math.min(scaleX, scaleY); + uiScale = clamp(viewportScale * 1.2f, 0.8f, 1.8f); + adaptedFontSize = (int) Math.min(32, Math.max(18, 24 * uiScale)); + } + + return new GameScreenScaleMetrics( + uiScale, + STABLE_GAME_SCALE, + squareSize, + circleRadius, + bulletRadius, + adaptedFontSize); + } + + private static float clamp(float value, float min, float max) { + return Math.min(Math.max(min, value), max); + } + + public float uiScale() { + return uiScale; + } + + public float gameScale() { + return gameScale; + } + + public float adaptedSquareSize() { + return adaptedSquareSize; + } + + public float adaptedCircleRadius() { + return adaptedCircleRadius; + } + + public float adaptedBulletRadius() { + return adaptedBulletRadius; + } + + public int adaptedFontSize() { + return adaptedFontSize; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/layout/SafeAreaInsets.java b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/SafeAreaInsets.java new file mode 100644 index 0000000..9ba15c3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/SafeAreaInsets.java @@ -0,0 +1,48 @@ +package ru.project.tower.screens.gamescreen.layout; + + +public final class SafeAreaInsets { + private static final SafeAreaInsets ZERO = new SafeAreaInsets(0, 0, 0, 0); + + private final int left; + private final int top; + private final int right; + private final int bottom; + + private SafeAreaInsets(int left, int top, int right, int bottom) { + this.left = left; + this.top = top; + this.right = right; + this.bottom = bottom; + } + + public static SafeAreaInsets zero() { + return ZERO; + } + + public static SafeAreaInsets of(int left, int top, int right, int bottom) { + if (left < 0 || top < 0 || right < 0 || bottom < 0) { + throw new IllegalArgumentException("Safe area insets must be non-negative"); + } + if (left == 0 && top == 0 && right == 0 && bottom == 0) { + return ZERO; + } + return new SafeAreaInsets(left, top, right, bottom); + } + + public int left() { + return left; + } + + public int top() { + return top; + } + + public int right() { + return right; + } + + public int bottom() { + return bottom; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/layout/SafeAreaInsetsProvider.java b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/SafeAreaInsetsProvider.java new file mode 100644 index 0000000..f543b44 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/layout/SafeAreaInsetsProvider.java @@ -0,0 +1,12 @@ +package ru.project.tower.screens.gamescreen.layout; + + +public interface SafeAreaInsetsProvider { + SafeAreaInsetsProvider NONE = () -> SafeAreaInsets.zero(); + + SafeAreaInsets current(); + + static SafeAreaInsetsProvider none() { + return NONE; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/model/GameScreenGameOverOverlayModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/model/GameScreenGameOverOverlayModel.java new file mode 100644 index 0000000..3aacdb8 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/model/GameScreenGameOverOverlayModel.java @@ -0,0 +1,269 @@ +package ru.project.tower.screens.gamescreen.model; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; + +public final class GameScreenGameOverOverlayModel { + private ShapeRenderer shapeRenderer; + private SpriteBatch batch; + private BitmapFont font; + private BitmapFont titleFont; + private Rectangle restartButton; + private Rectangle menuButton; + private Color neonRed; + private Color neonBlue; + private Color neonCyan; + private Color neonPink; + private Color neonWhite; + private Color hoverColor; + private Color pressedColor; + private Color scratchColor; + private Color scratchColor2; + private float uiScale; + private float time; + private float pulseSpeed; + private float viewportWidth; + private float viewportHeight; + private int currentWave; + private int money; + private boolean restartHovered; + private boolean restartPressed; + private boolean menuHovered; + private boolean menuPressed; + + public GameScreenGameOverOverlayModel populate( + RenderContext renderContext, ViewState viewState) { + this.shapeRenderer = renderContext.shapeRenderer; + this.batch = renderContext.batch; + this.font = renderContext.font; + this.titleFont = renderContext.titleFont; + this.neonRed = renderContext.neonRed; + this.neonBlue = renderContext.neonBlue; + this.neonCyan = renderContext.neonCyan; + this.neonPink = renderContext.neonPink; + this.neonWhite = renderContext.neonWhite; + this.hoverColor = renderContext.hoverColor; + this.pressedColor = renderContext.pressedColor; + this.scratchColor = renderContext.scratchColor; + this.scratchColor2 = renderContext.scratchColor2; + this.restartButton = viewState.restartButton; + this.menuButton = viewState.menuButton; + this.uiScale = viewState.uiScale; + this.time = viewState.time; + this.pulseSpeed = viewState.pulseSpeed; + this.viewportWidth = viewState.viewportWidth; + this.viewportHeight = viewState.viewportHeight; + this.currentWave = viewState.currentWave; + this.money = viewState.money; + this.restartHovered = viewState.restartHovered; + this.restartPressed = viewState.restartPressed; + this.menuHovered = viewState.menuHovered; + this.menuPressed = viewState.menuPressed; + return this; + } + + public static final class RenderContext { + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final BitmapFont font; + private final BitmapFont titleFont; + private final Color neonRed; + private final Color neonBlue; + private final Color neonCyan; + private final Color neonPink; + private final Color neonWhite; + private final Color hoverColor; + private final Color pressedColor; + private final Color scratchColor; + private final Color scratchColor2; + + public RenderContext( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + BitmapFont titleFont, + Color neonRed, + Color neonBlue, + Color neonCyan, + Color neonPink, + Color neonWhite, + Color hoverColor, + Color pressedColor, + Color scratchColor, + Color scratchColor2) { + this.shapeRenderer = shapeRenderer; + this.batch = batch; + this.font = font; + this.titleFont = titleFont; + this.neonRed = neonRed; + this.neonBlue = neonBlue; + this.neonCyan = neonCyan; + this.neonPink = neonPink; + this.neonWhite = neonWhite; + this.hoverColor = hoverColor; + this.pressedColor = pressedColor; + this.scratchColor = scratchColor; + this.scratchColor2 = scratchColor2; + } + } + + public static final class ViewState { + private final Rectangle restartButton; + private final Rectangle menuButton; + private final float uiScale; + private final float time; + private final float pulseSpeed; + private final float viewportWidth; + private final float viewportHeight; + private final int currentWave; + private final int money; + private final boolean restartHovered; + private final boolean restartPressed; + private final boolean menuHovered; + private final boolean menuPressed; + + public ViewState( + Rectangle restartButton, + Rectangle menuButton, + float uiScale, + float time, + float pulseSpeed, + float viewportWidth, + float viewportHeight, + int currentWave, + int money, + boolean restartHovered, + boolean restartPressed, + boolean menuHovered, + boolean menuPressed) { + this.restartButton = restartButton; + this.menuButton = menuButton; + this.uiScale = uiScale; + this.time = time; + this.pulseSpeed = pulseSpeed; + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.currentWave = currentWave; + this.money = money; + this.restartHovered = restartHovered; + this.restartPressed = restartPressed; + this.menuHovered = menuHovered; + this.menuPressed = menuPressed; + } + } + + public ShapeRenderer getShapeRenderer() { + return shapeRenderer; + } + + public SpriteBatch getBatch() { + return batch; + } + + public BitmapFont getFont() { + return font; + } + + public BitmapFont getTitleFont() { + return titleFont; + } + + public Rectangle getRestartButton() { + return restartButton; + } + + public Rectangle getMenuButton() { + return menuButton; + } + + public Color getNeonRed() { + return neonRed; + } + + public Color getNeonBlue() { + return neonBlue; + } + + public Color getNeonCyan() { + return neonCyan; + } + + public Color getNeonPink() { + return neonPink; + } + + public Color getNeonWhite() { + return neonWhite; + } + + public Color getHoverColor() { + return hoverColor; + } + + public Color getPressedColor() { + return pressedColor; + } + + public Color getScratchColor() { + return scratchColor; + } + + public float getUiScale() { + return uiScale; + } + + public float getTime() { + return time; + } + + public float getPulseSpeed() { + return pulseSpeed; + } + + public float getViewportWidth() { + return viewportWidth; + } + + public float getViewportHeight() { + return viewportHeight; + } + + public int getCurrentWave() { + return currentWave; + } + + public int getMoney() { + return money; + } + + public boolean isRestartHovered() { + return restartHovered; + } + + public boolean isRestartPressed() { + return restartPressed; + } + + public boolean isMenuHovered() { + return menuHovered; + } + + public boolean isMenuPressed() { + return menuPressed; + } + + public Color withAlpha(Color base, float alpha) { + scratchColor.set(base); + scratchColor.a = alpha; + return scratchColor; + } + + public Color withAlpha2(Color base, float alpha) { + scratchColor2.set(base); + scratchColor2.a = alpha; + return scratchColor2; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/model/GameScreenPauseOverlayModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/model/GameScreenPauseOverlayModel.java new file mode 100644 index 0000000..5e20638 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/model/GameScreenPauseOverlayModel.java @@ -0,0 +1,128 @@ +package ru.project.tower.screens.gamescreen.model; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Rectangle; + +public final class GameScreenPauseOverlayModel { + public static final CharSequence RESUME_LABEL = "ПРОДОЛЖИТЬ"; + public static final CharSequence EXIT_LABEL = "В МЕНЮ"; + public static final Color OVERLAY_TINT = new Color(0f, 0f, 0f, 0.7f); + + private int viewportWidth; + private int viewportHeight; + private float uiScale; + private Rectangle resumeButton; + private Rectangle exitButton; + private Color resumeColor; + private Color exitColor; + private Color hoverColor; + private Color pressedColor; + private boolean resumeHovered; + private boolean resumePressed; + private boolean exitHovered; + private boolean exitPressed; + + public GameScreenPauseOverlayModel() {} + + public GameScreenPauseOverlayModel populate( + Color resumeColor, + Color exitColor, + Color hoverColor, + Color pressedColor, + boolean resumeHovered, + boolean resumePressed, + boolean exitHovered, + boolean exitPressed) { + this.resumeColor = resumeColor; + this.exitColor = exitColor; + this.hoverColor = hoverColor; + this.pressedColor = pressedColor; + this.resumeHovered = resumeHovered; + this.resumePressed = resumePressed; + this.exitHovered = exitHovered; + this.exitPressed = exitPressed; + return this; + } + + public GameScreenPauseOverlayModel positionButtons( + Rectangle resumeButton, + Rectangle exitButton, + int viewportWidth, + int viewportHeight, + float uiScale) { + validateViewport(viewportWidth, viewportHeight); + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.uiScale = uiScale; + + float width = 300f * uiScale; + float height = 60f * uiScale; + float spacing = 20f * uiScale; + float startY = viewportHeight / 2f + spacing / 2f; + float x = (viewportWidth - width) / 2f; + + resumeButton.set(x, startY, width, height); + exitButton.set(x, startY - height - spacing, width, height); + this.resumeButton = resumeButton; + this.exitButton = exitButton; + return this; + } + + public int getViewportWidth() { + return viewportWidth; + } + + public int getViewportHeight() { + return viewportHeight; + } + + public float getUiScale() { + return uiScale; + } + + public Rectangle getResumeButton() { + return resumeButton; + } + + public Rectangle getExitButton() { + return exitButton; + } + + public Color getResumeColor() { + return resumeColor; + } + + public Color getExitColor() { + return exitColor; + } + + public Color getHoverColor() { + return hoverColor; + } + + public Color getPressedColor() { + return pressedColor; + } + + public boolean isResumeHovered() { + return resumeHovered; + } + + public boolean isResumePressed() { + return resumePressed; + } + + public boolean isExitHovered() { + return exitHovered; + } + + public boolean isExitPressed() { + return exitPressed; + } + + private static void validateViewport(int viewportWidth, int viewportHeight) { + if (viewportWidth <= 0 || viewportHeight <= 0) { + throw new IllegalArgumentException("Viewport must be positive"); + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenGameOverOverlayRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenGameOverOverlayRenderer.java new file mode 100644 index 0000000..27ec534 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenGameOverOverlayRenderer.java @@ -0,0 +1,208 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.screens.gamescreen.model.GameScreenGameOverOverlayModel; +import ru.project.tower.screens.gamescreen.ui.NeonButtonRenderer; +import ru.project.tower.screens.gamescreen.ui.NeonTextRenderer; + +public final class GameScreenGameOverOverlayRenderer { + private static final String TITLE = "ИГРА ОКОНЧЕНА"; + private static final String WAVE_LABEL = "Волна: "; + private static final String MONEY_LABEL = "Накоплено: ₽"; + private static final String COINS_LABEL = "Получено монет: "; + private static final String RESTART_LABEL = "ПЕРЕЗАПУСК"; + private static final String MENU_LABEL = "В МЕНЮ"; + + private final NeonTextRenderer textRenderer = new NeonTextRenderer(); + private final GlyphLayout titleLayout = new GlyphLayout(); + private final GlyphLayout waveLayout = new GlyphLayout(); + private final GlyphLayout moneyLayout = new GlyphLayout(); + private final GlyphLayout coinsLayout = new GlyphLayout(); + private final StringBuilder waveText = new StringBuilder(16); + private final StringBuilder moneyText = new StringBuilder(24); + private final StringBuilder coinsText = new StringBuilder(24); + + public void render(GameScreenGameOverOverlayModel model) { + float glow = 0.5f + 0.5f * (float) Math.sin(model.getTime() * model.getPulseSpeed()); + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + renderButtons(model, glow); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + + model.getBatch().begin(); + float titleY = drawTitle(model, glow); + drawStats(model, glow, titleY); + drawButtonTexts(model); + model.getFont().getData().setScale(1.0f); + model.getBatch().end(); + } + + private void renderButtons(GameScreenGameOverOverlayModel model, float glow) { + ShapeRenderer shapeRenderer = model.getShapeRenderer(); + + shapeRenderer.begin(ShapeType.Filled); + renderButtonFill( + model, + model.getRestartButton(), + model.getNeonRed(), + model.isRestartHovered(), + model.isRestartPressed(), + glow); + renderButtonFill( + model, + model.getMenuButton(), + model.getNeonBlue(), + model.isMenuHovered(), + model.isMenuPressed(), + glow); + shapeRenderer.end(); + + shapeRenderer.begin(ShapeType.Line); + renderButtonBorder( + model, + model.getRestartButton(), + model.getNeonRed(), + model.isRestartHovered(), + model.isRestartPressed(), + glow); + renderButtonBorder( + model, + model.getMenuButton(), + model.getNeonBlue(), + model.isMenuHovered(), + model.isMenuPressed(), + glow); + shapeRenderer.end(); + } + + private void renderButtonFill( + GameScreenGameOverOverlayModel model, + Rectangle button, + Color defaultColor, + boolean hovered, + boolean pressed, + float glow) { + Color borderColor = + NeonButtonRenderer.borderColor( + defaultColor, + model.getHoverColor(), + model.getPressedColor(), + hovered, + pressed); + NeonButtonRenderer.drawFill( + model.getShapeRenderer(), + button, + borderColor, + model.getScratchColor(), + model.getUiScale(), + glow); + } + + private void renderButtonBorder( + GameScreenGameOverOverlayModel model, + Rectangle button, + Color defaultColor, + boolean hovered, + boolean pressed, + float glow) { + Color borderColor = + NeonButtonRenderer.borderColor( + defaultColor, + model.getHoverColor(), + model.getPressedColor(), + hovered, + pressed); + NeonButtonRenderer.drawBorder( + model.getShapeRenderer(), + button, + borderColor, + model.getScratchColor(), + glow, + hovered); + } + + private float drawTitle(GameScreenGameOverOverlayModel model, float glow) { + BitmapFont font = model.getTitleFont(); + SpriteBatch batch = model.getBatch(); + float maxTextWidth = model.getViewportWidth() * 0.8f; + float baseScale = model.getUiScale(); + + font.getData().setScale(baseScale); + titleLayout.setText(font, TITLE); + + if (titleLayout.width > maxTextWidth) { + baseScale *= maxTextWidth / titleLayout.width; + font.getData().setScale(baseScale); + titleLayout.setText(font, TITLE); + } + + float titleX = (model.getViewportWidth() - titleLayout.width) / 2f; + float titleY = model.getViewportHeight() * 0.7f; + + Color titleColor = model.withAlpha(model.getNeonRed(), 0.7f + 0.3f * glow); + font.setColor(model.withAlpha2(titleColor, 0.3f * glow)); + font.draw(batch, TITLE, titleX + 2f, titleY + 2f); + font.draw(batch, TITLE, titleX - 2f, titleY - 2f); + + font.setColor(titleColor); + font.draw(batch, TITLE, titleX, titleY); + font.getData().setScale(1.0f); + + return titleY; + } + + private void drawStats(GameScreenGameOverOverlayModel model, float glow, float titleY) { + BitmapFont font = model.getFont(); + SpriteBatch batch = model.getBatch(); + + font.setColor(model.withAlpha(model.getNeonCyan(), 0.7f + 0.3f * glow)); + + waveText.setLength(0); + waveText.append(WAVE_LABEL).append(model.getCurrentWave()); + moneyText.setLength(0); + moneyText.append(MONEY_LABEL).append(model.getMoney()); + coinsText.setLength(0); + coinsText.append(COINS_LABEL).append(model.getCurrentWave()); + + waveLayout.setText(font, waveText); + moneyLayout.setText(font, moneyText); + coinsLayout.setText(font, coinsText); + + float statsY = titleY - titleLayout.height - 20f * model.getUiScale(); + font.draw(batch, waveText, (model.getViewportWidth() - waveLayout.width) / 2f, statsY); + + statsY -= 30f * model.getUiScale(); + font.draw(batch, moneyText, (model.getViewportWidth() - moneyLayout.width) / 2f, statsY); + + font.setColor(model.getNeonPink()); + statsY -= 30f * model.getUiScale(); + font.draw(batch, coinsText, (model.getViewportWidth() - coinsLayout.width) / 2f, statsY); + } + + private void drawButtonTexts(GameScreenGameOverOverlayModel model) { + model.getFont().setColor(model.getNeonWhite()); + textRenderer.drawNeonButtonText( + model.getFont(), + model.getBatch(), + RESTART_LABEL, + "", + model.getRestartButton(), + model.getNeonPink()); + model.getFont().setColor(model.getNeonWhite()); + textRenderer.drawNeonButtonText( + model.getFont(), + model.getBatch(), + MENU_LABEL, + "", + model.getMenuButton(), + model.getNeonPink()); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenMenuButtonRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenMenuButtonRenderer.java new file mode 100644 index 0000000..e071c2b --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenMenuButtonRenderer.java @@ -0,0 +1,130 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.screens.gamescreen.visual.GameScreenVisualTokens; + +public final class GameScreenMenuButtonRenderer { + private static final float MENU_VISUAL_OFFSET_Y = 0f; + private static final float MENU_STRIPE_RENDER_GAP = 11f; + private static final String MENU_CHROME_HEX = GameScreenVisualTokens.ColorTokens.CYAN; + + private final Rectangle visualButton = new Rectangle(); + + public void render( + ShapeRenderer shapeRenderer, + Rectangle button, + Color defaultColor, + Color hoverColor, + Color pressedColor, + Color stripeColor, + Color scratchColor, + float uiScale, + float glow, + boolean hovered, + boolean pressed) { + if (button == null) return; + float visualScale = button.width / GameScreenVisualTokens.Menu.SIZE; + visualButton.set( + button.x, + button.y + MENU_VISUAL_OFFSET_Y * visualScale, + button.width, + button.height); + + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + shapeRenderer.setColor( + GameScreenVisualTokens.Menu.FILL_RED, + GameScreenVisualTokens.Menu.FILL_GREEN, + GameScreenVisualTokens.Menu.FILL_BLUE, + pressed + ? GameScreenVisualTokens.Menu.PRESSED_FILL_ALPHA + : GameScreenVisualTokens.Menu.FILL_ALPHA); + shapeRenderer.rect(visualButton.x, visualButton.y, visualButton.width, visualButton.height); + if (hovered) { + setCyanChrome(shapeRenderer, 0.08f + glow * 0.08f); + shapeRenderer.rect( + visualButton.x + 3f * visualScale, + visualButton.y + 3f * visualScale, + visualButton.width - 6f * visualScale, + visualButton.height - 6f * visualScale); + } + shapeRenderer.end(); + + shapeRenderer.begin(ShapeRenderer.ShapeType.Line); + shapeRenderer.setColor(1f, 1f, 1f, hovered ? 0.28f : 0.16f); + shapeRenderer.rect(visualButton.x, visualButton.y, visualButton.width, visualButton.height); + setCyanChrome(shapeRenderer, hovered ? 0.32f : 0.18f); + shapeRenderer.rect( + visualButton.x + visualScale, + visualButton.y + visualScale, + visualButton.width - 2f * visualScale, + visualButton.height - 2f * visualScale); + shapeRenderer.end(); + + shapeRenderer.begin(ShapeRenderer.ShapeType.Filled); + drawHamburgerGlow(shapeRenderer, visualButton, visualScale); + drawHamburgerStripes(shapeRenderer, visualButton, stripeColor, visualScale); + shapeRenderer.end(); + } + + private void drawHamburgerGlow(ShapeRenderer shapeRenderer, Rectangle button, float uiScale) { + float stripeWidth = GameScreenVisualTokens.Menu.STRIPE_WIDTH * uiScale; + float stripeHeight = GameScreenVisualTokens.Menu.STRIPE_HEIGHT * uiScale; + float spacing = MENU_STRIPE_RENDER_GAP * uiScale; + float x = button.x + (button.width - stripeWidth) / 2f; + float y = button.y + (button.height - stripeHeight * 3f - spacing * 2f) / 2f; + + drawHamburgerSoftGlow(shapeRenderer, x, y, stripeWidth, stripeHeight, spacing, uiScale); + shapeRenderer.setColor(1f, 1f, 1f, 0.18f); + for (int i = 0; i < 3; i++) { + shapeRenderer.rect(x, y + i * (stripeHeight + spacing), stripeWidth, stripeHeight); + } + } + + private void drawHamburgerSoftGlow( + ShapeRenderer shapeRenderer, + float x, + float y, + float stripeWidth, + float stripeHeight, + float spacing, + float uiScale) { + for (int i = 0; i < 3; i++) { + float stripeY = y + i * (stripeHeight + spacing); + shapeRenderer.setColor(1f, 1f, 1f, 0.045f); + shapeRenderer.rect( + x - 4f * uiScale, + stripeY - 3f * uiScale, + stripeWidth + 8f * uiScale, + stripeHeight + 6f * uiScale); + shapeRenderer.setColor(1f, 1f, 1f, 0.022f); + shapeRenderer.rect( + x - 8f * uiScale, + stripeY - 6f * uiScale, + stripeWidth + 16f * uiScale, + stripeHeight + 12f * uiScale); + } + } + + private void drawHamburgerStripes( + ShapeRenderer shapeRenderer, Rectangle button, Color stripeColor, float uiScale) { + float stripeWidth = GameScreenVisualTokens.Menu.STRIPE_WIDTH * uiScale; + float stripeHeight = GameScreenVisualTokens.Menu.STRIPE_HEIGHT * uiScale; + float spacing = MENU_STRIPE_RENDER_GAP * uiScale; + float x = button.x + (button.width - stripeWidth) / 2f; + float y = button.y + (button.height - stripeHeight * 3f - spacing * 2f) / 2f; + + shapeRenderer.setColor(stripeColor); + for (int i = 0; i < 3; i++) { + shapeRenderer.rect(x, y + i * (stripeHeight + spacing), stripeWidth, stripeHeight); + } + } + + private void setCyanChrome(ShapeRenderer shapeRenderer, float alpha) { + if (!"#22d9ff".equals(MENU_CHROME_HEX)) { + throw new IllegalStateException("Menu chrome must stay on the cyan token"); + } + shapeRenderer.setColor(34f / 255f, 217f / 255f, 1f, alpha); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenPauseOverlayRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenPauseOverlayRenderer.java new file mode 100644 index 0000000..9f6f1c5 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenPauseOverlayRenderer.java @@ -0,0 +1,243 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShaderProgram; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Disposable; +import ru.project.tower.screens.gamescreen.model.GameScreenPauseOverlayModel; +import ru.project.tower.screens.gamescreen.ui.NeonButtonRenderer; +import ru.project.tower.screens.gamescreen.ui.NeonTextRenderer; + +public final class GameScreenPauseOverlayRenderer implements Disposable { + private static final float BLUR_RADIUS = 2.0f; + private static final float BUTTON_GLOW = 1.0f; + private static final CharSequence EMPTY_SECONDARY_TEXT = ""; + + static { + ShaderProgram.pedantic = false; + } + + private static final String VERTEX_SHADER = + "attribute vec4 " + + ShaderProgram.POSITION_ATTRIBUTE + + ";\n" + + "attribute vec4 " + + ShaderProgram.COLOR_ATTRIBUTE + + ";\n" + + "attribute vec2 " + + ShaderProgram.TEXCOORD_ATTRIBUTE + + "0;\n" + + "uniform mat4 u_projTrans;\n" + + "varying vec4 v_color;\n" + + "varying vec2 v_texCoords;\n" + + "\n" + + "void main() {\n" + + " v_color = " + + ShaderProgram.COLOR_ATTRIBUTE + + ";\n" + + " v_texCoords = " + + ShaderProgram.TEXCOORD_ATTRIBUTE + + "0;\n" + + " gl_Position = u_projTrans * " + + ShaderProgram.POSITION_ATTRIBUTE + + ";\n" + + "}\n"; + + private static final String FRAGMENT_SHADER = + "#ifdef GL_ES\n" + + "precision mediump float;\n" + + "#endif\n" + + "varying vec4 v_color;\n" + + "varying vec2 v_texCoords;\n" + + "uniform sampler2D u_texture;\n" + + "uniform float resolution;\n" + + "uniform float radius;\n" + + "uniform vec2 dir;\n" + + "\n" + + "void main() {\n" + + " vec4 sum = vec4(0.0);\n" + + " vec2 tc = v_texCoords;\n" + + " float blur = radius/resolution;\n" + + " float hstep = dir.x;\n" + + " float vstep = dir.y;\n" + + " sum += texture2D(u_texture, vec2(tc.x - 4.0*blur*hstep, tc.y -" + + " 4.0*blur*vstep)) * 0.0162162162;\n" + + " sum += texture2D(u_texture, vec2(tc.x - 3.0*blur*hstep, tc.y -" + + " 3.0*blur*vstep)) * 0.0540540541;\n" + + " sum += texture2D(u_texture, vec2(tc.x - 2.0*blur*hstep, tc.y -" + + " 2.0*blur*vstep)) * 0.1216216216;\n" + + " sum += texture2D(u_texture, vec2(tc.x - 1.0*blur*hstep, tc.y -" + + " 1.0*blur*vstep)) * 0.1945945946;\n" + + " sum += texture2D(u_texture, vec2(tc.x, tc.y)) * 0.2270270270;\n" + + " sum += texture2D(u_texture, vec2(tc.x + 1.0*blur*hstep, tc.y +" + + " 1.0*blur*vstep)) * 0.1945945946;\n" + + " sum += texture2D(u_texture, vec2(tc.x + 2.0*blur*hstep, tc.y +" + + " 2.0*blur*vstep)) * 0.1216216216;\n" + + " sum += texture2D(u_texture, vec2(tc.x + 3.0*blur*hstep, tc.y +" + + " 3.0*blur*vstep)) * 0.0540540541;\n" + + " sum += texture2D(u_texture, vec2(tc.x + 4.0*blur*hstep, tc.y +" + + " 4.0*blur*vstep)) * 0.0162162162;\n" + + " gl_FragColor = v_color * vec4(sum.rgb, 1.0);\n" + + "}"; + + private final SpriteBatch batch; + private final ShapeRenderer shapeRenderer; + private final BitmapFont font; + private final PauseScreenshotSource pauseScreenshotOwner; + private final Color scratchColor; + private final NeonTextRenderer textRenderer = new NeonTextRenderer(); + + private ShaderProgram blurShader; + + public GameScreenPauseOverlayRenderer( + SpriteBatch batch, + ShapeRenderer shapeRenderer, + BitmapFont font, + PauseScreenshotSource pauseScreenshotOwner, + Color scratchColor) { + this.batch = batch; + this.shapeRenderer = shapeRenderer; + this.font = font; + this.pauseScreenshotOwner = pauseScreenshotOwner; + this.scratchColor = scratchColor; + } + + public void initShader() { + blurShader = new ShaderProgram(VERTEX_SHADER, FRAGMENT_SHADER); + if (!blurShader.isCompiled()) { + Gdx.app.error( + "GameScreenPauseOverlayRenderer", + "Shader compilation failed: " + blurShader.getLog()); + } + } + + public void render(GameScreenPauseOverlayModel model) { + if (model == null) return; + + renderBlurredScreenshot(model); + renderOverlayAndButtonFills(model); + renderButtonBorders(model); + renderButtonText(model); + } + + @Override + public void dispose() { + if (blurShader != null) { + blurShader.dispose(); + blurShader = null; + } + } + + private void renderBlurredScreenshot(GameScreenPauseOverlayModel model) { + if (!pauseScreenshotOwner.hasScreenshot()) return; + + if (blurShader == null) { + initShader(); + } + + batch.setShader(blurShader); + blurShader.bind(); + blurShader.setUniformf("resolution", model.getViewportWidth()); + blurShader.setUniformf("radius", BLUR_RADIUS); + blurShader.setUniformf("dir", 1f, 0f); + + batch.begin(); + pauseScreenshotOwner.draw(batch, 0, 0, model.getViewportWidth(), model.getViewportHeight()); + batch.end(); + batch.setShader(null); + } + + private void renderOverlayAndButtonFills(GameScreenPauseOverlayModel model) { + Gdx.gl.glEnable(GL20.GL_BLEND); + shapeRenderer.begin(ShapeType.Filled); + shapeRenderer.setColor(GameScreenPauseOverlayModel.OVERLAY_TINT); + shapeRenderer.rect(0, 0, model.getViewportWidth(), model.getViewportHeight()); + drawButtonFill( + model, + model.getResumeButton(), + model.getResumeColor(), + model.isResumeHovered(), + model.isResumePressed()); + drawButtonFill( + model, + model.getExitButton(), + model.getExitColor(), + model.isExitHovered(), + model.isExitPressed()); + shapeRenderer.end(); + } + + private void renderButtonBorders(GameScreenPauseOverlayModel model) { + shapeRenderer.begin(ShapeType.Line); + drawButtonBorder( + model, + model.getResumeButton(), + model.getResumeColor(), + model.isResumeHovered(), + model.isResumePressed()); + drawButtonBorder( + model, + model.getExitButton(), + model.getExitColor(), + model.isExitHovered(), + model.isExitPressed()); + shapeRenderer.end(); + } + + private void renderButtonText(GameScreenPauseOverlayModel model) { + batch.begin(); + textRenderer.drawNeonButtonText( + font, + batch, + GameScreenPauseOverlayModel.RESUME_LABEL, + EMPTY_SECONDARY_TEXT, + model.getResumeButton(), + Color.WHITE); + textRenderer.drawNeonButtonText( + font, + batch, + GameScreenPauseOverlayModel.EXIT_LABEL, + EMPTY_SECONDARY_TEXT, + model.getExitButton(), + Color.WHITE); + batch.end(); + } + + private void drawButtonFill( + GameScreenPauseOverlayModel model, + Rectangle button, + Color color, + boolean hovered, + boolean pressed) { + Color borderColor = + NeonButtonRenderer.borderColor( + color, model.getHoverColor(), model.getPressedColor(), hovered, pressed); + NeonButtonRenderer.drawFill( + shapeRenderer, button, borderColor, scratchColor, model.getUiScale(), BUTTON_GLOW); + } + + private void drawButtonBorder( + GameScreenPauseOverlayModel model, + Rectangle button, + Color color, + boolean hovered, + boolean pressed) { + Color borderColor = + NeonButtonRenderer.borderColor( + color, model.getHoverColor(), model.getPressedColor(), hovered, pressed); + NeonButtonRenderer.drawBorder( + shapeRenderer, button, borderColor, scratchColor, BUTTON_GLOW, hovered); + } + + public interface PauseScreenshotSource { + boolean hasScreenshot(); + + void draw(SpriteBatch batch, float x, float y, float width, float height); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenRenderModelAssembler.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenRenderModelAssembler.java new file mode 100644 index 0000000..fab3843 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenRenderModelAssembler.java @@ -0,0 +1,295 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.entities.bullets.BulletData; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.BonusBallData; +import ru.project.tower.entities.particles.FloatingText; +import ru.project.tower.entities.particles.NeonParticle; +import ru.project.tower.screens.gamescreen.assets.GameScreenFontAssets.HudFonts; +import ru.project.tower.screens.gamescreen.assets.GameScreenMaterialAssets; +import ru.project.tower.screens.gamescreen.gameobjects.GameScreenGameObjectModel; +import ru.project.tower.screens.gamescreen.hud.GameScreenHudModel; +import ru.project.tower.screens.gamescreen.model.GameScreenGameOverOverlayModel; +import ru.project.tower.screens.gamescreen.model.GameScreenPauseOverlayModel; +import ru.project.tower.screens.gamescreen.vfx.GameScreenVfxModel; + +public final class GameScreenRenderModelAssembler { + private final GameScreenGameObjectModel gameObjectModel; + private final GameScreenHudModel hudModel; + private final GameScreenVfxModel vfxModel; + private final GameScreenPauseOverlayModel pauseOverlayModel; + private final GameScreenGameOverOverlayModel gameOverOverlayModel; + + public GameScreenRenderModelAssembler( + GameScreenGameObjectModel gameObjectModel, + GameScreenHudModel hudModel, + GameScreenVfxModel vfxModel, + GameScreenPauseOverlayModel pauseOverlayModel, + GameScreenGameOverOverlayModel gameOverOverlayModel) { + this.gameObjectModel = gameObjectModel; + this.hudModel = hudModel; + this.vfxModel = vfxModel; + this.pauseOverlayModel = pauseOverlayModel; + this.gameOverOverlayModel = gameOverOverlayModel; + } + + public GameScreenGameObjectModel gameObjectModel() { + return gameObjectModel; + } + + public GameScreenHudModel hudModel() { + return hudModel; + } + + public GameScreenVfxModel vfxModel() { + return vfxModel; + } + + public GameScreenPauseOverlayModel pauseOverlayModel( + Rectangle resumeButton, + Rectangle exitButton, + int viewportWidth, + int viewportHeight, + float uiScale, + Color neonGreen, + Color neonRed, + Color neonCyan, + Color neonPink, + boolean pointerOverResume, + boolean pointerPressedResume, + boolean pointerOverExit, + boolean pointerPressedExit) { + pauseOverlayModel.positionButtons( + resumeButton, exitButton, viewportWidth, viewportHeight, uiScale); + return pauseOverlayModel.populate( + neonGreen, + neonRed, + neonCyan, + neonPink, + pointerOverResume, + pointerPressedResume, + pointerOverExit, + pointerPressedExit); + } + + public void populateLiveModels( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + BitmapFont floatingTextFont, + HudFonts hudFonts, + GameScreenMaterialAssets materialAssets, + Rectangle square, + Array circles, + Array bonusBalls, + Array bullets, + Array bulletParticles, + Array floatingTexts, + AbilitySystem abilitySystem, + long nowMs, + float time, + float pulseSpeed, + float uiScale, + float gameScale, + float adaptedSquareSize, + float shootingRange, + boolean reviewVisuals, + int currentStrongCircleHealth, + int currentBasicCircleHealth, + int viewportWidth, + int viewportHeight, + boolean waveActive, + long enemiesPerWave, + long enemiesSpawnedThisWave, + int currentWave, + long waveStartTime, + long timeBetweenWaves, + int squareHealth, + int maxSquareHealth, + int money, + int runCurrencyEarned, + int effectiveDamage, + float effectiveSpeed, + int healthRegenRate, + float effectiveCooldownMs, + float effectiveCritChance, + float effectiveCritMultiplier, + float waveIndicatorHeight, + float waveIndicatorPadding, + Color shadowWeaverColor, + Color glacialWardenColor, + Color basicCircleColor, + Color fastCircleColor, + Color splitterCircleColor, + Color spawnerCircleColor, + Color neonRed, + Color neonBlue, + Color neonCyan, + Color neonGreen, + Color neonYellow, + Color neonPink, + Color neonPurple, + Color priceTextColor, + Color bossProjectileColor, + Color scratchColor, + Color scratchColor3, + Color scratchColor4, + Color scratchColor5) { + gameObjectModel.populateRenderContext( + shapeRenderer, + batch, + materialAssets, + scratchColor, + scratchColor3, + scratchColor4, + scratchColor5); + gameObjectModel.populateView( + square, + circles, + bonusBalls, + bullets, + abilitySystem, + nowMs, + time, + pulseSpeed, + uiScale, + gameScale, + shootingRange, + reviewVisuals, + currentStrongCircleHealth, + currentBasicCircleHealth, + shadowWeaverColor, + glacialWardenColor, + basicCircleColor, + fastCircleColor, + splitterCircleColor, + spawnerCircleColor, + neonBlue, + neonCyan, + neonGreen, + neonYellow, + bossProjectileColor); + + hudModel.populateRenderContext( + shapeRenderer, + batch, + font, + hudFonts, + materialAssets, + neonRed, + neonCyan, + neonBlue, + neonPink, + priceTextColor, + scratchColor); + hudModel.populateWaveAndStats( + viewportWidth, + viewportHeight, + uiScale, + time, + pulseSpeed, + waveIndicatorHeight, + waveIndicatorPadding, + waveActive, + enemiesPerWave, + enemiesSpawnedThisWave, + currentWave, + nowMs, + waveStartTime, + timeBetweenWaves, + squareHealth, + maxSquareHealth, + money, + runCurrencyEarned); + hudModel.populateCombatStats( + effectiveDamage, + effectiveSpeed, + healthRegenRate, + effectiveCooldownMs, + effectiveCritChance, + effectiveCritMultiplier); + + vfxModel.populateRenderContext( + shapeRenderer, batch, font, materialAssets, floatingTextFont, scratchColor); + vfxModel.populateView( + bulletParticles, + floatingTexts, + abilitySystem, + square, + adaptedSquareSize, + viewportWidth, + viewportHeight, + time, + nowMs, + pulseSpeed, + uiScale, + neonBlue, + neonCyan, + neonGreen, + neonYellow, + neonRed, + neonPurple); + } + + public GameScreenGameOverOverlayModel gameOverOverlayModel( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + BitmapFont gameOverTitleFont, + Rectangle restartButton, + Rectangle menuButton, + float uiScale, + float time, + float pulseSpeed, + int viewportWidth, + int viewportHeight, + int currentWave, + int money, + boolean pointerOverRestart, + boolean pointerPressedRestart, + boolean pointerOverMenu, + boolean pointerPressedMenu, + Color neonRed, + Color neonBlue, + Color neonCyan, + Color neonPink, + Color scratchColor, + Color scratchColor2) { + return gameOverOverlayModel.populate( + new GameScreenGameOverOverlayModel.RenderContext( + shapeRenderer, + batch, + font, + gameOverTitleFont, + neonRed, + neonBlue, + neonCyan, + neonPink, + Color.WHITE, + neonCyan, + neonPink, + scratchColor, + scratchColor2), + new GameScreenGameOverOverlayModel.ViewState( + restartButton, + menuButton, + uiScale, + time, + pulseSpeed, + viewportWidth, + viewportHeight, + currentWave, + money, + pointerOverRestart, + pointerPressedRestart, + pointerOverMenu, + pointerPressedMenu)); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenUpgradePickOverlay.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenUpgradePickOverlay.java new file mode 100644 index 0000000..e154ea5 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenUpgradePickOverlay.java @@ -0,0 +1,261 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.screens.gamescreen.layout.GameScreenLayout; +import ru.project.tower.upgrades.UpgradeSystem; + +public final class GameScreenUpgradePickOverlay { + private static final float REVEAL_CARD_DURATION_SECONDS = 0.28f; + private static final float REVEAL_STAGGER_SECONDS = 0.08f; + private static final float REROLL_DURATION_SECONDS = 0.34f; + private static final float REVEAL_OFFSET_Y = 96f; + private static final float REROLL_OFFSET_Y = 112f; + + private final GameScreenUpgradePickOverlayRenderer renderer; + private final Rectangle[] buttons = {new Rectangle(), new Rectangle(), new Rectangle()}; + private final Rectangle rerollButton = new Rectangle(); + private final GlyphLayout titleLayout = new GlyphLayout(); + private final GlyphLayout optionLayout = new GlyphLayout(); + private final GlyphLayout rerollLayout = new GlyphLayout(); + private AnimationPhase animationPhase = AnimationPhase.IDLE; + private float animationElapsed = 0f; + private UpgradeSystem.OfferCardView[] outgoingOffer = new UpgradeSystem.OfferCardView[0]; + private UpgradeSystem.OfferCardView[] incomingOffer = new UpgradeSystem.OfferCardView[0]; + + public GameScreenUpgradePickOverlay(Color cyan, Color green, Color purple) { + renderer = new GameScreenUpgradePickOverlayRenderer(cyan, green, purple); + } + + public void rebuild(int viewportWidth, int viewportHeight, float uiScale) { + GameScreenLayout.Rect[] cards = + GameScreenLayout.upgradePickCards(viewportWidth, viewportHeight, uiScale); + for (int i = 0; i < buttons.length; i++) { + setRect(buttons[i], cards[i]); + } + setRect( + rerollButton, + GameScreenLayout.upgradePickRerollButton(viewportWidth, viewportHeight, uiScale)); + } + + public void render( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont upgradePickFont, + UpgradeSystem.OfferCardView[] pickOptions, + int rerollsRemaining, + int rerollCost, + float uiScale, + String title) { + CardVisual[] cardVisuals = cardVisuals(pickOptions); + renderer.render( + shapeRenderer, + batch, + upgradePickFont, + titleLayout, + optionLayout, + rerollLayout, + cardVisuals, + rerollButton, + rerollsRemaining, + rerollCost, + uiScale, + title); + } + + public void startReveal(UpgradeSystem.OfferCardView[] offer) { + incomingOffer = copyOffer(offer); + outgoingOffer = new UpgradeSystem.OfferCardView[0]; + animationPhase = AnimationPhase.REVEAL; + animationElapsed = 0f; + } + + public void startReroll( + UpgradeSystem.OfferCardView[] previousOffer, UpgradeSystem.OfferCardView[] nextOffer) { + outgoingOffer = copyOffer(previousOffer); + incomingOffer = copyOffer(nextOffer); + animationPhase = AnimationPhase.REROLL; + animationElapsed = 0f; + } + + public void update(float delta) { + if (animationPhase == AnimationPhase.IDLE) return; + animationElapsed += Math.max(0f, delta); + if (animationElapsed >= animationDuration()) { + animationPhase = AnimationPhase.IDLE; + animationElapsed = animationDuration(); + outgoingOffer = new UpgradeSystem.OfferCardView[0]; + } + } + + public boolean canAcceptSelection() { + return animationPhase == AnimationPhase.IDLE; + } + + CardVisual[] cardVisuals(UpgradeSystem.OfferCardView[] currentOffer) { + UpgradeSystem.OfferCardView[] current = offerForVisuals(currentOffer); + if (animationPhase == AnimationPhase.REROLL && outgoingOffer.length > 0) { + CardVisual[] visuals = new CardVisual[outgoingOffer.length + incomingOffer.length]; + float progress = easedProgress(animationElapsed / REROLL_DURATION_SECONDS); + for (int i = 0; i < outgoingOffer.length; i++) { + visuals[i] = + visualFor( + outgoingOffer[i], + i, + -REROLL_OFFSET_Y * progress, + 1f - progress * 0.55f, + 1f - progress * 0.04f, + true); + } + for (int i = 0; i < incomingOffer.length; i++) { + visuals[outgoingOffer.length + i] = + visualFor( + incomingOffer[i], + i, + REROLL_OFFSET_Y * (1f - progress), + progress, + 0.96f + progress * 0.04f, + false); + } + return visuals; + } + + CardVisual[] visuals = new CardVisual[current.length]; + for (int i = 0; i < current.length; i++) { + float progress = 1f; + if (animationPhase == AnimationPhase.REVEAL) { + progress = + easedProgress( + (animationElapsed - i * REVEAL_STAGGER_SECONDS) + / REVEAL_CARD_DURATION_SECONDS); + } + visuals[i] = + visualFor( + current[i], + i, + REVEAL_OFFSET_Y * (1f - progress), + progress, + 0.96f + progress * 0.04f, + false); + } + return visuals; + } + + public Rectangle[] buttons() { + return buttons; + } + + public Rectangle rerollButton() { + return rerollButton; + } + + private CardVisual visualFor( + UpgradeSystem.OfferCardView view, + int slot, + float offsetY, + float alpha, + float scale, + boolean exiting) { + Rectangle base = buttons[Math.min(slot, buttons.length - 1)]; + return new CardVisual(view, slot, base, offsetY, clamp(alpha), scale, exiting); + } + + private UpgradeSystem.OfferCardView[] offerForVisuals( + UpgradeSystem.OfferCardView[] currentOffer) { + if (animationPhase != AnimationPhase.IDLE && incomingOffer.length > 0) return incomingOffer; + return currentOffer == null ? new UpgradeSystem.OfferCardView[0] : currentOffer; + } + + private float animationDuration() { + if (animationPhase == AnimationPhase.REVEAL) { + return REVEAL_CARD_DURATION_SECONDS + REVEAL_STAGGER_SECONDS * (buttons.length - 1); + } + if (animationPhase == AnimationPhase.REROLL) return REROLL_DURATION_SECONDS; + return 0f; + } + + private static UpgradeSystem.OfferCardView[] copyOffer(UpgradeSystem.OfferCardView[] offer) { + if (offer == null || offer.length == 0) return new UpgradeSystem.OfferCardView[0]; + UpgradeSystem.OfferCardView[] copy = new UpgradeSystem.OfferCardView[offer.length]; + System.arraycopy(offer, 0, copy, 0, offer.length); + return copy; + } + + private static float easedProgress(float progress) { + float clamped = clamp(progress); + return clamped * clamped * (3f - 2f * clamped); + } + + private static float clamp(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + private static void setRect(Rectangle rect, GameScreenLayout.Rect layoutRect) { + rect.set(layoutRect.x(), layoutRect.y(), layoutRect.width(), layoutRect.height()); + } + + private enum AnimationPhase { + IDLE, + REVEAL, + REROLL + } + + static final class CardVisual { + private final UpgradeSystem.OfferCardView view; + private final int slot; + private final Rectangle baseBounds; + private final float offsetY; + private final float alpha; + private final float scale; + private final boolean exiting; + + private CardVisual( + UpgradeSystem.OfferCardView view, + int slot, + Rectangle baseBounds, + float offsetY, + float alpha, + float scale, + boolean exiting) { + this.view = view; + this.slot = slot; + this.baseBounds = baseBounds; + this.offsetY = offsetY; + this.alpha = alpha; + this.scale = scale; + this.exiting = exiting; + } + + UpgradeSystem.OfferCardView view() { + return view; + } + + int slot() { + return slot; + } + + Rectangle baseBounds() { + return baseBounds; + } + + float offsetY() { + return offsetY; + } + + float alpha() { + return alpha; + } + + float scale() { + return scale; + } + + boolean exiting() { + return exiting; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenUpgradePickOverlayRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenUpgradePickOverlayRenderer.java new file mode 100644 index 0000000..285987d --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/GameScreenUpgradePickOverlayRenderer.java @@ -0,0 +1,365 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Align; +import ru.project.tower.support.ScreenPalette; +import ru.project.tower.upgrades.UpgradeSystem; + +public final class GameScreenUpgradePickOverlayRenderer { + private static final float UPGRADE_PICK_FONT_SCALE = 1.5f; + + private final Color cyan; + private final Color green; + private final Color purple; + private final Rectangle visualBoundsScratch = new Rectangle(); + + public GameScreenUpgradePickOverlayRenderer(Color cyan, Color green, Color purple) { + this.cyan = cyan; + this.green = green; + this.purple = purple; + } + + static String displayRarity(String rarity) { + if ("uncommon".equals(rarity)) return "редк."; + if ("rare".equals(rarity)) return "особ."; + return "обычн."; + } + + static String displayFamily(String family) { + if ("attack".equals(family) || "damage".equals(family)) return "атака"; + if ("speed".equals(family)) return "темп"; + if ("control".equals(family)) return "замедл."; + if ("shield".equals(family) || "defense".equals(family)) return "защита"; + if ("utility".equals(family)) return "польза"; + if ("ability".equals(family)) return "умения"; + if ("risk".equals(family)) return "риск"; + return "сборка"; + } + + static String displayTags(String[] tags) { + if (tags == null || tags.length == 0) return "-"; + StringBuilder line = new StringBuilder(displayTag(tags[0])); + for (int i = 1; i < tags.length; i++) { + line.append('/').append(displayTag(tags[i])); + } + return line.toString(); + } + + static String rerollText(int rerollCost, int rerollsRemaining) { + return rerollsRemaining > 0 + ? "Переброс " + rerollCost + " (" + rerollsRemaining + ")" + : "Переброс недоступен"; + } + + private static String displayTag(String tag) { + if ("pure".equals(tag)) return "точно"; + if ("damage".equals(tag)) return "урон"; + if ("crit".equals(tag)) return "крит"; + if ("burst".equals(tag)) return "всплеск"; + if ("boss".equals(tag)) return "босс"; + if ("single-target".equals(tag)) return "цель"; + if ("execute".equals(tag)) return "казнь"; + if ("aoe".equals(tag)) return "радиус"; + if ("control".equals(tag)) return "замедл."; + if ("slow".equals(tag)) return "замедл."; + if ("pull".equals(tag)) return "притяж."; + if ("shield".equals(tag)) return "щит"; + if ("regen".equals(tag)) return "восст."; + if ("sustain".equals(tag)) return "стойкость"; + if ("armor".equals(tag)) return "броня"; + if ("health".equals(tag)) return "здоровье"; + if ("tempo".equals(tag)) return "темп"; + if ("cadence".equals(tag)) return "частота"; + if ("money".equals(tag)) return "монеты"; + if ("shop".equals(tag)) return "магазин"; + if ("reroll".equals(tag)) return "переброс"; + if ("choice".equals(tag)) return "выбор"; + if ("bonus".equals(tag)) return "бонус"; + if ("pickup".equals(tag)) return "сбор"; + if ("turret".equals(tag)) return "турель"; + if ("explosion".equals(tag)) return "взрыв"; + if ("cooldown".equals(tag)) return "пауза"; + if ("risk".equals(tag)) return "риск"; + if ("pierce".equals(tag)) return "пробой"; + if ("swarm".equals(tag)) return "толпа"; + if ("ability".equals(tag)) return "умение"; + return "эффект"; + } + + public void render( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + GlyphLayout titleLayout, + GlyphLayout optionLayout, + GlyphLayout rerollLayout, + GameScreenUpgradePickOverlay.CardVisual[] cardVisuals, + Rectangle rerollButton, + int rerollsRemaining, + int rerollCost, + float uiScale, + String title) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + renderFilledPanelPass(shapeRenderer, cardVisuals, rerollButton, uiScale); + renderButtonBorderPass(shapeRenderer, cardVisuals, rerollButton, rerollsRemaining); + + batch.begin(); + drawTitle(batch, font, titleLayout, cardVisuals, uiScale, title); + drawOptions(batch, font, optionLayout, cardVisuals, uiScale); + drawRerollButton( + batch, font, rerollLayout, rerollButton, rerollsRemaining, rerollCost, uiScale); + font.getData().setScale(1.0f); + batch.end(); + } + + private void renderFilledPanelPass( + ShapeRenderer shapeRenderer, + GameScreenUpgradePickOverlay.CardVisual[] cardVisuals, + Rectangle rerollButton, + float uiScale) { + shapeRenderer.begin(ShapeType.Filled); + shapeRenderer.setColor(0.01f, 0.014f, 0.023f, 0.72f); + shapeRenderer.rect(0, 0, Gdx.graphics.getWidth(), Gdx.graphics.getHeight()); + + for (GameScreenUpgradePickOverlay.CardVisual visual : cardVisuals) { + Rectangle r = visualBounds(visual); + if (r == null) continue; + Color t = tint(visual.slot()); + float alpha = visual.alpha(); + shapeRenderer.setColor( + ScreenPalette.PANEL_SOLID.r, + ScreenPalette.PANEL_SOLID.g, + ScreenPalette.PANEL_SOLID.b, + 0.92f * alpha); + shapeRenderer.rect(r.x, r.y, r.width, r.height); + shapeRenderer.setColor(1f, 1f, 1f, 0.035f * alpha); + shapeRenderer.rect(r.x, r.y + r.height * 0.54f, r.width, r.height * 0.46f); + shapeRenderer.setColor(t.r, t.g, t.b, 0.34f * alpha); + shapeRenderer.rect(r.x, r.y, Math.max(3f, 3f * uiScale), r.height); + shapeRenderer.setColor(t.r, t.g, t.b, 0.10f * alpha); + shapeRenderer.rect(r.x + 3f * uiScale, r.y, r.width - 3f * uiScale, r.height); + } + if (rerollButton != null) { + shapeRenderer.setColor( + ScreenPalette.PANEL.r, + ScreenPalette.PANEL.g, + ScreenPalette.PANEL.b, + 0.92f); + shapeRenderer.rect( + rerollButton.x, rerollButton.y, rerollButton.width, rerollButton.height); + shapeRenderer.setColor( + ScreenPalette.CYAN.r, + ScreenPalette.CYAN.g, + ScreenPalette.CYAN.b, + 0.08f); + shapeRenderer.rect( + rerollButton.x, rerollButton.y, rerollButton.width, rerollButton.height); + } + shapeRenderer.end(); + } + + private void renderButtonBorderPass( + ShapeRenderer shapeRenderer, + GameScreenUpgradePickOverlay.CardVisual[] cardVisuals, + Rectangle rerollButton, + int rerollsRemaining) { + shapeRenderer.begin(ShapeType.Line); + for (GameScreenUpgradePickOverlay.CardVisual visual : cardVisuals) { + Rectangle r = visualBounds(visual); + if (r == null) continue; + Color t = tint(visual.slot()); + float alpha = visual.exiting() ? visual.alpha() * 0.50f : visual.alpha(); + shapeRenderer.setColor(t.r, t.g, t.b, 0.46f * alpha); + shapeRenderer.rect(r.x, r.y, r.width, r.height); + shapeRenderer.setColor(1f, 1f, 1f, 0.07f * alpha); + shapeRenderer.rect(r.x + 1f, r.y + 1f, r.width - 2f, r.height - 2f); + } + if (rerollButton != null) { + Color t = rerollsRemaining > 0 ? cyan : Color.DARK_GRAY; + shapeRenderer.setColor(t.r, t.g, t.b, rerollsRemaining > 0 ? 0.74f : 0.36f); + shapeRenderer.rect( + rerollButton.x, rerollButton.y, rerollButton.width, rerollButton.height); + } + shapeRenderer.end(); + } + + private void drawTitle( + SpriteBatch batch, + BitmapFont font, + GlyphLayout titleLayout, + GameScreenUpgradePickOverlay.CardVisual[] cardVisuals, + float uiScale, + String title) { + if (cardVisuals.length == 0) return; + font.setColor(ScreenPalette.TEXT.r, ScreenPalette.TEXT.g, ScreenPalette.TEXT.b, 1f); + float titleScale = 0.84f * uiScale; + font.getData().setScale(fontScale(titleScale)); + titleLayout.setText(font, title); + float maxTitleWidth = Gdx.graphics.getWidth() - 16f * uiScale; + if (titleLayout.width > maxTitleWidth) { + titleScale = Math.max(0.78f * uiScale, titleScale * maxTitleWidth / titleLayout.width); + font.getData().setScale(fontScale(titleScale)); + titleLayout.setText(font, title); + } + font.draw( + batch, + title, + (Gdx.graphics.getWidth() - titleLayout.width) / 2f, + Math.min( + Gdx.graphics.getHeight() - 24f * uiScale, + cardVisuals[0].baseBounds().y + + cardVisuals[0].baseBounds().height + + 38f * uiScale)); + font.getData().setScale(fontScale(uiScale)); + } + + private void drawOptions( + SpriteBatch batch, + BitmapFont font, + GlyphLayout optionLayout, + GameScreenUpgradePickOverlay.CardVisual[] cardVisuals, + float uiScale) { + for (GameScreenUpgradePickOverlay.CardVisual visual : cardVisuals) { + Rectangle r = visualBounds(visual); + UpgradeSystem.OfferCardView view = visual.view(); + if (r == null || view == null) continue; + String label = view.kind().label; + drawCenteredLine( + batch, + font, + optionLayout, + label, + 1f, + 1f, + 1f, + visual.alpha(), + r, + r.y + r.height * 0.72f, + 0.96f * uiScale); + drawCenteredLine( + batch, + font, + optionLayout, + view.effectText(), + 0.94f, + 0.88f, + 0.62f, + visual.alpha(), + r, + r.y + r.height * 0.52f, + 0.78f * uiScale); + String metadata = + displayRarity(view.rarity()) + + " | " + + displayFamily(view.family()) + + " " + + view.stacks() + + "/" + + view.maxStacks(); + drawCenteredLine( + batch, + font, + optionLayout, + metadata, + 0.74f, + 0.76f, + 0.84f, + visual.alpha(), + r, + r.y + r.height * 0.34f, + 0.72f * uiScale); + drawCenteredLine( + batch, + font, + optionLayout, + displayTags(view.tags()), + 0.66f, + 0.69f, + 0.78f, + visual.alpha(), + r, + r.y + r.height * 0.20f, + 0.66f * uiScale); + } + font.getData().setScale(fontScale(uiScale)); + } + + private void drawRerollButton( + SpriteBatch batch, + BitmapFont font, + GlyphLayout rerollLayout, + Rectangle rerollButton, + int rerollsRemaining, + int rerollCost, + float uiScale) { + if (rerollButton == null) return; + String text = rerollText(rerollCost, rerollsRemaining); + font.setColor(rerollsRemaining > 0 ? Color.WHITE : Color.LIGHT_GRAY); + font.getData().setScale(fontScale(0.86f * uiScale)); + rerollLayout.setText( + font, text, font.getColor(), rerollButton.width - 18f, Align.center, true); + font.draw( + batch, + rerollLayout, + rerollButton.x + 9f, + rerollButton.y + (rerollButton.height + rerollLayout.height) / 2f); + font.getData().setScale(fontScale(uiScale)); + } + + private void drawCenteredLine( + SpriteBatch batch, + BitmapFont font, + GlyphLayout layout, + String text, + float colorR, + float colorG, + float colorB, + float alpha, + Rectangle bounds, + float baselineY, + float scale) { + font.setColor(colorR, colorG, colorB, alpha); + float adjustedScale = scale; + font.getData().setScale(fontScale(adjustedScale)); + layout.setText(font, text); + float maxWidth = bounds.width - 16f; + if (layout.width > maxWidth) { + adjustedScale = Math.max(scale * 0.62f, adjustedScale * maxWidth / layout.width); + font.getData().setScale(fontScale(adjustedScale)); + layout.setText(font, text); + } + font.draw(batch, text, bounds.x + (bounds.width - layout.width) / 2f, baselineY); + } + + private static float fontScale(float scale) { + return scale / UPGRADE_PICK_FONT_SCALE; + } + + private Color tint(int index) { + if (index == 0) return cyan; + if (index == 1) return green; + return purple; + } + + private Rectangle visualBounds(GameScreenUpgradePickOverlay.CardVisual visual) { + Rectangle base = visual.baseBounds(); + if (base == null) return null; + float scale = visual.scale(); + float width = base.width * scale; + float height = base.height * scale; + return visualBoundsScratch.set( + base.x + (base.width - width) / 2f, + base.y + visual.offsetY() + (base.height - height) / 2f, + width, + height); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/render/PauseScreenshotOwner.java b/core/src/main/java/ru/project/tower/screens/gamescreen/render/PauseScreenshotOwner.java new file mode 100644 index 0000000..0b89b51 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/render/PauseScreenshotOwner.java @@ -0,0 +1,35 @@ +package ru.project.tower.screens.gamescreen.render; + +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.g2d.TextureRegion; +import com.badlogic.gdx.utils.ScreenUtils; + +public final class PauseScreenshotOwner + implements GameScreenPauseOverlayRenderer.PauseScreenshotSource { + private TextureRegion screenshotRegion; + + public void capture(int x, int y, int width, int height) { + disposeAndClear(); + TextureRegion frame = ScreenUtils.getFrameBufferTexture(x, y, width, height); + screenshotRegion = new TextureRegion(frame); + } + + @Override + public boolean hasScreenshot() { + return screenshotRegion != null; + } + + @Override + public void draw(SpriteBatch batch, float x, float y, float width, float height) { + if (screenshotRegion != null) { + batch.draw(screenshotRegion, x, y, width, height); + } + } + + public void disposeAndClear() { + if (screenshotRegion != null && screenshotRegion.getTexture() != null) { + screenshotRegion.getTexture().dispose(); + } + screenshotRegion = null; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunFrameEvents.java b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunFrameEvents.java new file mode 100644 index 0000000..5213b46 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunFrameEvents.java @@ -0,0 +1,55 @@ +package ru.project.tower.screens.gamescreen.run; + +import java.util.Objects; +import ru.project.tower.run.RunFrameEvents; + +public final class GameScreenRunFrameEvents implements RunFrameEvents { + private final Host host; + + public GameScreenRunFrameEvents(Host host) { + this.host = Objects.requireNonNull(host, "host"); + } + + @Override + public void regeneratePlayer(long nowMs) { + host.regeneratePlayer(nowMs); + } + + @Override + public void autoShootIfReady(long nowMs) { + if (nowMs - host.lastShotTime() > host.effectiveShotCooldown()) { + host.shootAtNearestCircle(nowMs); + } + } + + @Override + public boolean damagePlayerIfVulnerable(int damage, long cooldownMs) { + return host.damagePlayerIfVulnerable(damage, cooldownMs); + } + + @Override + public boolean isPlayerDead() { + return host.playerHealth() <= 0; + } + + @Override + public void gameOver() { + host.gameOver(); + } + + public interface Host { + void regeneratePlayer(long nowMs); + + long lastShotTime(); + + float effectiveShotCooldown(); + + void shootAtNearestCircle(long nowMs); + + boolean damagePlayerIfVulnerable(int damage, long cooldownMs); + + int playerHealth(); + + void gameOver(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunFrameHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunFrameHostAdapter.java new file mode 100644 index 0000000..6465c08 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunFrameHostAdapter.java @@ -0,0 +1,62 @@ +package ru.project.tower.screens.gamescreen.run; + +import java.util.Objects; + +public final class GameScreenRunFrameHostAdapter implements GameScreenRunFrameEvents.Host { + private final Port port; + + public GameScreenRunFrameHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void regeneratePlayer(long nowMs) { + port.regeneratePlayer(nowMs); + } + + @Override + public long lastShotTime() { + return port.lastShotTime(); + } + + @Override + public float effectiveShotCooldown() { + return port.effectiveShotCooldown(); + } + + @Override + public void shootAtNearestCircle(long nowMs) { + port.shootAtNearestCircle(nowMs); + } + + @Override + public boolean damagePlayerIfVulnerable(int damage, long cooldownMs) { + return port.damagePlayerIfVulnerable(damage, cooldownMs); + } + + @Override + public int playerHealth() { + return port.playerHealth(); + } + + @Override + public void gameOver() { + port.gameOver(); + } + + public interface Port { + void regeneratePlayer(long nowMs); + + long lastShotTime(); + + float effectiveShotCooldown(); + + void shootAtNearestCircle(long nowMs); + + boolean damagePlayerIfVulnerable(int damage, long cooldownMs); + + int playerHealth(); + + void gameOver(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunSnapshotHost.java b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunSnapshotHost.java new file mode 100644 index 0000000..eb80d86 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunSnapshotHost.java @@ -0,0 +1,96 @@ +package ru.project.tower.screens.gamescreen.run; + +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.GameState; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.run.RunRngStreams; +import ru.project.tower.run.RunSnapshotCoordinator; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; +import ru.project.tower.waves.WaveController; +import ru.project.tower.waves.WaveTuning; + +public final class GameScreenRunSnapshotHost implements RunSnapshotCoordinator.Host { + private final Host host; + private final GameState gameState; + private final Array selectedAbilities; + private final Array spawnedBosses; + private final PlayerCombatState playerCombatState; + private final BulletSystem bulletSystem; + private final ShopUpgradeSystem shopUpgradeSystem; + private final WaveController waveController; + + public GameScreenRunSnapshotHost( + Host host, + GameState gameState, + Array selectedAbilities, + Array spawnedBosses, + PlayerCombatState playerCombatState, + BulletSystem bulletSystem, + ShopUpgradeSystem shopUpgradeSystem, + WaveController waveController) { + this.host = Objects.requireNonNull(host, "host"); + this.gameState = Objects.requireNonNull(gameState, "gameState"); + this.selectedAbilities = Objects.requireNonNull(selectedAbilities, "selectedAbilities"); + this.spawnedBosses = Objects.requireNonNull(spawnedBosses, "spawnedBosses"); + this.playerCombatState = Objects.requireNonNull(playerCombatState, "playerCombatState"); + this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem"); + this.shopUpgradeSystem = Objects.requireNonNull(shopUpgradeSystem, "shopUpgradeSystem"); + this.waveController = Objects.requireNonNull(waveController, "waveController"); + } + + @Override + public GameState gameState() { + return gameState; + } + + @Override + public Array selectedAbilities() { + return selectedAbilities; + } + + @Override + public Array spawnedBosses() { + return spawnedBosses; + } + + @Override + public PlayerCombatState playerCombatState() { + return playerCombatState; + } + + @Override + public BulletSystem bulletSystem() { + return bulletSystem; + } + + @Override + public ShopUpgradeSystem shopUpgradeSystem() { + return shopUpgradeSystem; + } + + @Override + public WaveController waveController() { + return waveController; + } + + @Override + public void applyWaveTuning(WaveTuning tuning) { + host.applyWaveTuning(tuning); + } + + @Override + public RunRngStreams rngStreams() { + return host.rngStreams(); + } + + public interface Host { + void applyWaveTuning(WaveTuning tuning); + + default RunRngStreams rngStreams() { + return null; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunSnapshotHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunSnapshotHostAdapter.java new file mode 100644 index 0000000..8ea2799 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/run/GameScreenRunSnapshotHostAdapter.java @@ -0,0 +1,31 @@ +package ru.project.tower.screens.gamescreen.run; + +import java.util.Objects; +import ru.project.tower.run.RunRngStreams; +import ru.project.tower.waves.WaveTuning; + +public final class GameScreenRunSnapshotHostAdapter implements GameScreenRunSnapshotHost.Host { + private final Port port; + + public GameScreenRunSnapshotHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void applyWaveTuning(WaveTuning tuning) { + port.applyWaveTuning(tuning); + } + + @Override + public RunRngStreams rngStreams() { + return port.rngStreams(); + } + + public interface Port { + void applyWaveTuning(WaveTuning tuning); + + default RunRngStreams rngStreams() { + return null; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopController.java b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopController.java new file mode 100644 index 0000000..0657016 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopController.java @@ -0,0 +1,122 @@ +package ru.project.tower.screens.gamescreen.shop; + +import com.badlogic.gdx.utils.Array; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.screens.gamescreen.actions.GameScreenActionRouter; +import ru.project.tower.screens.gamescreen.hud.GameScreenHudModel; +import ru.project.tower.screens.gamescreen.input.GameScreenInputModel; +import ru.project.tower.screens.gamescreen.layout.SafeAreaInsets; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; + +public final class GameScreenShopController { + private final ShopUpgradeSystem shopUpgradeSystem; + private final GameScreenShopPanelState panelState = new GameScreenShopPanelState(); + + public GameScreenShopController(ShopUpgradeSystem shopUpgradeSystem) { + this.shopUpgradeSystem = shopUpgradeSystem; + } + + public void reset() { + shopUpgradeSystem.reset(); + } + + public void rebuild( + int viewportWidth, + int viewportHeight, + float uiScale, + Array selectedAbilities, + PlayerAbilities playerAbilities, + SafeAreaInsets safeArea) { + panelState.rebuild( + viewportWidth, + viewportHeight, + uiScale, + selectedAbilities, + playerAbilities, + safeArea); + } + + public void showPanel(GameScreenActionRouter.UpgradePanel panel) { + panelState.showPanel(panel); + } + + public void toggleShop() { + panelState.toggleShop(); + } + + public void updateTransition(float delta) { + panelState.updateTransition(delta); + } + + public void setShopOpenImmediately(boolean open) { + panelState.setShopOpenImmediately(open); + } + + public void setShopTransitionProgressForReview(float progress) { + panelState.setShopTransitionProgressForReview(progress); + } + + public void scroll(int direction) { + panelState.scroll(direction); + } + + public void scrollBy(float amount) { + panelState.scrollBy(amount); + } + + public void setDeterministicReviewPricing(boolean enabled) { + panelState.setDeterministicReviewPricing(enabled); + } + + public void buyUpgrade(GameScreenActionRouter.Upgrade upgrade) { + switch (upgrade) { + case DAMAGE: + buyUpgrade(ShopUpgradeKind.DAMAGE); + break; + case SPEED: + buyUpgrade(ShopUpgradeKind.SPEED); + break; + case COOLDOWN: + buyUpgrade(ShopUpgradeKind.COOLDOWN); + break; + case CRIT_CHANCE: + buyUpgrade(ShopUpgradeKind.CRIT_CHANCE); + break; + case CRIT_MULTIPLIER: + buyUpgrade(ShopUpgradeKind.CRIT_MULTIPLIER); + break; + case HEALTH: + buyUpgrade(ShopUpgradeKind.HEALTH); + break; + case REGEN: + buyUpgrade(ShopUpgradeKind.REGEN); + break; + } + } + + public void buyUpgrade(ShopUpgradeKind kind) { + shopUpgradeSystem.tryBuy(kind); + } + + public void fillInputModelShopFields( + GameScreenInputModel inputModel, PlayerAbilities playerAbilities) { + panelState.fillInputModelShopFields(inputModel, playerAbilities); + } + + public void fillHudPanelFields( + GameScreenHudModel hudModel, AbilitySystem abilitySystem, long nowMs) { + fillHudPanelFields(hudModel, null, abilitySystem, nowMs); + } + + public void fillHudPanelFields( + GameScreenHudModel hudModel, + ShopUpgradePreviewValues previewValues, + AbilitySystem abilitySystem, + long nowMs) { + panelState.fillHudPanelFields( + hudModel, shopUpgradeSystem, previewValues, abilitySystem, nowMs); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopPanelState.java b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopPanelState.java new file mode 100644 index 0000000..91751a8 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopPanelState.java @@ -0,0 +1,721 @@ +package ru.project.tower.screens.gamescreen.shop; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.abilities.AbilityKind; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.abilities.PlayerAbilities; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.screens.gamescreen.actions.GameScreenActionRouter; +import ru.project.tower.screens.gamescreen.hud.GameScreenHudModel; +import ru.project.tower.screens.gamescreen.input.GameScreenInputModel; +import ru.project.tower.screens.gamescreen.layout.GameScreenLayout; +import ru.project.tower.screens.gamescreen.layout.SafeAreaInsets; +import ru.project.tower.support.ScreenPalette; +import ru.project.tower.upgrades.shop.ShopUpgradeCategory; +import ru.project.tower.upgrades.shop.ShopUpgradeDefinition; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; +import ru.project.tower.upgrades.shop.ShopUpgradeRegistry; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; + +public final class GameScreenShopPanelState { + private static final float SHOP_TRANSITION_DURATION_SECONDS = 0.22f; + private static final Color UPGRADE_DAMAGE = ScreenPalette.UPGRADE_DAMAGE.create(); + private static final Color UPGRADE_SPEED = ScreenPalette.UPGRADE_SPEED.create(); + private static final Color UPGRADE_COOLDOWN = ScreenPalette.UPGRADE_COOLDOWN.create(); + private static final Color UPGRADE_CRIT_CHANCE = ScreenPalette.UPGRADE_CRIT_CHANCE.create(); + private static final Color UPGRADE_CRIT_MULTIPLIER = + ScreenPalette.UPGRADE_CRIT_MULTIPLIER.create(); + private static final Color UPGRADE_HEALTH = ScreenPalette.UPGRADE_HEALTH.create(); + private static final Color UPGRADE_REGEN = ScreenPalette.UPGRADE_REGEN.create(); + private static final Color ABILITY_FREEZE = ScreenPalette.ABILITY_FREEZE.create(); + private static final Color ABILITY_EXPLOSION = ScreenPalette.ABILITY_EXPLOSION.create(); + private static final Color ABILITY_SHIELD = ScreenPalette.ABILITY_SHIELD.create(); + private static final Color ABILITY_IMPULSE = ScreenPalette.ABILITY_IMPULSE.create(); + private static final Color ABILITY_DASH = ScreenPalette.ABILITY_DASH.create(); + private static final Color ABILITY_PULL = ScreenPalette.ABILITY_PULL.create(); + private static final Color ABILITY_TURRET = ScreenPalette.ABILITY_TURRET.create(); + private static final int SHOP_CATEGORY_BUTTON_COUNT = 10; + + private Rectangle healthUpgradeButton; + private Rectangle damageUpgradeButton; + private Rectangle speedUpgradeButton; + private Rectangle cooldownUpgradeButton; + private Rectangle regenUpgradeButton; + private Rectangle critChanceUpgradeButton; + private Rectangle critMultiplierUpgradeButton; + private Rectangle attackDepartmentButton; + private Rectangle healthDepartmentButton; + private Rectangle abilitiesDepartmentButton; + private Rectangle scrollUpButton; + private Rectangle scrollDownButton; + private Rectangle shopToggleButton; + private Rectangle topBar; + private Rectangle waveBar; + private Rectangle statStrip; + private Rectangle shopSheet; + private Rectangle shopHandle; + private Rectangle abilityDock; + private Rectangle dockLabel; + private Rectangle freezeAbilityButton; + private Rectangle explosionAbilityButton; + private Rectangle shieldAbilityButton; + private Rectangle impulseAbilityButton; + private Rectangle dashAbilityButton; + private Rectangle pullAbilityButton; + private Rectangle turretAbilityButton; + private final Rectangle healthUpgradeButtonBounds = new Rectangle(); + private final Rectangle damageUpgradeButtonBounds = new Rectangle(); + private final Rectangle speedUpgradeButtonBounds = new Rectangle(); + private final Rectangle cooldownUpgradeButtonBounds = new Rectangle(); + private final Rectangle regenUpgradeButtonBounds = new Rectangle(); + private final Rectangle critChanceUpgradeButtonBounds = new Rectangle(); + private final Rectangle critMultiplierUpgradeButtonBounds = new Rectangle(); + private final Rectangle attackDepartmentButtonBounds = new Rectangle(); + private final Rectangle healthDepartmentButtonBounds = new Rectangle(); + private final Rectangle abilitiesDepartmentButtonBounds = new Rectangle(); + private final Rectangle scrollUpButtonBounds = new Rectangle(); + private final Rectangle scrollDownButtonBounds = new Rectangle(); + private final Rectangle shopToggleButtonBounds = new Rectangle(); + private final Rectangle topBarBounds = new Rectangle(); + private final Rectangle waveBarBounds = new Rectangle(); + private final Rectangle statStripBounds = new Rectangle(); + private final Rectangle shopSheetBounds = new Rectangle(); + private final Rectangle shopHandleBounds = new Rectangle(); + private final Rectangle abilityDockBounds = new Rectangle(); + private final Rectangle dockLabelBounds = new Rectangle(); + private final Rectangle freezeAbilityButtonBounds = new Rectangle(); + private final Rectangle explosionAbilityButtonBounds = new Rectangle(); + private final Rectangle shieldAbilityButtonBounds = new Rectangle(); + private final Rectangle impulseAbilityButtonBounds = new Rectangle(); + private final Rectangle dashAbilityButtonBounds = new Rectangle(); + private final Rectangle pullAbilityButtonBounds = new Rectangle(); + private final Rectangle turretAbilityButtonBounds = new Rectangle(); + private final Rectangle[] visibleShopUpgradeButtonBounds = + createRectangles(SHOP_CATEGORY_BUTTON_COUNT); + private final Rectangle[] visibleShopUpgradeButtons = new Rectangle[SHOP_CATEGORY_BUTTON_COUNT]; + private final ShopUpgradeKind[] visibleShopUpgradeKinds = + new ShopUpgradeKind[SHOP_CATEGORY_BUTTON_COUNT]; + + private boolean showAttackUpgrades = true; + private boolean showAbilities = false; + private boolean shopExpanded = false; + private float shopTransitionProgress = 0f; + private float scrollOffset = 0f; + private float maxScrollOffset = 0f; + private float minScrollOffset = 0f; + private float buttonHeight = 0f; + private float departmentButtonsY = 0f; + private float touchPadding = 0f; + private boolean deterministicReviewPricing = false; + + public GameScreenShopPanelState() {} + + public void rebuild( + int viewportWidth, + int viewportHeight, + float uiScale, + Array selectedAbilities, + PlayerAbilities playerAbilities) { + rebuild( + viewportWidth, + viewportHeight, + uiScale, + selectedAbilities, + playerAbilities, + SafeAreaInsets.zero()); + } + + public void rebuild( + int viewportWidth, + int viewportHeight, + float uiScale, + Array selectedAbilities, + PlayerAbilities playerAbilities, + SafeAreaInsets safeArea) { + int abilityButtonCount = selectedAbilityButtonCount(selectedAbilities, playerAbilities); + GameScreenLayout layout = + calculateLayout( + viewportWidth, viewportHeight, uiScale, abilityButtonCount, safeArea); + + applyLayoutMetrics(layout); + rebuildVisiblePanelButtons(layout, selectedAbilities, playerAbilities); + rebuildOwnedAbilityDockButtons(layout, selectedAbilities, playerAbilities); + } + + private GameScreenLayout calculateLayout( + int viewportWidth, + int viewportHeight, + float uiScale, + int abilityButtonCount, + SafeAreaInsets safeArea) { + return GameScreenLayout.calculate( + viewportWidth, + viewportHeight, + uiScale, + scrollOffset, + visiblePanel(), + abilityButtonCount, + safeArea); + } + + private GameScreenLayout.Panel visiblePanel() { + if (showAttackUpgrades) return GameScreenLayout.Panel.ATTACK; + if (showAbilities) return GameScreenLayout.Panel.ABILITIES; + return GameScreenLayout.Panel.HEALTH; + } + + private ShopUpgradeCategory visibleShopCategory() { + if (showAttackUpgrades) return ShopUpgradeCategory.ATTACK; + if (showAbilities) return ShopUpgradeCategory.BONUS; + return ShopUpgradeCategory.DEFENSE; + } + + private void applyLayoutMetrics(GameScreenLayout layout) { + buttonHeight = layout.rowAdvance(); + departmentButtonsY = layout.departmentButtonsY(); + minScrollOffset = layout.minScrollOffset(); + maxScrollOffset = layout.maxScrollOffset(); + scrollOffset = Math.min(Math.max(scrollOffset, minScrollOffset), maxScrollOffset); + touchPadding = layout.touchPadding(); + + attackDepartmentButton = + setRect(attackDepartmentButtonBounds, layout.attackDepartmentButton()); + healthDepartmentButton = + setRect(healthDepartmentButtonBounds, layout.healthDepartmentButton()); + abilitiesDepartmentButton = + setRect(abilitiesDepartmentButtonBounds, layout.abilitiesDepartmentButton()); + scrollUpButton = setRect(scrollUpButtonBounds, layout.scrollUpButton()); + scrollDownButton = setRect(scrollDownButtonBounds, layout.scrollDownButton()); + shopToggleButton = setRect(shopToggleButtonBounds, layout.shopToggleButton()); + topBar = setRect(topBarBounds, layout.topBar()); + waveBar = setRect(waveBarBounds, layout.waveBar()); + statStrip = setRect(statStripBounds, layout.statStrip()); + shopSheet = setRect(shopSheetBounds, layout.shopSheet()); + shopHandle = setRect(shopHandleBounds, layout.shopHandle()); + abilityDock = setRect(abilityDockBounds, layout.abilityDock()); + dockLabel = setRect(dockLabelBounds, layout.dockLabel()); + } + + private void rebuildVisiblePanelButtons( + GameScreenLayout layout, + Array selectedAbilities, + PlayerAbilities playerAbilities) { + rebuildShopUpgradeButtons(layout, visibleShopCategory()); + clearAbilityRectangles(); + } + + public void showPanel(GameScreenActionRouter.UpgradePanel panel) { + showAttackUpgrades = panel == GameScreenActionRouter.UpgradePanel.ATTACK; + showAbilities = panel == GameScreenActionRouter.UpgradePanel.ABILITIES; + shopExpanded = true; + scrollOffset = 0f; + } + + public void toggleShop() { + shopExpanded = !shopExpanded; + if (!shopExpanded) { + scrollOffset = 0f; + } + } + + public boolean shopExpanded() { + return shopExpanded; + } + + public void updateTransition(float delta) { + float step = Math.max(0f, delta) / SHOP_TRANSITION_DURATION_SECONDS; + if (shopExpanded) { + shopTransitionProgress = Math.min(1f, shopTransitionProgress + step); + } else { + shopTransitionProgress = Math.max(0f, shopTransitionProgress - step); + } + } + + public void setShopOpenImmediately(boolean open) { + shopExpanded = open; + shopTransitionProgress = open ? 1f : 0f; + if (!open) { + scrollOffset = 0f; + } + } + + public void setShopTransitionProgressForReview(float progress) { + shopExpanded = progress > 0f; + shopTransitionProgress = Math.max(0f, Math.min(1f, progress)); + } + + public float shopTransitionProgress() { + return shopTransitionProgress; + } + + public void scroll(int direction) { + scrollBy(direction * buttonHeight); + } + + public void scrollBy(float amount) { + scrollOffset = + Math.min(maxScrollOffset, Math.max(minScrollOffset, scrollOffset + amount)); + } + + public float scrollOffset() { + return scrollOffset; + } + + public float maxScrollOffset() { + return maxScrollOffset; + } + + public void setDeterministicReviewPricing(boolean enabled) { + deterministicReviewPricing = enabled; + } + + public void fillInputModelShopFields( + GameScreenInputModel inputModel, PlayerAbilities playerAbilities) { + inputModel.fillShopFields( + new GameScreenInputModel.ShopFields( + showAttackUpgrades, + showAbilities, + shopExpanded, + playerAbilities.purchasedAbilityCount(), + touchPadding, + departmentButtonsY, + shopExpanded ? attackDepartmentButton : null, + shopExpanded ? healthDepartmentButton : null, + shopExpanded ? abilitiesDepartmentButton : null, + shopExpanded ? scrollUpButton : null, + shopExpanded ? scrollDownButton : null, + shopToggleButton, + statStrip, + shopExpanded ? damageUpgradeButton : null, + shopExpanded ? speedUpgradeButton : null, + shopExpanded ? cooldownUpgradeButton : null, + shopExpanded ? critChanceUpgradeButton : null, + shopExpanded ? critMultiplierUpgradeButton : null, + shopExpanded ? healthUpgradeButton : null, + shopExpanded ? regenUpgradeButton : null, + visibleShopUpgradeButtons(), + visibleShopUpgradeKinds(), + freezeAbilityButton, + explosionAbilityButton, + shieldAbilityButton, + impulseAbilityButton, + dashAbilityButton, + pullAbilityButton, + turretAbilityButton)); + } + + private Rectangle[] visibleShopUpgradeButtons() { + Rectangle[] buttons = new Rectangle[SHOP_CATEGORY_BUTTON_COUNT]; + if (!shopExpanded) return buttons; + System.arraycopy(visibleShopUpgradeButtons, 0, buttons, 0, buttons.length); + return buttons; + } + + private ShopUpgradeKind[] visibleShopUpgradeKinds() { + ShopUpgradeKind[] kinds = new ShopUpgradeKind[SHOP_CATEGORY_BUTTON_COUNT]; + if (!shopExpanded) return kinds; + System.arraycopy(visibleShopUpgradeKinds, 0, kinds, 0, kinds.length); + return kinds; + } + + public void fillHudPanelFields( + GameScreenHudModel hudModel, + ShopUpgradeSystem shopUpgradeSystem, + AbilitySystem abilitySystem, + long now) { + fillHudPanelFields(hudModel, shopUpgradeSystem, null, abilitySystem, now); + } + + public void fillHudPanelFields( + GameScreenHudModel hudModel, + ShopUpgradeSystem shopUpgradeSystem, + ShopUpgradePreviewValues previewValues, + AbilitySystem abilitySystem, + long now) { + fillShopPanelHeader(hudModel); + ShopUpgradePreviewValues effectivePreview = + previewValues == null ? fallbackPreviewValues(shopUpgradeSystem) : previewValues; + fillAttackButtons(hudModel, shopUpgradeSystem, effectivePreview); + fillHealthButtons(hudModel, shopUpgradeSystem, effectivePreview); + fillBonusButtons(hudModel, shopUpgradeSystem, effectivePreview); + fillAbilityButtons(hudModel, abilitySystem, now); + } + + private void fillShopPanelHeader(GameScreenHudModel hudModel) { + hudModel.fillShopPanelFields( + new GameScreenHudModel.ShopPanelFields( + departmentButtonsY, + showAttackUpgrades, + showAbilities, + shopExpanded, + shopTransitionProgress, + attackDepartmentButton, + healthDepartmentButton, + abilitiesDepartmentButton, + shopToggleButton, + topBar, + waveBar, + statStrip, + shopSheet, + shopHandle, + abilityDock, + dockLabel)); + } + + private void fillAttackButtons( + GameScreenHudModel hudModel, + ShopUpgradeSystem shopUpgradeSystem, + ShopUpgradePreviewValues previewValues) { + ShopUpgradePreviewValues.PreviewRow[] rows = + previewValues.rows(ShopUpgradeCategory.ATTACK, shopUpgradeSystem); + for (int i = 0; i < rows.length; i++) { + ShopUpgradePreviewValues.PreviewRow row = rows[i]; + if (deterministicReviewPricing) { + row = row.withPrice(attackPrice(shopUpgradeSystem, row.kind())); + } + hudModel.setAttackButton( + i, visibleBoundsFor(ShopUpgradeCategory.ATTACK, i), colorFor(row.kind()), row); + } + } + + private CharSequence currentValue( + ShopUpgradePreviewValues previewValues, ShopUpgradeKind kind) { + return previewValues == null ? null : previewValues.currentValue(kind); + } + + private CharSequence nextValue(ShopUpgradePreviewValues previewValues, ShopUpgradeKind kind) { + return previewValues == null ? null : previewValues.nextValue(kind); + } + + private ShopUpgradePreviewValues fallbackPreviewValues(ShopUpgradeSystem shopUpgradeSystem) { + return ShopUpgradePreviewValues.fromEffectiveStats( + shopUpgradeSystem, 0, 0f, 0f, 0f, 1f, 100, 0, 1f, 1f, 1f); + } + + private float damageDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE); + return EndlessBalanceSpec.shopDamageBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopDamageBonusAtLevel(level); + } + + private float speedDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.SPEED); + return EndlessBalanceSpec.shopSpeedBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopSpeedBonusAtLevel(level); + } + + private float cooldownDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN); + return EndlessBalanceSpec.shopCooldownReductionAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopCooldownReductionAtLevel(level); + } + + private float critChanceDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE); + return EndlessBalanceSpec.shopCritChanceBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopCritChanceBonusAtLevel(level); + } + + private float critMultiplierDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.CRIT_MULTIPLIER); + return EndlessBalanceSpec.shopCritMultiplierBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopCritMultiplierBonusAtLevel(level); + } + + private int nextShopLevel(int level) { + return Math.min(EndlessBalanceSpec.SHOP_MAX_LEVEL, level + 1); + } + + private int attackPrice(ShopUpgradeSystem shopUpgradeSystem, ShopUpgradeKind kind) { + if (!deterministicReviewPricing) { + return shopUpgradeSystem.cost(kind); + } + switch (kind) { + case DAMAGE: + return 12; + case SPEED: + return 10; + case COOLDOWN: + return 4; + case CRIT_CHANCE: + return 15; + default: + return shopUpgradeSystem.cost(kind); + } + } + + private void fillHealthButtons( + GameScreenHudModel hudModel, + ShopUpgradeSystem shopUpgradeSystem, + ShopUpgradePreviewValues previewValues) { + ShopUpgradePreviewValues.PreviewRow[] rows = + previewValues.rows(ShopUpgradeCategory.DEFENSE, shopUpgradeSystem); + for (int i = 0; i < rows.length; i++) { + hudModel.setHealthButton( + i, + visibleBoundsFor(ShopUpgradeCategory.DEFENSE, i), + colorFor(rows[i].kind()), + rows[i]); + } + } + + private void fillBonusButtons( + GameScreenHudModel hudModel, + ShopUpgradeSystem shopUpgradeSystem, + ShopUpgradePreviewValues previewValues) { + ShopUpgradePreviewValues.PreviewRow[] rows = + previewValues.rows(ShopUpgradeCategory.BONUS, shopUpgradeSystem); + for (int i = 0; i < rows.length; i++) { + hudModel.setBonusButton( + i, + visibleBoundsFor(ShopUpgradeCategory.BONUS, i), + colorFor(rows[i].kind()), + rows[i]); + } + } + + private void fillAbilityButtons( + GameScreenHudModel hudModel, AbilitySystem abilitySystem, long now) { + hudModel.clearAbilityButtons(); + hudModel.setAbilityButton( + AbilityKind.FREEZE, + freezeAbilityButton, + ABILITY_FREEZE, + remainingCooldownMs(abilitySystem, AbilityKind.FREEZE, now)); + hudModel.setAbilityButton( + AbilityKind.EXPLOSION, + explosionAbilityButton, + ABILITY_EXPLOSION, + remainingCooldownMs(abilitySystem, AbilityKind.EXPLOSION, now)); + hudModel.setAbilityButton( + AbilityKind.SHIELD, + shieldAbilityButton, + ABILITY_SHIELD, + remainingCooldownMs(abilitySystem, AbilityKind.SHIELD, now)); + hudModel.setAbilityButton( + AbilityKind.IMPULSE, + impulseAbilityButton, + ABILITY_IMPULSE, + remainingCooldownMs(abilitySystem, AbilityKind.IMPULSE, now)); + hudModel.setAbilityButton( + AbilityKind.DASH, + dashAbilityButton, + ABILITY_DASH, + remainingCooldownMs(abilitySystem, AbilityKind.DASH, now)); + hudModel.setAbilityButton( + AbilityKind.PULL, + pullAbilityButton, + ABILITY_PULL, + remainingCooldownMs(abilitySystem, AbilityKind.PULL, now)); + hudModel.setAbilityButton( + AbilityKind.TURRET, + turretAbilityButton, + ABILITY_TURRET, + remainingCooldownMs(abilitySystem, AbilityKind.TURRET, now)); + } + + private Rectangle visibleBoundsFor(ShopUpgradeCategory category, int index) { + return visibleShopCategory() == category ? visibleShopUpgradeButtons[index] : null; + } + + private Color colorFor(ShopUpgradeKind kind) { + switch (kind) { + case DAMAGE: + case EXECUTE_DAMAGE: + case BOSS_DAMAGE: + case CONTROL_DAMAGE: + return UPGRADE_DAMAGE; + case SPEED: + case PIERCE: + case SPLASH_DAMAGE: + return UPGRADE_SPEED; + case COOLDOWN: + return UPGRADE_COOLDOWN; + case CRIT_CHANCE: + return UPGRADE_CRIT_CHANCE; + case CRIT_MULTIPLIER: + return UPGRADE_CRIT_MULTIPLIER; + case HEALTH: + case CONTACT_ARMOR: + case PROJECTILE_ARMOR: + case SHIELD_CAPACITY: + case SHIELD_RECHARGE: + case EMERGENCY_BARRIER: + case SAFE_MOBILITY: + case SLOW_AURA: + return UPGRADE_HEALTH; + case REGEN: + case WAVE_RECOVERY: + return UPGRADE_REGEN; + case PICKUP_RADIUS: + case BONUS_DURATION: + case BONUS_POWER: + case BONUS_STABILITY: + case BONUS_PITY: + case BONUS_FILTER: + case COIN_INCOME: + case SHOP_DISCOUNT: + case CARD_REROLL: + case SYNERGY_CATALYST: + return UPGRADE_CRIT_MULTIPLIER; + default: + return UPGRADE_DAMAGE; + } + } + + private GameScreenLayout.Rect layoutButton( + GameScreenLayout layout, ShopUpgradeCategory category, int index) { + if (category == ShopUpgradeCategory.ATTACK) return layout.attackButton(index); + if (category == ShopUpgradeCategory.DEFENSE) return layout.healthButton(index); + return layout.abilityButton(index); + } + + private void assignLegacyUpgradeButton(ShopUpgradeKind kind, Rectangle bounds) { + switch (kind) { + case DAMAGE: + damageUpgradeButton = bounds; + break; + case SPEED: + speedUpgradeButton = bounds; + break; + case COOLDOWN: + cooldownUpgradeButton = bounds; + break; + case CRIT_CHANCE: + critChanceUpgradeButton = bounds; + break; + case CRIT_MULTIPLIER: + critMultiplierUpgradeButton = bounds; + break; + case HEALTH: + healthUpgradeButton = bounds; + break; + case REGEN: + regenUpgradeButton = bounds; + break; + default: + break; + } + } + + private void rebuildShopUpgradeButtons(GameScreenLayout layout, ShopUpgradeCategory category) { + clearUpgradeRectangles(); + clearVisibleShopUpgradeButtons(); + ShopUpgradeDefinition[] definitions = ShopUpgradeRegistry.byCategory(category); + for (int i = 0; i < definitions.length; i++) { + GameScreenLayout.Rect layoutRect = layoutButton(layout, category, i); + Rectangle bounds = setRect(visibleShopUpgradeButtonBounds[i], layoutRect); + visibleShopUpgradeButtons[i] = bounds; + visibleShopUpgradeKinds[i] = definitions[i].kind(); + assignLegacyUpgradeButton(definitions[i].kind(), bounds); + } + } + + private void clearUpgradeRectangles() { + healthUpgradeButton = null; + regenUpgradeButton = null; + damageUpgradeButton = null; + speedUpgradeButton = null; + cooldownUpgradeButton = null; + critChanceUpgradeButton = null; + critMultiplierUpgradeButton = null; + } + + private void clearVisibleShopUpgradeButtons() { + for (int i = 0; i < visibleShopUpgradeButtons.length; i++) { + visibleShopUpgradeButtons[i] = null; + visibleShopUpgradeKinds[i] = null; + } + } + + private void rebuildOwnedAbilityDockButtons( + GameScreenLayout layout, + Array selectedAbilities, + PlayerAbilities playerAbilities) { + clearAbilityRectangles(); + if (selectedAbilities == null) return; + + int abilityIndex = 0; + for (AbilityKind ability : selectedAbilities) { + if (!playerAbilities.hasAbility(ability)) continue; + if (abilityIndex >= 3) break; + + assignAbilityButton(ability, layout.dockAbilityButton(abilityIndex)); + abilityIndex++; + } + } + + private void assignAbilityButton(AbilityKind ability, GameScreenLayout.Rect abilityButton) { + switch (ability) { + case FREEZE: + freezeAbilityButton = setRect(freezeAbilityButtonBounds, abilityButton); + break; + case EXPLOSION: + explosionAbilityButton = setRect(explosionAbilityButtonBounds, abilityButton); + break; + case SHIELD: + shieldAbilityButton = setRect(shieldAbilityButtonBounds, abilityButton); + break; + case IMPULSE: + impulseAbilityButton = setRect(impulseAbilityButtonBounds, abilityButton); + break; + case DASH: + dashAbilityButton = setRect(dashAbilityButtonBounds, abilityButton); + break; + case PULL: + pullAbilityButton = setRect(pullAbilityButtonBounds, abilityButton); + break; + case TURRET: + turretAbilityButton = setRect(turretAbilityButtonBounds, abilityButton); + break; + default: + break; + } + } + + private void clearAbilityRectangles() { + freezeAbilityButton = null; + explosionAbilityButton = null; + shieldAbilityButton = null; + impulseAbilityButton = null; + dashAbilityButton = null; + pullAbilityButton = null; + turretAbilityButton = null; + } + + private int selectedAbilityButtonCount( + Array selectedAbilities, PlayerAbilities playerAbilities) { + if (selectedAbilities == null) return 0; + + int count = 0; + for (AbilityKind ability : selectedAbilities) { + if (playerAbilities.hasAbility(ability)) { + count++; + } + } + return count; + } + + private long remainingCooldownMs(AbilitySystem abilitySystem, AbilityKind ability, long now) { + if (deterministicReviewPricing) { + return ability == AbilityKind.SHIELD ? 3_200L : 0L; + } + return abilitySystem == null ? 0L : abilitySystem.remainingCooldownMs(ability, now); + } + + private Rectangle setRect(Rectangle rect, GameScreenLayout.Rect layoutRect) { + if (layoutRect == null) return null; + rect.set(layoutRect.x(), layoutRect.y(), layoutRect.width(), layoutRect.height()); + return rect; + } + + private static Rectangle[] createRectangles(int count) { + Rectangle[] rectangles = new Rectangle[count]; + for (int i = 0; i < rectangles.length; i++) { + rectangles[i] = new Rectangle(); + } + return rectangles; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopUpgradeHost.java b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopUpgradeHost.java new file mode 100644 index 0000000..e5748c5 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopUpgradeHost.java @@ -0,0 +1,63 @@ +package ru.project.tower.screens.gamescreen.shop; + +import java.util.Objects; +import ru.project.tower.upgrades.shop.ShopUpgradeHost; + +public final class GameScreenShopUpgradeHost implements ShopUpgradeHost { + private final Host host; + + public GameScreenShopUpgradeHost(Host host) { + this.host = Objects.requireNonNull(host, "host"); + } + + @Override + public boolean spendMoney(int amount) { + return host.spendMoney(amount); + } + + @Override + public void addMaxHealth(int amount) { + host.addMaxHealth(amount); + } + + @Override + public void healToFull() { + host.healToFull(); + } + + @Override + public void addBaseDamage(int amount) { + host.addBaseDamage(amount); + } + + @Override + public void addBaseSpeed(float amount) { + host.addBaseSpeed(amount); + } + + @Override + public void reduceShotCooldown(float amount, float minimum) { + host.reduceShotCooldown(amount, minimum); + } + + @Override + public void addHealthRegen(int amount) { + host.addHealthRegen(amount); + } + + public interface Host { + boolean spendMoney(int amount); + + void addMaxHealth(int amount); + + void healToFull(); + + void addBaseDamage(int amount); + + void addBaseSpeed(float amount); + + void reduceShotCooldown(float amount, float minimum); + + void addHealthRegen(int amount); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopUpgradeHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopUpgradeHostAdapter.java new file mode 100644 index 0000000..c9467ad --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/GameScreenShopUpgradeHostAdapter.java @@ -0,0 +1,62 @@ +package ru.project.tower.screens.gamescreen.shop; + +import java.util.Objects; + +public final class GameScreenShopUpgradeHostAdapter implements GameScreenShopUpgradeHost.Host { + private final Port port; + + public GameScreenShopUpgradeHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public boolean spendMoney(int amount) { + return port.spendMoney(amount); + } + + @Override + public void addMaxHealth(int amount) { + port.addMaxHealth(amount); + } + + @Override + public void healToFull() { + port.healToFull(); + } + + @Override + public void addBaseDamage(int amount) { + port.addBaseDamage(amount); + } + + @Override + public void addBaseSpeed(float amount) { + port.addBaseSpeed(amount); + } + + @Override + public void reduceShotCooldown(float amount, float minimum) { + port.reduceShotCooldown(amount, minimum); + } + + @Override + public void addHealthRegen(int amount) { + port.addHealthRegen(amount); + } + + public interface Port { + boolean spendMoney(int amount); + + void addMaxHealth(int amount); + + void healToFull(); + + void addBaseDamage(int amount); + + void addBaseSpeed(float amount); + + void reduceShotCooldown(float amount, float minimum); + + void addHealthRegen(int amount); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/shop/ShopUpgradePreviewValues.java b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/ShopUpgradePreviewValues.java new file mode 100644 index 0000000..3da0980 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/shop/ShopUpgradePreviewValues.java @@ -0,0 +1,312 @@ +package ru.project.tower.screens.gamescreen.shop; + +import java.util.Locale; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.support.CompactNumberFormatter; +import ru.project.tower.upgrades.shop.ShopUpgradeCategory; +import ru.project.tower.upgrades.shop.ShopUpgradeDefinition; +import ru.project.tower.upgrades.shop.ShopUpgradeKind; +import ru.project.tower.upgrades.shop.ShopUpgradeRegistry; +import ru.project.tower.upgrades.shop.ShopUpgradeSystem; + +public final class ShopUpgradePreviewValues { + private static final ShopUpgradeKind[] KINDS = ShopUpgradeKind.values(); + private static final int HEALTH_GAIN = 20; + + private final String[] currentValues = new String[KINDS.length]; + private final String[] nextValues = new String[KINDS.length]; + + private ShopUpgradePreviewValues() {} + + public static ShopUpgradePreviewValues fromEffectiveStats( + ShopUpgradeSystem shopUpgradeSystem, + float effectiveDamage, + float effectiveSpeed, + float effectiveCooldownMs, + float effectiveCritChance, + float effectiveCritMultiplier, + int maxHealth, + int healthRegenRate, + float damageDeltaScale, + float speedDeltaScale, + float cooldownDeltaScale) { + ShopUpgradePreviewValues values = new ShopUpgradePreviewValues(); + values.fill( + shopUpgradeSystem, + effectiveDamage, + effectiveSpeed, + effectiveCooldownMs, + effectiveCritChance, + effectiveCritMultiplier, + maxHealth, + healthRegenRate, + damageDeltaScale, + speedDeltaScale, + cooldownDeltaScale); + return values; + } + + public String currentValue(ShopUpgradeKind kind) { + return currentValues[index(kind)]; + } + + public String nextValue(ShopUpgradeKind kind) { + return nextValues[index(kind)]; + } + + public PreviewRow[] rows(ShopUpgradeCategory category, ShopUpgradeSystem shopUpgradeSystem) { + ShopUpgradeDefinition[] definitions = ShopUpgradeRegistry.byCategory(category); + PreviewRow[] rows = new PreviewRow[definitions.length]; + for (int i = 0; i < definitions.length; i++) { + ShopUpgradeDefinition definition = definitions[i]; + ShopUpgradeKind kind = definition.kind(); + int level = shopUpgradeSystem.level(kind); + rows[i] = + new PreviewRow( + kind, + definition.gameName(), + definition.shortDescription(), + definition.iconToken(), + definition.colorToken(), + currentValue(kind), + nextValue(kind), + shopUpgradeSystem.cost(kind), + level >= definition.maxLevel()); + } + return rows; + } + + private void fill( + ShopUpgradeSystem shopUpgradeSystem, + float effectiveDamage, + float effectiveSpeed, + float effectiveCooldownMs, + float effectiveCritChance, + float effectiveCritMultiplier, + int maxHealth, + int healthRegenRate, + float damageDeltaScale, + float speedDeltaScale, + float cooldownDeltaScale) { + for (ShopUpgradeKind kind : KINDS) { + int level = shopUpgradeSystem.level(kind); + set(kind, "ур. " + level, "ур. " + nextShopLevel(level)); + } + set( + ShopUpgradeKind.DAMAGE, + number(effectiveDamage), + number(effectiveDamage + damageDelta(shopUpgradeSystem) * damageDeltaScale)); + set( + ShopUpgradeKind.SPEED, + number(effectiveSpeed), + number(effectiveSpeed + speedDelta(shopUpgradeSystem) * speedDeltaScale)); + set( + ShopUpgradeKind.COOLDOWN, + seconds(effectiveCooldownMs), + seconds( + Math.max( + PlayerCombatState.MIN_SHOT_COOLDOWN_MS, + effectiveCooldownMs + - cooldownDelta(shopUpgradeSystem) * cooldownDeltaScale))); + set( + ShopUpgradeKind.CRIT_CHANCE, + percent(effectiveCritChance), + percent( + Math.min( + EndlessBalanceSpec.CRIT_CHANCE_CAP, + effectiveCritChance + critChanceDelta(shopUpgradeSystem)))); + set( + ShopUpgradeKind.CRIT_MULTIPLIER, + multiplier(effectiveCritMultiplier), + multiplier(effectiveCritMultiplier + critMultiplierDelta(shopUpgradeSystem))); + set(ShopUpgradeKind.HEALTH, number(maxHealth), number(maxHealth + HEALTH_GAIN)); + set( + ShopUpgradeKind.REGEN, + perSecond(healthRegenRate), + perSecond(healthRegenRate + regenDelta(shopUpgradeSystem))); + } + + private void set(ShopUpgradeKind kind, String currentValue, String nextValue) { + currentValues[index(kind)] = currentValue; + nextValues[index(kind)] = nextValue; + } + + private static int damageDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.DAMAGE); + return EndlessBalanceSpec.shopDamageBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopDamageBonusAtLevel(level); + } + + private static float speedDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.SPEED); + return EndlessBalanceSpec.shopSpeedBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopSpeedBonusAtLevel(level); + } + + private static float cooldownDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.COOLDOWN); + return EndlessBalanceSpec.shopCooldownReductionAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopCooldownReductionAtLevel(level); + } + + private static int regenDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.REGEN); + return EndlessBalanceSpec.shopRegenBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopRegenBonusAtLevel(level); + } + + private static float critChanceDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.CRIT_CHANCE); + return EndlessBalanceSpec.shopCritChanceBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopCritChanceBonusAtLevel(level); + } + + private static float critMultiplierDelta(ShopUpgradeSystem shopUpgradeSystem) { + int level = shopUpgradeSystem.level(ShopUpgradeKind.CRIT_MULTIPLIER); + return EndlessBalanceSpec.shopCritMultiplierBonusAtLevel(nextShopLevel(level)) + - EndlessBalanceSpec.shopCritMultiplierBonusAtLevel(level); + } + + private static int nextShopLevel(int level) { + return Math.min(EndlessBalanceSpec.SHOP_MAX_LEVEL, level + 1); + } + + private static String number(long value) { + return CompactNumberFormatter.format(value); + } + + private static String number(float value) { + float abs = Math.abs(value); + if (abs >= 1_000f) { + return CompactNumberFormatter.format(value); + } + if (Math.abs(value - Math.round(value)) < 0.005f) { + return Long.toString(Math.round(value)); + } + String text = String.format(Locale.ROOT, "%.2f", value); + while (text.endsWith("0")) { + text = text.substring(0, text.length() - 1); + } + if (text.endsWith(".")) { + text = text.substring(0, text.length() - 1); + } + return text; + } + + private static String seconds(float milliseconds) { + return CompactNumberFormatter.seconds(milliseconds).replace('s', 'с'); + } + + private static String percent(float fraction) { + return Math.round(fraction * 100f) + "%"; + } + + private static String multiplier(float value) { + int tenths = Math.round(value * 10f); + return (tenths / 10) + "." + Math.abs(tenths % 10) + "×"; + } + + private static String perSecond(long value) { + if (value <= 0L) { + return "0"; + } + return "+" + number(value); + } + + private static int index(ShopUpgradeKind kind) { + return kind.ordinal(); + } + + public static final class PreviewRow { + private final ShopUpgradeKind kind; + private final String gameName; + private final String description; + private final String iconToken; + private final String colorToken; + private final String currentValue; + private final String nextValue; + private final int price; + private final boolean maxed; + + private PreviewRow( + ShopUpgradeKind kind, + String gameName, + String description, + String iconToken, + String colorToken, + String currentValue, + String nextValue, + int price, + boolean maxed) { + this.kind = kind; + this.gameName = gameName; + this.description = description; + this.iconToken = iconToken; + this.colorToken = colorToken; + this.currentValue = currentValue; + this.nextValue = nextValue; + this.price = price; + this.maxed = maxed; + } + + public ShopUpgradeKind kind() { + return kind; + } + + public String gameName() { + return gameName; + } + + public String description() { + return description; + } + + public String iconToken() { + return iconToken; + } + + public String colorToken() { + return colorToken; + } + + public String currentValue() { + return currentValue; + } + + public String nextValue() { + return nextValue; + } + + public int price() { + return price; + } + + PreviewRow withPrice(int price) { + return new PreviewRow( + kind, + gameName, + description, + iconToken, + colorToken, + currentValue, + nextValue, + price, + maxed); + } + + public boolean maxed() { + return maxed; + } + + public String disabledReason(int availableMoney) { + if (maxed) { + return "Максимум"; + } + if (availableMoney < price) { + return "Не хватает " + number(price - availableMoney) + "₽"; + } + return null; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenBossEffectsHost.java b/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenBossEffectsHost.java new file mode 100644 index 0000000..6b4ff83 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenBossEffectsHost.java @@ -0,0 +1,20 @@ +package ru.project.tower.screens.gamescreen.special; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; +import ru.project.tower.screens.gamescreen.vfx.GameScreenParticleEffects; +import ru.project.tower.support.BossEffectsHost; + +public final class GameScreenBossEffectsHost implements BossEffectsHost { + private final GameScreenParticleEffects particleEffects; + + public GameScreenBossEffectsHost(GameScreenParticleEffects particleEffects) { + this.particleEffects = Objects.requireNonNull(particleEffects, "particleEffects"); + } + + @Override + public void createBossParticles( + float x, float y, float radius, Color color, float scale, int count) { + particleEffects.createBossParticles(x, y, radius, color, scale, count); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenSpecialCircleHost.java b/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenSpecialCircleHost.java new file mode 100644 index 0000000..dab2524 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenSpecialCircleHost.java @@ -0,0 +1,57 @@ +package ru.project.tower.screens.gamescreen.special; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.Rectangle; +import java.util.Objects; +import ru.project.tower.entities.circles.bosses.SwarmQueenData; +import ru.project.tower.screens.gamescreen.vfx.GameScreenParticleEffects; +import ru.project.tower.systems.CollisionSystem; +import ru.project.tower.systems.SpecialCircleSystem; + +public final class GameScreenSpecialCircleHost implements SpecialCircleSystem.Host { + private final Host host; + private final GameScreenParticleEffects particleEffects; + + public GameScreenSpecialCircleHost(Host host, GameScreenParticleEffects particleEffects) { + this.host = Objects.requireNonNull(host, "host"); + this.particleEffects = Objects.requireNonNull(particleEffects, "particleEffects"); + } + + @Override + public void spawnQueenMinion(SwarmQueenData queen) { + host.spawnQueenMinion(queen); + } + + @Override + public void createBossParticles( + float x, float y, float radius, Color color, float spread, int count) { + particleEffects.createBossParticles(x, y, radius, color, spread, count); + } + + @Override + public void createExplosion(float x, float y, Color color) { + particleEffects.createExplosion(x, y, color); + } + + @Override + public void createExplosion(float x, float y, float[] color) { + particleEffects.createExplosion(x, y, color); + } + + @Override + public boolean isCircleIntersectingSquare(Circle circle, Rectangle square) { + return CollisionSystem.circleIntersectsSquare(circle, square); + } + + @Override + public boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs) { + return host.damagePlayerIfVulnerable(damage, damageCooldownMs); + } + + public interface Host { + void spawnQueenMinion(SwarmQueenData queen); + + boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenSpecialCircleHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenSpecialCircleHostAdapter.java new file mode 100644 index 0000000..3216e99 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/special/GameScreenSpecialCircleHostAdapter.java @@ -0,0 +1,28 @@ +package ru.project.tower.screens.gamescreen.special; + +import java.util.Objects; +import ru.project.tower.entities.circles.bosses.SwarmQueenData; + +public final class GameScreenSpecialCircleHostAdapter implements GameScreenSpecialCircleHost.Host { + private final Port port; + + public GameScreenSpecialCircleHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void spawnQueenMinion(SwarmQueenData queen) { + port.spawnQueenMinion(queen); + } + + @Override + public boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs) { + return port.damagePlayerIfVulnerable(damage, damageCooldownMs); + } + + public interface Port { + void spawnQueenMinion(SwarmQueenData queen); + + boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/ui/NeonButtonRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/ui/NeonButtonRenderer.java new file mode 100644 index 0000000..93ed37c --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/ui/NeonButtonRenderer.java @@ -0,0 +1,75 @@ +package ru.project.tower.screens.gamescreen.ui; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; + +public final class NeonButtonRenderer { + private NeonButtonRenderer() {} + + public static void drawFill( + ShapeRenderer shapeRenderer, + Rectangle button, + Color borderColor, + Color scratchColor, + float uiScale, + float glow) { + if (button == null) return; + + shapeRenderer.setColor(0f, 0f, 0f, 0.9f); + shapeRenderer.rect(button.x, button.y, button.width, button.height); + + if (glow > 0.3f) { + shapeRenderer.setColor(withAlpha(scratchColor, borderColor, 0.15f * glow)); + + float glowSize = 4f * uiScale * glow; + shapeRenderer.rect( + button.x - glowSize, + button.y - glowSize, + button.width + glowSize * 2, + button.height + glowSize * 2); + } + } + + public static void drawBorder( + ShapeRenderer shapeRenderer, + Rectangle button, + Color borderColor, + Color scratchColor, + float glow, + boolean hovered) { + if (button == null) return; + + shapeRenderer.setColor(withAlpha(scratchColor, borderColor, 0.8f + 0.2f * glow)); + + int borderThickness = borderThickness(hovered); + for (int i = 0; i < borderThickness; i++) { + shapeRenderer.rect( + button.x + i, button.y + i, button.width - i * 2, button.height - i * 2); + } + } + + public static Color borderColor( + Color defaultColor, + Color hoverColor, + Color pressedColor, + boolean hovered, + boolean pressed) { + if (pressed) { + return pressedColor; + } else if (hovered) { + return hoverColor; + } + return defaultColor; + } + + public static int borderThickness(boolean hovered) { + return hovered ? 3 : 2; + } + + public static Color withAlpha(Color scratchColor, Color base, float alpha) { + scratchColor.set(base); + scratchColor.a = alpha; + return scratchColor; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/ui/NeonTextRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/ui/NeonTextRenderer.java new file mode 100644 index 0000000..32cd1d1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/ui/NeonTextRenderer.java @@ -0,0 +1,112 @@ +package ru.project.tower.screens.gamescreen.ui; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.math.Rectangle; + +public final class NeonTextRenderer { + private final GlyphLayout mainLayout = new GlyphLayout(); + private final GlyphLayout secondaryLayout = new GlyphLayout(); + + public void drawPricedButtonText( + BitmapFont font, + SpriteBatch batch, + CharSequence mainText, + CharSequence priceText, + Rectangle button, + Color priceTextColor) { + mainLayout.setText(font, mainText); + secondaryLayout.setText(font, priceText); + + float totalWidth = mainLayout.width + secondaryLayout.width; + float maxWidth = button.width * 0.9f; + float scale = 1.0f; + if (totalWidth > maxWidth) { + scale = maxWidth / totalWidth; + } + + font.getData().setScale(scale); + + mainLayout.setText(font, mainText); + secondaryLayout.setText(font, priceText); + totalWidth = mainLayout.width + secondaryLayout.width; + + float textX = button.x + (button.width - totalWidth) / 2; + float textY = button.y + (button.height + mainLayout.height) / 2; + + font.setColor(1f, 1f, 1f, 1f); + font.draw(batch, mainText, textX, textY); + + font.setColor(priceTextColor); + font.draw(batch, priceText, textX + mainLayout.width, textY); + + font.getData().setScale(1.0f); + font.setColor(1f, 1f, 1f, 1f); + } + + public void drawNeonButtonText( + BitmapFont font, + SpriteBatch batch, + CharSequence mainText, + CharSequence secondaryText, + Rectangle button, + Color secondaryColor) { + mainLayout.setText(font, mainText); + secondaryLayout.setText(font, secondaryText); + + float totalWidth = mainLayout.width; + if (secondaryText.length() > 0) { + totalWidth += secondaryLayout.width; + } + float maxWidth = button.width * 0.9f; + + float scale = 1.0f; + if (totalWidth > maxWidth) { + scale = maxWidth / totalWidth; + } + + font.getData().setScale(scale); + + mainLayout.setText(font, mainText); + secondaryLayout.setText(font, secondaryText); + totalWidth = mainLayout.width; + if (secondaryText.length() > 0) { + totalWidth += secondaryLayout.width; + } + + float textX = button.x + (button.width - totalWidth) / 2; + float textY = button.y + (button.height + mainLayout.height) / 2; + + font.setColor(Color.WHITE); + font.draw(batch, mainText, textX, textY); + + if (secondaryText.length() > 0) { + font.setColor(secondaryColor); + font.draw(batch, secondaryText, textX + mainLayout.width, textY); + } + + font.getData().setScale(1.0f); + } + + public void drawCenteredText( + BitmapFont font, SpriteBatch batch, CharSequence text, Rectangle button) { + mainLayout.setText(font, text); + + float maxButtonWidth = button.width * 0.9f; + if (mainLayout.width > maxButtonWidth) { + float scale = maxButtonWidth / mainLayout.width; + font.getData().setScale(scale); + mainLayout.setText(font, text); + } + + font.draw( + batch, + text, + button.x + (button.width - mainLayout.width) / 2, + button.y + (button.height + mainLayout.height) / 2); + + font.getData().setScale(1.0f); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/upgrades/GameScreenUpgradeHost.java b/core/src/main/java/ru/project/tower/screens/gamescreen/upgrades/GameScreenUpgradeHost.java new file mode 100644 index 0000000..6a4b6dc --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/upgrades/GameScreenUpgradeHost.java @@ -0,0 +1,74 @@ +package ru.project.tower.screens.gamescreen.upgrades; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.player.PlayerCombatState; +import ru.project.tower.upgrades.UpgradeHost; + +public final class GameScreenUpgradeHost implements UpgradeHost { + private final Host host; + private final BulletSystem bulletSystem; + private final PlayerCombatState playerCombatState; + + public GameScreenUpgradeHost( + Host host, BulletSystem bulletSystem, PlayerCombatState playerCombatState) { + this.host = Objects.requireNonNull(host, "host"); + this.bulletSystem = Objects.requireNonNull(bulletSystem, "bulletSystem"); + this.playerCombatState = Objects.requireNonNull(playerCombatState, "playerCombatState"); + } + + @Override + public void addRunDamagePercentBonus(int percent) { + int bonus = Math.max(1, bulletSystem.getBaseDamage() * percent / 100); + bulletSystem.addRunDamageBonus(bonus); + } + + @Override + public void addRunPierceBonus(int bonus) { + bulletSystem.addRunPierceBonus(bonus); + } + + @Override + public void addRunAoeBonus(float radiusBonus) { + bulletSystem.addRunAoeBonus(radiusBonus); + } + + @Override + public void addMaxHealth(int amount) { + playerCombatState.setMaxHealth(playerCombatState.getMaxHealth() + amount); + } + + @Override + public void healToFull() { + playerCombatState.setHealth(playerCombatState.getMaxHealth()); + } + + @Override + public void addHealthRegen(int perSecond) { + playerCombatState.setHealthRegenRate(playerCombatState.getHealthRegenRate() + perSecond); + } + + @Override + public void addFloatingText(float x, float y, String text, Color color) { + host.addFloatingText(x, y, text, color); + } + + @Override + public float getPlayerCenterX() { + return host.playerCenterX(); + } + + @Override + public float getPlayerCenterY() { + return host.playerCenterY(); + } + + public interface Host { + void addFloatingText(float x, float y, String text, Color color); + + float playerCenterX(); + + float playerCenterY(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/upgrades/GameScreenUpgradeHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/upgrades/GameScreenUpgradeHostAdapter.java new file mode 100644 index 0000000..942e2ea --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/upgrades/GameScreenUpgradeHostAdapter.java @@ -0,0 +1,35 @@ +package ru.project.tower.screens.gamescreen.upgrades; + +import com.badlogic.gdx.graphics.Color; +import java.util.Objects; + +public final class GameScreenUpgradeHostAdapter implements GameScreenUpgradeHost.Host { + private final Port port; + + public GameScreenUpgradeHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void addFloatingText(float x, float y, String text, Color color) { + port.addUpgradeFloatingText(x, y, text, color); + } + + @Override + public float playerCenterX() { + return port.playerCenterX(); + } + + @Override + public float playerCenterY() { + return port.playerCenterY(); + } + + public interface Port { + void addUpgradeFloatingText(float x, float y, String text, Color color); + + float playerCenterX(); + + float playerCenterY(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenBackgroundRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenBackgroundRenderer.java new file mode 100644 index 0000000..275e255 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenBackgroundRenderer.java @@ -0,0 +1,106 @@ +package ru.project.tower.screens.gamescreen.vfx; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.entities.particles.NeonParticle; +import ru.project.tower.screens.gamescreen.visual.GameScreenVisualTokens; + +public final class GameScreenBackgroundRenderer { + public void render( + ShapeRenderer shapeRenderer, + Array backgroundParticles, + float width, + float height, + float time, + float uiScale, + Color gridColor, + Color scratchColor) { + renderNeonGrid(shapeRenderer, width, height, time, uiScale, gridColor, scratchColor); + renderBackgroundParticles(shapeRenderer, backgroundParticles); + } + + private void renderNeonGrid( + ShapeRenderer shapeRenderer, + float width, + float height, + float time, + float uiScale, + Color gridColor, + Color scratchColor) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + shapeRenderer.begin(ShapeType.Line); + + float minorSpacing = GameScreenVisualTokens.Grid.MINOR_SPACING * uiScale; + float strongSpacing = GameScreenVisualTokens.Grid.STRONG_SPACING * uiScale; + float minorOffset = gridDriftOffset(minorSpacing, time, uiScale); + float strongOffset = gridDriftOffset(strongSpacing, time, uiScale); + + drawGridLines( + shapeRenderer, + width, + height, + minorSpacing, + minorOffset, + scratchColor.set( + GameScreenVisualTokens.Grid.MINOR_RED, + GameScreenVisualTokens.Grid.MINOR_GREEN, + GameScreenVisualTokens.Grid.MINOR_BLUE, + GameScreenVisualTokens.Grid.MINOR_ALPHA)); + drawGridLines( + shapeRenderer, + width, + height, + strongSpacing, + strongOffset, + scratchColor.set( + GameScreenVisualTokens.Grid.STRONG_RED, + GameScreenVisualTokens.Grid.STRONG_GREEN, + GameScreenVisualTokens.Grid.STRONG_BLUE, + GameScreenVisualTokens.Grid.STRONG_ALPHA)); + + shapeRenderer.end(); + } + + private float gridDriftOffset(float spacing, float time, float uiScale) { + float baseOffset = GameScreenVisualTokens.Grid.OFFSET * uiScale; + float drift = time * GameScreenVisualTokens.Grid.DRIFT_SPEED * uiScale; + float offset = (baseOffset + drift) % spacing; + return offset < 0f ? offset + spacing : offset; + } + + private void drawGridLines( + ShapeRenderer shapeRenderer, + float width, + float height, + float spacing, + float offset, + Color color) { + shapeRenderer.setColor(color); + for (float y = offset - spacing; y < height; y += spacing) { + shapeRenderer.line(0f, y, width, y); + } + for (float x = offset - spacing; x < width; x += spacing) { + shapeRenderer.line(x, 0f, x, height); + } + } + + private void renderBackgroundParticles( + ShapeRenderer shapeRenderer, Array backgroundParticles) { + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + + shapeRenderer.begin(ShapeType.Filled); + for (NeonParticle particle : backgroundParticles) { + shapeRenderer.setColor(particle.color); + shapeRenderer.circle(particle.position.x, particle.position.y, particle.size); + } + shapeRenderer.end(); + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenFloatingTextLayout.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenFloatingTextLayout.java new file mode 100644 index 0000000..a75b3c3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenFloatingTextLayout.java @@ -0,0 +1,117 @@ +package ru.project.tower.screens.gamescreen.vfx; + +final class GameScreenFloatingTextLayout { + private GameScreenFloatingTextLayout() {} + + static float fittedScale( + float rawWidth, + float rawHeight, + float viewportWidth, + float viewportHeight, + float safeInset) { + return fittedScale( + rawWidth, + rawHeight, + viewportWidth, + viewportHeight, + safeInset, + safeInset, + safeInset, + safeInset); + } + + static float fittedScale( + float rawWidth, + float rawHeight, + float viewportWidth, + float viewportHeight, + float leftInset, + float rightInset, + float bottomInset, + float topInset) { + float maxWidth = Math.max(1f, viewportWidth - leftInset - rightInset); + float maxHeight = Math.max(1f, viewportHeight - bottomInset - topInset); + float widthScale = rawWidth <= 0f ? 1f : maxWidth / rawWidth; + float heightScale = rawHeight <= 0f ? 1f : maxHeight / rawHeight; + return Math.min(1f, Math.min(widthScale, heightScale)); + } + + static Bounds bounds( + float centerX, + float baselineY, + float width, + float height, + float safeInset, + float viewportWidth, + float viewportHeight) { + return bounds( + centerX, + baselineY, + width, + height, + safeInset, + safeInset, + safeInset, + safeInset, + viewportWidth, + viewportHeight); + } + + static Bounds bounds( + float centerX, + float baselineY, + float width, + float height, + float leftInset, + float rightInset, + float bottomInset, + float topInset, + float viewportWidth, + float viewportHeight) { + float left = + clamp( + centerX - width / 2f, + leftInset, + Math.max(leftInset, viewportWidth - width - rightInset)); + float bottom = + clamp( + baselineY - height, + bottomInset, + Math.max(bottomInset, viewportHeight - height - topInset)); + return new Bounds(left, bottom, left + width, bottom + height); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + static final class Bounds { + private final float left; + private final float bottom; + private final float right; + private final float top; + + private Bounds(float left, float bottom, float right, float top) { + this.left = left; + this.bottom = bottom; + this.right = right; + this.top = top; + } + + float left() { + return left; + } + + float bottom() { + return bottom; + } + + float right() { + return right; + } + + float top() { + return top; + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenFloatingTextSystem.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenFloatingTextSystem.java new file mode 100644 index 0000000..89f9964 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenFloatingTextSystem.java @@ -0,0 +1,43 @@ +package ru.project.tower.screens.gamescreen.vfx; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.utils.Array; +import com.badlogic.gdx.utils.Pools; +import ru.project.tower.entities.particles.FloatingText; + +public final class GameScreenFloatingTextSystem { + private final Array items = new Array<>(); + + public void spawn(float x, float y, String text, Color color) { + FloatingText floatingText = Pools.obtain(FloatingText.class); + floatingText.configure(x, y, text, color); + items.add(floatingText); + } + + public void spawn( + float x, + float y, + String text, + Color color, + float maxLifetime, + float initialScale, + float riseSpeed) { + FloatingText floatingText = Pools.obtain(FloatingText.class); + floatingText.configure(x, y, text, color, maxLifetime, initialScale, riseSpeed); + items.add(floatingText); + } + + public void update(float delta) { + for (int i = items.size - 1; i >= 0; i--) { + FloatingText text = items.get(i); + text.update(delta); + if (!text.isAlive()) { + Pools.free(items.removeIndex(i)); + } + } + } + + public Array items() { + return items; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenImpactEffects.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenImpactEffects.java new file mode 100644 index 0000000..e48fca7 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenImpactEffects.java @@ -0,0 +1,82 @@ +package ru.project.tower.screens.gamescreen.vfx; + +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Matrix4; + +public final class GameScreenImpactEffects { + private float shakeIntensity = 0f; + private float hitstopTimer = 0f; + private Matrix4 originalShapeProjection; + private Matrix4 originalBatchProjection; + private final Matrix4 shakeMatrix = new Matrix4(); + + public void applyDamageShake(int damage) { + shakeIntensity = Math.max(shakeIntensity, Math.min(15f, damage * 1.5f)); + } + + public void applyCritHitstop() { + hitstopTimer = Math.max(hitstopTimer, 0.05f); + shakeIntensity = Math.max(shakeIntensity, 4f); + } + + public void applyBossKillShake() { + shakeIntensity = Math.max(shakeIntensity, 25f); + hitstopTimer = Math.max(hitstopTimer, 0.08f); + } + + public void applyKillShake() { + shakeIntensity = Math.max(shakeIntensity, 2f); + } + + public void applyShake(float intensity, float hitstopSeconds) { + shakeIntensity = Math.max(shakeIntensity, intensity); + hitstopTimer = Math.max(hitstopTimer, hitstopSeconds); + } + + public void captureBaseProjection(ShapeRenderer shapeRenderer, SpriteBatch batch) { + if (shapeRenderer != null) { + originalShapeProjection = new Matrix4(shapeRenderer.getProjectionMatrix()); + } + if (batch != null) { + originalBatchProjection = new Matrix4(batch.getProjectionMatrix()); + } + } + + public void resetProjection(ShapeRenderer shapeRenderer, SpriteBatch batch) { + if (originalShapeProjection != null && shapeRenderer != null) { + shapeRenderer.setProjectionMatrix(originalShapeProjection); + } + if (originalBatchProjection != null && batch != null) { + batch.setProjectionMatrix(originalBatchProjection); + } + } + + public float applyFrameEffects(float delta, ShapeRenderer shapeRenderer, SpriteBatch batch) { + float rawDelta = delta; + if (hitstopTimer > 0f) { + hitstopTimer -= rawDelta; + delta *= 0.15f; + } + + if (shakeIntensity > 0.01f) { + float shakeX = MathUtils.random(-shakeIntensity, shakeIntensity); + float shakeY = MathUtils.random(-shakeIntensity, shakeIntensity); + shakeIntensity = Math.max(0f, shakeIntensity - 60f * rawDelta); + if (originalShapeProjection != null + && originalBatchProjection != null + && shapeRenderer != null + && batch != null) { + shakeMatrix.set(originalShapeProjection).translate(shakeX, shakeY, 0f); + shapeRenderer.setProjectionMatrix(shakeMatrix); + shakeMatrix.set(originalBatchProjection).translate(shakeX, shakeY, 0f); + batch.setProjectionMatrix(shakeMatrix); + } + } else { + shakeIntensity = 0f; + } + + return delta; + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenParticleEffects.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenParticleEffects.java new file mode 100644 index 0000000..8ce239b --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenParticleEffects.java @@ -0,0 +1,179 @@ +package ru.project.tower.screens.gamescreen.vfx; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.utils.Array; +import com.badlogic.gdx.utils.Pools; +import ru.project.tower.entities.bullets.BulletData; +import ru.project.tower.entities.particles.NeonParticle; + +public final class GameScreenParticleEffects { + static final int MAX_BULLET_TRAIL_PARTICLES = 180; + private static final int MAX_BULLET_TRAIL_SPAWNS_PER_UPDATE = 24; + private static final float NORMAL_BULLET_TRAIL_PARTICLES_PER_SECOND = 18f; + private static final float CRIT_BULLET_TRAIL_PARTICLES_PER_SECOND = 36f; + private static final float SPAWN_BUDGET_EPSILON = 0.0001f; + + private final Array bulletParticles = new Array<>(); + private final Array backgroundParticles = new Array<>(); + private final Color scratchColor = new Color(); + + public void initBackgroundParticles(float width, float height, float uiScale, Color[] colors) { + clearParticles(backgroundParticles); + int particleCount = (int) (30 * uiScale); + for (int i = 0; i < particleCount; i++) { + float x = MathUtils.random(0f, width); + float y = MathUtils.random(0f, height); + + Color color = scratchColor; + if (colors.length > 0) { + color.set(colors[MathUtils.random(colors.length - 1)]); + } + color.a = 0.3f; + + NeonParticle particle = Pools.obtain(NeonParticle.class); + particle.configure(x, y, color); + particle.maxLifetime = MathUtils.random(5f, 10f); + particle.lifetime = particle.maxLifetime; + particle.velocity.scl(0.2f); + particle.size *= 2f; + + backgroundParticles.add(particle); + } + } + + public void update(float delta, float width, float height) { + for (int i = backgroundParticles.size - 1; i >= 0; i--) { + NeonParticle particle = backgroundParticles.get(i); + particle.update(delta); + + if (particle.lifetime <= 0f) { + particle.position.x = MathUtils.random(0f, width); + particle.position.y = MathUtils.random(0f, height); + particle.lifetime = particle.maxLifetime; + particle.color.a = 1f; + } + } + + for (int i = bulletParticles.size - 1; i >= 0; i--) { + NeonParticle particle = bulletParticles.get(i); + particle.update(delta); + + if (particle.lifetime <= 0f) { + Pools.free(bulletParticles.removeIndex(i)); + } + } + } + + public void emitBulletTrails( + float delta, + Array bullets, + Color playerBulletColor, + Color critBulletColor, + Color enemyBulletColor) { + if (delta <= 0f + || bullets.size == 0 + || bulletParticles.size >= MAX_BULLET_TRAIL_PARTICLES) { + return; + } + + int remainingThisUpdate = MAX_BULLET_TRAIL_SPAWNS_PER_UPDATE; + for (BulletData bullet : bullets) { + if (remainingThisUpdate == 0 || bulletParticles.size >= MAX_BULLET_TRAIL_PARTICLES) { + break; + } + + float rate = + bullet.isCrit() + ? CRIT_BULLET_TRAIL_PARTICLES_PER_SECOND + : NORMAL_BULLET_TRAIL_PARTICLES_PER_SECOND; + bullet.trailParticleSpawnBudget += delta * rate; + int spawnCount = (int) (bullet.trailParticleSpawnBudget + SPAWN_BUDGET_EPSILON); + if (spawnCount <= 0) { + continue; + } + + int capacity = MAX_BULLET_TRAIL_PARTICLES - bulletParticles.size; + spawnCount = Math.min(spawnCount, Math.min(remainingThisUpdate, capacity)); + bullet.trailParticleSpawnBudget -= spawnCount; + remainingThisUpdate -= spawnCount; + + Color color = playerBulletColor; + if (bullet.isEnemyBullet()) { + color = enemyBulletColor; + } else if (bullet.isCrit()) { + color = critBulletColor; + } + for (int i = 0; i < spawnCount; i++) { + spawnBulletParticle(bullet.getX(), bullet.getY(), color); + } + } + } + + public void spawnBulletParticle(float x, float y, Color color) { + NeonParticle particle = Pools.obtain(NeonParticle.class); + particle.configure(x, y, color); + bulletParticles.add(particle); + } + + public void createExplosion(float x, float y, Color color) { + createExplosion(x, y, color, 20); + } + + public void createExplosion(float x, float y, Color color, int particleCount) { + int resolvedParticleCount = particleCount > 0 ? particleCount : 20; + for (int i = 0; i < resolvedParticleCount; i++) { + NeonParticle particle = Pools.obtain(NeonParticle.class); + particle.configure(x, y, color); + float angle = MathUtils.random(0f, 360f); + float speed = MathUtils.random(25f, 50f); + particle.velocity.set(MathUtils.cosDeg(angle) * speed, MathUtils.sinDeg(angle) * speed); + + particle.maxLifetime = MathUtils.random(0.1f, 0.3f); + particle.lifetime = particle.maxLifetime; + particle.size = MathUtils.random(2f, 4f); + + bulletParticles.add(particle); + } + } + + public void createExplosion(float x, float y, float[] color) { + createExplosion(x, y, scratchColor.set(color[0], color[1], color[2], color[3])); + } + + public void createBossParticles( + float x, float y, float radius, Color color, float spread, int count) { + for (int i = 0; i < count; i++) { + float angle = MathUtils.random(360f); + float distance = MathUtils.random(radius * 0.5f, radius * spread); + float particleX = x + distance * MathUtils.cosDeg(angle); + float particleY = y + distance * MathUtils.sinDeg(angle); + + float size = MathUtils.random(2f, 5f); + float lifetime = MathUtils.random(0.5f, 1.5f); + + NeonParticle particle = Pools.obtain(NeonParticle.class); + particle.configure(particleX, particleY, color); + particle.velocity.set(MathUtils.random(-50f, 50f), MathUtils.random(-50f, 50f)); + particle.size = size; + particle.maxLifetime = lifetime; + particle.lifetime = lifetime; + + bulletParticles.add(particle); + } + } + + public Array bulletParticles() { + return bulletParticles; + } + + public Array backgroundParticles() { + return backgroundParticles; + } + + private void clearParticles(Array particles) { + while (particles.size > 0) { + Pools.free(particles.removeIndex(particles.size - 1)); + } + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenVfxModel.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenVfxModel.java new file mode 100644 index 0000000..6ff59e7 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenVfxModel.java @@ -0,0 +1,318 @@ +package ru.project.tower.screens.gamescreen.vfx; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.entities.particles.FloatingText; +import ru.project.tower.entities.particles.NeonParticle; +import ru.project.tower.screens.gamescreen.assets.GameScreenMaterialAssets; + +public final class GameScreenVfxModel { + private final GameScreenVfxRenderContext renderContext = new GameScreenVfxRenderContext(); + private final GameScreenVfxViewModel viewModel = new GameScreenVfxViewModel(); + + public GameScreenVfxModel() {} + + GameScreenVfxRenderContext renderContext() { + return renderContext; + } + + GameScreenVfxViewModel viewModel() { + return viewModel; + } + + public void populateRenderContext(RenderContextData data) { + populateRenderContext( + data.shapeRenderer(), + data.batch(), + data.font(), + data.materialAssets(), + data.floatingTextFont(), + data.scratchColor()); + } + + public void populateRenderContext( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + GameScreenMaterialAssets materialAssets, + BitmapFont floatingTextFont, + Color scratchColor) { + renderContext.shapeRenderer = shapeRenderer; + renderContext.batch = batch; + renderContext.font = font; + renderContext.materialAssets = materialAssets; + renderContext.floatingTextFont = floatingTextFont; + renderContext.scratchColor = scratchColor; + } + + public void populateView(ViewState state) { + populateView( + state.bulletParticles(), + state.floatingTexts(), + state.abilitySystem(), + state.square(), + state.adaptedSquareSize(), + state.viewportWidth(), + state.viewportHeight(), + state.time(), + state.nowMs(), + state.pulseSpeed(), + state.uiScale(), + state.neonBlue(), + state.neonCyan(), + state.neonGreen(), + state.neonYellow(), + state.neonRed(), + state.neonPurple()); + } + + public void populateView( + Array bulletParticles, + Array floatingTexts, + AbilitySystem abilitySystem, + Rectangle square, + float adaptedSquareSize, + int viewportWidth, + int viewportHeight, + float time, + long nowMs, + float pulseSpeed, + float uiScale, + Color neonBlue, + Color neonCyan, + Color neonGreen, + Color neonYellow, + Color neonRed, + Color neonPurple) { + viewModel.bulletParticles = bulletParticles; + viewModel.floatingTexts = floatingTexts; + viewModel.abilitySystem = abilitySystem; + viewModel.square = square; + viewModel.adaptedSquareSize = adaptedSquareSize; + viewModel.viewportWidth = viewportWidth; + viewModel.viewportHeight = viewportHeight; + viewModel.time = time; + viewModel.nowMs = nowMs; + viewModel.pulseSpeed = pulseSpeed; + viewModel.uiScale = uiScale; + viewModel.neonBlue = neonBlue; + viewModel.neonCyan = neonCyan; + viewModel.neonGreen = neonGreen; + viewModel.neonYellow = neonYellow; + viewModel.neonRed = neonRed; + viewModel.neonPurple = neonPurple; + } + + public static final class RenderContextData { + private final ShapeRenderer shapeRenderer; + private final SpriteBatch batch; + private final BitmapFont font; + private final GameScreenMaterialAssets materialAssets; + private final BitmapFont floatingTextFont; + private final Color scratchColor; + + public RenderContextData( + ShapeRenderer shapeRenderer, + SpriteBatch batch, + BitmapFont font, + GameScreenMaterialAssets materialAssets, + BitmapFont floatingTextFont, + Color scratchColor) { + this.shapeRenderer = shapeRenderer; + this.batch = batch; + this.font = font; + this.materialAssets = materialAssets; + this.floatingTextFont = floatingTextFont; + this.scratchColor = scratchColor; + } + + public ShapeRenderer shapeRenderer() { + return shapeRenderer; + } + + public SpriteBatch batch() { + return batch; + } + + public BitmapFont font() { + return font; + } + + public GameScreenMaterialAssets materialAssets() { + return materialAssets; + } + + public BitmapFont floatingTextFont() { + return floatingTextFont; + } + + public Color scratchColor() { + return scratchColor; + } + } + + public static final class ViewState { + private final Array bulletParticles; + private final Array floatingTexts; + private final AbilitySystem abilitySystem; + private final Rectangle square; + private final float adaptedSquareSize; + private final int viewportWidth; + private final int viewportHeight; + private final float time; + private final long nowMs; + private final float pulseSpeed; + private final float uiScale; + private final Color neonBlue; + private final Color neonCyan; + private final Color neonGreen; + private final Color neonYellow; + private final Color neonRed; + private final Color neonPurple; + + public ViewState( + Array bulletParticles, + Array floatingTexts, + AbilitySystem abilitySystem, + Rectangle square, + float adaptedSquareSize, + int viewportWidth, + int viewportHeight, + float time, + long nowMs, + float pulseSpeed, + float uiScale, + Color neonBlue, + Color neonCyan, + Color neonGreen, + Color neonYellow, + Color neonRed, + Color neonPurple) { + this.bulletParticles = bulletParticles; + this.floatingTexts = floatingTexts; + this.abilitySystem = abilitySystem; + this.square = square; + this.adaptedSquareSize = adaptedSquareSize; + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.time = time; + this.nowMs = nowMs; + this.pulseSpeed = pulseSpeed; + this.uiScale = uiScale; + this.neonBlue = neonBlue; + this.neonCyan = neonCyan; + this.neonGreen = neonGreen; + this.neonYellow = neonYellow; + this.neonRed = neonRed; + this.neonPurple = neonPurple; + } + + public Array bulletParticles() { + return bulletParticles; + } + + public Array floatingTexts() { + return floatingTexts; + } + + public AbilitySystem abilitySystem() { + return abilitySystem; + } + + public Rectangle square() { + return square; + } + + public float adaptedSquareSize() { + return adaptedSquareSize; + } + + public int viewportWidth() { + return viewportWidth; + } + + public int viewportHeight() { + return viewportHeight; + } + + public float time() { + return time; + } + + public long nowMs() { + return nowMs; + } + + public float pulseSpeed() { + return pulseSpeed; + } + + public float uiScale() { + return uiScale; + } + + public Color neonBlue() { + return neonBlue; + } + + public Color neonCyan() { + return neonCyan; + } + + public Color neonGreen() { + return neonGreen; + } + + public Color neonYellow() { + return neonYellow; + } + + public Color neonRed() { + return neonRed; + } + + public Color neonPurple() { + return neonPurple; + } + } +} + +final class GameScreenVfxRenderContext { + ShapeRenderer shapeRenderer; + SpriteBatch batch; + BitmapFont font; + GameScreenMaterialAssets materialAssets; + BitmapFont floatingTextFont; + Color scratchColor; + + Color withAlpha(Color base, float alpha) { + scratchColor.set(base); + scratchColor.a = alpha; + return scratchColor; + } +} + +final class GameScreenVfxViewModel { + Array bulletParticles; + Array floatingTexts; + AbilitySystem abilitySystem; + Rectangle square; + float adaptedSquareSize; + int viewportWidth; + int viewportHeight; + float time; + long nowMs; + float pulseSpeed; + float uiScale; + Color neonBlue; + Color neonCyan; + Color neonGreen; + Color neonYellow; + Color neonRed; + Color neonPurple; +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenVfxRenderer.java b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenVfxRenderer.java new file mode 100644 index 0000000..f8b4c30 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/vfx/GameScreenVfxRenderer.java @@ -0,0 +1,667 @@ +package ru.project.tower.screens.gamescreen.vfx; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.GlyphLayout; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer; +import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; +import com.badlogic.gdx.math.MathUtils; +import ru.project.tower.abilities.AbilityEffect; +import ru.project.tower.abilities.AbilitySystem; +import ru.project.tower.abilities.Turret; +import ru.project.tower.entities.particles.FloatingText; +import ru.project.tower.entities.particles.NeonParticle; + +public final class GameScreenVfxRenderer { + private static final float FLOATING_TEXT_SAFE_INSET = 8f; + private static final float FLOATING_TEXT_TOP_SAFE_INSET = 112f; + private static final float FLOATING_TEXT_BOTTOM_SAFE_INSET = 76f; + private static final float FLOATING_TEXT_FONT_SCALE = 1.5f; + private static final float FREEZE_VISUAL_RADIUS_SCALE = 1.34f; + private static final float EXPLOSION_VISUAL_RADIUS_SCALE = 1.24f; + private static final float EXPLOSION_VISUAL_MIN_PROGRESS = 0.42f; + + public interface Host { + GameScreenVfxModel vfxModel(); + } + + private final Host host; + private final GlyphLayout floatingTextLayout = new GlyphLayout(); + + public GameScreenVfxRenderer(Host host) { + this.host = host; + } + + public void renderVfx() { + renderTurrets(); + renderAbilityEffects(); + renderParticles(); + renderFloatingTexts(); + } + + private GameScreenVfxModel model() { + return host.vfxModel(); + } + + void renderTurrets() { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + if (view.abilitySystem.getActiveTurrets().size == 0) return; + if (renderTurretMaterials(context, view)) return; + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + shapeRenderer.begin(ShapeType.Filled); + float pulse = 0.5f + 0.5f * (float) Math.sin(view.time * view.pulseSpeed * 2f); + long now = view.nowMs; + for (Turret turret : view.abilitySystem.getActiveTurrets()) { + float lifeFrac = 1f - (now - turret.spawnedAt) / (float) AbilitySystem.TURRET_LIFETIME; + lifeFrac = Math.max(0f, Math.min(1f, lifeFrac)); + float core = 8f * view.uiScale; + for (int i = 3; i >= 1; i--) { + float alpha = 0.25f * pulse * (4 - i) / 3f * lifeFrac; + shapeRenderer.setColor(view.neonGreen.r, view.neonGreen.g, view.neonGreen.b, alpha); + shapeRenderer.circle(turret.x, turret.y, core + 4f * i * view.uiScale); + } + shapeRenderer.setColor(view.neonGreen.r, view.neonGreen.g, view.neonGreen.b, lifeFrac); + shapeRenderer.circle(turret.x, turret.y, core); + } + shapeRenderer.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + private boolean renderTurretMaterials(GameScreenVfxRenderContext context, GameScreenVfxViewModel view) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.abilityTurretCore() == null + || context.materialAssets.radialGlow() == null) { + return false; + } + SpriteBatch batch = context.batch; + Texture glow = context.materialAssets.radialGlow(); + Texture coreTexture = context.materialAssets.abilityTurretCore(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + batch.begin(); + long now = view.nowMs; + for (Turret turret : view.abilitySystem.getActiveTurrets()) { + float lifeFrac = 1f - (now - turret.spawnedAt) / (float) AbilitySystem.TURRET_LIFETIME; + lifeFrac = Math.max(0f, Math.min(1f, lifeFrac)); + float pulse = 0.84f + 0.16f * (float) Math.sin(view.time * view.pulseSpeed * 2.4f); + float glowSize = 34f * view.uiScale * pulse; + batch.setColor(view.neonGreen.r, view.neonGreen.g, view.neonGreen.b, 0.28f * lifeFrac); + batch.draw(glow, turret.x - glowSize / 2f, turret.y - glowSize / 2f, glowSize, glowSize); + float coreSize = 18f * view.uiScale; + batch.setColor(view.neonGreen.r, view.neonGreen.g, view.neonGreen.b, lifeFrac); + batch.draw( + coreTexture, + turret.x - coreSize / 2f, + turret.y - coreSize / 2f, + coreSize, + coreSize); + } + batch.setColor(Color.WHITE); + batch.end(); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + return true; + } + + void renderAbilityEffects() { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + + shapeRenderer.begin(ShapeType.Filled); + for (AbilityEffect effect : view.abilitySystem.getActiveEffects()) { + if (effect.type.equals("freeze")) { + renderFreezeEffect(effect); + } else if (effect.type.equals("explosion")) { + renderExplosionEffect(effect); + } else if (effect.type.equals("shield")) { + renderShieldEffect(effect); + } else if (effect.type.equals("impulse")) { + renderImpulseEffect(effect); + } + } + shapeRenderer.end(); + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + void renderParticles() { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + + Gdx.gl.glEnable(GL20.GL_BLEND); + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE); + + shapeRenderer.begin(ShapeType.Filled); + for (NeonParticle particle : view.bulletParticles) { + shapeRenderer.setColor(particle.color); + shapeRenderer.circle(particle.position.x, particle.position.y, particle.size); + } + shapeRenderer.end(); + + Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA); + } + + void renderFloatingTexts() { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + SpriteBatch batch = context.batch; + BitmapFont font = context.floatingTextFont; + float uiScale = view.uiScale; + + batch.begin(); + for (FloatingText text : view.floatingTexts) { + font.setColor(text.color); + float fontScale = text.scale * uiScale / FLOATING_TEXT_FONT_SCALE; + font.getData().setScale(fontScale); + floatingTextLayout.setText(font, text.text); + float safeInset = FLOATING_TEXT_SAFE_INSET * uiScale; + float topSafeInset = FLOATING_TEXT_TOP_SAFE_INSET * uiScale; + float bottomSafeInset = FLOATING_TEXT_BOTTOM_SAFE_INSET * uiScale; + float fitScale = + GameScreenFloatingTextLayout.fittedScale( + floatingTextLayout.width, + floatingTextLayout.height, + view.viewportWidth, + view.viewportHeight, + safeInset, + safeInset, + bottomSafeInset, + topSafeInset); + if (fitScale < 1f) { + font.getData().setScale(fontScale * fitScale); + floatingTextLayout.setText(font, text.text); + } + GameScreenFloatingTextLayout.Bounds bounds = + GameScreenFloatingTextLayout.bounds( + text.position.x, + text.position.y + floatingTextLayout.height, + floatingTextLayout.width, + floatingTextLayout.height, + safeInset, + safeInset, + bottomSafeInset, + topSafeInset, + view.viewportWidth, + view.viewportHeight); + font.draw(batch, text.text, bounds.left(), bounds.top()); + } + font.getData().setScale(1.0f); + font.setColor(Color.WHITE); + batch.end(); + } + + private void renderFreezeEffect(AbilityEffect effect) { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + float percentage = effect.getRemainingPercentage(); + float freezeProgress = 1f - percentage; + float baseRadius = effect.radius * FREEZE_VISUAL_RADIUS_SCALE * (0.34f + freezeProgress * 0.34f); + float pulseAmount = 0.3f * (float) Math.sin(view.time * 7f); + + renderAbilityShockwaveSprite( + context, + shapeRenderer, + view.neonCyan, + effect.x, + effect.y, + baseRadius * 2.4f, + percentage * 0.36f); + renderFreezeRings(effect, context, view, shapeRenderer, percentage, baseRadius, pulseAmount); + renderFreezeFragments( + effect, context, view, shapeRenderer, percentage, baseRadius, pulseAmount); + shapeRenderer.setColor( + context.withAlpha(view.neonCyan, percentage * (0.22f + pulseAmount * 0.06f))); + shapeRenderer.circle(effect.x, effect.y, baseRadius * 0.10f * (1 + pulseAmount * 0.3f)); + } + + private void renderFreezeRings( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float baseRadius, + float pulseAmount) { + int ringCount = 3; + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Line); + for (int i = 0; i < ringCount; i++) { + float ringRadius = baseRadius * (0.72f + i * 0.16f + pulseAmount * 0.025f); + shapeRenderer.setColor(context.withAlpha(view.neonCyan, percentage * (0.30f - i * 0.055f))); + shapeRenderer.circle(effect.x, effect.y, ringRadius, 96); + } + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Filled); + } + + private void renderFreezeFragments( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float baseRadius, + float pulseAmount) { + int fragmentCount = 9; + float edgeRadius = baseRadius * 0.94f; + shapeRenderer.setColor(context.withAlpha(view.neonCyan, percentage * 0.54f)); + for (int i = 0; i < fragmentCount; i++) { + float angle = i * MathUtils.PI2 / fragmentCount + view.time * 0.13f; + float tangent = angle + MathUtils.PI / 2f; + float length = baseRadius * (0.18f + (i % 3) * 0.035f); + float midX = effect.x + MathUtils.cos(angle) * edgeRadius; + float midY = effect.y + MathUtils.sin(angle) * edgeRadius; + float halfDx = MathUtils.cos(tangent) * length * 0.5f; + float halfDy = MathUtils.sin(tangent) * length * 0.5f; + shapeRenderer.rectLine( + midX - halfDx, + midY - halfDy, + midX + halfDx, + midY + halfDy, + 1.6f * view.uiScale * (1f + pulseAmount * 0.2f)); + } + } + + private void renderExplosionEffect(AbilityEffect effect) { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + float percentage = effect.getRemainingPercentage(); + float explosionProgress = 1 - percentage; + float pulse = 0.2f * (float) Math.sin(view.time * 15f); + float explosionRadius = + effect.radius + * EXPLOSION_VISUAL_RADIUS_SCALE + * Math.max(EXPLOSION_VISUAL_MIN_PROGRESS, explosionProgress); + + renderAbilityShockwaveSprite( + context, + shapeRenderer, + view.neonYellow, + effect.x, + effect.y, + explosionRadius * 2.15f, + percentage * 0.70f); + renderExplosionWaves( + effect, context, view, shapeRenderer, percentage, explosionRadius, pulse); + renderExplosionFragments( + effect, + context, + view, + shapeRenderer, + percentage, + explosionProgress, + explosionRadius, + pulse); + renderExplosionCore( + effect, context, view, shapeRenderer, percentage, explosionRadius, pulse); + } + + private void renderExplosionWaves( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float explosionRadius, + float pulse) { + final float cR = view.neonYellow.r, cG = view.neonYellow.g, cB = view.neonYellow.b; + final float oR = view.neonRed.r, oG = view.neonRed.g, oB = view.neonRed.b; + + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Line); + for (int i = 0; i < 8; i++) { + float waveProgress = (float) i / 7; + float waveRadius = explosionRadius * (0.3f + waveProgress * 0.7f); + + context.scratchColor.set( + cR * (1 - waveProgress) + oR * waveProgress, + cG * (1 - waveProgress) + oG * waveProgress, + cB * (1 - waveProgress) + oB * waveProgress, + percentage * (0.30f - waveProgress * 0.13f + pulse * 0.2f * (1 - waveProgress))); + shapeRenderer.setColor(context.scratchColor); + shapeRenderer.circle(effect.x, effect.y, waveRadius, 96); + } + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Filled); + } + + private void renderExplosionFragments( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float explosionProgress, + float explosionRadius, + float pulse) { + final float cR = view.neonYellow.r, cG = view.neonYellow.g, cB = view.neonYellow.b; + final float oR = view.neonRed.r, oG = view.neonRed.g, oB = view.neonRed.b; + int fragmentCount = 10; + float edgeRadius = explosionRadius * (0.74f + pulse * 0.08f); + context.scratchColor.set( + cR * 0.3f + oR * 0.7f, + cG * 0.3f + oG * 0.7f, + cB * 0.3f + oB * 0.7f, + percentage * 0.48f); + shapeRenderer.setColor(context.scratchColor); + for (int i = 0; i < fragmentCount; i++) { + float angle = i * MathUtils.PI2 / fragmentCount + view.time * 0.55f; + float tangent = angle + MathUtils.PI / 2f; + float length = explosionRadius * (0.12f + (i % 4) * 0.025f); + float midX = effect.x + MathUtils.cos(angle) * edgeRadius; + float midY = effect.y + MathUtils.sin(angle) * edgeRadius; + float halfDx = MathUtils.cos(tangent) * length * 0.5f; + float halfDy = MathUtils.sin(tangent) * length * 0.5f; + shapeRenderer.rectLine( + midX - halfDx, + midY - halfDy, + midX + halfDx, + midY + halfDy, + 2.0f * view.uiScale * (1 - explosionProgress * 0.35f)); + } + } + + private void renderExplosionCore( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float explosionRadius, + float pulse) { + shapeRenderer.setColor( + context.withAlpha(view.neonYellow, percentage * (0.9f + pulse * 0.1f))); + shapeRenderer.circle(effect.x, effect.y, explosionRadius * 0.2f * (1 + pulse)); + } + + private void renderShieldEffect(AbilityEffect effect) { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + float percentage = effect.getRemainingPercentage(); + float pulse = 0.3f * (float) Math.sin(view.time * 8f); + float slowPulse = 0.2f * (float) Math.sin(view.time * 3f); + float shieldRadius = view.adaptedSquareSize * 1.5f * (1 + pulse * 0.1f); + float centerX = view.square.x + view.square.width / 2f; + float centerY = view.square.y + view.square.height / 2f; + + renderShieldMaterialShell( + context, + view, + shapeRenderer, + percentage, + pulse, + slowPulse, + shieldRadius, + centerX, + centerY); + renderShieldSegments(context, view, shapeRenderer, percentage, pulse, shieldRadius, centerX, centerY); + } + + private void renderShieldMaterialShell( + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float pulse, + float slowPulse, + float shieldRadius, + float centerX, + float centerY) { + if (context.batch == null + || context.materialAssets == null + || context.materialAssets.abilityShieldShell() == null) { + shapeRenderer.setColor(context.withAlpha(view.neonCyan, percentage * 0.16f)); + shapeRenderer.circle(centerX, centerY, shieldRadius, 96); + return; + } + shapeRenderer.end(); + SpriteBatch batch = context.batch; + float shellSize = shieldRadius * 2.16f; + batch.begin(); + batch.setColor( + view.neonCyan.r, + view.neonCyan.g, + Math.min(1f, view.neonCyan.b + slowPulse * 0.25f), + percentage * (0.46f + pulse * 0.08f)); + batch.draw( + context.materialAssets.abilityShieldShell(), + centerX - shellSize / 2f, + centerY - shellSize / 2f, + shellSize, + shellSize); + batch.setColor(view.neonBlue.r, view.neonBlue.g, view.neonBlue.b, percentage * 0.16f); + float innerSize = shieldRadius * 1.50f; + batch.draw( + context.materialAssets.abilityShieldShell(), + centerX - innerSize / 2f, + centerY - innerSize / 2f, + innerSize, + innerSize); + batch.setColor(Color.WHITE); + batch.end(); + shapeRenderer.begin(ShapeType.Filled); + } + + private void renderShieldSegments( + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float pulse, + float shieldRadius, + float centerX, + float centerY) { + int segmentCount = 5; + context.scratchColor.set( + view.neonCyan.r, + view.neonCyan.g, + view.neonCyan.b, + percentage * (0.34f + pulse * 0.16f)); + shapeRenderer.setColor(context.scratchColor); + for (int i = 0; i < segmentCount; i++) { + float angle = i * MathUtils.PI2 / segmentCount + view.time * 0.38f; + float tangent = angle + MathUtils.PI / 2f; + float radius = shieldRadius * (0.97f + i * 0.004f); + float segmentLength = shieldRadius * 0.32f; + float midX = centerX + MathUtils.cos(angle) * radius; + float midY = centerY + MathUtils.sin(angle) * radius; + float halfDx = MathUtils.cos(tangent) * segmentLength * 0.5f; + float halfDy = MathUtils.sin(tangent) * segmentLength * 0.5f; + shapeRenderer.rectLine( + midX - halfDx, + midY - halfDy, + midX + halfDx, + midY + halfDy, + 1.9f * view.uiScale); + } + } + + private void renderImpulseEffect(AbilityEffect effect) { + GameScreenVfxModel model = model(); + GameScreenVfxRenderContext context = model.renderContext(); + GameScreenVfxViewModel view = model.viewModel(); + ShapeRenderer shapeRenderer = context.shapeRenderer; + float percentage = effect.getRemainingPercentage(); + float impulseProgress = 1 - percentage; + float impulseRadius = effect.radius * impulseProgress; + float ringThickness = effect.radius * 0.15f * (1 - impulseProgress * 0.7f); + float pulse = 0.2f * (float) Math.sin(view.time * 12f); + + renderAbilityShockwaveSprite( + context, + shapeRenderer, + view.neonPurple, + effect.x, + effect.y, + impulseRadius * 2.0f, + percentage * 0.38f); + renderImpulseRing( + effect, + context, + view, + shapeRenderer, + percentage, + impulseRadius, + ringThickness, + pulse); + renderImpulseSparks( + effect, + context, + view, + shapeRenderer, + percentage, + impulseProgress, + impulseRadius, + ringThickness); + renderImpulseLines( + effect, + context, + view, + shapeRenderer, + percentage, + impulseProgress, + impulseRadius, + pulse); + renderImpulseCore( + effect, context, view, shapeRenderer, percentage, impulseProgress, impulseRadius); + } + + private void renderImpulseRing( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float impulseRadius, + float ringThickness, + float pulse) { + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Line); + shapeRenderer.setColor( + context.withAlpha(view.neonPurple, percentage * (0.44f + pulse * 0.18f))); + shapeRenderer.circle(effect.x, effect.y, impulseRadius, 64); + shapeRenderer.circle(effect.x, effect.y, Math.max(0f, impulseRadius - ringThickness), 64); + shapeRenderer.end(); + shapeRenderer.begin(ShapeType.Filled); + } + + private void renderImpulseSparks( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float impulseProgress, + float impulseRadius, + float ringThickness) { + int sparkCount = 20; + for (int i = 0; i < sparkCount; i++) { + if (MathUtils.randomBoolean(0.5f)) { + float angle = MathUtils.random() * MathUtils.PI2; + float sparkRadius = + MathUtils.random( + impulseRadius - ringThickness * 0.5f, + impulseRadius + ringThickness * 0.5f); + float sparkSize = + MathUtils.random(1.5f, 3.5f) * view.uiScale * (1 - impulseProgress * 0.5f); + + shapeRenderer.setColor( + context.withAlpha( + view.neonPurple, percentage * MathUtils.random(0.5f, 1.0f))); + shapeRenderer.circle( + effect.x + MathUtils.cos(angle) * sparkRadius, + effect.y + MathUtils.sin(angle) * sparkRadius, + sparkSize); + } + } + } + + private void renderImpulseLines( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float impulseProgress, + float impulseRadius, + float pulse) { + int lineCount = 8; + for (int i = 0; i < lineCount; i++) { + float angle = i * MathUtils.PI / (lineCount / 2f) + view.time * 1.5f; + float lineLength = impulseRadius * 0.6f * (0.7f + pulse * 0.3f); + + shapeRenderer.setColor( + context.withAlpha( + view.neonPurple, percentage * 0.3f * (1 - impulseProgress * 0.5f))); + shapeRenderer.rectLine( + effect.x, + effect.y, + effect.x + MathUtils.cos(angle) * lineLength, + effect.y + MathUtils.sin(angle) * lineLength, + 1.5f * view.uiScale * (1 - impulseProgress * 0.5f)); + } + } + + private void renderImpulseCore( + AbilityEffect effect, + GameScreenVfxRenderContext context, + GameScreenVfxViewModel view, + ShapeRenderer shapeRenderer, + float percentage, + float impulseProgress, + float impulseRadius) { + shapeRenderer.setColor( + context.withAlpha( + view.neonPurple, percentage * 0.5f * (1 - impulseProgress * 0.7f))); + shapeRenderer.circle( + effect.x, effect.y, impulseRadius * 0.15f * (1 - impulseProgress * 0.5f)); + } + + private void renderAbilityShockwaveSprite( + GameScreenVfxRenderContext context, + ShapeRenderer shapeRenderer, + Color color, + float centerX, + float centerY, + float size, + float alpha) { + if (size <= 0f + || alpha <= 0f + || context.batch == null + || context.materialAssets == null + || context.materialAssets.abilityShockwave() == null) { + return; + } + shapeRenderer.end(); + SpriteBatch batch = context.batch; + batch.begin(); + batch.setColor(color.r, color.g, color.b, alpha); + batch.draw( + context.materialAssets.abilityShockwave(), + centerX - size / 2f, + centerY - size / 2f, + size, + size); + batch.setColor(Color.WHITE); + batch.end(); + shapeRenderer.begin(ShapeType.Filled); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/visual/GameScreenVisualTokens.java b/core/src/main/java/ru/project/tower/screens/gamescreen/visual/GameScreenVisualTokens.java new file mode 100644 index 0000000..8450ad3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/visual/GameScreenVisualTokens.java @@ -0,0 +1,110 @@ +package ru.project.tower.screens.gamescreen.visual; + +public final class GameScreenVisualTokens { + public static final float REFERENCE_WIDTH = 405f; + public static final float REFERENCE_HEIGHT = 682f; + + private GameScreenVisualTokens() {} + + public static final class ColorTokens { + public static final String TEXT = "#f7f9ff"; + public static final String CYAN = "#22d9ff"; + public static final String BLUE = "#5366ff"; + public static final String GOLD = "#ffcf4a"; + public static final String GREEN = "#63e68d"; + + private ColorTokens() {} + } + + public static final class Grid { + public static final float MINOR_SPACING = 80f; + public static final float STRONG_SPACING = 240f; + public static final float OFFSET = 20f; + public static final float DRIFT_SPEED = 8f; + public static final float MINOR_RED = 57f / 255f; + public static final float MINOR_GREEN = 91f / 255f; + public static final float MINOR_BLUE = 1f; + public static final float MINOR_ALPHA = 0.22f; + public static final float STRONG_RED = 76f / 255f; + public static final float STRONG_GREEN = 118f / 255f; + public static final float STRONG_BLUE = 1f; + public static final float STRONG_ALPHA = 0.30f; + + private Grid() {} + } + + public static final class Hud { + public static final float TOPBAR_HEIGHT = 46f; + public static final float WAVEBAR_HEIGHT = 6f; + public static final float STAT_STRIP_HEIGHT = 30f; + public static final float STAT_STRIP_GAP = 5f; + + private Hud() {} + } + + public static final class Layout { + public static final float MARGIN = 12f; + public static final float TOP_MARGIN = 10f; + public static final float MENU_RIGHT = 14f; + public static final float SHOP_BOTTOM = 84f; + public static final float SHOP_PADDING_X = 10f; + public static final float HANDLE_WIDTH = 42f; + public static final float HANDLE_HEIGHT = 4f; + public static final float CARD_GAP = 8f; + + private Layout() {} + } + + public static final class Menu { + public static final float SIZE = 42f; + public static final float STRIPE_WIDTH = 18f; + public static final float STRIPE_HEIGHT = 2f; + public static final float STRIPE_GAP = 2f; + public static final float FILL_RED = 12f / 255f; + public static final float FILL_GREEN = 15f / 255f; + public static final float FILL_BLUE = 25f / 255f; + public static final float FILL_ALPHA = 0.82f; + public static final float PRESSED_FILL_ALPHA = 0.96f; + + private Menu() {} + } + + public static final class Shop { + public static final float SHEET_HEIGHT = 196f; + public static final float TAB_HEIGHT = 34f; + public static final float CARD_HEIGHT = 58f; + public static final float PRICE_HEIGHT = 23f; + public static final float ACTIVE_TAB_FILL_RED = 34f / 255f; + public static final float ACTIVE_TAB_FILL_GREEN = 217f / 255f; + public static final float ACTIVE_TAB_FILL_BLUE = 1f; + public static final float ACTIVE_TAB_FILL_ALPHA = 0.16f; + + private Shop() {} + } + + public static final class Dock { + public static final float HEIGHT = 64f; + public static final float BUTTON_SIZE = 48f; + public static final float COOLDOWN_INNER_STOP = 0.52f; + public static final float TOGGLE_FILL_RED = 34f / 255f; + public static final float TOGGLE_FILL_GREEN = 217f / 255f; + public static final float TOGGLE_FILL_BLUE = 1f; + public static final float TOGGLE_FILL_ALPHA = 0.16f; + + private Dock() {} + } + + public static final class Type { + public static final int TOPBAR_SIZE = 16; + public static final int STAT_SIZE = 12; + public static final int TAB_SIZE = 14; + public static final int CARD_TITLE_SIZE = 14; + public static final int CARD_VALUE_SIZE = 17; + public static final int CARD_ARROW_SIZE = 15; + public static final int PRICE_SIZE = 13; + public static final int DOCK_LABEL_SIZE = 13; + public static final int DOCK_COOLDOWN_SIZE = 15; + + private Type() {} + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveClearRewardHandler.java b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveClearRewardHandler.java new file mode 100644 index 0000000..e3c2362 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveClearRewardHandler.java @@ -0,0 +1,101 @@ +package ru.project.tower.screens.gamescreen.waves; + +import com.badlogic.gdx.math.MathUtils; +import java.util.Objects; +import ru.project.tower.run.GameplayColor; +import ru.project.tower.run.GameplayEventSink; +import ru.project.tower.run.GameplayEvents; +import ru.project.tower.support.RandomSource; +import ru.project.tower.waves.WaveController; + +public final class GameScreenWaveClearRewardHandler { + private static final int EXPLOSION_COUNT = 30; + private static final float MIN_EXPLOSION_DISTANCE = 20f; + private static final float MAX_EXPLOSION_DISTANCE = 60f; + private static final float HORIZONTAL_SAFE_INSET = 48f; + private static final float WAVE_CLEAR_TOP_INSET = 72f; + private static final float CURRENCY_TOP_INSET = 42f; + private static final float WAVE_CLEAR_MIN_Y = 112f; + private static final float CURRENCY_MIN_Y = 144f; + + private final Host host; + private final GameplayEventSink events; + private final RandomSource random; + + public GameScreenWaveClearRewardHandler( + Host host, GameplayEventSink events, RandomSource random) { + this.host = Objects.requireNonNull(host, "host"); + this.events = Objects.requireNonNull(events, "events"); + this.random = Objects.requireNonNull(random, "random"); + } + + public void handle(WaveController.WaveClearReward reward) { + Objects.requireNonNull(reward, "reward"); + + host.addMoney(reward.moneyBonus); + host.addCurrency(reward.currencyReward); + + float cx = safeTextX(host.playerCenterX()); + float cy = host.playerCenterY(); + events.emit( + GameplayEvents.floatingText( + cx, + safeTextY(cy + 50f, WAVE_CLEAR_MIN_Y, WAVE_CLEAR_TOP_INSET), + "Волна " + reward.clearedWave + " очищена! +" + reward.moneyBonus, + GameplayColor.NEON_GREEN, + 4.0f, + 1.5f, + 0f)); + events.emit( + GameplayEvents.floatingText( + cx, + safeTextY(cy + 80f, CURRENCY_MIN_Y, CURRENCY_TOP_INSET), + "+" + reward.currencyReward + " монет", + GameplayColor.NEON_YELLOW, + 3.5f, + 1.2f, + 0f)); + events.emit(GameplayEvents.shake(6f, 0f)); + + for (int i = 0; i < EXPLOSION_COUNT; i++) { + float angle = random.angle(); + float dist = random.range(MIN_EXPLOSION_DISTANCE, MAX_EXPLOSION_DISTANCE); + events.emit( + GameplayEvents.explosion( + cx + MathUtils.cos(angle) * dist, + cy + MathUtils.sin(angle) * dist, + GameplayColor.NEON_GREEN, + EXPLOSION_COUNT)); + } + } + + private float safeTextX(float desiredX) { + float width = host.viewportWidth(); + if (width <= HORIZONTAL_SAFE_INSET * 2f) { + return Math.max(0f, width * 0.5f); + } + return MathUtils.clamp(desiredX, HORIZONTAL_SAFE_INSET, width - HORIZONTAL_SAFE_INSET); + } + + private float safeTextY(float desiredY, float minY, float topInset) { + float height = host.viewportHeight(); + if (height <= minY + topInset) { + return Math.max(0f, height * 0.5f); + } + return MathUtils.clamp(desiredY, minY, height - topInset); + } + + public interface Host { + void addMoney(int amount); + + void addCurrency(int amount); + + float playerCenterX(); + + float playerCenterY(); + + float viewportWidth(); + + float viewportHeight(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveClearRewardHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveClearRewardHostAdapter.java new file mode 100644 index 0000000..0afe9d0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveClearRewardHostAdapter.java @@ -0,0 +1,56 @@ +package ru.project.tower.screens.gamescreen.waves; + +import java.util.Objects; + +public final class GameScreenWaveClearRewardHostAdapter + implements GameScreenWaveClearRewardHandler.Host { + private final Port port; + + public GameScreenWaveClearRewardHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void addMoney(int amount) { + port.addMoney(amount); + } + + @Override + public void addCurrency(int amount) { + port.addCurrency(amount); + } + + @Override + public float playerCenterX() { + return port.playerCenterX(); + } + + @Override + public float playerCenterY() { + return port.playerCenterY(); + } + + @Override + public float viewportWidth() { + return port.viewportWidth(); + } + + @Override + public float viewportHeight() { + return port.viewportHeight(); + } + + public interface Port { + void addMoney(int amount); + + void addCurrency(int amount); + + float playerCenterX(); + + float playerCenterY(); + + float viewportWidth(); + + float viewportHeight(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveRunEvents.java b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveRunEvents.java new file mode 100644 index 0000000..7c6eed0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveRunEvents.java @@ -0,0 +1,91 @@ +package ru.project.tower.screens.gamescreen.waves; + +import java.util.Objects; +import ru.project.tower.DebugLog; +import ru.project.tower.waves.WaveController; +import ru.project.tower.waves.WaveRunEvents; +import ru.project.tower.waves.WaveTuning; + +public final class GameScreenWaveRunEvents implements WaveRunEvents { + private final Host host; + + public GameScreenWaveRunEvents(Host host) { + this.host = Objects.requireNonNull(host, "host"); + } + + @Override + public void waveStarted(int wave) {} + + @Override + public void waveTuning(WaveTuning tuning) { + logWaveTuning(tuning); + + host.applyWaveTuning(tuning); + } + + private void logWaveTuning(WaveTuning tuning) { + if (DebugLog.DEBUG) { + DebugLog.log( + "WaveSystem", + "Starting Wave " + + tuning.currentWave() + + " [Count: " + + tuning.enemiesPerWave() + + ", HP: " + + tuning.basicCircleHealth() + + ", DMG: " + + tuning.circleDamage() + + ", SpeedX: " + + tuning.enemySpeedMultiplier() + + "]"); + } + } + + @Override + public void spawnWaveCircle() { + host.spawnWaveCircle(); + } + + @Override + public void spawnRandomBoss() { + host.spawnRandomBoss(); + } + + @Override + public void waveCleared(WaveController.WaveClearReward reward) { + host.handleWaveCleared(reward); + } + + @Override + public void triggerUpgradePick() { + host.triggerUpgradePick(); + } + + @Override + public void waveClearFinished(WaveController.WaveClearReward reward) { + host.endWave(); + logWaveClearFinished(reward); + } + + private void logWaveClearFinished(WaveController.WaveClearReward reward) { + if (DebugLog.DEBUG) { + DebugLog.log( + "WaveSystem", + "Wave " + reward.clearedWave + " cleared! Bonus: " + reward.moneyBonus); + } + } + + public interface Host { + void applyWaveTuning(WaveTuning tuning); + + void spawnWaveCircle(); + + void spawnRandomBoss(); + + void handleWaveCleared(WaveController.WaveClearReward reward); + + void triggerUpgradePick(); + + void endWave(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveRunHostAdapter.java b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveRunHostAdapter.java new file mode 100644 index 0000000..fda4816 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/gamescreen/waves/GameScreenWaveRunHostAdapter.java @@ -0,0 +1,57 @@ +package ru.project.tower.screens.gamescreen.waves; + +import java.util.Objects; +import ru.project.tower.waves.WaveController; +import ru.project.tower.waves.WaveTuning; + +public final class GameScreenWaveRunHostAdapter implements GameScreenWaveRunEvents.Host { + private final Port port; + + public GameScreenWaveRunHostAdapter(Port port) { + this.port = Objects.requireNonNull(port, "port"); + } + + @Override + public void applyWaveTuning(WaveTuning tuning) { + port.applyWaveTuning(tuning); + } + + @Override + public void spawnWaveCircle() { + port.spawnWaveCircle(); + } + + @Override + public void spawnRandomBoss() { + port.spawnRandomBoss(); + } + + @Override + public void handleWaveCleared(WaveController.WaveClearReward reward) { + port.handleWaveCleared(reward); + } + + @Override + public void triggerUpgradePick() { + port.triggerUpgradePick(); + } + + @Override + public void endWave() { + port.endWave(); + } + + public interface Port { + void applyWaveTuning(WaveTuning tuning); + + void spawnWaveCircle(); + + void spawnRandomBoss(); + + void handleWaveCleared(WaveController.WaveClearReward reward); + + void triggerUpgradePick(); + + void endWave(); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/main/MainScreenScaleMetrics.java b/core/src/main/java/ru/project/tower/screens/main/MainScreenScaleMetrics.java new file mode 100644 index 0000000..bc6070f --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/main/MainScreenScaleMetrics.java @@ -0,0 +1,46 @@ +package ru.project.tower.screens.main; + +public final class MainScreenScaleMetrics { + private static final float BASE_WIDTH = 1280f; + private static final float BASE_HEIGHT = 720f; + + private final float uiScale; + private final int adaptedFontSize; + private final boolean portrait; + + private MainScreenScaleMetrics(float uiScale, int adaptedFontSize, boolean portrait) { + this.uiScale = uiScale; + this.adaptedFontSize = adaptedFontSize; + this.portrait = portrait; + } + + public static MainScreenScaleMetrics calculate(float viewportWidth, float viewportHeight) { + boolean isPortrait = viewportHeight > viewportWidth; + float scaleX = viewportWidth / BASE_WIDTH; + float scaleY = viewportHeight / BASE_HEIGHT; + + if (isPortrait) { + float uiScale = clamp(scaleY * 1.2f, 1.0f, 2.2f); + return new MainScreenScaleMetrics(uiScale, (int) clamp(28 * uiScale, 24, 42), true); + } + + float uiScale = clamp(Math.min(scaleX, scaleY) * 1.2f, 0.8f, 1.8f); + return new MainScreenScaleMetrics(uiScale, (int) clamp(24 * uiScale, 20, 36), false); + } + + public float uiScale() { + return uiScale; + } + + public int adaptedFontSize() { + return adaptedFontSize; + } + + public boolean isPortrait() { + return portrait; + } + + private static float clamp(float value, float min, float max) { + return Math.min(max, Math.max(min, value)); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/talenttree/TalentTreeScaleMetrics.java b/core/src/main/java/ru/project/tower/screens/talenttree/TalentTreeScaleMetrics.java new file mode 100644 index 0000000..18918e5 --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/talenttree/TalentTreeScaleMetrics.java @@ -0,0 +1,40 @@ +package ru.project.tower.screens.talenttree; + +public final class TalentTreeScaleMetrics { + private static final float BASE_WIDTH = 1280f; + private static final float BASE_HEIGHT = 720f; + + private final float uiScale; + private final int adaptedFontSize; + + private TalentTreeScaleMetrics(float uiScale, int adaptedFontSize) { + this.uiScale = uiScale; + this.adaptedFontSize = adaptedFontSize; + } + + public static TalentTreeScaleMetrics calculate(float viewportWidth, float viewportHeight) { + boolean isPortrait = viewportHeight > viewportWidth; + float scaleX = viewportWidth / BASE_WIDTH; + float scaleY = viewportHeight / BASE_HEIGHT; + + if (isPortrait) { + float uiScale = clamp(scaleY, 1.0f, 1.8f); + return new TalentTreeScaleMetrics(uiScale, (int) clamp(22 * uiScale, 20, 32)); + } + + float uiScale = clamp(Math.min(scaleX, scaleY), 0.7f, 1.5f); + return new TalentTreeScaleMetrics(uiScale, (int) clamp(20 * uiScale, 18, 28)); + } + + public float uiScale() { + return uiScale; + } + + public int adaptedFontSize() { + return adaptedFontSize; + } + + private static float clamp(float value, float min, float max) { + return Math.min(max, Math.max(min, value)); + } +} diff --git a/core/src/main/java/ru/project/tower/screens/talenttree/TalentTreeScreenDependencies.java b/core/src/main/java/ru/project/tower/screens/talenttree/TalentTreeScreenDependencies.java new file mode 100644 index 0000000..099231f --- /dev/null +++ b/core/src/main/java/ru/project/tower/screens/talenttree/TalentTreeScreenDependencies.java @@ -0,0 +1,30 @@ +package ru.project.tower.screens.talenttree; + +import java.util.Objects; +import ru.project.tower.GameContext; +import ru.project.tower.player.PlayerStats; +import ru.project.tower.talents.TalentTree; + + +public final class TalentTreeScreenDependencies { + private final TalentTree talentTree; + private final PlayerStats playerStats; + + public TalentTreeScreenDependencies(TalentTree talentTree, PlayerStats playerStats) { + this.talentTree = Objects.requireNonNull(talentTree, "talentTree"); + this.playerStats = Objects.requireNonNull(playerStats, "playerStats"); + } + + public static TalentTreeScreenDependencies from(GameContext ctx) { + Objects.requireNonNull(ctx, "ctx"); + return new TalentTreeScreenDependencies(ctx.talentTree, ctx.playerStats); + } + + public TalentTree talentTree() { + return talentTree; + } + + public PlayerStats playerStats() { + return playerStats; + } +} diff --git a/core/src/main/java/ru/project/tower/support/BossEffectsHost.java b/core/src/main/java/ru/project/tower/support/BossEffectsHost.java new file mode 100644 index 0000000..611a6d7 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/BossEffectsHost.java @@ -0,0 +1,7 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.graphics.Color; + +public interface BossEffectsHost { + void createBossParticles(float x, float y, float radius, Color color, float scale, int count); +} diff --git a/core/src/main/java/ru/project/tower/support/CompactNumberFormatter.java b/core/src/main/java/ru/project/tower/support/CompactNumberFormatter.java new file mode 100644 index 0000000..7f1ce4f --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/CompactNumberFormatter.java @@ -0,0 +1,54 @@ +package ru.project.tower.support; + +import java.util.Locale; + +public final class CompactNumberFormatter { + private CompactNumberFormatter() {} + + public static String format(long value) { + if (value == Long.MIN_VALUE) { + return "-9.2E"; + } + long abs = Math.abs(value); + if (abs < 1_000L) { + return Long.toString(value); + } + if (abs < 1_000_000L) { + return suffix(value, 1_000f, "K"); + } + if (abs < 1_000_000_000L) { + return suffix(value, 1_000_000f, "M"); + } + if (abs < 1_000_000_000_000L) { + return suffix(value, 1_000_000_000f, "B"); + } + return suffix(value, 1_000_000_000_000f, "T"); + } + + public static String format(float value) { + return format(Math.round(value)); + } + + public static String seconds(float milliseconds) { + return String.format(Locale.ROOT, "%.2fs", milliseconds / 1000f); + } + + public static String signedPerSecond(long value) { + if (value <= 0L) { + return "0/s"; + } + return "+" + format(value) + "/s"; + } + + private static String suffix(long value, float unit, String suffix) { + float scaled = value / unit; + float absScaled = Math.abs(scaled); + if (absScaled >= 999.5f) { + return String.format(Locale.ROOT, "%.1f%s", Math.copySign(999.9f, scaled), suffix); + } + if (absScaled >= 100f || Math.abs(scaled - Math.round(scaled)) < 0.05f) { + return String.format(Locale.ROOT, "%.0f%s", scaled, suffix); + } + return String.format(Locale.ROOT, "%.1f%s", scaled, suffix); + } +} diff --git a/core/src/main/java/ru/project/tower/support/GameAssetService.java b/core/src/main/java/ru/project/tower/support/GameAssetService.java new file mode 100644 index 0000000..6fea22c --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/GameAssetService.java @@ -0,0 +1,8 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.audio.Sound; +import com.badlogic.gdx.utils.Disposable; + +public interface GameAssetService extends Disposable { + Sound loadOptionalSound(String path, long minBytes, String logName, String logTag); +} diff --git a/core/src/main/java/ru/project/tower/support/GameClock.java b/core/src/main/java/ru/project/tower/support/GameClock.java new file mode 100644 index 0000000..a4cafbb --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/GameClock.java @@ -0,0 +1,15 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.utils.TimeUtils; + +public interface GameClock { + GameClock SYSTEM = + new GameClock() { + @Override + public long nowMs() { + return TimeUtils.millis(); + } + }; + + long nowMs(); +} diff --git a/core/src/main/java/ru/project/tower/support/GameplaySoundAssets.java b/core/src/main/java/ru/project/tower/support/GameplaySoundAssets.java new file mode 100644 index 0000000..a17eb2d --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/GameplaySoundAssets.java @@ -0,0 +1,52 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.audio.Sound; +import com.badlogic.gdx.utils.Disposable; +import java.util.Objects; +import ru.project.tower.DebugLog; +import ru.project.tower.run.GameplaySound; + +public final class GameplaySoundAssets implements Disposable { + private static final long MIN_SOUND_BYTES = 200L; + + private final GameAssetService assetService; + private final String logTag; + private Sound shotSound; + private Sound popSound; + + public GameplaySoundAssets(GameAssetService assetService, String logTag) { + this.assetService = Objects.requireNonNull(assetService, "assetService"); + this.logTag = logTag; + } + + public void load() { + shotSound = assetService.loadOptionalSound("shot.wav", MIN_SOUND_BYTES, "shot", logTag); + popSound = assetService.loadOptionalSound("pop.wav", MIN_SOUND_BYTES, "pop", logTag); + if (shotSound == null && popSound == null) { + DebugLog.log(logTag, "Sound files not found. Game will run without sound effects."); + } + } + + public void play(GameplaySound sound, float volume, float pitch, float pan) { + switch (sound) { + case SHOT: + if (shotSound != null) { + shotSound.play(volume, pitch, pan); + } + break; + case POP: + if (popSound != null) { + popSound.play(volume, pitch, pan); + } + break; + default: + throw new IllegalArgumentException("Unsupported gameplay sound: " + sound); + } + } + + @Override + public void dispose() { + shotSound = null; + popSound = null; + } +} diff --git a/core/src/main/java/ru/project/tower/support/GdxInputProcessorInstaller.java b/core/src/main/java/ru/project/tower/support/GdxInputProcessorInstaller.java new file mode 100644 index 0000000..a70c314 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/GdxInputProcessorInstaller.java @@ -0,0 +1,11 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.InputProcessor; + +public final class GdxInputProcessorInstaller implements InputProcessorInstaller { + @Override + public void setInputProcessor(InputProcessor inputProcessor) { + Gdx.input.setInputProcessor(inputProcessor); + } +} diff --git a/core/src/main/java/ru/project/tower/support/GdxViewportSize.java b/core/src/main/java/ru/project/tower/support/GdxViewportSize.java new file mode 100644 index 0000000..ef1fb6f --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/GdxViewportSize.java @@ -0,0 +1,15 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.Gdx; + +public final class GdxViewportSize implements ViewportSize { + @Override + public int getWidth() { + return Gdx.graphics.getWidth(); + } + + @Override + public int getHeight() { + return Gdx.graphics.getHeight(); + } +} diff --git a/core/src/main/java/ru/project/tower/support/InputProcessorInstaller.java b/core/src/main/java/ru/project/tower/support/InputProcessorInstaller.java new file mode 100644 index 0000000..e298916 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/InputProcessorInstaller.java @@ -0,0 +1,7 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.InputProcessor; + +public interface InputProcessorInstaller { + void setInputProcessor(InputProcessor inputProcessor); +} diff --git a/core/src/main/java/ru/project/tower/support/LibGdxAssetService.java b/core/src/main/java/ru/project/tower/support/LibGdxAssetService.java new file mode 100644 index 0000000..0633126 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/LibGdxAssetService.java @@ -0,0 +1,77 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.assets.AssetManager; +import com.badlogic.gdx.audio.Sound; +import com.badlogic.gdx.files.FileHandle; +import ru.project.tower.DebugLog; + +public final class LibGdxAssetService implements GameAssetService { + private final AssetManager assetManager; + private boolean disposed; + + public LibGdxAssetService() { + this(new AssetManager()); + } + + LibGdxAssetService(AssetManager assetManager) { + this.assetManager = assetManager; + } + + @Override + public Sound loadOptionalSound(String path, long minBytes, String logName, String logTag) { + ensureOpen(); + + FileHandle file = Gdx.files.internal(path); + if (!file.exists()) { + return null; + } + + long length = file.length(); + if (length < minBytes) { + logSmallSound(path, length, logTag); + return null; + } + + try { + if (!assetManager.isLoaded(path, Sound.class)) { + assetManager.load(path, Sound.class); + assetManager.finishLoadingAsset(path); + } + Sound sound = assetManager.get(path, Sound.class); + logLoadedSound(logName, logTag); + return sound; + } catch (RuntimeException e) { + Gdx.app.error(logTag, path + " failed to decode, skipping", e); + if (assetManager.isLoaded(path)) { + assetManager.unload(path); + } + return null; + } + } + + @Override + public void dispose() { + if (disposed) return; + disposed = true; + assetManager.dispose(); + } + + private void ensureOpen() { + if (disposed) { + throw new IllegalStateException("GameAssetService has been disposed"); + } + } + + private void logLoadedSound(String logName, String logTag) { + if (DebugLog.DEBUG) { + DebugLog.log(logTag, "Loaded " + logName + " sound"); + } + } + + private void logSmallSound(String fileName, long length, String logTag) { + if (DebugLog.DEBUG) { + DebugLog.log(logTag, fileName + " too small (" + length + " bytes), skipping"); + } + } +} diff --git a/core/src/main/java/ru/project/tower/support/MathUtilsRandomSource.java b/core/src/main/java/ru/project/tower/support/MathUtilsRandomSource.java new file mode 100644 index 0000000..0061a76 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/MathUtilsRandomSource.java @@ -0,0 +1,25 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.math.MathUtils; + +public final class MathUtilsRandomSource implements RandomSource { + @Override + public float nextFloat() { + return MathUtils.random(); + } + + @Override + public int nextIntInclusive(int max) { + return MathUtils.random(max); + } + + @Override + public float range(float min, float max) { + return MathUtils.random(min, max); + } + + @Override + public float angle() { + return MathUtils.random(MathUtils.PI2); + } +} diff --git a/core/src/main/java/ru/project/tower/support/PausableGameClock.java b/core/src/main/java/ru/project/tower/support/PausableGameClock.java new file mode 100644 index 0000000..46df469 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/PausableGameClock.java @@ -0,0 +1,42 @@ +package ru.project.tower.support; + +import java.util.Objects; + +public final class PausableGameClock implements GameClock { + private final GameClock wallClock; + private long totalPausedMs; + private long pauseStartedAtWallMs; + private boolean paused; + + public PausableGameClock(GameClock wallClock) { + this.wallClock = Objects.requireNonNull(wallClock, "wallClock"); + } + + @Override + public long nowMs() { + long wallNow = paused ? pauseStartedAtWallMs : wallClock.nowMs(); + return wallNow - totalPausedMs; + } + + public void pause() { + if (paused) { + return; + } + paused = true; + pauseStartedAtWallMs = wallClock.nowMs(); + } + + public void resume() { + if (!paused) { + return; + } + totalPausedMs += wallClock.nowMs() - pauseStartedAtWallMs; + paused = false; + } + + public void resetToWallNow() { + totalPausedMs = 0L; + pauseStartedAtWallMs = 0L; + paused = false; + } +} diff --git a/core/src/main/java/ru/project/tower/support/RandomSource.java b/core/src/main/java/ru/project/tower/support/RandomSource.java new file mode 100644 index 0000000..6d8a0d1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/RandomSource.java @@ -0,0 +1,11 @@ +package ru.project.tower.support; + +public interface RandomSource { + float nextFloat(); + + int nextIntInclusive(int max); + + float range(float min, float max); + + float angle(); +} diff --git a/core/src/main/java/ru/project/tower/support/RangeChecks.java b/core/src/main/java/ru/project/tower/support/RangeChecks.java new file mode 100644 index 0000000..7d3c2bf --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/RangeChecks.java @@ -0,0 +1,25 @@ +package ru.project.tower.support; + +public final class RangeChecks { + private RangeChecks() {} + + public static float distanceSquared(float x1, float y1, float x2, float y2) { + float dx = x1 - x2; + float dy = y1 - y2; + return dx * dx + dy * dy; + } + + public static float radiusSquared(float radius) { + return radius * radius; + } + + public static boolean withinRadiusInclusive( + float x1, float y1, float x2, float y2, float radius) { + return distanceSquared(x1, y1, x2, y2) <= radiusSquared(radius); + } + + public static boolean withinRadiusExclusive( + float x1, float y1, float x2, float y2, float radius) { + return distanceSquared(x1, y1, x2, y2) < radiusSquared(radius); + } +} diff --git a/core/src/main/java/ru/project/tower/support/RobotoFontFactory.java b/core/src/main/java/ru/project/tower/support/RobotoFontFactory.java new file mode 100644 index 0000000..4bae918 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/RobotoFontFactory.java @@ -0,0 +1,161 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.graphics.Texture.TextureFilter; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator; +import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator.Hinting; + + +public final class RobotoFontFactory { + public static final String FONT_CHARACTERS = + "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ" + + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789][_!$%#@|\\/?-+=()*&.;:,{}\"´`'<>" + + "₽◆♥→"; + public static final String FONT_CHARACTERS_WITH_DEFAULTS = + FreeTypeFontGenerator.DEFAULT_CHARS + + "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"; + + private static final String UI_PATH = "ui.ttf"; + private static final String ROBOTO_PATH = "roboto.ttf"; + + private RobotoFontFactory() {} + + public static boolean exists() { + return Gdx.files.internal(fontPath()).exists(); + } + + public static BitmapFont generate(FontSpec spec) { + return generateFromPath(fontPath(), spec); + } + + public static BitmapFont generateUi(FontSpec spec) { + return generateFromPath(UI_PATH, spec); + } + + private static BitmapFont generateFromPath(String path, FontSpec spec) { + FreeTypeFontGenerator generator = new FreeTypeFontGenerator(Gdx.files.internal(path)); + try { + return generator.generateFont(spec.toParameter()); + } finally { + generator.dispose(); + } + } + + private static String fontPath() { + return Gdx.files.internal(ROBOTO_PATH).exists() ? ROBOTO_PATH : UI_PATH; + } + + public static BitmapFont fallback() { + return new BitmapFont(); + } + + public static BitmapFont fallback(float scale) { + BitmapFont font = fallback(); + font.getData().setScale(scale); + return font; + } + + public static FontSpec spec( + int size, + float borderWidth, + Color borderColor, + Color color, + int shadowOffsetX, + int shadowOffsetY, + Color shadowColor) { + return new FontSpec( + size, + borderWidth, + borderColor, + color, + shadowOffsetX, + shadowOffsetY, + shadowColor, + FONT_CHARACTERS); + } + + public static final class FontSpec { + private final int size; + private final float borderWidth; + private final Color borderColor; + private final Color color; + private final int shadowOffsetX; + private final int shadowOffsetY; + private final Color shadowColor; + private final String characters; + + public FontSpec( + int size, + float borderWidth, + Color borderColor, + Color color, + int shadowOffsetX, + int shadowOffsetY, + Color shadowColor, + String characters) { + this.size = size; + this.borderWidth = borderWidth; + this.borderColor = borderColor; + this.color = color; + this.shadowOffsetX = shadowOffsetX; + this.shadowOffsetY = shadowOffsetY; + this.shadowColor = shadowColor; + this.characters = characters; + } + + public FontSpec withSize(int size) { + return new FontSpec( + size, + borderWidth, + borderColor, + color, + shadowOffsetX, + shadowOffsetY, + shadowColor, + characters); + } + + public FontSpec withBorderWidth(float borderWidth) { + return new FontSpec( + size, + borderWidth, + borderColor, + color, + shadowOffsetX, + shadowOffsetY, + shadowColor, + characters); + } + + public FontSpec withCharacters(String characters) { + return new FontSpec( + size, + borderWidth, + borderColor, + color, + shadowOffsetX, + shadowOffsetY, + shadowColor, + characters); + } + + private FreeTypeFontGenerator.FreeTypeFontParameter toParameter() { + FreeTypeFontGenerator.FreeTypeFontParameter parameter = + new FreeTypeFontGenerator.FreeTypeFontParameter(); + parameter.size = size; + parameter.borderWidth = borderWidth; + parameter.borderColor = borderColor; + parameter.color = color; + parameter.shadowOffsetX = shadowOffsetX; + parameter.shadowOffsetY = shadowOffsetY; + parameter.shadowColor = shadowColor; + parameter.characters = characters; + parameter.minFilter = TextureFilter.Linear; + parameter.magFilter = TextureFilter.Linear; + parameter.hinting = Hinting.Slight; + return parameter; + } + } +} diff --git a/core/src/main/java/ru/project/tower/support/ScreenAssets.java b/core/src/main/java/ru/project/tower/support/ScreenAssets.java new file mode 100644 index 0000000..08ed28d --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/ScreenAssets.java @@ -0,0 +1,98 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.BitmapFont; +import com.badlogic.gdx.graphics.g2d.NinePatch; +import com.badlogic.gdx.graphics.g2d.TextureAtlas; +import com.badlogic.gdx.graphics.g2d.TextureRegion; +import com.badlogic.gdx.graphics.glutils.PixmapTextureData; +import com.badlogic.gdx.scenes.scene2d.ui.Skin; +import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable; +import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable; +import com.badlogic.gdx.utils.Disposable; +import java.util.ArrayList; +import java.util.List; + + + + + + + + + + + +public final class ScreenAssets implements Disposable { + private final Skin skin = new Skin(); + private final List ownedTextures = new ArrayList<>(); + private TextureAtlas atlas; + private boolean disposed; + + public Skin skin() { + ensureOpen(); + return skin; + } + + public BitmapFont font(String name, BitmapFont font) { + ensureOpen(); + skin.add(name, font); + return font; + } + + public TextureAtlas setAtlas(TextureAtlas atlas) { + ensureOpen(); + if (this.atlas != null) { + throw new IllegalStateException("ScreenAssets already owns an atlas"); + } + this.atlas = atlas; + return atlas; + } + + public TextureRegionDrawable own(Texture texture) { + ensureOpen(); + ownedTextures.add(texture); + return new TextureRegionDrawable(new TextureRegion(texture)); + } + + public TextureRegionDrawable own(Pixmap pixmap) { + ensureOpen(); + Texture texture = textureFromPixmap(pixmap); + ownedTextures.add(texture); + return new TextureRegionDrawable(new TextureRegion(texture)); + } + + public NinePatchDrawable ownNinePatch(Pixmap pixmap, int split) { + ensureOpen(); + Texture texture = textureFromPixmap(pixmap); + ownedTextures.add(texture); + return new NinePatchDrawable(new NinePatch(texture, split, split, split, split)); + } + + private static Texture textureFromPixmap(Pixmap pixmap) { + return new Texture(new PixmapTextureData(pixmap, null, false, true)); + } + + @Override + public void dispose() { + if (disposed) return; + disposed = true; + + skin.dispose(); + if (atlas != null) { + atlas.dispose(); + atlas = null; + } + for (Texture texture : ownedTextures) { + texture.dispose(); + } + ownedTextures.clear(); + } + + private void ensureOpen() { + if (disposed) { + throw new IllegalStateException("ScreenAssets has been disposed"); + } + } +} diff --git a/core/src/main/java/ru/project/tower/support/ScreenPalette.java b/core/src/main/java/ru/project/tower/support/ScreenPalette.java new file mode 100644 index 0000000..4f7cc89 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/ScreenPalette.java @@ -0,0 +1,106 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.graphics.Color; + + + + +public enum ScreenPalette { + NEON_RED(1f, 0.2f, 0.2f, 1f), + NEON_GREEN(0.2f, 1f, 0.2f, 1f), + NEON_BLUE(0.2f, 0.6f, 1f, 1f), + NEON_DEEP_BLUE(0.2f, 0.2f, 1f, 1f), + NEON_YELLOW(1f, 1f, 0.2f, 1f), + NEON_CYAN(0f, 0.9f, 0.9f, 1f), + NEON_BRIGHT_CYAN(0.2f, 1f, 1f, 1f), + NEON_PINK(1f, 0.2f, 0.6f, 1f), + NEON_BRIGHT_PINK(1f, 0.2f, 1f, 1f), + NEON_PURPLE(0.5f, 0f, 1f, 1f), + NEON_LIGHT_PURPLE(0.6f, 0.2f, 1f, 1f), + SELECTION_RED(1f, 0.1f, 0.2f, 1f), + SELECTION_GREEN(0.1f, 1f, 0.2f, 1f), + BACKGROUND(0.05f, 0.05f, 0.1f, 1f), + TALENT_BACKGROUND(0.02f, 0.02f, 0.05f, 1f), + FONT_SHADOW(0f, 0f, 0f, 0.5f), + LOCKED_GRAY(0.3f, 0.3f, 0.3f, 0.5f), + PURCHASED_GREEN(0.2f, 1f, 0.3f, 1f), + DISABLED_RED(0.3f, 0.1f, 0.1f, 0.8f), + DISABLED_GREEN(0.1f, 0.3f, 0.1f, 0.8f), + DISABLED_DARK_GREEN(0.1f, 0.2f, 0.1f, 0.8f), + DISABLED_YELLOW(0.3f, 0.3f, 0.1f, 0.8f), + DISABLED_PINK(0.3f, 0.1f, 0.2f, 0.8f), + INACTIVE_ATTACK(0.3f, 0.1f, 0.1f, 0.7f), + INACTIVE_HEALTH(0.1f, 0.3f, 0.1f, 0.7f), + INACTIVE_ABILITIES(0.2f, 0.1f, 0.3f, 0.7f), + UPGRADE_DAMAGE(0.9f, 0.5f, 0.1f, 1f), + UPGRADE_SPEED(0.1f, 0.6f, 0.9f, 1f), + UPGRADE_COOLDOWN(0.9f, 0.8f, 0.1f, 1f), + UPGRADE_CRIT_CHANCE(1f, 0.2f, 0.8f, 1f), + UPGRADE_CRIT_MULTIPLIER(0.8f, 0.2f, 1f, 1f), + ABILITY_FREEZE(0.2f, 0.7f, 1f, 1f), + ABILITY_EXPLOSION(1f, 0.5f, 0.1f, 1f), + ABILITY_SHIELD(0.4f, 1f, 0.4f, 1f), + ABILITY_IMPULSE(0.9f, 0.4f, 0.9f, 1f), + ABILITY_DASH(0.3f, 0.9f, 1f, 1f), + ABILITY_PULL(0.6f, 0.3f, 1f, 1f), + ABILITY_TURRET(0.2f, 1f, 0.4f, 1f), + UPGRADE_HEALTH(0.7f, 0.2f, 0.2f, 1f), + UPGRADE_REGEN(0.3f, 0.8f, 0.3f, 1f), + ENABLED_REGEN(0.4f, 0.8f, 0.4f, 1f), + BUTTON_DISABLED(0.3f, 0.3f, 0.3f, 1f), + TEXT_PRICE(1f, 1f, 0.5f, 1f), + TEXT_COOLDOWN(1f, 0.5f, 0.5f, 1f), + SHADOW_WEAVER(0.8f, 0.2f, 0.8f, 1f), + GLACIAL_WARDEN(0.7f, 0.8f, 1f, 1f), + VOLATILE_REACTOR(1f, 0.5f, 0.2f, 1f), + BASIC_CIRCLE(1f, 0.5f, 0.5f, 1f), + FAST_CIRCLE(1f, 1f, 0f, 1f), + SPLITTER_CIRCLE(0.3f, 1f, 0.3f, 1f), + SPAWNER_CIRCLE(1f, 0.3f, 1f, 1f), + SCREEN(0.035f, 0.043f, 0.071f, 1f), + FIELD(0.043f, 0.059f, 0.106f, 1f), + PANEL(0.059f, 0.078f, 0.129f, 0.9f), + PANEL_SOLID(0.067f, 0.09f, 0.153f, 1f), + LINE(0.52f, 0.61f, 1f, 0.28f), + TEXT(0.969f, 0.976f, 1f, 1f), + MUTED(0.667f, 0.702f, 0.784f, 1f), + QUIET(0.435f, 0.471f, 0.565f, 1f), + CYAN(0.133f, 0.851f, 1f, 1f), + BLUE(0.325f, 0.4f, 1f, 1f), + GOLD(1f, 0.812f, 0.29f, 1f), + GREEN(0.388f, 0.902f, 0.553f, 1f), + RED(1f, 0.38f, 0.478f, 1f), + ORANGE(1f, 0.624f, 0.18f, 1f), + PINK(1f, 0.31f, 0.847f, 1f), + BASIC_ENEMY(0.302f, 0.486f, 1f, 1f), + ARMORED_ENEMY(1f, 0.608f, 0.718f, 1f), + SUPPORT_ENEMY(0.145f, 0.875f, 0.71f, 1f); + + public final float r; + public final float g; + public final float b; + public final float a; + + ScreenPalette(float r, float g, float b, float a) { + this.r = r; + this.g = g; + this.b = b; + this.a = a; + } + + public Color set(Color target) { + return target.set(r, g, b, a); + } + + public Color set(Color target, float alpha) { + return target.set(r, g, b, alpha); + } + + public Color dim(Color target, float alpha) { + return target.set(r * 0.7f, g * 0.7f, b * 0.7f, alpha); + } + + public Color create() { + return new Color(r, g, b, a); + } +} diff --git a/core/src/main/java/ru/project/tower/support/SeededRandomSource.java b/core/src/main/java/ru/project/tower/support/SeededRandomSource.java new file mode 100644 index 0000000..176d4d4 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/SeededRandomSource.java @@ -0,0 +1,101 @@ +package ru.project.tower.support; + +import java.util.Objects; + + +public final class SeededRandomSource implements RandomSource { + private static final long MULTIPLIER = 6364136223846793005L; + private static final long INCREMENT = 1442695040888963407L; + private static final float FLOAT_UNIT = 0x1.0p-24f; + + private long state; + + public SeededRandomSource(long seed) { + this.state = mix(seed); + } + + private SeededRandomSource(long seed, long state) { + this.state = state == 0L ? mix(seed) : state; + } + + public static SeededRandomSource fromSnapshot(Snapshot snapshot) { + Snapshot source = Objects.requireNonNull(snapshot, "snapshot"); + return new SeededRandomSource(source.seed(), source.state()); + } + + public Snapshot snapshot(long seed) { + return new Snapshot(seed, state); + } + + public long state() { + return state; + } + + @Override + public float nextFloat() { + return ((nextLong() >>> 40) & 0xFFFFFFL) * FLOAT_UNIT; + } + + @Override + public int nextIntInclusive(int max) { + if (max < 0) { + throw new IllegalArgumentException("max must be >= 0: " + max); + } + if (max == Integer.MAX_VALUE) { + return (int) (nextLong() >>> 1); + } + return (int) (nextFloat() * (max + 1L)); + } + + @Override + public float range(float min, float max) { + if (min > max) { + throw new IllegalArgumentException("min must be <= max: " + min + " > " + max); + } + return min + (max - min) * nextFloat(); + } + + @Override + public float angle() { + return nextFloat() * (float) (Math.PI * 2.0); + } + + private long nextLong() { + state = state * MULTIPLIER + INCREMENT; + return mix(state); + } + + public static long forkSeed(long seed, String streamName) { + Objects.requireNonNull(streamName, "streamName"); + long hash = 1125899906842597L; + for (int i = 0; i < streamName.length(); i++) { + hash = 31L * hash + streamName.charAt(i); + } + return mix(seed ^ hash); + } + + private static long mix(long value) { + long z = value + 0x9E3779B97F4A7C15L; + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + + public static final class Snapshot { + private final long seed; + private final long state; + + public Snapshot(long seed, long state) { + this.seed = seed; + this.state = state; + } + + public long seed() { + return seed; + } + + public long state() { + return state; + } + } +} diff --git a/core/src/main/java/ru/project/tower/support/UiAtlasBuilder.java b/core/src/main/java/ru/project/tower/support/UiAtlasBuilder.java new file mode 100644 index 0000000..a9d63f7 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/UiAtlasBuilder.java @@ -0,0 +1,88 @@ +package ru.project.tower.support; + +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.graphics.g2d.PixmapPacker; +import com.badlogic.gdx.graphics.g2d.TextureAtlas; +import com.badlogic.gdx.utils.Disposable; +import java.util.ArrayList; +import java.util.List; + + + + + + + + + + + + + + + + + + + + + + + + + +public final class UiAtlasBuilder implements Disposable, AutoCloseable { + private final PixmapPacker packer; + private final List packedPixmaps = new ArrayList<>(); + private boolean built; + private boolean disposed; + + public UiAtlasBuilder(int pageWidth, int pageHeight) { + + + this.packer = new PixmapPacker(pageWidth, pageHeight, Pixmap.Format.RGBA8888, 2, true); + } + + public void add(String regionName, Pixmap pixmap) { + ensureOpen(); + if (pixmap != null) { + packedPixmaps.add(pixmap); + } + packer.pack(regionName, pixmap); + } + + public TextureAtlas build() { + ensureOpen(); + built = true; + try { + return packer.generateTextureAtlas( + Texture.TextureFilter.Linear, Texture.TextureFilter.Linear, false); + } finally { + dispose(); + } + } + + @Override + public void dispose() { + if (disposed) return; + disposed = true; + packer.dispose(); + for (Pixmap pixmap : packedPixmaps) { + pixmap.dispose(); + } + packedPixmaps.clear(); + } + + @Override + public void close() { + dispose(); + } + + private void ensureOpen() { + if (disposed || built) { + throw new IllegalStateException( + "UiAtlasBuilder is single-use and has already been closed"); + } + } +} diff --git a/core/src/main/java/ru/project/tower/support/ViewportSize.java b/core/src/main/java/ru/project/tower/support/ViewportSize.java new file mode 100644 index 0000000..57307d9 --- /dev/null +++ b/core/src/main/java/ru/project/tower/support/ViewportSize.java @@ -0,0 +1,7 @@ +package ru.project.tower.support; + +public interface ViewportSize { + int getWidth(); + + int getHeight(); +} diff --git a/core/src/main/java/ru/project/tower/systems/BonusBallSystem.java b/core/src/main/java/ru/project/tower/systems/BonusBallSystem.java new file mode 100644 index 0000000..d147b50 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/BonusBallSystem.java @@ -0,0 +1,252 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.utils.Array; +import com.badlogic.gdx.utils.Pools; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.entities.circles.BonusBallData; + +public class BonusBallSystem { + private static final int BONUS_WAVES = 5; + private static final int MAX_ACTIVE_BONUSES = 8; + private static final int MAX_ACTIVE_PER_TYPE = 3; + private static final float OUT_OF_BOUNDS_MARGIN = 100f; + + private final Array bonusBalls; + private final Array activeBonuses = new Array<>(); + + public BonusBallSystem(Array bonusBalls) { + this.bonusBalls = bonusBalls; + } + + public CollectionEvent collect(int index) { + BonusBallData ball = bonusBalls.get(index); + BonusBallData.BonusType type = ball.getBonusType(); + float x = ball.getX(); + float y = ball.getY(); + CollectionEffect effect = apply(type); + + BonusBallData removed = bonusBalls.removeIndex(index); + Pools.free(removed); + + return new CollectionEvent(type, x, y, effect.text, effect); + } + + public CollectionEffect apply(BonusBallData.BonusType type) { + CollectionEffect effect = effectFor(type); + if (effect.active) { + addActiveBonus(type); + } + return effect; + } + + private void addActiveBonus(BonusBallData.BonusType type) { + if (activeStacks(type) >= MAX_ACTIVE_PER_TYPE) { + return; + } + if (activeBonuses.size >= MAX_ACTIVE_BONUSES) { + removeMostStackedBonusExcept(type); + } + activeBonuses.add(new ActiveBonus(type, BONUS_WAVES)); + } + + private void removeMostStackedBonusExcept(BonusBallData.BonusType protectedType) { + int bestIndex = -1; + int bestStacks = -1; + for (int i = 0; i < activeBonuses.size; i++) { + BonusBallData.BonusType type = activeBonuses.get(i).type; + if (type == protectedType) continue; + int stacks = activeStacks(type); + if (stacks > bestStacks) { + bestStacks = stacks; + bestIndex = i; + } + } + if (bestIndex < 0 && activeBonuses.size > 0) { + bestIndex = 0; + } + if (bestIndex >= 0) { + activeBonuses.removeIndex(bestIndex); + } + } + + public void endWave() { + for (int i = activeBonuses.size - 1; i >= 0; i--) { + ActiveBonus bonus = activeBonuses.get(i); + bonus.wavesLeft--; + if (bonus.wavesLeft <= 0) { + activeBonuses.removeIndex(i); + } + } + } + + public void update(float delta, float viewportWidth, float viewportHeight) { + for (int i = bonusBalls.size - 1; i >= 0; i--) { + BonusBallData ball = bonusBalls.get(i); + ball.update(delta); + + if (isOutOfBounds(ball, viewportWidth, viewportHeight)) { + Pools.free(bonusBalls.removeIndex(i)); + } + } + } + + public void clear() { + for (int i = bonusBalls.size - 1; i >= 0; i--) { + Pools.free(bonusBalls.get(i)); + } + bonusBalls.clear(); + activeBonuses.clear(); + } + + public float getCooldownMultiplier() { + int stacks = activeStacks(BonusBallData.BonusType.FIRE_RATE); + return EndlessBalanceSpec.activeBonusMultiplier(BonusBallData.BonusType.FIRE_RATE, stacks); + } + + public float getSpeedMultiplier() { + int stacks = activeStacks(BonusBallData.BonusType.BULLET_SPEED); + return EndlessBalanceSpec.activeBonusMultiplier( + BonusBallData.BonusType.BULLET_SPEED, stacks); + } + + public float getDamageMultiplier() { + int stacks = activeStacks(BonusBallData.BonusType.DAMAGE); + return EndlessBalanceSpec.activeBonusMultiplier(BonusBallData.BonusType.DAMAGE, stacks); + } + + public float getCritChanceBonus() { + return Math.min(0.18f, activeStacks(BonusBallData.BonusType.CRIT_WINDOW) * 0.06f); + } + + public float getPickupRadiusMultiplier() { + int stacks = activeStacks(BonusBallData.BonusType.PICKUP_MAGNET); + if (stacks <= 0) return 1f; + return Math.min(1.9f, 1.45f + (stacks - 1) * 0.18f); + } + + public int activeStacks(BonusBallData.BonusType type) { + int stacks = 0; + for (ActiveBonus bonus : activeBonuses) { + if (bonus.type == type) { + stacks++; + } + } + return stacks; + } + + public int getActiveBonusCount() { + return activeBonuses.size; + } + + private static boolean isOutOfBounds( + BonusBallData ball, float viewportWidth, float viewportHeight) { + return ball.getX() < -OUT_OF_BOUNDS_MARGIN + || ball.getX() > viewportWidth + OUT_OF_BOUNDS_MARGIN + || ball.getY() < -OUT_OF_BOUNDS_MARGIN + || ball.getY() > viewportHeight + OUT_OF_BOUNDS_MARGIN; + } + + public static String previewText(BonusBallData.BonusType type) { + return effectFor(type).text; + } + + private static CollectionEffect effectFor(BonusBallData.BonusType type) { + switch (type) { + case BULLET_SPEED: + return CollectionEffect.active("СКОРОСТЬ ПУЛЬ!"); + case DAMAGE: + return CollectionEffect.active("ДВОЙНОЙ УРОН!"); + case FIRE_RATE: + return CollectionEffect.active("СКОРОСТРЕЛЬНОСТЬ!"); + case CRIT_WINDOW: + return CollectionEffect.active("КРИТ-ОКНО!"); + case PICKUP_MAGNET: + return CollectionEffect.active("МАГНИТ ПОЛЯ!"); + case SHIELD_PULSE: + return new CollectionEffect("ИМПУЛЬС ЩИТА!", false, 0, 0, 1, 25); + case HEAL: + return new CollectionEffect("РЕМОНТ!", false, 20, 0, 0, 0); + case ABILITY_CHARGE: + return new CollectionEffect("ЗАРЯД СПОСОБНОСТИ!", false, 0, 0, 1, 0); + case COIN_BURST: + return new CollectionEffect("ВСПЛЕСК МОНЕТ!", false, 0, 6, 0, 0); + default: + throw new IllegalArgumentException("Unknown bonus type: " + type); + } + } + + private static final class ActiveBonus { + private final BonusBallData.BonusType type; + private int wavesLeft; + + private ActiveBonus(BonusBallData.BonusType type, int wavesLeft) { + this.type = type; + this.wavesLeft = wavesLeft; + } + } + + public static final class CollectionEvent { + public final BonusBallData.BonusType type; + public final float x; + public final float y; + public final String text; + public final CollectionEffect effect; + + private CollectionEvent( + BonusBallData.BonusType type, + float x, + float y, + String text, + CollectionEffect effect) { + this.type = type; + this.x = x; + this.y = y; + this.text = text; + this.effect = effect; + } + } + + public static final class CollectionEffect { + private final String text; + private final boolean active; + private final int healAmount; + private final int coinAmount; + private final int abilityCharge; + private final int shieldPulse; + + private CollectionEffect( + String text, + boolean active, + int healAmount, + int coinAmount, + int abilityCharge, + int shieldPulse) { + this.text = text; + this.active = active; + this.healAmount = healAmount; + this.coinAmount = coinAmount; + this.abilityCharge = abilityCharge; + this.shieldPulse = shieldPulse; + } + + private static CollectionEffect active(String text) { + return new CollectionEffect(text, true, 0, 0, 0, 0); + } + + public int healAmount() { + return healAmount; + } + + public int coinAmount() { + return coinAmount; + } + + public int abilityCharge() { + return abilityCharge; + } + + public int shieldPulse() { + return shieldPulse; + } + } +} diff --git a/core/src/main/java/ru/project/tower/systems/BossPlayerCombatModifiers.java b/core/src/main/java/ru/project/tower/systems/BossPlayerCombatModifiers.java new file mode 100644 index 0000000..62fcac0 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/BossPlayerCombatModifiers.java @@ -0,0 +1,38 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.bosses.GlacialWardenData; + +public final class BossPlayerCombatModifiers { + private BossPlayerCombatModifiers() {} + + public static float attackSpeedMultiplier(Array circles) { + Objects.requireNonNull(circles, "circles"); + float multiplier = 1f; + for (BaseCircleData circle : circles) { + if (circle instanceof GlacialWardenData) { + multiplier = + Math.min( + multiplier, + ((GlacialWardenData) circle).getActiveAttackSpeedMultiplier()); + } + } + return multiplier; + } + + public static float projectileSpeedMultiplier(Array circles) { + Objects.requireNonNull(circles, "circles"); + float multiplier = 1f; + for (BaseCircleData circle : circles) { + if (circle instanceof GlacialWardenData) { + multiplier = + Math.min( + multiplier, + ((GlacialWardenData) circle).getActiveProjectileSpeedMultiplier()); + } + } + return multiplier; + } +} diff --git a/core/src/main/java/ru/project/tower/systems/BossProjectileFactory.java b/core/src/main/java/ru/project/tower/systems/BossProjectileFactory.java new file mode 100644 index 0000000..0819124 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/BossProjectileFactory.java @@ -0,0 +1,151 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.utils.Pools; +import ru.project.tower.entities.bullets.BulletData; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.bosses.GlacialWardenData; +import ru.project.tower.entities.circles.bosses.IllusionMasterData; +import ru.project.tower.entities.circles.bosses.ShadowWeaverData; +import ru.project.tower.entities.circles.bosses.SwarmQueenData; +import ru.project.tower.entities.circles.bosses.VolatileReactorData; + +public final class BossProjectileFactory { + private static final Color SHADOW_WEAVER_PROJECTILE_COLOR = new Color(0.6f, 0.2f, 1f, 1f); + private static final Color VOLATILE_REACTOR_MISSILE_COLOR = new Color(1f, 1f, 0.2f, 1f); + private static final Color ILLUSION_REAL_PROJECTILE_COLOR = new Color(1f, 1f, 1f, 1f); + private static final Color ILLUSION_FAKE_PROJECTILE_COLOR = new Color(0.5f, 0.5f, 0.5f, 0.7f); + + public interface AngleSource { + float angle(); + } + + private BossProjectileFactory() {} + + public static void spawnQueenProjectiles(SwarmQueenData queen, BulletSystem bulletSystem) { + float angleStep = 360f / SwarmQueenData.getBulletsPerVolley(); + for (int i = 0; i < SwarmQueenData.getBulletsPerVolley(); i++) { + float angle = i * angleStep; + float speed = SwarmQueenData.getBulletSpeed(); + addEnemyBullet( + bulletSystem, + queen.getX(), + queen.getY(), + SwarmQueenData.getBulletRadius(), + MathUtils.cosDeg(angle) * speed, + MathUtils.sinDeg(angle) * speed, + SwarmQueenData.getBulletDamage(), + queen.getProjectileColor()); + } + queen.onProjectileAttackStart(); + } + + public static void spawnShadowWeaverProjectile( + ShadowWeaverData weaver, BulletSystem bulletSystem, float targetX, float targetY) { + float dx = targetX - weaver.getX(); + float dy = targetY - weaver.getY(); + float length = (float) Math.sqrt(dx * dx + dy * dy); + float vx = 0f; + float vy = 0f; + if (length > 0f) { + float speed = ShadowWeaverData.getProjectileSpeed(); + vx = dx / length * speed; + vy = dy / length * speed; + } + + addEnemyBullet( + bulletSystem, + weaver.getX(), + weaver.getY(), + ShadowWeaverData.getProjectileRadius(), + vx, + vy, + ShadowWeaverData.getProjectileDamage(), + SHADOW_WEAVER_PROJECTILE_COLOR); + weaver.markProjectileShot(); + weaver.createClones(); + } + + public static void spawnVolatileReactorMissiles( + VolatileReactorData reactor, BulletSystem bulletSystem, AngleSource angles) { + for (int j = 0; j < VolatileReactorData.getMissilesPerVolley(); j++) { + float angle = angles.angle(); + float speed = VolatileReactorData.getMissileSpeed(); + addEnemyBullet( + bulletSystem, + reactor.getX(), + reactor.getY(), + VolatileReactorData.getMissileRadius(), + MathUtils.cos(angle) * speed, + MathUtils.sin(angle) * speed, + VolatileReactorData.getMissileDamage(), + VOLATILE_REACTOR_MISSILE_COLOR); + } + reactor.markMissileShot(); + } + + public static void spawnGlacialWardenShards(GlacialWardenData warden, AngleSource angles) { + for (int j = 0; j < GlacialWardenData.getShardsPerVolley(); j++) { + float angle = angles.angle(); + Vector2 velocity = + new Vector2( + MathUtils.cos(angle) * GlacialWardenData.getShardSpeed(), + MathUtils.sin(angle) * GlacialWardenData.getShardSpeed()); + warden.addIceShard(warden.getX(), warden.getY(), velocity); + } + warden.markShardsShot(); + } + + public static void spawnIllusionMasterProjectiles( + IllusionMasterData master, + BulletSystem bulletSystem, + AngleSource angles, + float gameScale) { + for (int i = 0; i < IllusionMasterData.getRealProjectilesCount(); i++) { + spawnIllusionProjectile(master, bulletSystem, angles.angle(), false, gameScale); + } + + for (int i = 0; i < IllusionMasterData.getFakeProjectilesCount(); i++) { + spawnIllusionProjectile(master, bulletSystem, angles.angle(), true, gameScale); + } + } + + private static void spawnIllusionProjectile( + IllusionMasterData master, + BulletSystem bulletSystem, + float angle, + boolean isFake, + float gameScale) { + float radius = IllusionMasterData.getProjectileRadius() * gameScale; + float speed = IllusionMasterData.getProjectileSpeed() * gameScale; + int damage = isFake ? 1 : IllusionMasterData.getProjectileDamage(); + Color color = isFake ? ILLUSION_FAKE_PROJECTILE_COLOR : ILLUSION_REAL_PROJECTILE_COLOR; + + addEnemyBullet( + bulletSystem, + master.getX(), + master.getY(), + radius, + MathUtils.cos(angle) * speed, + MathUtils.sin(angle) * speed, + damage, + color); + } + + private static void addEnemyBullet( + BulletSystem bulletSystem, + float x, + float y, + float radius, + float vx, + float vy, + int damage, + Color color) { + BulletData bullet = Pools.obtain(BulletData.class); + bullet.configureAsEnemyBullet(x, y, radius, vx, vy, damage); + bullet.setColor(color); + bulletSystem.addEnemyBullet(bullet); + } +} diff --git a/core/src/main/java/ru/project/tower/systems/CircleSpatialGrid.java b/core/src/main/java/ru/project/tower/systems/CircleSpatialGrid.java new file mode 100644 index 0000000..db2a666 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/CircleSpatialGrid.java @@ -0,0 +1,166 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.utils.Array; +import java.util.Arrays; +import ru.project.tower.entities.circles.BaseCircleData; + + + + + + + + +public final class CircleSpatialGrid { + private static final float CELL_SIZE = 96f; + + private int[] cellHead = new int[16]; + private int[] cellKeyX = new int[16]; + private int[] cellKeyY = new int[16]; + private int[] nextInCell = new int[16]; + private int[] candidates = new int[16]; + private int mask = 15; + private int itemCount; + private float maxRadius; + + public void build(Array circles) { + itemCount = circles.size; + ensureItemCapacity(itemCount); + ensureCellCapacity(Math.max(16, itemCount * 2)); + Arrays.fill(cellHead, -1); + maxRadius = 0f; + + for (int i = 0; i < itemCount; i++) { + BaseCircleData circleData = circles.get(i); + if (circleData.circle.radius > maxRadius) { + maxRadius = circleData.circle.radius; + } + + int cellX = toCell(circleData.circle.x); + int cellY = toCell(circleData.circle.y); + int slot = findOrCreateCell(cellX, cellY); + nextInCell[i] = cellHead[slot]; + cellHead[slot] = i; + } + } + + public int itemCount() { + return itemCount; + } + + public float maxRadius() { + return maxRadius; + } + + public int queryRect(float minX, float minY, float maxX, float maxY) { + int count = 0; + int minCellX = toCell(minX); + int maxCellX = toCell(maxX); + int minCellY = toCell(minY); + int maxCellY = toCell(maxY); + + for (int cellY = minCellY; cellY <= maxCellY; cellY++) { + for (int cellX = minCellX; cellX <= maxCellX; cellX++) { + int slot = findCell(cellX, cellY); + if (slot < 0) continue; + for (int itemIndex = cellHead[slot]; + itemIndex >= 0; + itemIndex = nextInCell[itemIndex]) { + candidates[count++] = itemIndex; + } + } + } + return count; + } + + public int queryRadius(float x, float y, float radius) { + return queryRect(x - radius, y - radius, x + radius, y + radius); + } + + public void sortCandidatesAscending(int count) { + for (int i = 1; i < count; i++) { + int value = candidates[i]; + int j = i - 1; + while (j >= 0 && candidates[j] > value) { + candidates[j + 1] = candidates[j]; + j--; + } + candidates[j + 1] = value; + } + } + + public void sortCandidatesDescending(int count) { + for (int i = 1; i < count; i++) { + int value = candidates[i]; + int j = i - 1; + while (j >= 0 && candidates[j] < value) { + candidates[j + 1] = candidates[j]; + j--; + } + candidates[j + 1] = value; + } + } + + public int candidateAt(int index) { + return candidates[index]; + } + + private void ensureItemCapacity(int required) { + if (nextInCell.length >= required) return; + int capacity = nextPowerOfTwo(required); + nextInCell = new int[capacity]; + candidates = new int[capacity]; + } + + private void ensureCellCapacity(int required) { + if (cellHead.length >= required) return; + int capacity = nextPowerOfTwo(required); + cellHead = new int[capacity]; + cellKeyX = new int[capacity]; + cellKeyY = new int[capacity]; + mask = capacity - 1; + } + + private int findOrCreateCell(int cellX, int cellY) { + int slot = hash(cellX, cellY) & mask; + while (cellHead[slot] != -1) { + if (cellKeyX[slot] == cellX && cellKeyY[slot] == cellY) { + return slot; + } + slot = (slot + 1) & mask; + } + cellKeyX[slot] = cellX; + cellKeyY[slot] = cellY; + return slot; + } + + private int findCell(int cellX, int cellY) { + int slot = hash(cellX, cellY) & mask; + while (cellHead[slot] != -1) { + if (cellKeyX[slot] == cellX && cellKeyY[slot] == cellY) { + return slot; + } + slot = (slot + 1) & mask; + } + return -1; + } + + private static int toCell(float value) { + return (int) Math.floor(value / CELL_SIZE); + } + + private static int hash(int cellX, int cellY) { + int h = cellX * 0x1f1f1f1f; + h ^= cellY * 0x5f356495; + h ^= h >>> 16; + return h; + } + + private static int nextPowerOfTwo(int value) { + int result = 1; + while (result < value) { + result <<= 1; + } + return result; + } +} diff --git a/core/src/main/java/ru/project/tower/systems/CircleSpatialIndex.java b/core/src/main/java/ru/project/tower/systems/CircleSpatialIndex.java new file mode 100644 index 0000000..5b0f9a7 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/CircleSpatialIndex.java @@ -0,0 +1,35 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.utils.Array; +import ru.project.tower.entities.circles.BaseCircleData; + + + + +public final class CircleSpatialIndex { + private final CircleSpatialGrid grid = new CircleSpatialGrid(); + + public void build(Array enemies) { + grid.build(enemies); + } + + public float maxEnemyRadius() { + return grid.maxRadius(); + } + + public int queryRect(float minX, float minY, float maxX, float maxY) { + return grid.queryRect(minX, minY, maxX, maxY); + } + + public int queryRadius(float x, float y, float radius) { + return grid.queryRadius(x, y, radius); + } + + public void sortCandidatesAscending(int count) { + grid.sortCandidatesAscending(count); + } + + public int candidateAt(int index) { + return grid.candidateAt(index); + } +} diff --git a/core/src/main/java/ru/project/tower/systems/CollisionSystem.java b/core/src/main/java/ru/project/tower/systems/CollisionSystem.java new file mode 100644 index 0000000..9d1d23b --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/CollisionSystem.java @@ -0,0 +1,147 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.entities.circles.BaseCircleData; + +public final class CollisionSystem { + private static final float PUSH_EPSILON = 0.1f; + private static final float CENTER_EPSILON_SQUARED = 0.0001f; + private static final CircleSpatialIndex SEPARATION_INDEX = new CircleSpatialIndex(); + private static int lastSeparationCandidateChecks; + + private CollisionSystem() {} + + public static boolean circleIntersectsSquare(Circle circle, Rectangle square) { + float closestX = MathUtils.clamp(circle.x, square.x, square.x + square.width); + float closestY = MathUtils.clamp(circle.y, square.y, square.y + square.height); + + float distanceX = circle.x - closestX; + float distanceY = circle.y - closestY; + float distanceSquared = distanceX * distanceX + distanceY * distanceY; + + return distanceSquared < circle.radius * circle.radius; + } + + public static boolean resolveCircleSquare(BaseCircleData circleData, Rectangle square) { + Circle circle = circleData.circle; + if (!circleIntersectsSquare(circle, square)) { + return false; + } + + float closestX = MathUtils.clamp(circle.x, square.x, square.x + square.width); + float closestY = MathUtils.clamp(circle.y, square.y, square.y + square.height); + + float distanceX = circle.x - closestX; + float distanceY = circle.y - closestY; + float distanceSquared = distanceX * distanceX + distanceY * distanceY; + + if (distanceSquared < CENTER_EPSILON_SQUARED) { + resolveCenterInsideSquare(circleData, square); + return true; + } + + float distance = (float) Math.sqrt(distanceSquared); + if (distance >= circle.radius) { + return false; + } + + float overlap = circle.radius - distance; + float normalX = distanceX / distance; + float normalY = distanceY / distance; + + circle.x += normalX * (overlap + PUSH_EPSILON); + circle.y += normalY * (overlap + PUSH_EPSILON); + circleData.velocity.scl(0.5f); + return true; + } + + public static void separateCircles(Array circles, int iterations) { + lastSeparationCandidateChecks = 0; + for (int iteration = 0; iteration < iterations; iteration++) { + SEPARATION_INDEX.build(circles); + separateCirclePairs(circles, SEPARATION_INDEX); + } + } + + static int lastSeparationCandidateChecksForTests() { + return lastSeparationCandidateChecks; + } + + private static void resolveCenterInsideSquare(BaseCircleData circleData, Rectangle square) { + Circle circle = circleData.circle; + float distToLeft = circle.x - square.x; + float distToRight = (square.x + square.width) - circle.x; + float distToBottom = circle.y - square.y; + float distToTop = (square.y + square.height) - circle.y; + + float minHorizontal = Math.min(Math.abs(distToLeft), Math.abs(distToRight)); + float minVertical = Math.min(Math.abs(distToBottom), Math.abs(distToTop)); + + if (minHorizontal < minVertical) { + if (Math.abs(distToLeft) < Math.abs(distToRight)) { + circle.x = square.x - circle.radius - PUSH_EPSILON; + } else { + circle.x = square.x + square.width + circle.radius + PUSH_EPSILON; + } + circleData.velocity.x = 0f; + return; + } + + if (Math.abs(distToBottom) < Math.abs(distToTop)) { + circle.y = square.y - circle.radius - PUSH_EPSILON; + } else { + circle.y = square.y + square.height + circle.radius + PUSH_EPSILON; + } + circleData.velocity.y = 0f; + } + + private static void separateCirclePairs( + Array circles, CircleSpatialIndex spatialIndex) { + for (int i = 0; i < circles.size; i++) { + BaseCircleData circleData = circles.get(i); + Circle circle = circleData.circle; + int candidateCount = + spatialIndex.queryRadius( + circle.x, circle.y, circle.radius + spatialIndex.maxEnemyRadius()); + spatialIndex.sortCandidatesAscending(candidateCount); + + for (int candidate = 0; candidate < candidateCount; candidate++) { + int j = spatialIndex.candidateAt(candidate); + if (j <= i) { + continue; + } + lastSeparationCandidateChecks++; + BaseCircleData otherCircleData = circles.get(j); + Circle otherCircle = otherCircleData.circle; + + float dx = otherCircle.x - circle.x; + float dy = otherCircle.y - circle.y; + float distanceSquared = dx * dx + dy * dy; + float radiusSum = circle.radius + otherCircle.radius; + + if (distanceSquared >= radiusSum * radiusSum) { + continue; + } + + if (distanceSquared > CENTER_EPSILON_SQUARED) { + float distance = (float) Math.sqrt(distanceSquared); + float overlap = (radiusSum - distance) * 0.5f; + float dirX = dx / distance; + float dirY = dy / distance; + + circle.x -= dirX * overlap; + circle.y -= dirY * overlap; + otherCircle.x += dirX * overlap; + otherCircle.y += dirY * overlap; + continue; + } + + circle.x -= circle.radius * 0.5f; + otherCircle.x += otherCircle.radius * 0.5f; + } + } + } +} diff --git a/core/src/main/java/ru/project/tower/systems/EnemyClassifier.java b/core/src/main/java/ru/project/tower/systems/EnemyClassifier.java new file mode 100644 index 0000000..69493b8 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/EnemyClassifier.java @@ -0,0 +1,12 @@ +package ru.project.tower.systems; + +import ru.project.tower.content.BossContentRegistry; +import ru.project.tower.entities.circles.BaseCircleData; + +public final class EnemyClassifier { + private EnemyClassifier() {} + + public static boolean isBoss(BaseCircleData enemy) { + return BossContentRegistry.isBoss(enemy); + } +} diff --git a/core/src/main/java/ru/project/tower/systems/EnemyDeathHandler.java b/core/src/main/java/ru/project/tower/systems/EnemyDeathHandler.java new file mode 100644 index 0000000..4ff13d3 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/EnemyDeathHandler.java @@ -0,0 +1,81 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.math.MathUtils; +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.entities.circles.SplitterCircleData; +import ru.project.tower.run.GameplayEventSink; +import ru.project.tower.run.GameplayEvents; +import ru.project.tower.support.RandomSource; +import ru.project.tower.waves.SpawnController; + +public final class EnemyDeathHandler { + public interface Effects { + void createExplosion(float x, float y, float[] color); + } + + private static final int SPLITTER_SHARD_COUNT = 3; + private static final int SPLITTER_SHARD_DAMAGE = 1; + + private final Array circles; + private final Effects effects; + private final GameplayEventSink gameplayEventSink; + private final RandomSource random; + + public EnemyDeathHandler( + Array circles, + Effects effects, + GameplayEventSink gameplayEventSink, + RandomSource random) { + this.circles = Objects.requireNonNull(circles, "circles"); + this.effects = Objects.requireNonNull(effects, "effects"); + this.gameplayEventSink = Objects.requireNonNull(gameplayEventSink, "gameplayEventSink"); + this.random = Objects.requireNonNull(random, "random"); + } + + public void handleEnemyKilled( + BaseCircleData enemy, float gameScale, float enemySpeedMultiplier) { + Objects.requireNonNull(enemy, "enemy"); + + effects.createExplosion(enemy.circle.x, enemy.circle.y, enemy.getColor()); + gameplayEventSink.emit( + GameplayEvents.enemyKilled( + enemy.getClass().getSimpleName(), + enemy.getX(), + enemy.getY(), + enemy.getReward(), + EnemyClassifier.isBoss(enemy))); + spawnSplitterShardsIfNeeded(enemy, gameScale, enemySpeedMultiplier); + circles.removeValue(enemy, true); + BaseCircleData.freeIfSpawnPooled(enemy); + } + + private void spawnSplitterShardsIfNeeded( + BaseCircleData enemy, float gameScale, float enemySpeedMultiplier) { + if (!(enemy instanceof SplitterCircleData) || ((SplitterCircleData) enemy).isShard) { + return; + } + + for (int i = 0; i < SPLITTER_SHARD_COUNT; i++) { + float angle = (i * 120 + random.range(-20f, 20f)) * MathUtils.degreesToRadians; + float dirX = MathUtils.cos(angle); + float dirY = MathUtils.sin(angle); + float adjustedSpeed = + SpawnController.SPLITTER_SHARD_SPEED * gameScale * enemySpeedMultiplier; + + SplitterCircleData shard = Pools.obtain(SplitterCircleData.class); + shard.configure( + enemy.circle.x, + enemy.circle.y, + SpawnController.FAST_CIRCLE_RADIUS * gameScale, + dirX * adjustedSpeed, + dirY * adjustedSpeed, + SpawnController.FAST_CIRCLE_HEALTH, + SPLITTER_SHARD_DAMAGE, + true); + circles.add(shard); + } + } +} diff --git a/core/src/main/java/ru/project/tower/systems/EnemySimulationSystem.java b/core/src/main/java/ru/project/tower/systems/EnemySimulationSystem.java new file mode 100644 index 0000000..d88497c --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/EnemySimulationSystem.java @@ -0,0 +1,68 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.Rectangle; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.FastCircleData; +import ru.project.tower.entities.circles.SplitterCircleData; +import ru.project.tower.entities.circles.StrongCircleData; +import ru.project.tower.waves.SpawnController; + +public final class EnemySimulationSystem { + public static final float OUT_OF_BOUNDS_MARGIN = 800f; + public static final int DEFAULT_SEPARATION_ITERATIONS = 3; + + private EnemySimulationSystem() {} + + public static void steerTowardPlayer( + BaseCircleData circleData, Rectangle square, float gameScale) { + if (isBoss(circleData)) { + return; + } + + float squareCenterX = square.x + square.width / 2f; + float squareCenterY = square.y + square.height / 2f; + + float dirX = squareCenterX - circleData.getX(); + float dirY = squareCenterY - circleData.getY(); + + float length = (float) Math.sqrt(dirX * dirX + dirY * dirY); + if (length != 0f) { + dirX /= length; + dirY /= length; + } else { + dirX = 1f; + dirY = 0f; + } + + float adjustedSpeed = movementSpeed(circleData, gameScale); + circleData.velocity.set(dirX * adjustedSpeed, dirY * adjustedSpeed); + } + + public static boolean isBoss(BaseCircleData circleData) { + return EnemyClassifier.isBoss(circleData); + } + + public static boolean shouldRemoveOutOfBounds( + Circle circle, float viewportWidth, float viewportHeight) { + return circle.x < -OUT_OF_BOUNDS_MARGIN + || circle.x > viewportWidth + OUT_OF_BOUNDS_MARGIN + || circle.y < -OUT_OF_BOUNDS_MARGIN + || circle.y > viewportHeight + OUT_OF_BOUNDS_MARGIN; + } + + private static float movementSpeed(BaseCircleData circleData, float gameScale) { + if (circleData instanceof StrongCircleData) { + return SpawnController.STRONG_CIRCLE_SPEED * gameScale; + } + if (circleData instanceof FastCircleData) { + return SpawnController.FAST_CIRCLE_SPEED * gameScale; + } + if (circleData instanceof SplitterCircleData) { + return ((SplitterCircleData) circleData).isShard + ? SpawnController.SPLITTER_SHARD_SPEED * gameScale + : SpawnController.SPLITTER_CIRCLE_SPEED * gameScale; + } + return SpawnController.BASIC_CIRCLE_SPEED * gameScale; + } +} diff --git a/core/src/main/java/ru/project/tower/systems/PlayerShootingSystem.java b/core/src/main/java/ru/project/tower/systems/PlayerShootingSystem.java new file mode 100644 index 0000000..d636e7d --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/PlayerShootingSystem.java @@ -0,0 +1,193 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import java.util.Objects; +import ru.project.tower.abilities.Turret; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.run.GameplayEventSink; +import ru.project.tower.run.GameplayEvents; +import ru.project.tower.run.GameplaySound; +import ru.project.tower.support.MathUtilsRandomSource; +import ru.project.tower.support.RandomSource; + +public class PlayerShootingSystem { + private final RandomSource random; + private final ShotOrigin shotOrigin = new ShotOrigin(); + + public PlayerShootingSystem() { + this(new MathUtilsRandomSource()); + } + + public PlayerShootingSystem(RandomSource random) { + this.random = Objects.requireNonNull(random, "random"); + } + + public boolean shootAtNearestCircle( + Array circles, + Rectangle square, + BulletSystem bulletSystem, + GameplayEventSink gameplayEventSink, + ShotConfig config) { + Objects.requireNonNull(circles, "circles"); + Objects.requireNonNull(square, "square"); + Objects.requireNonNull(bulletSystem, "bulletSystem"); + Objects.requireNonNull(gameplayEventSink, "gameplayEventSink"); + Objects.requireNonNull(config, "config"); + + BaseCircleData nearestCircleData = findNearestCircleInRange(circles, square, config); + if (nearestCircleData == null) { + return false; + } + + float squareCenterX = square.x + square.width / 2f; + float squareCenterY = square.y + square.height / 2f; + Circle nearestCircle = nearestCircleData.circle; + + gameplayEventSink.emit(GameplayEvents.sound(GameplaySound.SHOT, 0.5f, 1f, 0f)); + + float angle = + MathUtils.atan2(nearestCircle.y - squareCenterY, nearestCircle.x - squareCenterX); + calculateShotOrigin(shotOrigin, square, squareCenterX, squareCenterY, nearestCircle, angle); + + boolean isCrit = random.nextFloat() < config.critChance; + float critMult = isCrit ? config.critMultiplier : 1f; + bulletSystem.fireBullet( + shotOrigin.x, + shotOrigin.y, + angle, + config.bulletRadius, + config.gameScale, + config.speedMultiplier, + config.damageMultiplier, + isCrit, + critMult); + return true; + } + + public void shootTurretAtTarget( + Turret turret, + BaseCircleData target, + BulletSystem bulletSystem, + GameplayEventSink gameplayEventSink, + float bulletRadius, + float scaledGameScale) { + Objects.requireNonNull(turret, "turret"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(bulletSystem, "bulletSystem"); + Objects.requireNonNull(gameplayEventSink, "gameplayEventSink"); + + float angle = MathUtils.atan2(target.circle.y - turret.y, target.circle.x - turret.x); + bulletSystem.fireTurretShot( + turret.x, turret.y, angle, bulletRadius, scaledGameScale, turret.damage); + gameplayEventSink.emit(GameplayEvents.sound(GameplaySound.SHOT, 0.25f, 1f, 0f)); + } + + private static BaseCircleData findNearestCircleInRange( + Array circles, Rectangle square, ShotConfig config) { + float squareCenterX = square.x + square.width / 2f; + float squareCenterY = square.y + square.height / 2f; + BaseCircleData nearestCircleData = null; + float nearestDistanceSquared = Float.MAX_VALUE; + float adjustedRange = config.shootingRange * config.gameScale; + float adjustedRangeSquared = adjustedRange * adjustedRange; + + for (BaseCircleData circleData : circles) { + Circle circle = circleData.circle; + float dx = circle.x - squareCenterX; + float dy = circle.y - squareCenterY; + float distanceSquared = dx * dx + dy * dy; + + if (distanceSquared < adjustedRangeSquared + && distanceSquared < nearestDistanceSquared) { + nearestCircleData = circleData; + nearestDistanceSquared = distanceSquared; + } + } + return nearestCircleData; + } + + private static void calculateShotOrigin( + ShotOrigin out, + Rectangle square, + float squareCenterX, + float squareCenterY, + Circle nearestCircle, + float angle) { + float cosTheta = MathUtils.cos(angle); + float sinTheta = MathUtils.sin(angle); + float absCos = Math.abs(cosTheta); + float absSin = Math.abs(sinTheta); + float hw = square.width / 2f; + float hh = square.height / 2f; + float startX; + float startY; + + if (absCos * hh >= absSin * hw) { + startX = squareCenterX + Math.signum(cosTheta) * hw; + if (absCos > 0.001f) { + startY = squareCenterY + hw * (sinTheta / absCos); + } else { + startY = squareCenterY + Math.signum(sinTheta) * hh; + } + } else { + startY = squareCenterY + Math.signum(sinTheta) * hh; + if (absSin > 0.001f) { + startX = squareCenterX + hh * (cosTheta / absSin); + } else { + startX = squareCenterX + Math.signum(cosTheta) * hw; + } + } + + float spawnDx = startX - squareCenterX; + float spawnDy = startY - squareCenterY; + float enemyDx = nearestCircle.x - squareCenterX; + float enemyDy = nearestCircle.y - squareCenterY; + float distToSpawnSquared = spawnDx * spawnDx + spawnDy * spawnDy; + float distToEnemySquared = enemyDx * enemyDx + enemyDy * enemyDy; + + if (distToEnemySquared < distToSpawnSquared) { + startX = squareCenterX; + startY = squareCenterY; + } + out.x = startX; + out.y = startY; + } + + public static final class ShotConfig { + public final float shootingRange; + public final float gameScale; + public final float bulletRadius; + public final float speedMultiplier; + public final float damageMultiplier; + public final float critChance; + public final float critMultiplier; + + public ShotConfig( + float shootingRange, + float gameScale, + float bulletRadius, + float speedMultiplier, + float damageMultiplier, + float critChance, + float critMultiplier) { + this.shootingRange = shootingRange; + this.gameScale = gameScale; + this.bulletRadius = bulletRadius; + this.speedMultiplier = speedMultiplier; + this.damageMultiplier = damageMultiplier; + this.critChance = critChance; + this.critMultiplier = critMultiplier; + } + } + + private static final class ShotOrigin { + private float x; + private float y; + + private ShotOrigin() {} + } +} diff --git a/core/src/main/java/ru/project/tower/systems/SpecialCircleBehaviors.java b/core/src/main/java/ru/project/tower/systems/SpecialCircleBehaviors.java new file mode 100644 index 0000000..1acaeda --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/SpecialCircleBehaviors.java @@ -0,0 +1,516 @@ +package ru.project.tower.systems; + +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 com.badlogic.gdx.utils.Pools; +import ru.project.tower.entities.bullets.BulletData; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.DasherCircleData; +import ru.project.tower.entities.circles.HealerCircleData; +import ru.project.tower.entities.circles.SniperCircleData; +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.RandomSource; +import ru.project.tower.support.RangeChecks; + +interface SpecialCircleBehavior { + boolean supports(BaseCircleData circle); + + void update(BaseCircleData circle, SpecialCircleUpdateContext context); + + default void beforeMove(BaseCircleData circle, float squareCenterX, float squareCenterY) {} + + default Class supportedType() { + return null; + } + + default boolean allowsIdleSteering(BaseCircleData circle) { + return true; + } +} + +final class SpecialCircleUpdateContext { + final Array circles; + final Rectangle square; + final BulletSystem bulletSystem; + final SpecialCircleSystem.Host host; + final float gameScale; + final RandomSource random; + final float squareCenterX; + final float squareCenterY; + + SpecialCircleUpdateContext( + Array circles, + Rectangle square, + BulletSystem bulletSystem, + SpecialCircleSystem.Host host, + float gameScale, + RandomSource random) { + this.circles = circles; + this.square = square; + this.bulletSystem = bulletSystem; + this.host = host; + this.gameScale = gameScale; + this.random = random; + this.squareCenterX = square.x + square.width / 2f; + this.squareCenterY = square.y + square.height / 2f; + } +} + +abstract class TypedSpecialCircleBehavior + implements SpecialCircleBehavior { + private final Class type; + + TypedSpecialCircleBehavior(Class type) { + this.type = type; + } + + @Override + public final Class supportedType() { + return type; + } + + @Override + public final boolean supports(BaseCircleData circle) { + return type.isInstance(circle); + } + + @Override + public final void update(BaseCircleData circle, SpecialCircleUpdateContext context) { + @SuppressWarnings("unchecked") + T typedCircle = (T) circle; + updateTyped(typedCircle, context); + } + + protected abstract void updateTyped(T circle, SpecialCircleUpdateContext context); +} + +final class SwarmQueenBehavior extends TypedSpecialCircleBehavior { + SwarmQueenBehavior() { + super(SwarmQueenData.class); + } + + @Override + protected void updateTyped(SwarmQueenData queen, SpecialCircleUpdateContext context) { + if (queen.shouldSpawnMinions()) { + context.host.spawnQueenMinion(queen); + queen.onMinionSpawn(); + } + if (queen.isReadyToFireProjectiles() && !queen.isCharging()) { + BossProjectileFactory.spawnQueenProjectiles(queen, context.bulletSystem); + } + } +} + +final class JuggernautBehavior extends TypedSpecialCircleBehavior { + private final Color neonRed; + private final Color neonBlue; + + JuggernautBehavior(Color neonRed, Color neonBlue) { + super(JuggernautData.class); + this.neonRed = neonRed; + this.neonBlue = neonBlue; + } + + @Override + protected void updateTyped(JuggernautData juggernaut, SpecialCircleUpdateContext context) { + juggernaut.update(); + + updateChargeStart(juggernaut, context); + emitChargeParticles(juggernaut, context); + updateShockwave(juggernaut, context); + updateChargeImpact(juggernaut, context); + } + + private void updateChargeStart(JuggernautData juggernaut, SpecialCircleUpdateContext context) { + if (juggernaut.shouldStartCharge()) { + juggernaut.setChargeDirection( + context.squareCenterX - juggernaut.getX(), + context.squareCenterY - juggernaut.getY()); + } + } + + private void emitChargeParticles( + JuggernautData juggernaut, SpecialCircleUpdateContext context) { + if (juggernaut.shouldExecuteCharge()) { + context.host.createBossParticles( + juggernaut.getX(), + juggernaut.getY(), + juggernaut.getRadius(), + neonRed, + 1.5f, + 10); + } + } + + private void updateShockwave(JuggernautData juggernaut, SpecialCircleUpdateContext context) { + if (juggernaut.shouldShockwave()) { + juggernaut.performShockwave(); + applyShockwaveToBullets(juggernaut, context); + context.host.createBossParticles( + juggernaut.getX(), + juggernaut.getY(), + juggernaut.getRadius(), + neonBlue, + 2.0f, + 20); + } + } + + private void applyShockwaveToBullets( + JuggernautData juggernaut, SpecialCircleUpdateContext context) { + for (BulletData bullet : context.bulletSystem.getBullets()) { + float dx = bullet.getX() - juggernaut.getX(); + float dy = bullet.getY() - juggernaut.getY(); + float distance = (float) Math.sqrt(dx * dx + dy * dy); + if (distance > 0f && distance < JuggernautData.getShockwaveRadius()) { + float force = + (1 - distance / JuggernautData.getShockwaveRadius()) + * JuggernautData.getShockwaveForce(); + bullet.velocity.add(dx / distance * force, dy / distance * force); + } + } + } + + private void updateChargeImpact(JuggernautData juggernaut, SpecialCircleUpdateContext context) { + if (juggernaut.isCharging() + && context.host.isCircleIntersectingSquare(juggernaut.circle, context.square)) { + juggernaut.endCharge(); + if (context.host.damagePlayerIfVulnerable(juggernaut.damage * 2, 500L)) { + context.host.createBossParticles( + context.squareCenterX, + context.squareCenterY, + context.square.width, + neonRed, + 1.5f, + 15); + } + } + } + + @Override + public boolean allowsIdleSteering(BaseCircleData circle) { + return !((JuggernautData) circle).isCharging(); + } +} + +final class ShadowWeaverBehavior extends TypedSpecialCircleBehavior { + private final Color neonPurple; + + ShadowWeaverBehavior(Color neonPurple) { + super(ShadowWeaverData.class); + this.neonPurple = neonPurple; + } + + @Override + protected void updateTyped(ShadowWeaverData weaver, SpecialCircleUpdateContext context) { + weaver.update(); + if (weaver.shouldBecomeInvisible()) { + context.host.createBossParticles( + weaver.getX(), weaver.getY(), weaver.getRadius(), neonPurple, 1.0f, 12); + } + + applyCloneContactDamage(weaver, context); + + if (weaver.shouldTeleport()) { + float angle = context.random.angle(); + float dist = context.random.range(100f, 300f); + weaver.startTeleport( + context.squareCenterX + MathUtils.cos(angle) * dist, + context.squareCenterY + MathUtils.sin(angle) * dist); + context.host.createBossParticles( + weaver.getX(), weaver.getY(), weaver.getRadius(), neonPurple, 1.2f, 10); + } + + if (weaver.shouldShoot()) { + BossProjectileFactory.spawnShadowWeaverProjectile( + weaver, context.bulletSystem, context.squareCenterX, context.squareCenterY); + } + } + + private void applyCloneContactDamage(ShadowWeaverData weaver, SpecialCircleUpdateContext context) { + Array clones = weaver.getClones(); + for (int i = clones.size - 1; i >= 0; i--) { + ShadowWeaverData.ShadowCloneData clone = clones.get(i); + if (RangeChecks.withinRadiusExclusive( + clone.getX(), + clone.getY(), + context.squareCenterX, + context.squareCenterY, + clone.getRadius())) { + context.host.damagePlayerIfVulnerable( + ShadowWeaverData.getCloneDamage(), + ShadowWeaverData.getCloneDamageCooldownMs()); + return; + } + } + } +} + +final class VolatileReactorBehavior extends TypedSpecialCircleBehavior { + private final Color neonYellow; + + VolatileReactorBehavior(Color neonYellow) { + super(VolatileReactorData.class); + this.neonYellow = neonYellow; + } + + @Override + protected void updateTyped(VolatileReactorData reactor, SpecialCircleUpdateContext context) { + reactor.update(); + + if (reactor.shouldCreateFireZone()) { + reactor.createFireZone(); + } + + boolean explodedThisFrame = false; + if (reactor.shouldExplode()) { + explodedThisFrame = true; + reactor.startExplosion(); + if (RangeChecks.withinRadiusExclusive( + reactor.getX(), + reactor.getY(), + context.squareCenterX, + context.squareCenterY, + VolatileReactorData.getExplosionRadius())) { + context.host.damagePlayerIfVulnerable(VolatileReactorData.getExplosionDamage(), 0L); + } + context.host.createBossParticles( + reactor.getX(), + reactor.getY(), + VolatileReactorData.getExplosionRadius(), + neonYellow, + 1.0f, + 30); + } + + if (!explodedThisFrame) { + applyFireZoneDamage(reactor, context); + } + + if (reactor.shouldShootMissiles()) { + BossProjectileFactory.spawnVolatileReactorMissiles( + reactor, context.bulletSystem, context.random::angle); + } + } + + private void applyFireZoneDamage( + VolatileReactorData reactor, SpecialCircleUpdateContext context) { + for (VolatileReactorData.FireZone zone : reactor.getFireZones()) { + if (RangeChecks.withinRadiusExclusive( + zone.x, + zone.y, + context.squareCenterX, + context.squareCenterY, + zone.radius)) { + context.host.damagePlayerIfVulnerable( + VolatileReactorData.getFireZoneDamage(), + VolatileReactorData.getFireZoneDamageCooldownMs()); + return; + } + } + } +} + +final class HealerBehavior extends TypedSpecialCircleBehavior { + HealerBehavior() { + super(HealerCircleData.class); + } + + @Override + protected void updateTyped(HealerCircleData healer, SpecialCircleUpdateContext context) { + if (!healer.shouldPulse()) return; + + float heals = HealerCircleData.getHealRadius(); + float heals2 = RangeChecks.radiusSquared(heals); + int amount = HealerCircleData.getHealAmount(); + for (BaseCircleData other : context.circles) { + if (other == healer) continue; + if (other.isDead()) continue; + if (RangeChecks.distanceSquared( + healer.getX(), healer.getY(), other.getX(), other.getY()) + <= heals2) { + other.health += amount; + } + } + context.host.createExplosion(healer.getX(), healer.getY(), healer.getColor()); + } +} + +final class DasherBehavior extends TypedSpecialCircleBehavior { + DasherBehavior() { + super(DasherCircleData.class); + } + + @Override + protected void updateTyped(DasherCircleData dasher, SpecialCircleUpdateContext context) { + dasher.tick(context.squareCenterX, context.squareCenterY); + } +} + +final class SniperBehavior extends TypedSpecialCircleBehavior { + private final Color neonRed; + + SniperBehavior(Color neonRed) { + super(SniperCircleData.class); + this.neonRed = neonRed; + } + + @Override + public void beforeMove(BaseCircleData circle, float squareCenterX, float squareCenterY) { + ((SniperCircleData) circle).setPlayerPosition(squareCenterX, squareCenterY); + } + + @Override + protected void updateTyped(SniperCircleData sniper, SpecialCircleUpdateContext context) { + if (!sniper.shouldShoot()) return; + + float dx = context.squareCenterX - sniper.getX(); + float dy = context.squareCenterY - sniper.getY(); + float len = (float) Math.sqrt(dx * dx + dy * dy); + if (len <= 0f) return; + + float speed = SniperCircleData.getProjectileSpeed(); + BulletData bullet = Pools.obtain(BulletData.class); + bullet.configureAsEnemyBullet( + sniper.getX(), + sniper.getY(), + SniperCircleData.getProjectileRadius(), + dx / len * speed, + dy / len * speed, + sniper.damage); + bullet.setColor(neonRed); + context.bulletSystem.addEnemyBullet(bullet); + } + + @Override + public boolean allowsIdleSteering(BaseCircleData circle) { + return !((SniperCircleData) circle).isHoldingPosition(); + } +} + +final class GlacialWardenBehavior extends TypedSpecialCircleBehavior { + private final Color neonCyan; + + GlacialWardenBehavior(Color neonCyan) { + super(GlacialWardenData.class); + this.neonCyan = neonCyan; + } + + @Override + protected void updateTyped(GlacialWardenData warden, SpecialCircleUpdateContext context) { + warden.update(); + warden.updatePlayerAuraState( + RangeChecks.withinRadiusExclusive( + warden.getX(), + warden.getY(), + context.squareCenterX, + context.squareCenterY, + warden.getAuraRadius())); + + if (warden.shouldStartPrison()) { + warden.startPrisonCast(context.squareCenterX, context.squareCenterY); + } + + boolean prisonDamagedThisFrame = false; + if (warden.isPrisonActive()) { + if (RangeChecks.withinRadiusExclusive( + warden.getPrisonChargePosition().x, + warden.getPrisonChargePosition().y, + context.squareCenterX, + context.squareCenterY, + GlacialWardenData.getPrisonRadius())) { + context.host.damagePlayerIfVulnerable(GlacialWardenData.getPrisonDamage(), 1000L); + prisonDamagedThisFrame = true; + } + } + + if (warden.shouldShootShards()) { + BossProjectileFactory.spawnGlacialWardenShards(warden, context.random::angle); + } + if (!prisonDamagedThisFrame) { + applyShardDamage(warden, context); + } + + if (warden.shouldFreeze()) { + if (RangeChecks.withinRadiusExclusive( + warden.getX(), + warden.getY(), + context.squareCenterX, + context.squareCenterY, + warden.getFreezeRadius())) { + warden.activateFreezeField(); + } + context.host.createBossParticles( + warden.getX(), warden.getY(), warden.getFreezeRadius(), neonCyan, 1.0f, 20); + } + } + + private void applyShardDamage(GlacialWardenData warden, SpecialCircleUpdateContext context) { + Array shards = warden.getActiveShards(); + for (int i = shards.size - 1; i >= 0; i--) { + GlacialWardenData.IceShard shard = shards.get(i); + if (RangeChecks.withinRadiusExclusive( + shard.x, + shard.y, + context.squareCenterX, + context.squareCenterY, + GlacialWardenData.getShardRadius())) { + context.host.damagePlayerIfVulnerable( + GlacialWardenData.getShardDamage(), + GlacialWardenData.getShardDamageCooldownMs()); + shards.removeIndex(i); + return; + } + } + } +} + +final class IllusionMasterBehavior extends TypedSpecialCircleBehavior { + IllusionMasterBehavior() { + super(IllusionMasterData.class); + } + + @Override + protected void updateTyped(IllusionMasterData master, SpecialCircleUpdateContext context) { + applyIllusionContactDamage(master, context); + + if (master.shouldCreateIllusions()) { + float angle = context.random.angle(); + float dist = 150f; + master.createIllusion( + master.getX() + MathUtils.cos(angle) * dist, + master.getY() + MathUtils.sin(angle) * dist); + } + if (master.shouldShootProjectiles()) { + BossProjectileFactory.spawnIllusionMasterProjectiles( + master, context.bulletSystem, context.random::angle, context.gameScale); + } + } + + private void applyIllusionContactDamage( + IllusionMasterData master, SpecialCircleUpdateContext context) { + Array illusions = master.getIllusions(); + for (int i = illusions.size - 1; i >= 0; i--) { + IllusionMasterData.IllusionData illusion = illusions.get(i); + if (RangeChecks.withinRadiusExclusive( + illusion.getX(), + illusion.getY(), + context.squareCenterX, + context.squareCenterY, + illusion.getRadius())) { + context.host.damagePlayerIfVulnerable( + IllusionMasterData.getIllusionContactDamage(), + IllusionMasterData.getIllusionDamageCooldownMs()); + return; + } + } + } +} diff --git a/core/src/main/java/ru/project/tower/systems/SpecialCircleSystem.java b/core/src/main/java/ru/project/tower/systems/SpecialCircleSystem.java new file mode 100644 index 0000000..660e864 --- /dev/null +++ b/core/src/main/java/ru/project/tower/systems/SpecialCircleSystem.java @@ -0,0 +1,194 @@ +package ru.project.tower.systems; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.utils.Array; +import java.util.IdentityHashMap; +import java.util.Map; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.bosses.SwarmQueenData; +import ru.project.tower.support.MathUtilsRandomSource; +import ru.project.tower.support.RandomSource; +import ru.project.tower.support.RangeChecks; + +public final class SpecialCircleSystem { + public interface Host { + void spawnQueenMinion(SwarmQueenData queen); + + void createBossParticles( + float x, float y, float radius, Color color, float spread, int count); + + void createExplosion(float x, float y, Color color); + + void createExplosion(float x, float y, float[] color); + + boolean isCircleIntersectingSquare(Circle circle, Rectangle square); + + boolean damagePlayerIfVulnerable(int damage, long damageCooldownMs); + } + + private static final SpecialCircleBehavior NO_BEHAVIOR = + new SpecialCircleBehavior() { + @Override + public boolean supports(BaseCircleData circle) { + return false; + } + + @Override + public void update(BaseCircleData circle, SpecialCircleUpdateContext context) {} + }; + + private final SpecialCircleBehavior[] behaviors; + private final Map, SpecialCircleBehavior> behaviorCache = new IdentityHashMap<>(); + private final boolean classStableDispatch; + private final RandomSource random; + + public SpecialCircleSystem( + Color neonRed, Color neonBlue, Color neonYellow, Color neonCyan, Color neonPurple) { + this(neonRed, neonBlue, neonYellow, neonCyan, neonPurple, new MathUtilsRandomSource()); + } + + public SpecialCircleSystem( + Color neonRed, + Color neonBlue, + Color neonYellow, + Color neonCyan, + Color neonPurple, + RandomSource random) { + this( + random, + new SwarmQueenBehavior(), + new JuggernautBehavior(neonRed, neonBlue), + new ShadowWeaverBehavior(neonPurple), + new VolatileReactorBehavior(neonYellow), + new HealerBehavior(), + new DasherBehavior(), + new SniperBehavior(neonRed), + new GlacialWardenBehavior(neonCyan), + new IllusionMasterBehavior()); + } + + SpecialCircleSystem(SpecialCircleBehavior... behaviors) { + this(new MathUtilsRandomSource(), behaviors); + } + + SpecialCircleSystem(RandomSource random, SpecialCircleBehavior... behaviors) { + this.random = random; + this.behaviors = behaviors; + this.classStableDispatch = supportsOnlyDependOnCircleClass(behaviors); + } + + public void prepareForMovement(BaseCircleData circle, Rectangle square) { + SpecialCircleBehavior behavior = behaviorFor(circle); + if (behavior == null) return; + + behavior.beforeMove(circle, square.x + square.width / 2f, square.y + square.height / 2f); + } + + public void update( + Array circles, + Rectangle square, + BulletSystem bulletSystem, + Host host, + float gameScale) { + SpecialCircleUpdateContext context = null; + + for (int i = circles.size - 1; i >= 0; i--) { + BaseCircleData circle = circles.get(i); + if (circle.isDead()) continue; + + SpecialCircleBehavior behavior = behaviorFor(circle); + if (behavior != null) { + if (context == null) { + context = + new SpecialCircleUpdateContext( + circles, square, bulletSystem, host, gameScale, random); + } + behavior.update(circle, context); + } + + if (behavior == null || behavior.allowsIdleSteering(circle)) { + float squareCenterX = + context == null ? square.x + square.width / 2f : context.squareCenterX; + float squareCenterY = + context == null ? square.y + square.height / 2f : context.squareCenterY; + steerTowardPlayerWhenIdle(circle, squareCenterX, squareCenterY, gameScale); + } + } + } + + private SpecialCircleBehavior behaviorFor(BaseCircleData circle) { + if (!classStableDispatch) { + return behaviorForBySupports(circle); + } + + Class circleClass = circle.getClass(); + SpecialCircleBehavior behavior = behaviorCache.get(circleClass); + if (behavior != null) { + return behavior == NO_BEHAVIOR ? null : behavior; + } + + behavior = resolveBehavior(circleClass); + behaviorCache.put(circleClass, behavior == null ? NO_BEHAVIOR : behavior); + return behavior; + } + + private SpecialCircleBehavior behaviorForBySupports(BaseCircleData circle) { + for (int i = 0; i < behaviors.length; i++) { + SpecialCircleBehavior behavior = behaviors[i]; + if (behavior.supports(circle)) { + return behavior; + } + } + return null; + } + + private SpecialCircleBehavior resolveBehavior(Class circleClass) { + for (int i = 0; i < behaviors.length; i++) { + SpecialCircleBehavior behavior = behaviors[i]; + if (isCircleClassSupported(circleClass, behavior.supportedType())) { + return behavior; + } + } + return null; + } + + private boolean isCircleClassSupported( + Class circleClass, Class supportedType) { + for (Class type = circleClass; type != null; type = type.getSuperclass()) { + if (type == supportedType) return true; + if (type == BaseCircleData.class) return false; + } + return false; + } + + private boolean supportsOnlyDependOnCircleClass(SpecialCircleBehavior[] behaviors) { + for (int i = 0; i < behaviors.length; i++) { + SpecialCircleBehavior behavior = behaviors[i]; + if (behavior.supportedType() == null) { + return false; + } + } + return true; + } + + private void steerTowardPlayerWhenIdle( + BaseCircleData circle, float squareCenterX, float squareCenterY, float gameScale) { + if (circle.isFrozen()) return; + + float dx = squareCenterX - circle.getX(); + float dy = squareCenterY - circle.getY(); + float dist2 = + RangeChecks.distanceSquared( + squareCenterX, squareCenterY, circle.getX(), circle.getY()); + + if (dist2 > RangeChecks.radiusSquared(10f)) { + float dist = (float) Math.sqrt(dist2); + float speed = circle.velocity.len(); + if (speed < 10f) speed = 40f * gameScale; + circle.velocity.set(dx / dist * speed, dy / dist * speed); + } + } +} diff --git a/core/src/main/java/ru/project/tower/talents/TalentData.java b/core/src/main/java/ru/project/tower/talents/TalentData.java new file mode 100644 index 0000000..ae213ed --- /dev/null +++ b/core/src/main/java/ru/project/tower/talents/TalentData.java @@ -0,0 +1,101 @@ +package ru.project.tower.talents; + +import com.badlogic.gdx.math.Vector2; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + + + +public class TalentData { + public enum TalentType { + DAMAGE, + HEALTH, + SPEED, + COOLDOWN, + REGEN, + CONTROL, + ECONOMY, + ABILITY, + UTILITY, + KEYSTONE + } + + private final String id; + private final String name; + private final String description; + private final TalentType type; + private final Vector2 position; + private final List dependencyIds; + private final List dependencyIdsView; + private final int maxLevel; + private final int baseCost; + private final float costMultiplier; + + public TalentData( + String id, + String name, + String description, + TalentType type, + float x, + float y, + int maxLevel, + int baseCost, + float costMultiplier) { + this.id = id; + this.name = name; + this.description = description; + this.type = type; + this.position = new Vector2(x, y); + this.maxLevel = maxLevel; + this.baseCost = baseCost; + this.costMultiplier = costMultiplier; + this.dependencyIds = new ArrayList<>(); + this.dependencyIdsView = Collections.unmodifiableList(dependencyIds); + } + + public void addDependency(String talentId) { + dependencyIds.add(talentId); + } + + public int getCost(int currentLevel) { + return (int) (baseCost * Math.pow(costMultiplier, currentLevel)); + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public TalentType getType() { + return type; + } + + public Vector2 getPosition() { + return new Vector2(position); + } + + public float getX() { + return position.x; + } + + public float getY() { + return position.y; + } + + public List getDependencyIds() { + return dependencyIdsView; + } + + public int getMaxLevel() { + return maxLevel; + } +} diff --git a/core/src/main/java/ru/project/tower/talents/TalentTree.java b/core/src/main/java/ru/project/tower/talents/TalentTree.java new file mode 100644 index 0000000..4438a7a --- /dev/null +++ b/core/src/main/java/ru/project/tower/talents/TalentTree.java @@ -0,0 +1,789 @@ +package ru.project.tower.talents; + +import com.badlogic.gdx.utils.Array; +import com.badlogic.gdx.utils.ObjectMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.balance.PowerAxis; +import ru.project.tower.player.PlayerStats; + + + + +public class TalentTree { + private static final TalentSpec[] TALENT_SPECS = { + rootTalent( + "health_1", + "Запас здоровья", + "+10 к максимуму здоровья за уровень.", + TalentData.TalentType.HEALTH, + 0, + 0, + 5, + 100, + 1.5f), + talent( + "damage_1", + "Сила выстрела", + "+1 к урону пули за уровень.", + TalentData.TalentType.DAMAGE, + 120, + 70, + 5, + 150, + 1.5f, + "health_1"), + talent( + "cooldown_1", + "Быстрый затвор", + "-50 мс перезарядки за уровень.", + TalentData.TalentType.COOLDOWN, + 120, + -70, + 5, + 200, + 1.5f, + "health_1"), + talent( + "speed_1", + "Скорость пуль", + "+20 к скорости пуль за уровень.", + TalentData.TalentType.SPEED, + -120, + -70, + 5, + 100, + 1.5f, + "health_1"), + talent( + "regen_1", + "Восстановление", + "+1 здоровья/с за уровень.", + TalentData.TalentType.REGEN, + -120, + 70, + 5, + 250, + 1.5f, + "health_1"), + talent( + "damage_2", + "Мощный выстрел", + "+5 к урону пули за уровень.", + TalentData.TalentType.DAMAGE, + 240, + 0, + 3, + 500, + 1.8f, + "damage_1", + "cooldown_1"), + talent( + "cooldown_2", + "Ускоренный огонь", + "-100 мс перезарядки за уровень.", + TalentData.TalentType.COOLDOWN, + 0, + -140, + 3, + 600, + 1.8f, + "cooldown_1", + "speed_1"), + talent( + "speed_2", + "Сверхбыстрые пули", + "+100 к скорости пуль за уровень.", + TalentData.TalentType.SPEED, + -240, + 0, + 3, + 400, + 1.8f, + "speed_1", + "regen_1"), + talent( + "health_2", + "Крепкая защита", + "+50 к максимуму здоровья за уровень.", + TalentData.TalentType.HEALTH, + 0, + 140, + 3, + 450, + 1.8f, + "regen_1", + "damage_1"), + talent( + "economy_1", + "Боевые трофеи", + "Даёт рейтинг ресурсов +5 за уровень.", + TalentData.TalentType.ECONOMY, + 0, + 260, + 4, + 300, + 1.65f, + "health_2"), + talent( + "ability_1", + "Тактика умений", + "Даёт рейтинг умений +5 за уровень.", + TalentData.TalentType.ABILITY, + 240, + -120, + 4, + 380, + 1.7f, + "cooldown_1"), + talent( + "utility_1", + "Надежная помощь", + "Даёт рейтинг поддержки +5 за уровень.", + TalentData.TalentType.UTILITY, + -240, + -120, + 4, + 320, + 1.65f, + "speed_1"), + talent( + "control_1", + "Замедление", + "Даёт рейтинг замедления +5 за уровень.", + TalentData.TalentType.CONTROL, + -360, + -140, + 4, + 350, + 1.7f, + "speed_2", + "utility_1"), + talent( + "regen_2", + "Сильное лечение", + "+2 здоровья/с за уровень.", + TalentData.TalentType.REGEN, + -120, + 220, + 3, + 620, + 1.85f, + "regen_1", + "economy_1"), + talent( + "ability_2", + "Быстрый откат", + "Даёт рейтинг умений +5 за уровень.", + TalentData.TalentType.ABILITY, + 360, + -140, + 3, + 700, + 1.9f, + "ability_1"), + talent( + "utility_bonus", + "Магнит бонусов", + "Даёт рейтинг поддержки +5 за уровень.", + TalentData.TalentType.UTILITY, + -360, + 100, + 3, + 720, + 1.8f, + "speed_2", + "utility_1"), + talent( + "ability_blast", + "Ударная волна", + "Даёт рейтинг умений +5 за уровень.", + TalentData.TalentType.ABILITY, + 360, + 100, + 3, + 780, + 1.85f, + "ability_2", + "damage_2"), + talent( + "control_execute", + "Урон по замедленным", + "Даёт рейтинг замедления +5 за уровень.", + TalentData.TalentType.CONTROL, + -480, + -260, + 3, + 740, + 1.85f, + "control_1"), + talent( + "engineering_turret", + "Боевая турель", + "Даёт рейтинг умений +5 за уровень.", + TalentData.TalentType.ABILITY, + 480, + -260, + 3, + 820, + 1.9f, + "ability_2"), + talent( + "attack_precision", + "Точный выстрел", + "+2 к урону пули за уровень.", + TalentData.TalentType.DAMAGE, + 360, + 220, + 3, + 760, + 1.85f, + "damage_2", + "ability_blast"), + talent( + "survival_barrier", + "Резерв выживания", + "+15 к максимуму здоровья за уровень.", + TalentData.TalentType.HEALTH, + -240, + 300, + 3, + 780, + 1.85f, + "regen_2"), + talent( + "economy_reroll", + "Лучший выбор", + "Даёт рейтинг ресурсов +5 за уровень.", + TalentData.TalentType.ECONOMY, + 240, + 300, + 3, + 760, + 1.85f, + "economy_1"), + talent( + "keystone_pierce", + "Сквозной выстрел", + "Даёт рейтинг пробоя +5 за уровень.", + TalentData.TalentType.KEYSTONE, + 0, + -280, + 3, + 800, + 2.0f, + "cooldown_2"), + talent( + "keystone_crit", + "Крит-мастер", + "Даёт рейтинг шанса крита +5 за уровень.", + TalentData.TalentType.KEYSTONE, + 360, + 360, + 5, + 700, + 1.8f, + "attack_precision"), + talent( + "keystone_aoe", + "Урон по области", + "Даёт рейтинг урона по области +5 за уровень.", + TalentData.TalentType.KEYSTONE, + -120, + 360, + 2, + 900, + 2.2f, + "regen_2", + "survival_barrier"), + talent( + "keystone_splash", + "Осколочный залп", + "Даёт рейтинг урона по области +5 за уровень.", + TalentData.TalentType.KEYSTONE, + -360, + 360, + 2, + 1100, + 2.1f, + "utility_bonus", + "keystone_aoe"), + talent( + "keystone_turret", + "Мощная турель", + "Даёт рейтинг умений +5 за уровень.", + TalentData.TalentType.KEYSTONE, + 480, + -420, + 2, + 1150, + 2.1f, + "engineering_turret"), + talent( + "keystone_control_execute", + "Контроль и добивание", + "Даёт рейтинг замедления +5 за уровень.", + TalentData.TalentType.KEYSTONE, + -480, + -420, + 2, + 1150, + 2.1f, + "control_execute"), + talent( + "keystone_shield_regen", + "Щит и лечение", + "Даёт рейтинг щита +5 за уровень.", + TalentData.TalentType.KEYSTONE, + -240, + 420, + 2, + 1120, + 2.1f, + "survival_barrier", + "regen_2"), + talent( + "keystone_economy_reroll", + "Мастер выбора", + "Даёт рейтинг ресурсов +5 за уровень.", + TalentData.TalentType.KEYSTONE, + 180, + 420, + 2, + 1120, + 2.1f, + "economy_reroll", + "economy_1") + }; + + private final PlayerStats stats; + private final ObjectMap talents; + private final Array roots; + private final ArrayList allTalents; + private final List allTalentsView; + + private static TalentSpec rootTalent( + String id, + String name, + String description, + TalentData.TalentType type, + float x, + float y, + int maxLevel, + int baseCost, + float costMultiplier) { + return new TalentSpec( + true, id, name, description, type, x, y, maxLevel, baseCost, costMultiplier); + } + + private static TalentSpec talent( + String id, + String name, + String description, + TalentData.TalentType type, + float x, + float y, + int maxLevel, + int baseCost, + float costMultiplier, + String... dependencyIds) { + return new TalentSpec( + false, + id, + name, + description, + type, + x, + y, + maxLevel, + baseCost, + costMultiplier, + dependencyIds); + } + + private static final class TalentSpec { + private final boolean root; + private final String id; + private final String name; + private final String description; + private final TalentData.TalentType type; + private final float x; + private final float y; + private final int maxLevel; + private final int baseCost; + private final float costMultiplier; + private final String[] dependencyIds; + + private TalentSpec( + boolean root, + String id, + String name, + String description, + TalentData.TalentType type, + float x, + float y, + int maxLevel, + int baseCost, + float costMultiplier, + String... dependencyIds) { + this.root = root; + this.id = id; + this.name = name; + this.description = description; + this.type = type; + this.x = x; + this.y = y; + this.maxLevel = maxLevel; + this.baseCost = baseCost; + this.costMultiplier = costMultiplier; + this.dependencyIds = dependencyIds; + } + } + + + + + public TalentTree(PlayerStats stats) { + this.stats = stats; + this.talents = new ObjectMap<>(); + this.roots = new Array<>(); + this.allTalents = new ArrayList<>(); + this.allTalentsView = Collections.unmodifiableList(allTalents); + initializeTree(); + } + + private void registerTalent(TalentData talent) { + talents.put(talent.getId(), talent); + allTalents.add(talent); + } + + private TalentData addRootTalent( + String id, + String name, + String description, + TalentData.TalentType type, + float x, + float y, + int maxLevel, + int baseCost, + float costMultiplier) { + TalentData root = + addTalent(id, name, description, type, x, y, maxLevel, baseCost, costMultiplier); + roots.add(root); + return root; + } + + private TalentData addTalent( + String id, + String name, + String description, + TalentData.TalentType type, + float x, + float y, + int maxLevel, + int baseCost, + float costMultiplier, + String... dependencyIds) { + TalentData talent = + new TalentData( + id, name, description, type, x, y, maxLevel, baseCost, costMultiplier); + for (String dependencyId : dependencyIds) { + talent.addDependency(dependencyId); + } + registerTalent(talent); + return talent; + } + + private TalentData addTalent(TalentSpec spec) { + if (spec.root) { + return addRootTalent( + spec.id, + spec.name, + spec.description, + spec.type, + spec.x, + spec.y, + spec.maxLevel, + spec.baseCost, + spec.costMultiplier); + } + return addTalent( + spec.id, + spec.name, + spec.description, + spec.type, + spec.x, + spec.y, + spec.maxLevel, + spec.baseCost, + spec.costMultiplier, + spec.dependencyIds); + } + + private void initializeTree() { + for (TalentSpec spec : TALENT_SPECS) { + addTalent(spec); + } + } + + public TalentData getTalent(String id) { + return talents.get(id); + } + + public List getAllTalents() { + return allTalentsView; + } + + public boolean isUnlocked(String talentId) { + TalentData talent = getTalent(talentId); + if (talent == null) return false; + if (talent.getDependencyIds().isEmpty()) return true; + + + + + for (String depId : talent.getDependencyIds()) { + if (getCurrentLevel(depId) == 0) { + return false; + } + } + return true; + } + + public int getCurrentLevel(String talentId) { + if (getTalent(talentId) == null) return 0; + return stats.getTalentNodeLevel(talentId); + } + + public boolean canUpgradeTalent(String talentId) { + TalentData talent = getTalent(talentId); + return talent != null + && isUnlocked(talentId) + && getCurrentLevel(talentId) < talent.getMaxLevel(); + } + + public boolean upgradeTalent(String talentId) { + if (!canUpgradeTalent(talentId)) return false; + stats.incrementTalentNode(talentId); + return true; + } + + public float getStatBonus(TalentData.TalentType type) { + float bonus = 0; + for (TalentData talent : talents.values()) { + if (talent.getType() != type) continue; + int level = getCurrentLevel(talent.getId()); + if (level <= 0) continue; + + switch (talent.getId()) { + case "health_1": + bonus += level * 10; + break; + case "health_2": + bonus += level * 50; + break; + case "survival_barrier": + bonus += level * 15; + break; + + case "damage_1": + bonus += level * 1; + break; + case "damage_2": + bonus += level * 5; + break; + case "attack_precision": + bonus += level * 2; + break; + + case "speed_1": + bonus += level * 20; + break; + case "speed_2": + bonus += level * 100; + break; + + case "cooldown_1": + bonus += level * 50; + break; + case "cooldown_2": + bonus += level * 100; + break; + + case "regen_1": + bonus += level * 1; + break; + case "regen_2": + bonus += level * 2; + break; + default: + break; + } + } + return bonus; + } + + public Map getPermanentAxisRatings() { + EnumMap ratings = new EnumMap<>(PowerAxis.class); + ratings.put( + PowerAxis.DAMAGE, + EndlessBalanceSpec.permanentTalentRating( + stats.getDamageTalentLevel() + getCurrentLevel("attack_precision"))); + ratings.put( + PowerAxis.UTILITY, + EndlessBalanceSpec.permanentTalentRating( + stats.getSpeedTalentLevel() + + getCurrentLevel("utility_1") + + getCurrentLevel("utility_bonus") + + getCurrentLevel("economy_reroll"))); + ratings.put( + PowerAxis.CADENCE, + EndlessBalanceSpec.permanentTalentRating( + stats.getCooldownTalentLevel() + + getCurrentLevel("ability_1") + + getCurrentLevel("ability_2"))); + ratings.put( + PowerAxis.REGEN, + EndlessBalanceSpec.permanentTalentRating( + stats.getRegenTalentLevel() + getCurrentLevel("economy_1"))); + ratings.put( + PowerAxis.HEALTH, + EndlessBalanceSpec.permanentTalentRating( + stats.getHealthTalentLevel() + + getCurrentLevel("economy_1") + + getCurrentLevel("survival_barrier") + + getCurrentLevel("keystone_shield_regen"))); + ratings.put( + PowerAxis.CRIT, + EndlessBalanceSpec.permanentTalentRating(getCurrentLevel("keystone_crit"))); + ratings.put( + PowerAxis.AOE, + EndlessBalanceSpec.permanentTalentRating( + getCurrentLevel("keystone_aoe") + getCurrentLevel("keystone_splash"))); + ratings.put( + PowerAxis.CONTROL, + EndlessBalanceSpec.permanentTalentRating( + getCurrentLevel("keystone_pierce") + + getCurrentLevel("control_1") + + getCurrentLevel("control_execute") + + getCurrentLevel("keystone_control_execute") + + getCurrentLevel("ability_1") + + getCurrentLevel("ability_2"))); + ratings.put( + PowerAxis.SHIELD, + EndlessBalanceSpec.permanentTalentRating( + stats.getHealthTalentLevel() + + getCurrentLevel("survival_barrier") + + getCurrentLevel("keystone_shield_regen"))); + ratings.put( + PowerAxis.ECONOMY, + EndlessBalanceSpec.permanentTalentRating( + getCurrentLevel("economy_1") + + getCurrentLevel("economy_reroll") + + getCurrentLevel("keystone_economy_reroll"))); + ratings.put( + PowerAxis.ABILITY, + EndlessBalanceSpec.permanentTalentRating( + getCurrentLevel("ability_1") + + getCurrentLevel("ability_2") + + getCurrentLevel("ability_blast") + + getCurrentLevel("engineering_turret") + + getCurrentLevel("keystone_turret"))); + ratings.put( + PowerAxis.BOSSING, + EndlessBalanceSpec.permanentTalentRating( + getCurrentLevel("attack_precision") + + getCurrentLevel("control_execute") + + getCurrentLevel("keystone_control_execute"))); + return ratings; + } + + public PowerAxis axisForTalent(String talentId) { + TalentData talent = getTalent(talentId); + if (talent == null) return PowerAxis.HEALTH; + switch (talent.getType()) { + case DAMAGE: + return PowerAxis.DAMAGE; + case SPEED: + return PowerAxis.UTILITY; + case COOLDOWN: + return PowerAxis.CADENCE; + case REGEN: + return PowerAxis.REGEN; + case CONTROL: + return PowerAxis.CONTROL; + case ABILITY: + return PowerAxis.ABILITY; + case UTILITY: + return PowerAxis.UTILITY; + case ECONOMY: + return PowerAxis.ECONOMY; + case KEYSTONE: + if ("keystone_crit".equals(talentId)) return PowerAxis.CRIT; + if ("keystone_aoe".equals(talentId)) return PowerAxis.AOE; + if ("keystone_splash".equals(talentId)) return PowerAxis.AOE; + if ("keystone_turret".equals(talentId)) return PowerAxis.ABILITY; + if ("keystone_shield_regen".equals(talentId)) return PowerAxis.SHIELD; + if ("keystone_economy_reroll".equals(talentId)) return PowerAxis.ECONOMY; + return PowerAxis.CONTROL; + case HEALTH: + default: + return PowerAxis.HEALTH; + } + } + + public String boundedRatingText(String talentId) { + PowerAxis axis = axisForTalent(talentId); + int currentRating = getPermanentAxisRatings().get(axis); + TalentData talent = getTalent(talentId); + int currentLevel = getCurrentLevel(talentId); + int nextLevel = + talent == null ? currentLevel : Math.min(talent.getMaxLevel(), currentLevel + 1); + int nextRating = EndlessBalanceSpec.permanentTalentRating(nextLevel); + return axisDisplayName(axis) + + " " + + currentRating + + "/" + + EndlessBalanceSpec.TALENT_AXIS_MAX_RATING + + " далее " + + nextRating; + } + + private static String axisDisplayName(PowerAxis axis) { + switch (axis) { + case DAMAGE: + return "Урон"; + case UTILITY: + return "Поддержка"; + case CADENCE: + return "Перезарядка"; + case REGEN: + return "Ремонт"; + case CRIT: + return "Крит"; + case AOE: + return "Урон по области"; + case CONTROL: + return "Замедление"; + case ECONOMY: + return "Ресурсы"; + case ABILITY: + return "Умения"; + case BOSSING: + return "Боссы"; + case ARMOR: + return "Броня"; + case PIERCE: + return "Пробой"; + case HEALTH: + return "Здоровье"; + case SHIELD: + default: + return "Щит"; + } + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/UpgradeHost.java b/core/src/main/java/ru/project/tower/upgrades/UpgradeHost.java new file mode 100644 index 0000000..ecccc15 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/UpgradeHost.java @@ -0,0 +1,31 @@ +package ru.project.tower.upgrades; + +import com.badlogic.gdx.graphics.Color; +import ru.project.tower.entities.bullets.BulletSystem; + + + + + + + +public interface UpgradeHost { + + void addRunDamagePercentBonus(int percent); + + void addRunPierceBonus(int bonus); + + void addRunAoeBonus(float radiusBonus); + + void addMaxHealth(int amount); + + void healToFull(); + + void addHealthRegen(int perSecond); + + void addFloatingText(float x, float y, String text, Color color); + + float getPlayerCenterX(); + + float getPlayerCenterY(); +} diff --git a/core/src/main/java/ru/project/tower/upgrades/UpgradeKind.java b/core/src/main/java/ru/project/tower/upgrades/UpgradeKind.java new file mode 100644 index 0000000..759fec1 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/UpgradeKind.java @@ -0,0 +1,73 @@ +package ru.project.tower.upgrades; + + +public enum UpgradeKind { + DAMAGE("Сильный заряд", "+15% урон", "damage", 8, "common", 1.20f, "pure", "damage"), + FIRE_RATE("Быстрый затвор", "-10% перезарядка", "speed", 7, "common", 1.10f, "cadence", "tempo"), + PIERCE("Пуля навылет", "+1 пробой", "control", 6, "common", 1.00f, "pierce", "swarm"), + AOE("Взрывная волна", "+25 радиус взрыва", "control", 7, "common", 1.00f, "aoe", "swarm"), + CRIT("Слабое место", "+5% шанс крита", "attack", 7, "common", 1.05f, "crit", "burst"), + HEAL("Запас прочности", "+30 здоровье", "shield", 6, "common", 1.00f, "health", "shield"), + REGEN("Ремонтный ритм", "+1 здоровья/с", "defense", 6, "common", 1.00f, "regen", "sustain"), + BOSS_BREAKER("Охотник на боссов", "+10% урон по боссам", "attack", 5, "uncommon", 0.80f, "boss", "single-target"), + EXECUTION_LINE("Добивание", "+8% по раненым", "attack", 5, "uncommon", 0.80f, "execute", "single-target"), + STATIC_FIELD("Сдерживающий заряд", "+18 радиус, +1 пробой", "control", 5, "uncommon", 0.80f, "slow", "aoe"), + PLATED_CORE("Бронезапас", "+20 здоровье, +1/с", "defense", 5, "uncommon", 0.75f, "armor", "health"), + COIN_COMPOUNDER("Монетный разгон", "+4% урон", "utility", 5, "common", 0.90f, "money", "shop"), + REROLL_SIGNAL("Точный переброс", "+2% шанс крита", "utility", 5, "uncommon", 0.75f, "reroll", "choice"), + BONUS_MAGNET("Бонусный магнит", "-4% перезарядка", "utility", 5, "common", 0.90f, "bonus", "pickup"), + TURRET_DIRECTIVE("Турельный залп", "+15 радиус взрыва", "ability", 5, "uncommon", 0.75f, "turret", "ability"), + BLAST_PROTOCOL("Громовой заряд", "+6% урон, +12 радиус", "ability", 5, "uncommon", 0.75f, "explosion", "ability"), + SHIELD_SCRIPT("Крепкий щит", "+15 здоровье", "ability", 5, "uncommon", 0.75f, "shield", "ability"), + VOLATILE_BARGAIN("Опасная сделка", "+18% урон", "risk", 4, "rare", 0.45f, "risk", "damage"), + GLASS_CANNON("Тонкая меткость", "+7% шанс крита", "risk", 4, "rare", 0.45f, "risk", "crit"); + + public final String label; + private final String effectText; + private final String family; + private final int maxStacks; + private final String rarity; + private final float offerWeight; + private final String[] tags; + + UpgradeKind( + String label, + String effectText, + String family, + int maxStacks, + String rarity, + float offerWeight, + String... tags) { + this.label = label; + this.effectText = effectText; + this.family = family; + this.maxStacks = maxStacks; + this.rarity = rarity; + this.offerWeight = offerWeight; + this.tags = tags == null ? new String[0] : tags.clone(); + } + + public String family() { + return family; + } + + public String effectText() { + return effectText; + } + + public int maxStacks() { + return maxStacks; + } + + public String rarity() { + return rarity; + } + + public float offerWeight() { + return offerWeight; + } + + public String[] tags() { + return tags.clone(); + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/UpgradeSynergy.java b/core/src/main/java/ru/project/tower/upgrades/UpgradeSynergy.java new file mode 100644 index 0000000..54a6519 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/UpgradeSynergy.java @@ -0,0 +1,51 @@ +package ru.project.tower.upgrades; + +public enum UpgradeSynergy { + TEMPO_CORE("tempo_core", UpgradeKind.DAMAGE, 1, UpgradeKind.FIRE_RATE, 1), + PRECISION_BURST("precision_burst", UpgradeKind.DAMAGE, 2, UpgradeKind.CRIT, 2), + BARRAGE("barrage", UpgradeKind.FIRE_RATE, 2, UpgradeKind.PIERCE, 1), + CONTROL_FIELD("control_field", UpgradeKind.PIERCE, 2, UpgradeKind.AOE, 2), + VITAL_ENGINE("vital_engine", UpgradeKind.HEAL, 2, UpgradeKind.REGEN, 2); + + private final String id; + private final UpgradeKind firstKind; + private final int firstStacks; + private final UpgradeKind secondKind; + private final int secondStacks; + + UpgradeSynergy( + String id, + UpgradeKind firstKind, + int firstStacks, + UpgradeKind secondKind, + int secondStacks) { + this.id = id; + this.firstKind = firstKind; + this.firstStacks = firstStacks; + this.secondKind = secondKind; + this.secondStacks = secondStacks; + } + + public String id() { + return id; + } + + boolean isTriggeredByCounts(int[] pickedCounts) { + return count(pickedCounts, firstKind) >= firstStacks + && count(pickedCounts, secondKind) >= secondStacks; + } + + boolean wouldTriggerAfterPick(UpgradeKind pickedKind, int[] pickedCounts) { + if (pickedKind != firstKind && pickedKind != secondKind) { + return false; + } + int first = count(pickedCounts, firstKind) + (pickedKind == firstKind ? 1 : 0); + int second = count(pickedCounts, secondKind) + (pickedKind == secondKind ? 1 : 0); + return first >= firstStacks && second >= secondStacks; + } + + private static int count(int[] pickedCounts, UpgradeKind kind) { + int index = kind.ordinal(); + return index < pickedCounts.length ? pickedCounts[index] : 0; + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/UpgradeSystem.java b/core/src/main/java/ru/project/tower/upgrades/UpgradeSystem.java new file mode 100644 index 0000000..8860203 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/UpgradeSystem.java @@ -0,0 +1,539 @@ +package ru.project.tower.upgrades; + +import com.badlogic.gdx.graphics.Color; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.support.RandomSource; + +public final class UpgradeSystem { + + private static final UpgradeKind[] ALL_KINDS = UpgradeKind.values(); + private static final UpgradeSynergy[] ALL_SYNERGIES = UpgradeSynergy.values(); + private static final int PICK_OPTION_COUNT = 3; + private static final int MAX_REROLLS_PER_OFFER = 2; + private static final int BASE_REROLL_COST = 15; + + private static final Color NEON_YELLOW = new Color(1f, 1f, 0.2f, 1f); + private static final float FLOATING_TEXT_Y_OFFSET = 50f; + private static final float FLOATING_TEXT_SCALE = 2.5f; + private static final float FLOATING_TEXT_LIFETIME = 1.3f; + private static final float FLOATING_TEXT_RISE = 20f; + + private final RandomSource random; + private final UpgradeHost host; + + private boolean pickActive = false; + private UpgradeKind[] pickOptions = null; + private final UpgradeKind[] pickOptionsBuffer = new UpgradeKind[PICK_OPTION_COUNT]; + private final UpgradeKind[] pickPool = new UpgradeKind[ALL_KINDS.length]; + private final int[] pickedCounts = new int[ALL_KINDS.length]; + private final boolean[] triggeredSynergies = new boolean[ALL_SYNERGIES.length]; + + private float runCooldownMult = 1.0f; + private float runCritBonus = 0f; + private int rerollsUsed; + + public UpgradeSystem(RandomSource random, UpgradeHost host) { + this.random = random; + this.host = host; + } + + public void triggerPick() { + drawPickOptions(true); + } + + private void drawPickOptions(boolean resetRerolls) { + int poolSize = fillAvailablePickPool(); + if (poolSize == 0) { + dismissPick(); + return; + } + + int optionCount = Math.min(PICK_OPTION_COUNT, poolSize); + UpgradeKind[] options = + optionCount == PICK_OPTION_COUNT ? pickOptionsBuffer : new UpgradeKind[optionCount]; + for (int i = 0; i < optionCount; i++) { + int idx = random.nextIntInclusive(poolSize - 1); + options[i] = pickPool[idx]; + int elementsAfterRemoved = poolSize - idx - 1; + if (elementsAfterRemoved > 0) { + System.arraycopy(pickPool, idx + 1, pickPool, idx, elementsAfterRemoved); + } + pickPool[--poolSize] = null; + } + pickOptions = options; + pickActive = true; + if (resetRerolls) { + rerollsUsed = 0; + } + } + + private int fillAvailablePickPool() { + int poolSize = 0; + for (UpgradeKind kind : ALL_KINDS) { + if (!isCapped(kind)) { + pickPool[poolSize++] = kind; + } + } + for (int i = poolSize; i < pickPool.length; i++) { + pickPool[i] = null; + } + return poolSize; + } + + public boolean isPickActive() { + return pickActive; + } + + public UpgradeKind[] getPickOptions() { + return pickOptions; + } + + public PickSnapshot snapshot() { + UpgradeKind[] options = null; + if (pickActive && pickOptions != null) { + options = new UpgradeKind[pickOptions.length]; + System.arraycopy(pickOptions, 0, options, 0, pickOptions.length); + } + return new PickSnapshot( + pickActive, + options, + runCooldownMult, + runCritBonus, + pickedCounts, + triggeredSynergies, + rerollsUsed); + } + + public void apply(PickSnapshot snapshot) { + if (snapshot == null) { + resetForNewRun(); + return; + } + pickActive = snapshot.pickActive(); + if (snapshot.pickOptions() == null) { + pickOptions = null; + } else { + UpgradeKind[] options = snapshot.pickOptions(); + System.arraycopy(options, 0, pickOptionsBuffer, 0, options.length); + pickOptions = pickOptionsBuffer; + } + runCooldownMult = snapshot.runCooldownMult(); + runCritBonus = snapshot.runCritBonus(); + rerollsUsed = snapshot.rerollsUsed(); + int[] snapshotCounts = snapshot.pickedCounts(); + for (int i = 0; i < pickedCounts.length; i++) { + pickedCounts[i] = i < snapshotCounts.length ? snapshotCounts[i] : 0; + } + boolean[] snapshotSynergies = snapshot.triggeredSynergies(); + for (int i = 0; i < triggeredSynergies.length; i++) { + triggeredSynergies[i] = i < snapshotSynergies.length && snapshotSynergies[i]; + } + } + + public void applyPick(int index) { + if (!pickActive || index < 0 || index >= pickOptions.length) return; + UpgradeKind k = pickOptions[index]; + if (isCapped(k)) { + dismissPick(); + return; + } + switch (k) { + case DAMAGE: + host.addRunDamagePercentBonus( + EndlessBalanceSpec.upgradeCardDamagePercent(pickedCounts[k.ordinal()])); + break; + case FIRE_RATE: + runCooldownMult *= + EndlessBalanceSpec.upgradeCardFireRateMultiplier(pickedCounts[k.ordinal()]); + break; + case PIERCE: + host.addRunPierceBonus(1); + break; + case AOE: + host.addRunAoeBonus(25f); + break; + case CRIT: + runCritBonus += 0.05f; + break; + case HEAL: + host.addMaxHealth(30); + host.healToFull(); + break; + case REGEN: + host.addHealthRegen(1); + break; + case BOSS_BREAKER: + host.addRunDamagePercentBonus(10); + runCritBonus += 0.01f; + break; + case EXECUTION_LINE: + host.addRunDamagePercentBonus(8); + break; + case STATIC_FIELD: + host.addRunAoeBonus(18f); + host.addRunPierceBonus(1); + break; + case PLATED_CORE: + host.addMaxHealth(20); + host.addHealthRegen(1); + break; + case COIN_COMPOUNDER: + host.addRunDamagePercentBonus(4); + break; + case REROLL_SIGNAL: + runCritBonus += 0.02f; + break; + case BONUS_MAGNET: + runCooldownMult *= 0.96f; + break; + case TURRET_DIRECTIVE: + host.addRunAoeBonus(15f); + break; + case BLAST_PROTOCOL: + host.addRunDamagePercentBonus(6); + host.addRunAoeBonus(12f); + break; + case SHIELD_SCRIPT: + host.addMaxHealth(15); + break; + case VOLATILE_BARGAIN: + host.addRunDamagePercentBonus(18); + break; + case GLASS_CANNON: + runCritBonus += 0.07f; + break; + } + pickedCounts[k.ordinal()]++; + applyNewSynergies(); + host.addFloatingText( + host.getPlayerCenterX(), + host.getPlayerCenterY() + FLOATING_TEXT_Y_OFFSET, + k.label, + NEON_YELLOW); + pickActive = false; + pickOptions = null; + rerollsUsed = 0; + } + + private boolean isCapped(UpgradeKind kind) { + return pickedCounts[kind.ordinal()] >= kind.maxStacks(); + } + + public void dismissPick() { + pickActive = false; + pickOptions = null; + rerollsUsed = 0; + } + + + public void resetForNewRun() { + dismissPick(); + runCooldownMult = 1.0f; + runCritBonus = 0f; + for (int i = 0; i < pickedCounts.length; i++) { + pickedCounts[i] = 0; + } + for (int i = 0; i < triggeredSynergies.length; i++) { + triggeredSynergies[i] = false; + } + rerollsUsed = 0; + } + + public float getRunCooldownMult() { + return runCooldownMult; + } + + public float getRunCritBonus() { + return runCritBonus; + } + + public boolean hasTriggeredSynergy(UpgradeSynergy synergy) { + return triggeredSynergies[synergy.ordinal()]; + } + + public int triggeredSynergyCount() { + int count = 0; + for (boolean triggered : triggeredSynergies) { + if (triggered) count++; + } + return count; + } + + public boolean rerollPick(float discountFraction) { + if (!pickActive || pickOptions == null || rerollsRemaining() <= 0) { + return false; + } + rerollsUsed++; + drawPickOptions(false); + return true; + } + + public int rerollsRemaining() { + return Math.max(0, MAX_REROLLS_PER_OFFER - 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 OfferCardView[] activeOfferViews() { + if (!pickActive || pickOptions == null) { + return new OfferCardView[0]; + } + OfferCardView[] views = new OfferCardView[pickOptions.length]; + for (int i = 0; i < pickOptions.length; i++) { + UpgradeKind kind = pickOptions[i]; + int stacks = pickedCounts[kind.ordinal()]; + views[i] = + new OfferCardView( + kind, + kind.family(), + stacks, + kind.maxStacks(), + stacks >= kind.maxStacks(), + completingSynergyId(kind), + kind.rarity(), + kind.offerWeight(), + kind.effectText(), + kind.tags()); + } + return views; + } + + private String completingSynergyId(UpgradeKind kind) { + for (UpgradeSynergy synergy : ALL_SYNERGIES) { + if (!triggeredSynergies[synergy.ordinal()] + && synergy.wouldTriggerAfterPick(kind, pickedCounts)) { + return synergy.id(); + } + } + return null; + } + + private void applyNewSynergies() { + for (UpgradeSynergy synergy : ALL_SYNERGIES) { + int index = synergy.ordinal(); + if (triggeredSynergies[index] || !synergy.isTriggeredByCounts(pickedCounts)) { + continue; + } + triggeredSynergies[index] = true; + applySynergy(synergy); + } + } + + private void applySynergy(UpgradeSynergy synergy) { + switch (synergy) { + case TEMPO_CORE: + runCooldownMult *= 0.97f; + break; + case PRECISION_BURST: + runCritBonus += 0.03f; + break; + case BARRAGE: + host.addRunPierceBonus(1); + break; + case CONTROL_FIELD: + host.addRunAoeBonus(20f); + break; + case VITAL_ENGINE: + host.addHealthRegen(2); + break; + default: + throw new IllegalArgumentException("Unknown upgrade synergy: " + synergy); + } + } + + public static final class PickSnapshot { + private final boolean pickActive; + private final UpgradeKind[] pickOptions; + private final float runCooldownMult; + private final float runCritBonus; + private final int[] pickedCounts; + private final boolean[] triggeredSynergies; + private final int rerollsUsed; + + public PickSnapshot( + boolean pickActive, + UpgradeKind[] pickOptions, + float runCooldownMult, + float runCritBonus) { + this(pickActive, pickOptions, runCooldownMult, runCritBonus, new int[0]); + } + + public PickSnapshot( + boolean pickActive, + UpgradeKind[] pickOptions, + float runCooldownMult, + float runCritBonus, + int[] pickedCounts) { + this( + pickActive, + pickOptions, + runCooldownMult, + runCritBonus, + pickedCounts, + new boolean[0], + 0); + } + + public PickSnapshot( + boolean pickActive, + UpgradeKind[] pickOptions, + float runCooldownMult, + float runCritBonus, + int[] pickedCounts, + boolean[] triggeredSynergies) { + this( + pickActive, + pickOptions, + runCooldownMult, + runCritBonus, + pickedCounts, + triggeredSynergies, + 0); + } + + public PickSnapshot( + boolean pickActive, + UpgradeKind[] pickOptions, + float runCooldownMult, + float runCritBonus, + int[] pickedCounts, + boolean[] triggeredSynergies, + int rerollsUsed) { + this.pickActive = pickActive; + if (pickOptions == null) { + this.pickOptions = null; + } else { + this.pickOptions = new UpgradeKind[pickOptions.length]; + System.arraycopy(pickOptions, 0, this.pickOptions, 0, pickOptions.length); + } + this.runCooldownMult = runCooldownMult; + this.runCritBonus = runCritBonus; + this.pickedCounts = new int[pickedCounts.length]; + System.arraycopy(pickedCounts, 0, this.pickedCounts, 0, pickedCounts.length); + this.triggeredSynergies = new boolean[triggeredSynergies.length]; + System.arraycopy( + triggeredSynergies, 0, this.triggeredSynergies, 0, triggeredSynergies.length); + this.rerollsUsed = Math.max(0, rerollsUsed); + } + + public boolean pickActive() { + return pickActive; + } + + public UpgradeKind[] pickOptions() { + if (pickOptions == null) { + return null; + } + UpgradeKind[] copy = new UpgradeKind[pickOptions.length]; + System.arraycopy(pickOptions, 0, copy, 0, pickOptions.length); + return copy; + } + + public float runCooldownMult() { + return runCooldownMult; + } + + public float runCritBonus() { + return runCritBonus; + } + + public int[] pickedCounts() { + int[] copy = new int[pickedCounts.length]; + System.arraycopy(pickedCounts, 0, copy, 0, pickedCounts.length); + return copy; + } + + public boolean[] triggeredSynergies() { + boolean[] copy = new boolean[triggeredSynergies.length]; + System.arraycopy(triggeredSynergies, 0, copy, 0, triggeredSynergies.length); + return copy; + } + + public int rerollsUsed() { + return rerollsUsed; + } + } + + public static final class OfferCardView { + private final UpgradeKind kind; + private final String family; + private final int stacks; + private final int maxStacks; + private final boolean unavailable; + private final String synergyId; + private final String rarity; + private final float offerWeight; + private final String effectText; + private final String[] tags; + + private OfferCardView( + UpgradeKind kind, + String family, + int stacks, + int maxStacks, + boolean unavailable, + String synergyId, + String rarity, + float offerWeight, + String effectText, + String[] tags) { + this.kind = kind; + this.family = family; + this.stacks = stacks; + this.maxStacks = maxStacks; + this.unavailable = unavailable; + this.synergyId = synergyId; + this.rarity = rarity; + this.offerWeight = offerWeight; + this.effectText = effectText; + this.tags = tags == null ? new String[0] : tags.clone(); + } + + public UpgradeKind kind() { + return kind; + } + + public String family() { + return family; + } + + public int stacks() { + return stacks; + } + + public int maxStacks() { + return maxStacks; + } + + public boolean unavailable() { + return unavailable; + } + + public boolean synergyReady() { + return synergyId != null; + } + + public String synergyId() { + return synergyId; + } + + public String rarity() { + return rarity; + } + + public float offerWeight() { + return offerWeight; + } + + public String effectText() { + return effectText; + } + + public String[] tags() { + return tags.clone(); + } + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeCategory.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeCategory.java new file mode 100644 index 0000000..8037302 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeCategory.java @@ -0,0 +1,17 @@ +package ru.project.tower.upgrades.shop; + +public enum ShopUpgradeCategory { + ATTACK("Атака"), + DEFENSE("Защита"), + BONUS("Бонусы"); + + private final String gameName; + + ShopUpgradeCategory(String gameName) { + this.gameName = gameName; + } + + public String gameName() { + return gameName; + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeDefinition.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeDefinition.java new file mode 100644 index 0000000..1abfa78 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeDefinition.java @@ -0,0 +1,80 @@ +package ru.project.tower.upgrades.shop; + +import java.util.Objects; +import ru.project.tower.balance.PowerAxis; + +public final class ShopUpgradeDefinition { + private final ShopUpgradeKind kind; + private final String id; + private final ShopUpgradeCategory category; + private final PowerAxis axis; + private final String gameName; + private final String shortDescription; + private final int maxLevel; + private final double costScale; + private final String iconToken; + private final String colorToken; + + ShopUpgradeDefinition( + ShopUpgradeKind kind, + String id, + ShopUpgradeCategory category, + PowerAxis axis, + String gameName, + String shortDescription, + int maxLevel, + double costScale, + String iconToken, + String colorToken) { + this.kind = Objects.requireNonNull(kind, "kind"); + this.id = Objects.requireNonNull(id, "id"); + this.category = Objects.requireNonNull(category, "category"); + this.axis = Objects.requireNonNull(axis, "axis"); + this.gameName = Objects.requireNonNull(gameName, "gameName"); + this.shortDescription = Objects.requireNonNull(shortDescription, "shortDescription"); + this.maxLevel = maxLevel; + this.costScale = costScale; + this.iconToken = Objects.requireNonNull(iconToken, "iconToken"); + this.colorToken = Objects.requireNonNull(colorToken, "colorToken"); + } + + public ShopUpgradeKind kind() { + return kind; + } + + public String id() { + return id; + } + + public ShopUpgradeCategory category() { + return category; + } + + public PowerAxis axis() { + return axis; + } + + public String gameName() { + return gameName; + } + + public String shortDescription() { + return shortDescription; + } + + public int maxLevel() { + return maxLevel; + } + + public double costScale() { + return costScale; + } + + public String iconToken() { + return iconToken; + } + + public String colorToken() { + return colorToken; + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeHost.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeHost.java new file mode 100644 index 0000000..016946b --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeHost.java @@ -0,0 +1,18 @@ +package ru.project.tower.upgrades.shop; + + +public interface ShopUpgradeHost { + boolean spendMoney(int amount); + + void addMaxHealth(int amount); + + void healToFull(); + + void addBaseDamage(int amount); + + void addBaseSpeed(float amount); + + void reduceShotCooldown(float amount, float minimum); + + void addHealthRegen(int amount); +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeKind.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeKind.java new file mode 100644 index 0000000..e96d29e --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeKind.java @@ -0,0 +1,45 @@ +package ru.project.tower.upgrades.shop; + + +public enum ShopUpgradeKind { + HEALTH(1.2), + DAMAGE(1.2), + SPEED(1.2), + COOLDOWN(1.2), + REGEN(1.2), + CRIT_CHANCE(1.5), + CRIT_MULTIPLIER(1.5), + PIERCE(1.28), + SPLASH_DAMAGE(1.30), + EXECUTE_DAMAGE(1.32), + BOSS_DAMAGE(1.35), + CONTROL_DAMAGE(1.32), + CONTACT_ARMOR(1.22), + PROJECTILE_ARMOR(1.24), + SHIELD_CAPACITY(1.26), + SHIELD_RECHARGE(1.26), + EMERGENCY_BARRIER(1.34), + WAVE_RECOVERY(1.24), + SAFE_MOBILITY(1.28), + SLOW_AURA(1.32), + PICKUP_RADIUS(1.18), + BONUS_DURATION(1.22), + BONUS_POWER(1.28), + BONUS_STABILITY(1.24), + BONUS_PITY(1.30), + BONUS_FILTER(1.30), + COIN_INCOME(1.26), + SHOP_DISCOUNT(1.30), + CARD_REROLL(1.32), + SYNERGY_CATALYST(1.34); + + private final double costScale; + + ShopUpgradeKind(double costScale) { + this.costScale = costScale; + } + + public double costScale() { + return costScale; + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeRegistry.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeRegistry.java new file mode 100644 index 0000000..c5cd751 --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeRegistry.java @@ -0,0 +1,373 @@ +package ru.project.tower.upgrades.shop; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.balance.PowerAxis; + +public final class ShopUpgradeRegistry { + private static final ShopUpgradeDefinition[] DEFINITIONS = { + attack( + ShopUpgradeKind.DAMAGE, + "attack.core_damage", + PowerAxis.DAMAGE, + "Урон", + "+урон пули.", + "sword", + "upgrade.damage"), + attack( + ShopUpgradeKind.SPEED, + "attack.bullet_speed", + PowerAxis.UTILITY, + "Скорость", + "+скорость пуль.", + "fast-forward", + "upgrade.speed"), + attack( + ShopUpgradeKind.COOLDOWN, + "attack.cadence", + PowerAxis.CADENCE, + "Перезарядка", + "-перезарядка.", + "zap", + "upgrade.cooldown"), + attack( + ShopUpgradeKind.CRIT_CHANCE, + "attack.crit_chance", + PowerAxis.CRIT, + "Шанс крита", + "+шанс крита.", + "crosshair", + "upgrade.critChance"), + attack( + ShopUpgradeKind.CRIT_MULTIPLIER, + "attack.crit_power", + PowerAxis.CRIT, + "Множитель крита", + "+сила крита.", + "sparkles", + "upgrade.critPower"), + attack( + ShopUpgradeKind.PIERCE, + "attack.pierce", + PowerAxis.PIERCE, + "Сквозной урон", + "+целей пробивает пуля.", + "arrow-right", + "upgrade.pierce"), + attack( + ShopUpgradeKind.SPLASH_DAMAGE, + "attack.splash", + PowerAxis.AOE, + "Радиус взрыва", + "+радиус урона по области.", + "burst", + "upgrade.aoe"), + attack( + ShopUpgradeKind.EXECUTE_DAMAGE, + "attack.execute", + PowerAxis.DAMAGE, + "Урон по раненым", + "+% урона по раненым.", + "scissors", + "upgrade.execute"), + attack( + ShopUpgradeKind.BOSS_DAMAGE, + "attack.bossing", + PowerAxis.BOSSING, + "Босс-урон", + "+% урона по боссам.", + "shield-alert", + "upgrade.bossing"), + attack( + ShopUpgradeKind.CONTROL_DAMAGE, + "attack.controlled", + PowerAxis.CONTROL, + "Урон по замедленным", + "+% урона по замедленным.", + "snowflake", + "upgrade.control"), + defense( + ShopUpgradeKind.HEALTH, + "defense.health", + PowerAxis.HEALTH, + "Здоровье", + "+20 к максимуму здоровья.", + "heart", + "upgrade.health"), + defense( + ShopUpgradeKind.REGEN, + "defense.regen", + PowerAxis.REGEN, + "Восстановление", + "+здоровья в секунду.", + "activity", + "upgrade.regen"), + defense( + ShopUpgradeKind.CONTACT_ARMOR, + "defense.contact_armor", + PowerAxis.ARMOR, + "Броня", + "-% урона от столкновений.", + "shield", + "upgrade.armor"), + defense( + ShopUpgradeKind.PROJECTILE_ARMOR, + "defense.projectile_armor", + PowerAxis.ARMOR, + "Защита от снарядов", + "-% урона от снарядов.", + "shield-check", + "upgrade.projectileArmor"), + defense( + ShopUpgradeKind.SHIELD_CAPACITY, + "defense.shield_capacity", + PowerAxis.SHIELD, + "Запас щита", + "+прочность щита.", + "battery", + "upgrade.shield"), + defense( + ShopUpgradeKind.SHIELD_RECHARGE, + "defense.shield_recharge", + PowerAxis.SHIELD, + "Восстановление щита", + "-перезарядка щита.", + "rotate-cw", + "upgrade.shieldRecharge"), + defense( + ShopUpgradeKind.EMERGENCY_BARRIER, + "defense.emergency_barrier", + PowerAxis.SHIELD, + "Спасительный барьер", + "+порог спасения.", + "octagon-alert", + "upgrade.barrier"), + defense( + ShopUpgradeKind.WAVE_RECOVERY, + "defense.wave_recovery", + PowerAxis.REGEN, + "Лечение после боя", + "+здоровья после зачистки.", + "flag", + "upgrade.waveRecovery"), + defense( + ShopUpgradeKind.SAFE_MOBILITY, + "defense.safe_mobility", + PowerAxis.ABILITY, + "Броня после рывка", + "-% урона после рывка.", + "move", + "upgrade.mobility"), + defense( + ShopUpgradeKind.SLOW_AURA, + "defense.slow_aura", + PowerAxis.CONTROL, + "Аура замедления", + "+% замедления рядом.", + "circle-dot", + "upgrade.slowAura"), + bonus( + ShopUpgradeKind.PICKUP_RADIUS, + "bonus.pickup_radius", + PowerAxis.UTILITY, + "Радиус сбора", + "+радиус сбора бонусов.", + "magnet", + "upgrade.pickup"), + bonus( + ShopUpgradeKind.BONUS_DURATION, + "bonus.duration", + PowerAxis.UTILITY, + "Длительность бонусов", + "+сек действия бонусов.", + "timer", + "upgrade.bonusDuration"), + bonus( + ShopUpgradeKind.BONUS_POWER, + "bonus.power", + PowerAxis.UTILITY, + "Сила бонусов", + "+% силы временных бонусов.", + "plus-circle", + "upgrade.bonusPower"), + bonus( + ShopUpgradeKind.BONUS_STABILITY, + "bonus.stability", + PowerAxis.UTILITY, + "Стойкость бонусов", + "-% потери силы бонусов.", + "layers", + "upgrade.bonusStability"), + bonus( + ShopUpgradeKind.BONUS_PITY, + "bonus.pity", + PowerAxis.UTILITY, + "Гарант бонуса", + "+шанс бонуса после серии без бонусов.", + "gift", + "upgrade.bonusPity"), + bonus( + ShopUpgradeKind.BONUS_FILTER, + "bonus.filter", + PowerAxis.UTILITY, + "Шанс нужного бонуса", + "+шанс нужного типа бонуса.", + "filter", + "upgrade.bonusFilter"), + bonus( + ShopUpgradeKind.COIN_INCOME, + "bonus.coin_income", + PowerAxis.ECONOMY, + "Доход", + "+% монет за зачистку.", + "coins", + "upgrade.coinIncome"), + bonus( + ShopUpgradeKind.SHOP_DISCOUNT, + "bonus.shop_discount", + PowerAxis.ECONOMY, + "Скидка", + "-% цены магазина.", + "badge-percent", + "upgrade.discount"), + bonus( + ShopUpgradeKind.CARD_REROLL, + "bonus.card_reroll", + PowerAxis.UTILITY, + "Цена переброса", + "-% цены переброса.", + "refresh-cw", + "upgrade.reroll"), + bonus( + ShopUpgradeKind.SYNERGY_CATALYST, + "bonus.synergy", + PowerAxis.UTILITY, + "Сила синергий", + "+% к срабатыванию синергий.", + "network", + "upgrade.synergy") + }; + private static final EnumMap BY_KIND = + new EnumMap<>(ShopUpgradeKind.class); + private static final EnumMap BY_CATEGORY = + new EnumMap<>(ShopUpgradeCategory.class); + + static { + for (ShopUpgradeDefinition definition : DEFINITIONS) { + BY_KIND.put(definition.kind(), definition); + } + for (ShopUpgradeCategory category : ShopUpgradeCategory.values()) { + List matches = new ArrayList<>(); + for (ShopUpgradeDefinition definition : DEFINITIONS) { + if (definition.category() == category) { + matches.add(definition); + } + } + BY_CATEGORY.put(category, matches.toArray(new ShopUpgradeDefinition[0])); + } + } + + private ShopUpgradeRegistry() {} + + public static ShopUpgradeDefinition[] all() { + ShopUpgradeDefinition[] copy = new ShopUpgradeDefinition[DEFINITIONS.length]; + System.arraycopy(DEFINITIONS, 0, copy, 0, DEFINITIONS.length); + return copy; + } + + public static ShopUpgradeDefinition[] byCategory(ShopUpgradeCategory category) { + ShopUpgradeDefinition[] source = BY_CATEGORY.get(category); + ShopUpgradeDefinition[] copy = new ShopUpgradeDefinition[source.length]; + System.arraycopy(source, 0, copy, 0, source.length); + return copy; + } + + public static ShopUpgradeDefinition definition(ShopUpgradeKind kind) { + ShopUpgradeDefinition definition = BY_KIND.get(kind); + if (definition == null) { + throw new IllegalArgumentException("No shop upgrade definition for " + kind); + } + return definition; + } + + private static ShopUpgradeDefinition attack( + ShopUpgradeKind kind, + String id, + PowerAxis axis, + String gameName, + String description, + String iconToken, + String colorToken) { + return definition( + kind, + id, + ShopUpgradeCategory.ATTACK, + axis, + gameName, + description, + iconToken, + colorToken); + } + + private static ShopUpgradeDefinition defense( + ShopUpgradeKind kind, + String id, + PowerAxis axis, + String gameName, + String description, + String iconToken, + String colorToken) { + return definition( + kind, + id, + ShopUpgradeCategory.DEFENSE, + axis, + gameName, + description, + iconToken, + colorToken); + } + + private static ShopUpgradeDefinition bonus( + ShopUpgradeKind kind, + String id, + PowerAxis axis, + String gameName, + String description, + String iconToken, + String colorToken) { + return definition( + kind, + id, + ShopUpgradeCategory.BONUS, + axis, + gameName, + description, + iconToken, + colorToken); + } + + private static ShopUpgradeDefinition definition( + ShopUpgradeKind kind, + String id, + ShopUpgradeCategory category, + PowerAxis axis, + String gameName, + String description, + String iconToken, + String colorToken) { + return new ShopUpgradeDefinition( + kind, + id, + category, + axis, + gameName, + description, + EndlessBalanceSpec.SHOP_MAX_LEVEL, + kind.costScale(), + iconToken, + colorToken); + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeSnapshot.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeSnapshot.java new file mode 100644 index 0000000..48ff34c --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeSnapshot.java @@ -0,0 +1,135 @@ +package ru.project.tower.upgrades.shop; + + +public final class ShopUpgradeSnapshot { + public static final int SCHEMA_VERSION = 1; + private static final ShopUpgradeKind[] KINDS = ShopUpgradeKind.values(); + + private final int schemaVersion; + private final int[] levels; + + private ShopUpgradeSnapshot(int schemaVersion, int[] levels) { + this.schemaVersion = schemaVersion; + this.levels = copyLevels(levels); + } + + public static ShopUpgradeSnapshot of( + int healthLevel, + int damageLevel, + int speedLevel, + int cooldownLevel, + int regenLevel, + int critChanceLevel, + int critMultiplierLevel) { + return new ShopUpgradeSnapshot( + SCHEMA_VERSION, + legacyLevels( + healthLevel, + damageLevel, + speedLevel, + cooldownLevel, + regenLevel, + critChanceLevel, + critMultiplierLevel)); + } + + public static ShopUpgradeSnapshot ofLevels(int[] levels) { + return new ShopUpgradeSnapshot(SCHEMA_VERSION, levels); + } + + public static ShopUpgradeSnapshot versioned( + int schemaVersion, + int healthLevel, + int damageLevel, + int speedLevel, + int cooldownLevel, + int regenLevel, + int critChanceLevel, + int critMultiplierLevel) { + return new ShopUpgradeSnapshot( + schemaVersion, + legacyLevels( + healthLevel, + damageLevel, + speedLevel, + cooldownLevel, + regenLevel, + critChanceLevel, + critMultiplierLevel)); + } + + public int schemaVersion() { + return schemaVersion; + } + + public int level(ShopUpgradeKind kind) { + int index = kind.ordinal(); + return index < levels.length ? levels[index] : 0; + } + + public int[] levels() { + return copyLevels(levels); + } + + public boolean hasNegativeLevel() { + for (int level : levels) { + if (level < 0) return true; + } + return false; + } + + public int getHealthLevel() { + return level(ShopUpgradeKind.HEALTH); + } + + public int getDamageLevel() { + return level(ShopUpgradeKind.DAMAGE); + } + + public int getSpeedLevel() { + return level(ShopUpgradeKind.SPEED); + } + + public int getCooldownLevel() { + return level(ShopUpgradeKind.COOLDOWN); + } + + public int getRegenLevel() { + return level(ShopUpgradeKind.REGEN); + } + + public int getCritChanceLevel() { + return level(ShopUpgradeKind.CRIT_CHANCE); + } + + public int getCritMultiplierLevel() { + return level(ShopUpgradeKind.CRIT_MULTIPLIER); + } + + private static int[] legacyLevels( + int healthLevel, + int damageLevel, + int speedLevel, + int cooldownLevel, + int regenLevel, + int critChanceLevel, + int critMultiplierLevel) { + int[] levels = new int[KINDS.length]; + levels[ShopUpgradeKind.HEALTH.ordinal()] = healthLevel; + levels[ShopUpgradeKind.DAMAGE.ordinal()] = damageLevel; + levels[ShopUpgradeKind.SPEED.ordinal()] = speedLevel; + levels[ShopUpgradeKind.COOLDOWN.ordinal()] = cooldownLevel; + levels[ShopUpgradeKind.REGEN.ordinal()] = regenLevel; + levels[ShopUpgradeKind.CRIT_CHANCE.ordinal()] = critChanceLevel; + levels[ShopUpgradeKind.CRIT_MULTIPLIER.ordinal()] = critMultiplierLevel; + return levels; + } + + private static int[] copyLevels(int[] source) { + int[] copy = new int[KINDS.length]; + if (source != null) { + System.arraycopy(source, 0, copy, 0, Math.min(source.length, copy.length)); + } + return copy; + } +} diff --git a/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeSystem.java b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeSystem.java new file mode 100644 index 0000000..9b0deff --- /dev/null +++ b/core/src/main/java/ru/project/tower/upgrades/shop/ShopUpgradeSystem.java @@ -0,0 +1,261 @@ +package ru.project.tower.upgrades.shop; + +import java.util.Objects; +import ru.project.tower.balance.EndlessBalanceSpec; + +public final class ShopUpgradeSystem { + + public enum PurchaseResult { + PURCHASED, + INSUFFICIENT_FUNDS, + MAX_LEVEL + } + + private static final int BASE_COST = 5; + private static final int HEALTH_GAIN = 20; + private static final float MIN_SHOT_COOLDOWN = 100f; + private static final ShopUpgradeKind[] UPGRADE_KINDS = ShopUpgradeKind.values(); + + private final ShopUpgradeHost host; + private final int[] levels = new int[UPGRADE_KINDS.length]; + + public ShopUpgradeSystem(ShopUpgradeHost host) { + this.host = Objects.requireNonNull(host, "host"); + } + + public int level(ShopUpgradeKind kind) { + return levels[index(kind)]; + } + + public int cost(ShopUpgradeKind kind) { + return EndlessBalanceSpec.shopCost(kind, level(kind)); + } + + public int damageBonus() { + return EndlessBalanceSpec.shopDamageBonusAtLevel(level(ShopUpgradeKind.DAMAGE)); + } + + public float speedBonus() { + return EndlessBalanceSpec.shopSpeedBonusAtLevel(level(ShopUpgradeKind.SPEED)); + } + + public float cooldownReduction() { + return EndlessBalanceSpec.shopCooldownReductionAtLevel(level(ShopUpgradeKind.COOLDOWN)); + } + + public int regenBonus() { + return EndlessBalanceSpec.shopRegenBonusAtLevel(level(ShopUpgradeKind.REGEN)); + } + + public float critChanceBonus() { + return EndlessBalanceSpec.shopCritChanceBonusAtLevel(level(ShopUpgradeKind.CRIT_CHANCE)); + } + + public float critMultiplierBonus() { + return EndlessBalanceSpec.shopCritMultiplierBonusAtLevel( + level(ShopUpgradeKind.CRIT_MULTIPLIER)); + } + + public int pierceBonus() { + return EndlessBalanceSpec.shopPierceBonusAtLevel(level(ShopUpgradeKind.PIERCE)); + } + + public float aoeRadiusBonus() { + return EndlessBalanceSpec.shopAoeRadiusBonusAtLevel(level(ShopUpgradeKind.SPLASH_DAMAGE)); + } + + public float executeDamageBonus() { + return EndlessBalanceSpec.shopExecuteDamageBonusAtLevel( + level(ShopUpgradeKind.EXECUTE_DAMAGE)); + } + + public float bossDamageBonus() { + return EndlessBalanceSpec.shopBossDamageBonusAtLevel(level(ShopUpgradeKind.BOSS_DAMAGE)); + } + + public float controlledDamageBonus() { + return EndlessBalanceSpec.shopControlledDamageBonusAtLevel( + level(ShopUpgradeKind.CONTROL_DAMAGE)); + } + + public float contactArmorReduction() { + return EndlessBalanceSpec.shopContactArmorReductionAtLevel( + level(ShopUpgradeKind.CONTACT_ARMOR)); + } + + public float projectileArmorReduction() { + return EndlessBalanceSpec.shopProjectileArmorReductionAtLevel( + level(ShopUpgradeKind.PROJECTILE_ARMOR)); + } + + public int shieldCapacityBonus() { + return EndlessBalanceSpec.shopShieldCapacityBonusAtLevel( + level(ShopUpgradeKind.SHIELD_CAPACITY)); + } + + public float shieldRechargeReduction() { + return EndlessBalanceSpec.shopShieldRechargeReductionAtLevel( + level(ShopUpgradeKind.SHIELD_RECHARGE)); + } + + public float emergencyBarrierThreshold() { + return EndlessBalanceSpec.shopEmergencyBarrierThresholdAtLevel( + level(ShopUpgradeKind.EMERGENCY_BARRIER)); + } + + public int waveRecoveryHeal() { + return EndlessBalanceSpec.shopWaveRecoveryHealAtLevel(level(ShopUpgradeKind.WAVE_RECOVERY)); + } + + public float safeMobilityReduction() { + return EndlessBalanceSpec.shopSafeMobilityReductionAtLevel( + level(ShopUpgradeKind.SAFE_MOBILITY)); + } + + public float slowAuraStrength() { + return EndlessBalanceSpec.shopSlowAuraStrengthAtLevel(level(ShopUpgradeKind.SLOW_AURA)); + } + + public float pickupRadiusBonus() { + return EndlessBalanceSpec.shopPickupRadiusBonusAtLevel( + level(ShopUpgradeKind.PICKUP_RADIUS)); + } + + public int bonusDurationBonus() { + return EndlessBalanceSpec.shopBonusDurationBonusAtLevel( + level(ShopUpgradeKind.BONUS_DURATION)); + } + + public float bonusPowerBonus() { + return EndlessBalanceSpec.shopBonusPowerBonusAtLevel(level(ShopUpgradeKind.BONUS_POWER)); + } + + public float bonusStabilityBonus() { + return EndlessBalanceSpec.shopBonusStabilityBonusAtLevel( + level(ShopUpgradeKind.BONUS_STABILITY)); + } + + public int bonusPityBonus() { + return EndlessBalanceSpec.shopBonusPityBonusAtLevel(level(ShopUpgradeKind.BONUS_PITY)); + } + + public float bonusFilterChance() { + return EndlessBalanceSpec.shopBonusFilterChanceAtLevel(level(ShopUpgradeKind.BONUS_FILTER)); + } + + public float coinIncomeBonus() { + return EndlessBalanceSpec.shopCoinIncomeBonusAtLevel(level(ShopUpgradeKind.COIN_INCOME)); + } + + public float shopDiscount() { + return EndlessBalanceSpec.shopDiscountAtLevel(level(ShopUpgradeKind.SHOP_DISCOUNT)); + } + + public float cardRerollDiscount() { + return EndlessBalanceSpec.shopCardRerollDiscountAtLevel(level(ShopUpgradeKind.CARD_REROLL)); + } + + public float synergyCatalystBonus() { + return EndlessBalanceSpec.shopSynergyCatalystBonusAtLevel( + level(ShopUpgradeKind.SYNERGY_CATALYST)); + } + + public PurchaseResult tryBuy(ShopUpgradeKind kind) { + if (level(kind) >= ShopUpgradeRegistry.definition(kind).maxLevel()) { + return PurchaseResult.MAX_LEVEL; + } + int cost = cost(kind); + if (!host.spendMoney(cost)) { + return PurchaseResult.INSUFFICIENT_FUNDS; + } + + levels[index(kind)]++; + applyPurchasedEffect(kind); + return PurchaseResult.PURCHASED; + } + + public void reset() { + for (int i = 0; i < levels.length; i++) { + levels[i] = 0; + } + } + + public ShopUpgradeSnapshot snapshot() { + return ShopUpgradeSnapshot.ofLevels(levels); + } + + public boolean apply(ShopUpgradeSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + if (snapshot.schemaVersion() != ShopUpgradeSnapshot.SCHEMA_VERSION + || snapshot.hasNegativeLevel()) { + return false; + } + + for (ShopUpgradeKind kind : UPGRADE_KINDS) { + levels[index(kind)] = snapshot.level(kind); + } + return true; + } + + private void applyPurchasedEffect(ShopUpgradeKind kind) { + switch (kind) { + case HEALTH: + host.addMaxHealth(HEALTH_GAIN); + host.healToFull(); + break; + case DAMAGE: + host.addBaseDamage( + EndlessBalanceSpec.shopDamageBonusAtLevel(level(kind)) + - EndlessBalanceSpec.shopDamageBonusAtLevel(level(kind) - 1)); + break; + case SPEED: + host.addBaseSpeed( + EndlessBalanceSpec.shopSpeedBonusAtLevel(level(kind)) + - EndlessBalanceSpec.shopSpeedBonusAtLevel(level(kind) - 1)); + break; + case COOLDOWN: + host.reduceShotCooldown( + EndlessBalanceSpec.shopCooldownReductionAtLevel(level(kind)) + - EndlessBalanceSpec.shopCooldownReductionAtLevel(level(kind) - 1), + MIN_SHOT_COOLDOWN); + break; + case REGEN: + host.addHealthRegen( + EndlessBalanceSpec.shopRegenBonusAtLevel(level(kind)) + - EndlessBalanceSpec.shopRegenBonusAtLevel(level(kind) - 1)); + break; + case CRIT_CHANCE: + case CRIT_MULTIPLIER: + case PIERCE: + case SPLASH_DAMAGE: + case EXECUTE_DAMAGE: + case BOSS_DAMAGE: + case CONTROL_DAMAGE: + case CONTACT_ARMOR: + case PROJECTILE_ARMOR: + case SHIELD_CAPACITY: + case SHIELD_RECHARGE: + case EMERGENCY_BARRIER: + case WAVE_RECOVERY: + case SAFE_MOBILITY: + case SLOW_AURA: + case PICKUP_RADIUS: + case BONUS_DURATION: + case BONUS_POWER: + case BONUS_STABILITY: + case BONUS_PITY: + case BONUS_FILTER: + case COIN_INCOME: + case SHOP_DISCOUNT: + case CARD_REROLL: + case SYNERGY_CATALYST: + break; + default: + throw new IllegalArgumentException("Unknown shop upgrade kind: " + kind); + } + } + + private static int index(ShopUpgradeKind kind) { + return Objects.requireNonNull(kind, "kind").ordinal(); + } +} diff --git a/core/src/main/java/ru/project/tower/waves/BossSpawnController.java b/core/src/main/java/ru/project/tower/waves/BossSpawnController.java new file mode 100644 index 0000000..659650f --- /dev/null +++ b/core/src/main/java/ru/project/tower/waves/BossSpawnController.java @@ -0,0 +1,184 @@ +package ru.project.tower.waves; + +import com.badlogic.gdx.graphics.Color; +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.MathUtils; +import com.badlogic.gdx.math.Rectangle; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.utils.Array; +import ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.content.BossContentRegistry; +import ru.project.tower.content.BossContentRegistry.BossSpec; +import ru.project.tower.entities.bullets.BulletSystem; +import ru.project.tower.entities.circles.BaseCircleData; +import ru.project.tower.entities.circles.BasicCircleData; +import ru.project.tower.entities.circles.bosses.GlacialWardenData; +import ru.project.tower.entities.circles.bosses.IllusionMasterData; +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; +import ru.project.tower.support.RandomSource; +import ru.project.tower.support.ViewportSize; +import ru.project.tower.systems.BossProjectileFactory; + +public final class BossSpawnController { + static final int BOSS_WAVE_INTERVAL = 20; + static final float BOSS_HEALTH_SCALE_BASE = 1.3f; + static final float BOSS_SPEED_SCALE_BASE = 1.1f; + static final float BOSS_WORLD_SPAWN_RADIUS = 420f; + + private static final float QUEEN_MINION_DISTANCE = 100f; + + private final Array circles; + private final Array spawnedBosses; + private final BulletSystem bulletSystem; + private final Rectangle square; + private final RandomSource random; + private final BossEffectsHost bossHost; + private final Vector2 bossSpawnPosition = new Vector2(); + + private float gameScale = 1f; + private int currentWave = 0; + + public BossSpawnController( + Array circles, + Array spawnedBosses, + BulletSystem bulletSystem, + Rectangle square, + RandomSource random, + ViewportSize viewport, + BossEffectsHost bossHost) { + this.circles = circles; + this.spawnedBosses = spawnedBosses; + this.bulletSystem = bulletSystem; + this.square = square; + this.random = random; + this.bossHost = bossHost; + } + + public void setRenderScale(float gameScale) { + this.gameScale = gameScale; + } + + public void setCurrentWave(int currentWave) { + this.currentWave = currentWave; + } + + static String[] allBossesForTests() { + return BossContentRegistry.ids(); + } + + public BaseCircleData spawnRandomBoss() { + String bossType = selectUnspawnedBossType(); + float angle = random.angle(); + Vector2 spawnPosition = calculateBossSpawnPositionInto(angle, bossSpawnPosition); + + float bossScaleLevel = (float) currentWave / BOSS_WAVE_INTERVAL; + float healthScale = (float) Math.pow(BOSS_HEALTH_SCALE_BASE, bossScaleLevel - 1); + float speedScale = (float) Math.pow(BOSS_SPEED_SCALE_BASE, bossScaleLevel - 1); + + BaseCircleData boss = + createBoss(bossType, spawnPosition.x, spawnPosition.y, angle, speedScale); + if (boss == null) { + return null; + } + + boss.health = + Math.max( + (int) (boss.health * healthScale), + EndlessBalanceSpec.bossHealthForWave(bossType, currentWave)); + circles.add(boss); + return boss; + } + + private Vector2 calculateBossSpawnPositionInto(float angle, Vector2 out) { + float squareCenterX = square.x + square.width / 2f; + float squareCenterY = square.y + square.height / 2f; + return out.set( + squareCenterX + MathUtils.cos(angle) * BOSS_WORLD_SPAWN_RADIUS, + squareCenterY + MathUtils.sin(angle) * BOSS_WORLD_SPAWN_RADIUS); + } + + private BaseCircleData createBoss( + String bossType, float spawnX, float spawnY, float angle, float speedScale) { + BossSpec boss = BossContentRegistry.findById(bossType); + if (boss == null) { + return null; + } + + Circle bossCircle = new Circle(spawnX, spawnY, boss.radius() * gameScale); + Vector2 velocity = createBossVelocity(angle, boss.speed(), speedScale); + return boss.create(bossCircle, velocity, bossHost); + } + + private String selectUnspawnedBossType() { + BossSpec[] bosses = BossContentRegistry.all(); + if (spawnedBosses.size >= bosses.length) { + spawnedBosses.clear(); + } + + String bossType; + do { + int index = random.nextIntInclusive(bosses.length - 1); + bossType = bosses[index].id(); + } while (spawnedBosses.contains(bossType, false)); + + spawnedBosses.add(bossType); + return bossType; + } + + private Vector2 createBossVelocity(float angle, float baseSpeed, float speedScale) { + return new Vector2( + -MathUtils.cos(angle) * baseSpeed * gameScale * speedScale, + -MathUtils.sin(angle) * baseSpeed * gameScale * speedScale); + } + + public void spawnQueenMinion(final SwarmQueenData queen) { + float angle = random.range(0f, 360f); + float x = queen.getX() + QUEEN_MINION_DISTANCE * MathUtils.cosDeg(angle); + float y = queen.getY() + QUEEN_MINION_DISTANCE * MathUtils.sinDeg(angle); + + Circle circle = new Circle(x, y, SpawnController.CIRCLE_RADIUS); + Vector2 velocity = new Vector2(); + BasicCircleData minion = + new BasicCircleData(circle, velocity, 2, 1) { + private final float[] scratchColor = new float[4]; + + @Override + public float[] getColor() { + Color color = queen.getMinionColor(); + scratchColor[0] = color.r; + scratchColor[1] = color.g; + scratchColor[2] = color.b; + scratchColor[3] = color.a; + return scratchColor; + } + }; + minion.markNoRewardSpawn(); + circles.add(minion); + } + + public void spawnQueenProjectiles(SwarmQueenData queen) { + BossProjectileFactory.spawnQueenProjectiles(queen, bulletSystem); + } + + public void spawnShadowWeaverProjectile(ShadowWeaverData weaver) { + float targetX = square.x + square.width / 2f; + float targetY = square.y + square.height / 2f; + BossProjectileFactory.spawnShadowWeaverProjectile(weaver, bulletSystem, targetX, targetY); + } + + public void spawnVolatileReactorMissiles(VolatileReactorData reactor) { + BossProjectileFactory.spawnVolatileReactorMissiles(reactor, bulletSystem, random::angle); + } + + public void spawnGlacialWardenShards(GlacialWardenData warden) { + BossProjectileFactory.spawnGlacialWardenShards(warden, random::angle); + } + + public void spawnIllusionMasterProjectiles(IllusionMasterData master) { + BossProjectileFactory.spawnIllusionMasterProjectiles( + master, bulletSystem, random::angle, gameScale); + } +} diff --git a/core/src/main/java/ru/project/tower/waves/SpawnController.java b/core/src/main/java/ru/project/tower/waves/SpawnController.java new file mode 100644 index 0000000..fa26145 --- /dev/null +++ b/core/src/main/java/ru/project/tower/waves/SpawnController.java @@ -0,0 +1,639 @@ +package ru.project.tower.waves; + +import com.badlogic.gdx.math.MathUtils; +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 ru.project.tower.balance.EndlessBalanceSpec; +import ru.project.tower.balance.EnemyComposition; +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.entities.circles.DasherCircleData; +import ru.project.tower.entities.circles.FastCircleData; +import ru.project.tower.entities.circles.HealerCircleData; +import ru.project.tower.entities.circles.SniperCircleData; +import ru.project.tower.entities.circles.SpawnerCircleData; +import ru.project.tower.entities.circles.SplitterCircleData; +import ru.project.tower.entities.circles.StrongCircleData; +import ru.project.tower.support.RandomSource; +import ru.project.tower.support.ViewportSize; + +public final class SpawnController { + + public static final float CIRCLE_RADIUS = 10f; + static final int BASIC_CIRCLE_HEALTH = 1; + public static final float BASIC_CIRCLE_SPEED = 150f; + + public static final float STRONG_CIRCLE_CHANCE = 0.10f; + static final int STRONG_CIRCLE_HEALTH = 3; + public static final float STRONG_CIRCLE_SPEED = 100f; + + public static final int FAST_CIRCLE_HEALTH = 1; + static final int FAST_START_WAVE = 3; + public static final float FAST_CIRCLE_SPEED = 250f; + public static final float FAST_CIRCLE_RADIUS = 5f; + + static final int SPLITTER_CIRCLE_HEALTH = 2; + static final int SPLITTER_START_WAVE = 7; + public static final float SPLITTER_CIRCLE_SPEED = 120f; + public static final float SPLITTER_SHARD_SPEED = 200f; + + static final float SPAWNER_CIRCLE_CHANCE = 0.15f; + public static final int SPAWNER_CIRCLE_HEALTH = 10; + static final int SPAWNER_START_WAVE = 15; + static final int SPAWNER_WAVE_PERIOD = 11; + static final float SPAWNER_CIRCLE_SPEED = 70f; + + static final float HEALER_CIRCLE_CHANCE = 0.08f; + static final int HEALER_CIRCLE_HEALTH = 6; + static final int HEALER_START_WAVE = 8; + + static final float DASHER_CIRCLE_CHANCE = 0.10f; + static final int DASHER_CIRCLE_HEALTH = 2; + static final int DASHER_START_WAVE = 6; + + static final float SNIPER_CIRCLE_CHANCE = 0.07f; + static final int SNIPER_CIRCLE_HEALTH = 3; + static final int SNIPER_START_WAVE = 7; + static final int SNIPER_DAMAGE_MULT = 2; + + static final int ELITE_START_WAVE = 5; + static final float ELITE_BASE_CHANCE = 0.05f; + static final float ELITE_MAX_CHANCE = 0.25f; + static final float ELITE_WAVE_STEP = 0.01f; + + static final float BONUS_BALL_TRIGGER_CHANCE = 0.2f; + static final float BONUS_BALL_RADIUS_MULTIPLIER = 1.5f; + static final float BONUS_BALL_BASE_SPEED = 50f; + static final float BONUS_BALL_SPEED_RANGE = 50f; + public static final float WORLD_SPAWN_RADIUS = 260f; + private static final BonusBallData.BonusType[] BONUS_TYPES = BonusBallData.BonusType.values(); + + private final Rectangle square; + private final Array circles; + private final Array bonusBalls; + private final RandomSource random; + private final RandomSource bonusRandom; + private final Vector2 spawnEdgePosition = new Vector2(); + private final Vector2 spawnVelocity = new Vector2(); + + private int currentWave = 0; + private float currentStrongCircleChance = STRONG_CIRCLE_CHANCE; + private float currentFastCircleChance = 0f; + private float currentSplitterCircleChance = 0f; + private int currentBasicCircleHealth = BASIC_CIRCLE_HEALTH; + private int currentStrongCircleHealth = STRONG_CIRCLE_HEALTH; + private int currentCircleDamage = 1; + private float currentEnemySpeedMultiplier = 1.0f; + private int bonusDropsThisWave = 0; + private int spawnAttemptsThisWave = 0; + + private float gameScale = 1.0f; + private float adaptedCircleRadius = 10f; + + public SpawnController( + Rectangle square, + Array circles, + Array bonusBalls, + RandomSource random, + ViewportSize viewport) { + this(square, circles, bonusBalls, random, random, viewport); + } + + public SpawnController( + Rectangle square, + Array circles, + Array bonusBalls, + RandomSource random, + RandomSource bonusRandom, + ViewportSize viewport) { + this.square = square; + this.circles = circles; + this.bonusBalls = bonusBalls; + this.random = random; + this.bonusRandom = bonusRandom; + } + + public void setWaveTuning( + int currentWave, + float strongChance, + float fastChance, + float splitterChance, + int basicHealth, + int strongHealth, + int damage, + float enemySpeedMultiplier) { + this.currentWave = currentWave; + this.currentStrongCircleChance = strongChance; + this.currentFastCircleChance = fastChance; + this.currentSplitterCircleChance = splitterChance; + this.currentBasicCircleHealth = basicHealth; + this.currentStrongCircleHealth = strongHealth; + this.currentCircleDamage = damage; + this.currentEnemySpeedMultiplier = enemySpeedMultiplier; + this.bonusDropsThisWave = 0; + this.spawnAttemptsThisWave = 0; + } + + public void setRenderScale(float gameScale, float adaptedCircleRadius) { + this.gameScale = gameScale; + this.adaptedCircleRadius = adaptedCircleRadius; + } + + public BaseCircleData spawnWaveCircle() { + EnemyArchetype archetype = selectArchetype(); + + float radius = archetype.radius(this); + + float squareCenterX = square.x + square.width / 2f; + float squareCenterY = square.y + square.height / 2f; + Vector2 spawnPosition = calculateSpawnEdgePositionInto(radius, spawnEdgePosition); + float x = spawnPosition.x; + float y = spawnPosition.y; + + float speed = archetype.speed(this); + Vector2 velocity = + calculateVelocityTowardInto( + x, y, squareCenterX, squareCenterY, speed, spawnVelocity); + BaseCircleData spawned = archetype.create(this, x, y, radius, velocity.x, velocity.y); + + if (archetype.isEliteEligible() && currentWave >= ELITE_START_WAVE) { + float eliteChance = + Math.min( + ELITE_MAX_CHANCE, + ELITE_BASE_CHANCE + (currentWave - ELITE_START_WAVE) * ELITE_WAVE_STEP); + if (random.nextFloat() < eliteChance) { + spawned.makeElite(); + } + } + + circles.add(spawned); + spawnAttemptsThisWave++; + + float dropChance = + Math.min( + BONUS_BALL_TRIGGER_CHANCE, + EndlessBalanceSpec.bonusDropBudgetForWave(currentWave) + / (float) Math.max(8, currentWave + 20)); + float bonusRoll = bonusRandom.nextFloat(); + if (canSpawnBonusBall() && (bonusRoll < dropChance || shouldPitySpawnBonusBall())) { + spawnBonusBall(); + bonusDropsThisWave++; + } + + return spawned; + } + + private boolean canSpawnBonusBall() { + return bonusDropsThisWave < EndlessBalanceSpec.bonusDropBudgetForWave(currentWave) + && bonusBalls.size < EndlessBalanceSpec.maxVisibleBonusBalls(); + } + + private boolean shouldPitySpawnBonusBall() { + int budget = EndlessBalanceSpec.bonusDropBudgetForWave(currentWave); + if (budget <= 0 || bonusDropsThisWave >= budget) { + return false; + } + float expectedSpawns = + Math.max(1f, EndlessBalanceSpec.waveTuning(currentWave).enemiesPerWave()); + int nextPityAttempt = + Math.max(1, Math.round(expectedSpawns * (bonusDropsThisWave + 1) / budget)); + return spawnAttemptsThisWave >= nextPityAttempt; + } + + private EnemyArchetype selectArchetype() { + EnemyComposition composition = EndlessBalanceSpec.compositionForWave(currentWave); + EnemyArchetype archetype = archetypeFor(composition.select(random.nextFloat())); + return archetype.isEligible(this) ? archetype : EnemyArchetype.BASIC; + } + + private static EnemyArchetype archetypeFor(String id) { + switch (id) { + case "STRONG": + return EnemyArchetype.STRONG; + case "FAST": + return EnemyArchetype.FAST; + case "SPLITTER": + return EnemyArchetype.SPLITTER; + case "HEALER": + return EnemyArchetype.HEALER; + case "DASHER": + return EnemyArchetype.DASHER; + case "SNIPER": + return EnemyArchetype.SNIPER; + case "SPAWNER": + return EnemyArchetype.SPAWNER; + case "BASIC": + default: + return EnemyArchetype.BASIC; + } + } + + private Vector2 calculateSpawnEdgePositionInto(float radius, Vector2 out) { + return calculateSpawnEdgePositionInto(random, radius, out); + } + + private float spawnBandAngle(int side, float sideCoord) { + switch (side) { + case 0: + return MathUtils.PI / 4f + sideCoord * MathUtils.PI / 2f; + case 1: + return -MathUtils.PI / 4f + sideCoord * MathUtils.PI / 2f; + case 2: + return MathUtils.PI + MathUtils.PI / 4f + sideCoord * MathUtils.PI / 2f; + case 3: + return MathUtils.PI * 3f / 4f + sideCoord * MathUtils.PI / 2f; + default: + return 0f; + } + } + + private Vector2 calculateVelocityTowardInto( + float fromX, float fromY, float targetX, float targetY, float speed, Vector2 out) { + float dirX = targetX - fromX; + float dirY = targetY - fromY; + float length = (float) Math.sqrt(dirX * dirX + dirY * dirY); + if (length > 0f) { + return out.set((dirX / length) * speed, (dirY / length) * speed); + } + return out.set(0f, 0f); + } + + private void spawnBonusBall() { + float radius = adaptedCircleRadius * BONUS_BALL_RADIUS_MULTIPLIER; + + Vector2 spawnPosition = + calculateSpawnEdgePositionInto(bonusRandom, radius, spawnEdgePosition); + float x = spawnPosition.x; + float y = spawnPosition.y; + float baseSpeed = + (BONUS_BALL_BASE_SPEED + bonusRandom.range(0f, BONUS_BALL_SPEED_RANGE)) * gameScale; + + float centerX = square.x + square.width / 2f; + float centerY = square.y + square.height / 2f; + Vector2 velocity = + calculateVelocityTowardInto(x, y, centerX, centerY, baseSpeed, spawnVelocity); + + BonusBallData.BonusType type = + BONUS_TYPES[bonusRandom.nextIntInclusive(BONUS_TYPES.length - 1)]; + BonusBallData ball = Pools.obtain(BonusBallData.class); + ball.configure(x, y, radius, velocity.x, velocity.y, type); + bonusBalls.add(ball); + } + + private Vector2 calculateSpawnEdgePositionInto(RandomSource source, float radius, Vector2 out) { + int side = source.nextIntInclusive(3); + float sideCoord = source.nextFloat(); + + float angle = spawnBandAngle(side, sideCoord); + float spawnDistance = WORLD_SPAWN_RADIUS + radius; + float squareCenterX = square.x + square.width / 2f; + float squareCenterY = square.y + square.height / 2f; + return out.set( + squareCenterX + MathUtils.cos(angle) * spawnDistance, + squareCenterY + MathUtils.sin(angle) * spawnDistance); + } + + public void processSpawnerChildren() { + int initialSize = circles.size; + for (int i = 0; i < initialSize; i++) { + BaseCircleData circleData = circles.get(i); + if (!(circleData instanceof SpawnerCircleData)) continue; + + SpawnerCircleData spawner = (SpawnerCircleData) circleData; + if (!spawner.shouldSpawnCircle()) continue; + + circles.add(createSpawnerChild(circleData)); + } + } + + private BasicCircleData createSpawnerChild(BaseCircleData circleData) { + float angle = random.angle(); + float dirX = MathUtils.cos(angle); + float dirY = MathUtils.sin(angle); + + float speed = BASIC_CIRCLE_SPEED * gameScale * currentEnemySpeedMultiplier; + BasicCircleData child = Pools.obtain(BasicCircleData.class); + child.configure( + circleData.circle.x, + circleData.circle.y, + adaptedCircleRadius, + dirX * speed, + dirY * speed, + currentBasicCircleHealth, + currentCircleDamage); + child.markSpawnerChild(); + return child; + } + + private enum EnemyArchetype { + SPAWNER(false) { + @Override + boolean isEligible(SpawnController controller) { + return controller.currentWave >= SPAWNER_START_WAVE + && (controller.currentWave - SPAWNER_START_WAVE) % SPAWNER_WAVE_PERIOD == 0; + } + + @Override + float chance(SpawnController controller) { + return SPAWNER_CIRCLE_CHANCE; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, SPAWNER_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + SpawnerCircleData data = Pools.obtain(SpawnerCircleData.class); + data.configure( + x, y, radius, vx, vy, controller.scaledHealth(SPAWNER_CIRCLE_HEALTH)); + return data; + } + }, + FAST(true) { + @Override + boolean isEligible(SpawnController controller) { + return controller.currentWave >= FAST_START_WAVE; + } + + @Override + float chance(SpawnController controller) { + return controller.currentFastCircleChance; + } + + @Override + float radius(SpawnController controller) { + return FAST_CIRCLE_RADIUS * controller.gameScale; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, FAST_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + FastCircleData data = Pools.obtain(FastCircleData.class); + data.configure( + x, y, radius, vx, vy, FAST_CIRCLE_HEALTH, controller.currentCircleDamage); + return data; + } + }, + SPLITTER(true) { + @Override + boolean isEligible(SpawnController controller) { + return controller.currentWave >= SPLITTER_START_WAVE; + } + + @Override + float chance(SpawnController controller) { + return controller.currentSplitterCircleChance; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, SPLITTER_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + SplitterCircleData data = Pools.obtain(SplitterCircleData.class); + data.configure( + x, + y, + radius, + vx, + vy, + controller.scaledHealth(SPLITTER_CIRCLE_HEALTH), + controller.currentCircleDamage, + false); + return data; + } + }, + HEALER(true) { + @Override + boolean isEligible(SpawnController controller) { + return controller.currentWave >= HEALER_START_WAVE; + } + + @Override + float chance(SpawnController controller) { + return HEALER_CIRCLE_CHANCE; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, BASIC_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + HealerCircleData data = Pools.obtain(HealerCircleData.class); + data.configure( + x, + y, + radius, + vx, + vy, + controller.scaledHealth(HEALER_CIRCLE_HEALTH), + controller.currentCircleDamage); + return data; + } + }, + DASHER(true) { + @Override + boolean isEligible(SpawnController controller) { + return controller.currentWave >= DASHER_START_WAVE; + } + + @Override + float chance(SpawnController controller) { + return DASHER_CIRCLE_CHANCE; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, BASIC_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + DasherCircleData data = Pools.obtain(DasherCircleData.class); + data.configure( + x, + y, + radius, + vx, + vy, + controller.scaledHealth(DASHER_CIRCLE_HEALTH), + controller.currentCircleDamage); + return data; + } + }, + SNIPER(true) { + @Override + boolean isEligible(SpawnController controller) { + return controller.currentWave >= SNIPER_START_WAVE; + } + + @Override + float chance(SpawnController controller) { + return SNIPER_CIRCLE_CHANCE; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, BASIC_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + SniperCircleData data = Pools.obtain(SniperCircleData.class); + data.configure( + x, + y, + radius, + vx, + vy, + controller.scaledHealth(SNIPER_CIRCLE_HEALTH), + controller.currentCircleDamage * SNIPER_DAMAGE_MULT); + return data; + } + }, + STRONG(true) { + @Override + float chance(SpawnController controller) { + return controller.currentStrongCircleChance; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, STRONG_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + StrongCircleData data = Pools.obtain(StrongCircleData.class); + data.configure( + x, + y, + radius, + vx, + vy, + controller.currentStrongCircleHealth, + controller.currentCircleDamage); + return data; + } + }, + BASIC(true) { + @Override + float chance(SpawnController controller) { + return 1f; + } + + @Override + float speed(SpawnController controller) { + return scaledSpeed(controller, BASIC_CIRCLE_SPEED); + } + + @Override + BaseCircleData create( + SpawnController controller, + float x, + float y, + float radius, + float vx, + float vy) { + BasicCircleData data = Pools.obtain(BasicCircleData.class); + data.configure( + x, + y, + radius, + vx, + vy, + controller.currentBasicCircleHealth, + controller.currentCircleDamage); + return data; + } + }; + + private final boolean eliteEligible; + + EnemyArchetype(boolean eliteEligible) { + this.eliteEligible = eliteEligible; + } + + boolean isEligible(SpawnController controller) { + return true; + } + + boolean isEliteEligible() { + return eliteEligible; + } + + float radius(SpawnController controller) { + return controller.adaptedCircleRadius; + } + + abstract float chance(SpawnController controller); + + abstract float speed(SpawnController controller); + + abstract BaseCircleData create( + SpawnController controller, float x, float y, float radius, float vx, float vy); + + static float scaledSpeed(SpawnController controller, float baseSpeed) { + return baseSpeed * controller.gameScale * controller.currentEnemySpeedMultiplier; + } + } + + private int scaledHealth(int baseHealth) { + return Math.max(baseHealth, baseHealth * currentBasicCircleHealth); + } +} diff --git a/core/src/main/java/ru/project/tower/waves/WaveController.java b/core/src/main/java/ru/project/tower/waves/WaveController.java new file mode 100644 index 0000000..781e522 --- /dev/null +++ b/core/src/main/java/ru/project/tower/waves/WaveController.java @@ -0,0 +1,267 @@ +package ru.project.tower.waves; + +import ru.project.tower.balance.EndlessBalanceSpec; + + + + + + + + + + + +public class WaveController { + + public static final int BOSS_WAVE_INTERVAL = 20; + public static final int SPLITTER_START_WAVE = 7; + public static final int BASIC_CIRCLE_HEALTH = 1; + public static final int STRONG_CIRCLE_HEALTH = 3; + public static final float STRONG_CIRCLE_CHANCE = 0.10f; + public static final float FAST_CIRCLE_CHANCE = 0.15f; + public static final float INITIAL_SPLITTER_CHANCE = 0.05f; + public static final float MAX_SPLITTER_CHANCE = 0.15f; + + private int currentWave = 0; + private boolean isWaveActive = false; + private long waveStartTime = 0L; + private long enemiesSpawnedThisWave = 0L; + private long enemiesPerWave = 0L; + private long lastSpawnTime = 0L; + private float spawnInterval = 0f; + + private int currentBasicCircleHealth = BASIC_CIRCLE_HEALTH; + private int currentStrongCircleHealth = STRONG_CIRCLE_HEALTH; + private int currentCircleDamage = 1; + private float currentEnemySpeedMultiplier = 1.0f; + private float currentStrongCircleChance = STRONG_CIRCLE_CHANCE; + private float currentFastCircleChance = 0f; + private float currentSplitterCircleChance = 0f; + + private int baseWaveClearBonus = 10; + private long timeBetweenWaves = 5000L; + + + + + + public void startNextWave(long nowMs) { + currentWave++; + isWaveActive = true; + waveStartTime = nowMs; + enemiesSpawnedThisWave = 0L; + + WaveTuning tuning = EndlessBalanceSpec.waveTuning(currentWave); + enemiesPerWave = tuning.enemiesPerWave(); + spawnInterval = (float) Math.max(200, 1000 / Math.pow(1.05, currentWave - 1)); + applyCurrentWaveScaling(); + + lastSpawnTime = nowMs; + } + + + + + + + public WaveTuning currentWaveTuning() { + if (currentWave > 0) { + return tuningForWave(currentWave, enemiesPerWave); + } + + return new WaveTuning( + currentWave, + enemiesPerWave, + currentStrongCircleChance, + currentFastCircleChance, + currentSplitterCircleChance, + currentBasicCircleHealth, + currentStrongCircleHealth, + currentCircleDamage, + currentEnemySpeedMultiplier); + } + + + + + + public WaveClearReward endWave(long nowMs) { + isWaveActive = false; + waveStartTime = nowMs; + + int moneyBonus = (int) (baseWaveClearBonus * Math.pow(1.15, currentWave - 1)); + int currencyReward = (currentWave % 5 == 0) ? 15 : 5; + boolean shouldShowUpgrade = currentWave > 0 && currentWave % 3 == 0; + + return new WaveClearReward(currentWave, moneyBonus, currencyReward, shouldShowUpgrade); + } + + + public void noteEnemySpawned(long nowMs) { + enemiesSpawnedThisWave++; + lastSpawnTime = nowMs; + } + + + public void resetForNewRun(long nowMs) { + currentWave = 0; + isWaveActive = false; + waveStartTime = nowMs; + enemiesSpawnedThisWave = 0L; + enemiesPerWave = 0L; + lastSpawnTime = 0L; + spawnInterval = 0f; + currentBasicCircleHealth = BASIC_CIRCLE_HEALTH; + currentStrongCircleHealth = STRONG_CIRCLE_HEALTH; + currentCircleDamage = 1; + currentEnemySpeedMultiplier = 1.0f; + currentStrongCircleChance = STRONG_CIRCLE_CHANCE; + currentFastCircleChance = 0f; + currentSplitterCircleChance = 0f; + } + + public static boolean isBossWave(int wave) { + return wave % BOSS_WAVE_INTERVAL == 0; + } + + public int getCurrentWave() { + return currentWave; + } + + public boolean isWaveActive() { + return isWaveActive; + } + + public long getWaveStartTime() { + return waveStartTime; + } + + public long getEnemiesSpawnedThisWave() { + return enemiesSpawnedThisWave; + } + + public long getEnemiesPerWave() { + return enemiesPerWave; + } + + public long getLastSpawnTime() { + return lastSpawnTime; + } + + public float getSpawnInterval() { + return spawnInterval; + } + + public int getCurrentBasicCircleHealth() { + return currentBasicCircleHealth; + } + + public int getCurrentStrongCircleHealth() { + return currentStrongCircleHealth; + } + + public int getCurrentCircleDamage() { + return currentCircleDamage; + } + + public float getCurrentEnemySpeedMultiplier() { + return currentEnemySpeedMultiplier; + } + + public float getCurrentStrongCircleChance() { + return currentStrongCircleChance; + } + + public float getCurrentFastCircleChance() { + return currentFastCircleChance; + } + + public float getCurrentSplitterCircleChance() { + return currentSplitterCircleChance; + } + + public int getBaseWaveClearBonus() { + return baseWaveClearBonus; + } + + public void setBaseWaveClearBonus(int baseWaveClearBonus) { + this.baseWaveClearBonus = baseWaveClearBonus; + } + + public long getTimeBetweenWaves() { + return timeBetweenWaves; + } + + public void setTimeBetweenWaves(long timeBetweenWaves) { + this.timeBetweenWaves = timeBetweenWaves; + } + + + public void setWaveStartTime(long waveStartTime) { + this.waveStartTime = waveStartTime; + } + + + + + public void setCurrentWave(int currentWave) { + this.currentWave = currentWave; + } + + public void setWaveActive(boolean waveActive) { + this.isWaveActive = waveActive; + } + + public void setEnemiesSpawnedThisWave(long enemiesSpawnedThisWave) { + this.enemiesSpawnedThisWave = enemiesSpawnedThisWave; + } + + public void setEnemiesPerWave(long enemiesPerWave) { + this.enemiesPerWave = enemiesPerWave; + } + + private void applyCurrentWaveScaling() { + WaveTuning tuning = tuningForWave(currentWave, enemiesPerWave); + currentStrongCircleChance = tuning.strongCircleChance(); + currentFastCircleChance = tuning.fastCircleChance(); + currentSplitterCircleChance = tuning.splitterCircleChance(); + currentBasicCircleHealth = tuning.basicCircleHealth(); + currentStrongCircleHealth = tuning.strongCircleHealth(); + currentCircleDamage = tuning.circleDamage(); + currentEnemySpeedMultiplier = tuning.enemySpeedMultiplier(); + } + + private static WaveTuning tuningForWave(int wave, long enemiesPerWave) { + WaveTuning tuning = EndlessBalanceSpec.waveTuning(wave); + return new WaveTuning( + wave, + enemiesPerWave > 0 ? enemiesPerWave : tuning.enemiesPerWave(), + tuning.strongCircleChance(), + tuning.fastCircleChance(), + tuning.splitterCircleChance(), + tuning.basicCircleHealth(), + tuning.strongCircleHealth(), + tuning.circleDamage(), + tuning.enemySpeedMultiplier()); + } + + + + + + public static final class WaveClearReward { + public final int clearedWave; + public final int moneyBonus; + public final int currencyReward; + public final boolean shouldShowUpgrade; + + public WaveClearReward( + int clearedWave, int moneyBonus, int currencyReward, boolean shouldShowUpgrade) { + this.clearedWave = clearedWave; + this.moneyBonus = moneyBonus; + this.currencyReward = currencyReward; + this.shouldShowUpgrade = shouldShowUpgrade; + } + } +} diff --git a/core/src/main/java/ru/project/tower/waves/WaveRunEvents.java b/core/src/main/java/ru/project/tower/waves/WaveRunEvents.java new file mode 100644 index 0000000..25cbfc2 --- /dev/null +++ b/core/src/main/java/ru/project/tower/waves/WaveRunEvents.java @@ -0,0 +1,22 @@ +package ru.project.tower.waves; + + + + + + +public interface WaveRunEvents { + void waveStarted(int wave); + + void waveTuning(WaveTuning tuning); + + void spawnWaveCircle(); + + void spawnRandomBoss(); + + void waveCleared(WaveController.WaveClearReward reward); + + void triggerUpgradePick(); + + void waveClearFinished(WaveController.WaveClearReward reward); +} diff --git a/core/src/main/java/ru/project/tower/waves/WaveRunOrchestrator.java b/core/src/main/java/ru/project/tower/waves/WaveRunOrchestrator.java new file mode 100644 index 0000000..19f855d --- /dev/null +++ b/core/src/main/java/ru/project/tower/waves/WaveRunOrchestrator.java @@ -0,0 +1,94 @@ +package ru.project.tower.waves; + +import java.util.Objects; + + + + + +public final class WaveRunOrchestrator { + private final WaveController waveController; + private final WaveRunEvents events; + + public WaveRunOrchestrator(WaveController waveController, WaveRunEvents events) { + this.waveController = Objects.requireNonNull(waveController, "waveController"); + this.events = Objects.requireNonNull(events, "events"); + } + + + + + + + + + public void update(long nowMs, int activeEnemyCount, boolean upgradePickActive) { + if (upgradePickActive) { + waveController.setWaveStartTime(nowMs); + return; + } + + if (!waveController.isWaveActive()) { + if (nowMs - waveController.getWaveStartTime() > waveController.getTimeBetweenWaves()) { + startNextWave(nowMs); + } + return; + } + + boolean spawnedEnemy = false; + if (shouldSpawnEnemy(nowMs)) { + events.spawnWaveCircle(); + waveController.noteEnemySpawned(nowMs); + spawnedEnemy = true; + } + + if (!spawnedEnemy && shouldClearWave(activeEnemyCount)) { + endWave(nowMs); + } + } + + public void startNextWave(long nowMs) { + waveController.startNextWave(nowMs); + + events.waveStarted(waveController.getCurrentWave()); + events.waveTuning( + new WaveTuning( + waveController.getCurrentWave(), + waveController.getEnemiesPerWave(), + waveController.getCurrentStrongCircleChance(), + waveController.getCurrentFastCircleChance(), + waveController.getCurrentSplitterCircleChance(), + waveController.getCurrentBasicCircleHealth(), + waveController.getCurrentStrongCircleHealth(), + waveController.getCurrentCircleDamage(), + waveController.getCurrentEnemySpeedMultiplier())); + + if (waveController.getCurrentWave() > 0 + && WaveController.isBossWave(waveController.getCurrentWave())) { + events.spawnRandomBoss(); + } + } + + public WaveController.WaveClearReward endWave(long nowMs) { + WaveController.WaveClearReward reward = waveController.endWave(nowMs); + events.waveCleared(reward); + + if (reward.shouldShowUpgrade) { + events.triggerUpgradePick(); + } + + events.waveClearFinished(reward); + + return reward; + } + + private boolean shouldSpawnEnemy(long nowMs) { + return waveController.getEnemiesSpawnedThisWave() < waveController.getEnemiesPerWave() + && nowMs - waveController.getLastSpawnTime() > waveController.getSpawnInterval(); + } + + private boolean shouldClearWave(int activeEnemyCount) { + return waveController.getEnemiesSpawnedThisWave() >= waveController.getEnemiesPerWave() + && activeEnemyCount == 0; + } +} diff --git a/core/src/main/java/ru/project/tower/waves/WaveTuning.java b/core/src/main/java/ru/project/tower/waves/WaveTuning.java new file mode 100644 index 0000000..9508c07 --- /dev/null +++ b/core/src/main/java/ru/project/tower/waves/WaveTuning.java @@ -0,0 +1,73 @@ +package ru.project.tower.waves; + + + + +public final class WaveTuning { + private final int currentWave; + private final long enemiesPerWave; + private final float strongCircleChance; + private final float fastCircleChance; + private final float splitterCircleChance; + private final int basicCircleHealth; + private final int strongCircleHealth; + private final int circleDamage; + private final float enemySpeedMultiplier; + + public WaveTuning( + int currentWave, + long enemiesPerWave, + float strongCircleChance, + float fastCircleChance, + float splitterCircleChance, + int basicCircleHealth, + int strongCircleHealth, + int circleDamage, + float enemySpeedMultiplier) { + this.currentWave = currentWave; + this.enemiesPerWave = enemiesPerWave; + this.strongCircleChance = strongCircleChance; + this.fastCircleChance = fastCircleChance; + this.splitterCircleChance = splitterCircleChance; + this.basicCircleHealth = basicCircleHealth; + this.strongCircleHealth = strongCircleHealth; + this.circleDamage = circleDamage; + this.enemySpeedMultiplier = enemySpeedMultiplier; + } + + public int currentWave() { + return currentWave; + } + + public long enemiesPerWave() { + return enemiesPerWave; + } + + public float strongCircleChance() { + return strongCircleChance; + } + + public float fastCircleChance() { + return fastCircleChance; + } + + public float splitterCircleChance() { + return splitterCircleChance; + } + + public int basicCircleHealth() { + return basicCircleHealth; + } + + public int strongCircleHealth() { + return strongCircleHealth; + } + + public int circleDamage() { + return circleDamage; + } + + public float enemySpeedMultiplier() { + return enemySpeedMultiplier; + } +} diff --git a/core/src/main/resources/default.fnt b/core/src/main/resources/default.fnt new file mode 100644 index 0000000..9cb460f --- /dev/null +++ b/core/src/main/resources/default.fnt @@ -0,0 +1,99 @@ +info face="Arial" size=32 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=1,1 +common lineHeight=37 base=26 scaleW=512 scaleH=512 pages=1 packed=0 +page id=0 file="default.png" +chars count=95 +char id=32 x=0 y=0 width=0 height=0 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=33 x=0 y=0 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=34 x=8 y=0 width=12 height=12 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=35 x=20 y=0 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=36 x=44 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=37 x=64 y=0 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=38 x=96 y=0 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=39 x=124 y=0 width=8 height=12 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=40 x=132 y=0 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=41 x=144 y=0 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=42 x=156 y=0 width=16 height=16 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=43 x=172 y=0 width=20 height=20 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=44 x=192 y=0 width=8 height=12 xoffset=0 yoffset=20 xadvance=8 page=0 chnl=0 +char id=45 x=200 y=0 width=12 height=4 xoffset=0 yoffset=14 xadvance=12 page=0 chnl=0 +char id=46 x=212 y=0 width=8 height=8 xoffset=0 yoffset=24 xadvance=8 page=0 chnl=0 +char id=47 x=220 y=0 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=48 x=236 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=49 x=256 y=0 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=50 x=268 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=51 x=288 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=52 x=308 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=53 x=328 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=54 x=348 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=55 x=368 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=56 x=388 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=57 x=408 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=58 x=428 y=0 width=8 height=24 xoffset=0 yoffset=4 xadvance=8 page=0 chnl=0 +char id=59 x=436 y=0 width=8 height=28 xoffset=0 yoffset=4 xadvance=8 page=0 chnl=0 +char id=60 x=444 y=0 width=20 height=20 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=61 x=464 y=0 width=20 height=12 xoffset=0 yoffset=10 xadvance=20 page=0 chnl=0 +char id=62 x=484 y=0 width=20 height=20 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=63 x=504 y=0 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=64 x=0 y=32 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=65 x=32 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=66 x=56 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=67 x=76 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=68 x=100 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=69 x=124 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=70 x=144 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=71 x=164 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=72 x=188 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=73 x=212 y=32 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=74 x=220 y=32 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=75 x=236 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=76 x=260 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=77 x=280 y=32 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=78 x=308 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=79 x=332 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=80 x=356 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=81 x=376 y=32 width=24 height=36 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=82 x=400 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=83 x=420 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=84 x=440 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=85 x=460 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=86 x=484 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=87 x=0 y=64 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=88 x=32 y=64 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=89 x=56 y=64 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=90 x=80 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=91 x=100 y=64 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=92 x=112 y=64 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=93 x=128 y=64 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=94 x=140 y=64 width=20 height=16 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=95 x=160 y=64 width=20 height=4 xoffset=0 yoffset=28 xadvance=20 page=0 chnl=0 +char id=96 x=180 y=64 width=12 height=12 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=97 x=192 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=98 x=212 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=99 x=232 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=100 x=252 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=101 x=272 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=102 x=292 y=64 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=103 x=308 y=64 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=104 x=328 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=105 x=348 y=64 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=106 x=356 y=64 width=12 height=40 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=107 x=368 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=108 x=388 y=64 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=109 x=396 y=64 width=32 height=24 xoffset=0 yoffset=8 xadvance=32 page=0 chnl=0 +char id=110 x=428 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=111 x=448 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=112 x=468 y=64 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=113 x=488 y=64 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=114 x=0 y=96 width=16 height=24 xoffset=0 yoffset=8 xadvance=16 page=0 chnl=0 +char id=115 x=16 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=116 x=36 y=96 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=117 x=52 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=118 x=72 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=119 x=92 y=96 width=28 height=24 xoffset=0 yoffset=8 xadvance=28 page=0 chnl=0 +char id=120 x=120 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=121 x=140 y=96 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=122 x=160 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=123 x=180 y=96 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=124 x=196 y=96 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=125 x=204 y=96 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=126 x=220 y=96 width=24 height=12 xoffset=0 yoffset=10 xadvance=24 page=0 chnl=0 \ No newline at end of file diff --git a/core/src/main/resources/font.fnt b/core/src/main/resources/font.fnt new file mode 100644 index 0000000..f48b561 --- /dev/null +++ b/core/src/main/resources/font.fnt @@ -0,0 +1,164 @@ +info face="Arial" size=32 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=1,1 +common lineHeight=37 base=26 scaleW=512 scaleH=512 pages=1 packed=0 +page id=0 file="font.png" +chars count=95 +char id=32 x=0 y=0 width=0 height=0 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=33 x=0 y=0 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=34 x=8 y=0 width=12 height=12 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=35 x=20 y=0 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=36 x=44 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=37 x=64 y=0 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=38 x=96 y=0 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=39 x=124 y=0 width=8 height=12 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=40 x=132 y=0 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=41 x=144 y=0 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=42 x=156 y=0 width=16 height=16 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=43 x=172 y=0 width=20 height=20 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=44 x=192 y=0 width=8 height=12 xoffset=0 yoffset=20 xadvance=8 page=0 chnl=0 +char id=45 x=200 y=0 width=12 height=4 xoffset=0 yoffset=14 xadvance=12 page=0 chnl=0 +char id=46 x=212 y=0 width=8 height=8 xoffset=0 yoffset=24 xadvance=8 page=0 chnl=0 +char id=47 x=220 y=0 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=48 x=236 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=49 x=256 y=0 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=50 x=268 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=51 x=288 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=52 x=308 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=53 x=328 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=54 x=348 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=55 x=368 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=56 x=388 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=57 x=408 y=0 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=58 x=428 y=0 width=8 height=24 xoffset=0 yoffset=4 xadvance=8 page=0 chnl=0 +char id=59 x=436 y=0 width=8 height=28 xoffset=0 yoffset=4 xadvance=8 page=0 chnl=0 +char id=60 x=444 y=0 width=20 height=20 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=61 x=464 y=0 width=20 height=12 xoffset=0 yoffset=10 xadvance=20 page=0 chnl=0 +char id=62 x=484 y=0 width=20 height=20 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=63 x=504 y=0 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=64 x=0 y=32 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=65 x=32 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=66 x=56 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=67 x=76 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=68 x=100 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=69 x=124 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=70 x=144 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=71 x=164 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=72 x=188 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=73 x=212 y=32 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=74 x=220 y=32 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=75 x=236 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=76 x=260 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=77 x=280 y=32 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=78 x=308 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=79 x=332 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=80 x=356 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=81 x=376 y=32 width=24 height=36 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=82 x=400 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=83 x=420 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=84 x=440 y=32 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=85 x=460 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=86 x=484 y=32 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=87 x=0 y=64 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=88 x=32 y=64 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=89 x=56 y=64 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=90 x=80 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=91 x=100 y=64 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=92 x=112 y=64 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=93 x=128 y=64 width=12 height=32 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=94 x=140 y=64 width=20 height=16 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=95 x=160 y=64 width=20 height=4 xoffset=0 yoffset=28 xadvance=20 page=0 chnl=0 +char id=96 x=180 y=64 width=12 height=12 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=97 x=192 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=98 x=212 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=99 x=232 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=100 x=252 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=101 x=272 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=102 x=292 y=64 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=103 x=308 y=64 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=104 x=328 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=105 x=348 y=64 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=106 x=356 y=64 width=12 height=40 xoffset=0 yoffset=0 xadvance=12 page=0 chnl=0 +char id=107 x=368 y=64 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=108 x=388 y=64 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=109 x=396 y=64 width=32 height=24 xoffset=0 yoffset=8 xadvance=32 page=0 chnl=0 +char id=110 x=428 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=111 x=448 y=64 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=112 x=468 y=64 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=113 x=488 y=64 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=114 x=0 y=96 width=16 height=24 xoffset=0 yoffset=8 xadvance=16 page=0 chnl=0 +char id=115 x=16 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=116 x=36 y=96 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=117 x=52 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=118 x=72 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=119 x=92 y=96 width=28 height=24 xoffset=0 yoffset=8 xadvance=28 page=0 chnl=0 +char id=120 x=120 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=121 x=140 y=96 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=122 x=160 y=96 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=123 x=180 y=96 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=124 x=196 y=96 width=8 height=32 xoffset=0 yoffset=0 xadvance=8 page=0 chnl=0 +char id=125 x=204 y=96 width=16 height=32 xoffset=0 yoffset=0 xadvance=16 page=0 chnl=0 +char id=126 x=220 y=96 width=24 height=12 xoffset=0 yoffset=10 xadvance=24 page=0 chnl=0 +char id=1040 x=244 y=96 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1041 x=268 y=96 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1042 x=288 y=96 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1043 x=308 y=96 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1044 x=328 y=96 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1045 x=352 y=96 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1046 x=372 y=96 width=32 height=32 xoffset=0 yoffset=0 xadvance=32 page=0 chnl=0 +char id=1047 x=404 y=96 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1048 x=424 y=96 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1049 x=448 y=96 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1050 x=472 y=96 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1051 x=492 y=96 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1052 x=0 y=128 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=1053 x=28 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1054 x=48 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1055 x=72 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1056 x=96 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1057 x=116 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1058 x=140 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1059 x=160 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1060 x=184 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1061 x=208 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1062 x=232 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1063 x=256 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1064 x=276 y=128 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=1065 x=304 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1066 x=328 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1067 x=348 y=128 width=24 height=32 xoffset=0 yoffset=0 xadvance=24 page=0 chnl=0 +char id=1068 x=372 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1069 x=392 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1070 x=412 y=128 width=28 height=32 xoffset=0 yoffset=0 xadvance=28 page=0 chnl=0 +char id=1071 x=440 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1072 x=460 y=128 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1073 x=480 y=128 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1074 x=0 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1075 x=20 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1076 x=40 y=160 width=20 height=32 xoffset=0 yoffset=0 xadvance=20 page=0 chnl=0 +char id=1077 x=60 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1078 x=80 y=160 width=28 height=24 xoffset=0 yoffset=8 xadvance=28 page=0 chnl=0 +char id=1079 x=108 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1080 x=128 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1081 x=148 y=160 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1082 x=168 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1083 x=188 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1084 x=208 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1085 x=228 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1086 x=248 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1087 x=268 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1088 x=288 y=160 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1089 x=308 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1090 x=328 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1091 x=348 y=160 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1092 x=368 y=160 width=28 height=24 xoffset=0 yoffset=8 xadvance=28 page=0 chnl=0 +char id=1093 x=396 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1094 x=416 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1095 x=436 y=160 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1096 x=456 y=160 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1097 x=476 y=160 width=20 height=32 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1098 x=0 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1099 x=20 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1100 x=40 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1101 x=60 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1102 x=80 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1103 x=100 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 +char id=1105 x=120 y=192 width=20 height=24 xoffset=0 yoffset=8 xadvance=20 page=0 chnl=0 \ No newline at end of file diff --git a/core/src/main/resources/uiskin.json b/core/src/main/resources/uiskin.json new file mode 100644 index 0000000..92d5818 --- /dev/null +++ b/core/src/main/resources/uiskin.json @@ -0,0 +1,22 @@ +{ + "com.badlogic.gdx.graphics.g2d.BitmapFont": { + "default-font": { + "file": "default.fnt" + } + }, + "com.badlogic.gdx.graphics.Color": { + "white": { "r": 1, "g": 1, "b": 1, "a": 1 }, + "black": { "r": 0, "g": 0, "b": 0, "a": 1 }, + "red": { "r": 1, "g": 0, "b": 0, "a": 1 }, + "green": { "r": 0, "g": 1, "b": 0, "a": 1 }, + "blue": { "r": 0, "g": 0, "b": 1, "a": 1 } + }, + "com.badlogic.gdx.scenes.scene2d.ui.TextButton$TextButtonStyle": { + "default": { + "font": "default-font", + "fontColor": "white", + "downFontColor": "red", + "overFontColor": "green" + } + } +} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md b/docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md new file mode 100644 index 0000000..b9e2172 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md @@ -0,0 +1,604 @@ +# TowerAn — Phase 3: Content, Meta & Retention + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Note on TDD:** This project has **no test infrastructure** (libGDX game, procedural rendering, no existing JUnit/Spock tests). The "write failing test first" ritual is not applicable. Verification is **visual / manual**: run the desktop launcher (`lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`), reproduce the scenario, confirm the change. Each task lists explicit visual checks in place of test assertions. Introducing a test harness is itself a Phase 3 candidate (Task 0) but is not a prerequisite. + +**Goal:** Close the content and retention gaps identified by the three game-design agents — new enemy archetypes, boss fight depth, environment variety, audio, onboarding, achievements, daily seeds, save/resume, and full-tree build diversity. + +**Architecture:** All enemies extend `BaseCircleData` in the flat `ru.project.tower` package. New systems (achievements, daily seed, settings) piggyback on existing `Preferences`-based persistence alongside `PlayerStats`/`PlayerAbilities`. Onboarding is a `FirstScreen` overlay (same pattern as the mid-run upgrade pick added in Phase 2). Biomes are data-driven palette swaps applied each wave. + +**Tech Stack:** Java 8, libGDX 1.13.1, `ShapeRenderer`/`SpriteBatch`, `Preferences` for persistence, `GlyphLayout` for tooltips. + +**Prerequisites:** Phases 1 and 2 merged. Run the game at least once to confirm Phase 2 overlays render correctly (the Phase 2 session discovered that `renderUI()`/`renderGameObjects()` are not wired — render pipeline is direct-to-`render()`; new renderers must be called explicitly from `render()`). + +--- + +## Task ordering + +Tasks are grouped by area. Within an area they go easiest → hardest. Between areas: + +1. **Enemy archetypes** (pure data classes, mechanical payoff, low risk) +2. **Boss depth** (enrage phase, reuses existing frameworks) +3. **Projectile variants** (ties into existing `BulletData` flags from Phase 1) +4. **Audio pack** (tiny code, requires asset decisions) +5. **Biomes** (procedural palette swap) +6. **Onboarding / tooltips** (touches many screens) +7. **Achievements & unlocks** (new persistent store) +8. **Daily seed & run modifiers** (roguelite hook) +9. **Save/resume mid-run** (serialization work) +10. **Ability mastery** (XP per ability, optional late-tier) + +Each Task group ends with a commit. Skip any group that the user explicitly de-prioritizes. + +--- + +# Area A — Enemy archetypes + +Two complaints from the variety agent: **only 5 enemy archetypes** (Basic/Strong/Fast/Spawner/Splitter), **all travel in a straight line toward center** (no behaviour differences beyond HP/speed/colour). Add 4 behaviourally distinct enemies. Each is a single file + a tiny spawn-chance hook in `FirstScreen.spawnCircle`. + +## Task A1: `HealerCircleData` — AoE healer + +**Files:** +- Create: `core/src/main/java/ru/project/tower/HealerCircleData.java` +- Modify: `core/src/main/java/ru/project/tower/FirstScreen.java` (add spawn branch + constants) + +- [ ] **Step 1: Create `HealerCircleData`** + +```java +package ru.project.tower; + +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.utils.TimeUtils; + +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 = TimeUtils.millis(); + + public HealerCircleData(Circle circle, Vector2 velocity, int health, int damage) { + super(circle, velocity, health, damage); + } + + public boolean shouldPulse() { + if (TimeUtils.millis() - lastHealTime > HEAL_INTERVAL_MS) { + lastHealTime = TimeUtils.millis(); + return true; + } + return false; + } + + public static float getHealRadius() { return HEAL_RADIUS; } + public static int getHealAmount() { return HEAL_AMOUNT; } + + @Override + public float[] getColor() { + return new float[]{0.2f, 1.0f, 0.5f, 1f}; + } + + @Override + protected int getBaseReward() { return 4; } +} +``` + +- [ ] **Step 2: Add spawn constants in `FirstScreen.java`** near the other enemy constants (search for `SPLITTER_CIRCLE_CHANCE`): + +```java +private static final float HEALER_CIRCLE_CHANCE = 0.08f; +private static final int HEALER_CIRCLE_HEALTH = 6; +private static final int HEALER_START_WAVE = 8; +``` + +- [ ] **Step 3: Add spawn branch in `spawnCircle()`** — after the `isSplitterCircle` branch, before `isStrongCircle`: + +```java +boolean isHealerCircle = !isSpawnerCircle && !isFastCircle && !isSplitterCircle + && currentWave >= HEALER_START_WAVE + && random < (currentFastCircleChance + currentSplitterCircleChance + HEALER_CIRCLE_CHANCE); +``` + +Then add a construction branch mirroring the other archetypes and set `spawned = new HealerCircleData(...)`. + +- [ ] **Step 4: Pulse loop in the per-frame circle iterator** (same block that spawns from `SpawnerCircleData`): + +```java +if (circleData instanceof HealerCircleData) { + HealerCircleData healer = (HealerCircleData) circleData; + if (healer.shouldPulse()) { + for (BaseCircleData other : circles) { + if (other == healer) continue; + if (Vector2.dst(healer.getX(), healer.getY(), other.getX(), other.getY()) + <= HealerCircleData.getHealRadius()) { + other.health += HealerCircleData.getHealAmount(); + } + } + createExplosion(healer.getX(), healer.getY(), + new Color(0.2f, 1f, 0.5f, 1f)); + } +} +``` + +- [ ] **Step 5: Visual check** — play until wave 8, spot a green enemy, watch nearby enemies gain HP (visible because `StrongCircleData.getColor` scales alpha with health — strong enemies near a healer should stay bright blue). + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/ru/project/tower/HealerCircleData.java \ + core/src/main/java/ru/project/tower/FirstScreen.java +git commit -m "feat: add HealerCircleData AoE healer enemy" +``` + +--- + +## Task A2: `DasherCircleData` — telegraphed charge + +Uses `JuggernautData`'s charge pattern at a smaller scale for a rank-and-file enemy. + +**Files:** +- Create: `core/src/main/java/ru/project/tower/DasherCircleData.java` +- Modify: `FirstScreen.java` (spawn branch, per-frame state tick, render tint for the "windup" phase) + +- [ ] **Step 1: Create class** — holds a `long dashReadyAt` and `boolean dashing`. At `update()` time: if idle and in range of player, enter windup for 800ms (velocity halved, visually pulses yellow), then lock a direction, multiply velocity ×3 for 400ms, return to normal. + +```java +package ru.project.tower; + +import com.badlogic.gdx.math.Circle; +import com.badlogic.gdx.math.Vector2; +import com.badlogic.gdx.utils.TimeUtils; + +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 enum State { IDLE, WINDUP, DASHING } + private State state = State.IDLE; + private long stateEnter = 0; + private Vector2 baseVelocity; + + public DasherCircleData(Circle circle, Vector2 velocity, int health, int damage) { + super(circle, velocity, health, damage); + this.baseVelocity = new Vector2(velocity); + } + + public boolean isWindup() { return state == State.WINDUP; } + public boolean isDashing() { return state == State.DASHING; } + + public void tick(float squareX, float squareY) { + long now = TimeUtils.millis(); + switch (state) { + case IDLE: + if (Vector2.dst(getX(), getY(), squareX, squareY) < 280f) { + state = State.WINDUP; + stateEnter = now; + velocity.scl(0.3f); + } + break; + case WINDUP: + if (now - stateEnter > WINDUP_MS) { + state = State.DASHING; + stateEnter = now; + 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 (now - stateEnter > DASH_MS) { + state = State.IDLE; + velocity.set(baseVelocity); + } + break; + } + } + + @Override + public float[] getColor() { + switch (state) { + case WINDUP: return new float[]{1f, 0.9f, 0.3f, 1f}; + case DASHING: return new float[]{1f, 0.4f, 0.1f, 1f}; + default: return new float[]{0.9f, 0.6f, 0.2f, 1f}; + } + } + + @Override + protected int getBaseReward() { return 3; } +} +``` + +- [ ] **Step 2: Spawn branch** in `spawnCircle` with `currentWave >= 6` and chance 0.10. + +- [ ] **Step 3: Call `dasher.tick(squareCenterX, squareCenterY)`** inside the per-frame circles loop (same spot that handles spawner spawning). + +- [ ] **Step 4: Visual check** — from wave 6 a dasher should pulse yellow for ~0.8s then streak toward the square at 3× speed. Good stress test for Dash/Shield abilities. + +- [ ] **Step 5: Commit.** + +--- + +## Task A3: `SniperCircleData` — ranged shooter + +Stays at range, fires `BulletData(isEnemyBullet=true)` projectiles. + +**Files:** as above. + +- [ ] **Step 1: Class holds `long lastShotAt` and overrides movement** — moves toward player until distance ~= 400, then stops (velocity set to 0) and fires every 2s. + +- [ ] **Step 2: Spawning & firing** mirror `ShadowWeaverData.shouldShoot()` usage on lines 5935–5940 of current FirstScreen. + +- [ ] **Step 3: Balance** — damage = 2× basic damage, HP = 0.5× basic. + +- [ ] **Step 4: Visual check** — sniper stops in a ring ~400px from square, red tracer shots visible. Shield/Dash should block them. + +- [ ] **Step 5: Commit.** + +--- + +## Task A4: `GnatCircleData` — 1-HP swarm + +Fastest type, 1 HP, spawned in packs. Overrides `BaseCircleData`'s single-spawn assumption by inserting a small "swarm" around a spawn roll. + +**Files:** as above. + +- [ ] **Step 1: Class is trivial** — health always 1, speed 1.8× basic, radius 0.6× basic. + +- [ ] **Step 2: Spawn as a pack** — when rolled, call `spawnCircle`-like helper 5 times with small offsets around the roll point. Add `isGnatSwarm` branch before the Basic branch. + +- [ ] **Step 3: Visual check** — from wave 10+, occasional pack of tiny pink dots darts in. AoE abilities should shred them. + +- [ ] **Step 4: Commit.** + +--- + +# Area B — Boss depth + +## Task B1: Enrage phase for all 6 bosses + +Add a shared `BaseBossData` abstract class **or** a `BossEnrage` mixin-helper invoked from each boss. Pragmatic path: helper invoked from each boss's `update()`. + +**Files:** +- Modify: `JuggernautData`, `SwarmQueenData`, `GlacialWardenData`, `IllusionMasterData`, `ShadowWeaverData`, `VolatileReactorData` + +- [ ] **Step 1: Add per-boss `maxHealth` field** — captured at construction time. Boss HP is already scaled in `spawnRandomBoss`; snapshot right after construction. + +- [ ] **Step 2: Add `boolean isEnraged()` per boss** — `health / maxHealth < 0.33f`. Cache the flag so enrage triggers once. + +- [ ] **Step 3: Multiply attack cadence by 1.5× when enraged** — find each `shouldShoot`/`shouldCharge`/`shouldCreateFireZone` cadence constant and divide by `isEnraged() ? 1.5f : 1f`. + +- [ ] **Step 4: Visual/audio telegraph** — one-time screen shake + red flash + `"ENRAGE!"` floating text when transitioning. Trigger from `FirstScreen.updateBosses` by checking `boss.isEnraged() && !boss.enrageTriggered`. + +- [ ] **Step 5: Visual check** — boss at low HP flashes red, floating text appears, projectile cadence noticeably faster. + +- [ ] **Step 6: Commit.** + +## Task B2: De-templatize the "projectile barrage + summon" pattern + +Agent noted 4 of 6 bosses use the same `burst + minions` pattern. Low-effort differentiator: + +- [ ] **Step 1: Give `ShadowWeaverData` a beam attack** (continuous line that deals 1 dmg/tick instead of its volley). +- [ ] **Step 2: Give `GlacialWardenData` a wall-spawn** (creates temporary immovable obstacles that bullets collide with). +- [ ] **Step 3: Commit.** + +_(Lower priority — skip if schedule tight.)_ + +--- + +# Area C — Projectile variants exposed to the player + +Phase 1 added `piercesLeft / homing / aoeRadius` to `BulletData` and Phase 2 wired pierce + AoE to keystones. `homing` is defined but unimplemented. + +## Task C1: Implement homing + +**Files:** `FirstScreen.java`, `BulletData.java` + +- [ ] **Step 1:** In the bullet-update loop (where `bulletData.move(delta)` lives), if `bullet.homing && !bullet.isEnemyBullet()`: find nearest enemy within 400px, compute desired velocity direction, lerp `bullet.velocity` toward it by `0.06f` per frame (caps turn rate so homing feels responsive but not snap-on). + +- [ ] **Step 2:** Add a new **Keystone talent** `keystone_homing` (tier 3) that sets `bullet.homing = true` on all bullets when level >= 1. Add registration in `TalentTree.initializeTree`, add `"keystone_homing"` to `PlayerStats.TALENT_NODE_IDS`. + +- [ ] **Step 3: Visual check** — with the keystone bought, bullets curve toward enemies. + +- [ ] **Step 4: Commit.** + +## Task C2: Chain lightning on hit + +New per-bullet flag `chainJumps` (int). On collision, if `chainJumps > 0`, find nearest enemy within 200px, spawn a short-lived line render + deal damage, decrement. + +- [ ] **Step 1:** Add field to `BulletData`. +- [ ] **Step 2:** Add collision-site chain logic in `FirstScreen` bullet-vs-circle resolution block (near the existing `bulletData.aoeRadius > 0f` check). +- [ ] **Step 3:** Render the chain as a thin additive-blend line from hit-point to hit-target, alpha fading over 150ms. +- [ ] **Step 4:** New keystone `keystone_chain`. Each level = +1 jump. +- [ ] **Step 5: Commit.** + +--- + +# Area D — Audio pack + +The project ships with **2 sounds** (`pop.wav`, `shot.wav`) and 1 PNG. Need a base pack. **Asset dependency** — cannot be completed in a code-only session. The plan's task is to wire up the loader; the user supplies the files. + +## Task D1: Sound asset loader + +**Files:** +- Create: `core/src/main/java/ru/project/tower/SoundAssets.java` +- Modify: `FirstScreen.java` + +- [ ] **Step 1: Create `SoundAssets` singleton** that lazy-loads and caches `Sound` handles: + +```java +public class SoundAssets { + private static SoundAssets instance; + private final Map cache = new HashMap<>(); + + public static SoundAssets get() { + if (instance == null) instance = new SoundAssets(); + return instance; + } + + public Sound load(String filename) { + Sound s = cache.get(filename); + if (s != null) return s; + FileHandle f = Gdx.files.internal(filename); + if (!f.exists() || f.length() < 200) return null; + try { s = Gdx.audio.newSound(f); cache.put(filename, s); return s; } + catch (Exception e) { return null; } + } + + public void play(String filename, float volume, float pitch) { + Sound s = load(filename); + if (s != null) s.play(volume, pitch, 0f); + } +} +``` + +- [ ] **Step 2: Replace direct `Gdx.audio.newSound` in `FirstScreen.show`** with `SoundAssets.get().load(...)`. + +- [ ] **Step 3: Add explicit call sites** for the 10 events the variety agent listed. For each event, add `SoundAssets.get().play(".wav", vol, pitch)`: + - `player_hit.wav` — in the 5 damage-to-player sites added in Phase 1 + - `boss_spawn.wav` — in `spawnRandomBoss` + - `ability_freeze.wav` / `_explosion.wav` / `_shield.wav` / `_impulse.wav` / `_dash.wav` / `_pull.wav` / `_turret.wav` — start of each `activateX` method + - `wave_start.wav` — first line of `startNextWave` + - `wave_clear.wav` — in `endWave` after bonus calculation + - `game_over.wav` — first line of `gameOver` + - `ui_click.wav` — in `handleUpgradeButtons` / `handleUpgradePickTouch` after a successful button hit + +- [ ] **Step 4:** Missing files degrade silently (loader returns null → play is no-op). No crash if user hasn't supplied assets yet. + +- [ ] **Step 5: Commit.** + +## Task D2: Music loop slots + +- [ ] **Step 1:** Add `Music menuTrack, combatTrack, bossTrack` fields; load via `Gdx.audio.newMusic(Gdx.files.internal("music/...ogg"))` guarded by `exists()`. +- [ ] **Step 2:** Start combat track in `show()`; switch to boss track when a boss is active (`isAnyBossOnScreen`); restore combat track when boss dies. +- [ ] **Step 3: Commit.** + +--- + +# Area E — Biomes / palette variety + +One `BACKGROUND_COLOR`, one particle palette. Agent: rotate every ~5 waves. + +## Task E1: `BiomePalette` data class + per-wave selection + +**Files:** +- Create: `core/src/main/java/ru/project/tower/BiomePalette.java` +- Modify: `FirstScreen.java` + +- [ ] **Step 1: Create class** + +```java +public class BiomePalette { + public final Color background; + public final Color particle1; + public final Color particle2; + public final String label; + public BiomePalette(Color bg, Color p1, Color p2, String label) { + this.background = bg; this.particle1 = p1; this.particle2 = p2; this.label = label; + } + public static final BiomePalette[] ALL = { + new BiomePalette(new Color(0.05f,0.05f,0.10f,1f), new Color(0.2f,0.6f,1f,1f), new Color(1f,0.2f,0.6f,1f), "Neon City"), + new BiomePalette(new Color(0.02f,0.08f,0.05f,1f), new Color(0.2f,1f,0.5f,1f), new Color(1f,1f,0.3f,1f), "Bio Jungle"), + new BiomePalette(new Color(0.08f,0.02f,0.02f,1f), new Color(1f,0.3f,0.1f,1f), new Color(1f,0.8f,0.2f,1f), "Magma Forge"), + new BiomePalette(new Color(0.03f,0.03f,0.12f,1f), new Color(0.5f,0.2f,1f,1f), new Color(0.2f,0.9f,1f,1f), "Void Shift"), + }; +} +``` + +- [ ] **Step 2: Select in `startNextWave`** — `currentBiome = BiomePalette.ALL[(currentWave / 5) % BiomePalette.ALL.length]`. Replace the static `BACKGROUND_COLOR` references in `render()` with `currentBiome.background`. + +- [ ] **Step 3: Background particle colors** — in the background particle spawn logic, pick randomly between `currentBiome.particle1` and `particle2`. + +- [ ] **Step 4: Biome transition floating text** — when biome changes, spawn a 3-second floating text with `currentBiome.label`. + +- [ ] **Step 5: Visual check** — at waves 5, 10, 15, 20 the background and particle colors rotate. + +- [ ] **Step 6: Commit.** + +## Task E2: Random wave modifiers + +Once per run, at wave-start, roll a 25% chance of a modifier from a small list: "double gravity" (pulls enemies toward center faster), "fog" (shooting range -30%), "rush" (+25% enemy speed, +25% reward). Display as floating text at wave start. Stores as fields on `FirstScreen`; cleared in `endWave`. + +- [ ] **Step 1:** Add `enum WaveModifier { NONE, GRAVITY, FOG, RUSH }` and `WaveModifier currentWaveModifier`. +- [ ] **Step 2:** Roll in `startNextWave`. +- [ ] **Step 3:** Apply effects: GRAVITY → multiply `currentEnemySpeedMultiplier` by 1.3 and pull velocity toward center each tick; FOG → shrink `SHOOTING_RANGE` effective value by 0.7; RUSH → multiply speed × 1.25, reward × 1.25. +- [ ] **Step 4:** Floating text at wave start with the label. +- [ ] **Step 5: Commit.** + +--- + +# Area F — Onboarding + +## Task F1: Tutorial overlay for first run + +**Files:** +- Modify: `FirstScreen.java`, `PlayerStats.java` (add `tutorialSeen` preference) + +- [ ] **Step 1:** Add boolean `tutorialSeen` to `PlayerStats` persisted in Preferences, default false. +- [ ] **Step 2:** In `FirstScreen.show`, if `!playerStats.tutorialSeen`, set `showTutorialStep = 0` and skip auto-starting wave 1. +- [ ] **Step 3:** Add `renderTutorialOverlay()` method — dims the screen and shows one tip at a time with an arrow pointing at the relevant UI region. + - Step 0: "Автоматическая турель в центре стреляет по ближайшему врагу" + - Step 1: "Кнопки способностей снизу — жмите когда заряжены" + - Step 2: "Каждые 3 волны выбирайте улучшение" + - Step 3: "Магазин и дерево талантов в главном меню" +- [ ] **Step 4:** Tap advances. Last step → `tutorialSeen = true; saveData();` and wave 1 starts. +- [ ] **Step 5: Call `renderTutorialOverlay()` at the end of `render()`** (alongside `renderUpgradePickOverlay`). +- [ ] **Step 6: Commit.** + +## Task F2: Tooltips on upgrade/talent/ability buttons + +- [ ] **Step 1:** Add `tooltipText` field on each button and track hover state (already tracked in `renderNeonButton` via `isHovered`). +- [ ] **Step 2:** When hovered, render a small panel above the button with text. One pass, one method — `drawTooltip(Rectangle btn, String text)`. +- [ ] **Step 3:** Wire per button — damage/speed/cooldown upgrades get stat deltas, ability buttons get radius/damage/duration values. +- [ ] **Step 4: Commit.** + +--- + +# Area G — Achievements & unlocks + +## Task G1: Achievements store + +**Files:** +- Create: `core/src/main/java/ru/project/tower/Achievements.java` +- Modify: `FirstScreen.java` (hook increment calls) + +- [ ] **Step 1: Define enum** of 15 achievements: + - `FIRST_BLOOD` (1 kill), `KILL_100`, `KILL_1000` + - `WAVE_10`, `WAVE_25`, `WAVE_50` + - `BOSS_ALL_6` (defeat each boss once) + - `NO_HIT_WAVE` (complete a wave with no damage taken) + - `FREEZE_100_FROZEN`, `SHATTER_50` + - `ELITE_KILL_100` + - `TALENT_TREE_COMPLETE`, `FULL_ABILITY_ROSTER` + - `DAILY_SEED_FIRST`, `RUN_WITH_ALL_3_KEYSTONES` + +- [ ] **Step 2: `Achievements` singleton with `Preferences`-backed unlocked set and counters**. `increment(key)`, `unlock(id)`, `isUnlocked(id)`. Each unlock awards a currency bounty (e.g., 25 for easy, 100 for hard). + +- [ ] **Step 3: Hook call sites** — kill counter in the bullet-kill block, wave counter in `endWave`, boss-defeated in the circle.health<=0 block when `isBossCircle`, etc. + +- [ ] **Step 4: Unlock toast** — on first unlock of an achievement, floating text "ДОСТИЖЕНИЕ: (+N монет)" + shake. + +- [ ] **Step 5:** Add an "Achievements" button on `MainScreen` → new `AchievementsScreen` with a scrollable list (use same stage/skin style as `AbilityShopScreen`). + +- [ ] **Step 6: Commit.** + +## Task G2: Cosmetic unlocks + +Simple: square tint unlocks (5 colours unlocked by achievement thresholds). Stored in `PlayerStats.selectedTint`. Applied in `renderNeonSquare`. + +- [ ] **Step 1:** Add `String selectedTint` / list of unlocked tints to `PlayerStats`. +- [ ] **Step 2:** Cosmetics screen launched from `MainScreen`. +- [ ] **Step 3: Commit.** + +--- + +# Area H — Daily seed & run modifiers + +## Task H1: Daily seed + +**Files:** +- Create: `core/src/main/java/ru/project/tower/DailySeed.java` +- Modify: `MainScreen.java`, `FirstScreen.java` + +- [ ] **Step 1:** `DailySeed.todaySeed()` returns `LocalDate.now().toString().hashCode()`. +- [ ] **Step 2:** New "Daily Challenge" button on main menu that starts a run with `MathUtils.random.setSeed(seed)` injected into `FirstScreen` before first `spawnCircle`. +- [ ] **Step 3:** Record best-wave-today in Preferences keyed by date. Display on main menu "Daily: best W14". +- [ ] **Step 4:** At wave end in daily mode, also save to a rolling 7-day log for a "last 7 days" leaderboard view. +- [ ] **Step 5: Commit.** + +## Task H2: Pre-run modifiers + +Before starting a run, offer an optional modifier card: "+30% reward if all 3 abilities selected", "Bosses +50% HP / reward ×2", etc. Applied as `GameState.runModifier` flag, checked in relevant methods. + +- [ ] Deferred — pair with Area E2 work. + +--- + +# Area I — Save mid-run + +Agent noted `GameState` is in-memory only. Serialize run to JSON on pause/quit so players can resume. + +**Files:** +- Modify: `GameState.java`, `FirstScreen.java` (serialize on pause, deserialize on show) + +- [ ] **Step 1:** Add `toJson()`/`fromJson(String)` on `GameState` using libGDX `Json`. Include: `currentWave`, `squareHealth`, `maxSquareHealth`, `money`, `runDamageBonus`, `runPierceBonus`, etc. (the Phase 2 `run*` fields). +- [ ] **Step 2:** On pause (`shouldPause` branch), write JSON string into `Preferences`. +- [ ] **Step 3:** On `FirstScreen.show`, if a saved run exists and user chose "Resume" from main menu, deserialize and jump to `startNextWave`-equivalent state. +- [ ] **Step 4:** Main menu gets a "Resume" button that appears only when save exists. +- [ ] **Step 5: Commit.** + +--- + +# Area J — Ability mastery + +Roguelite depth — each ability gains XP as it's used; XP unlocks passive modifiers. + +**Files:** +- Create: `core/src/main/java/ru/project/tower/AbilityMastery.java` +- Modify: `FirstScreen.java` (increment on each cast), `AbilitySelectionScreen.java` (show levels) + +- [ ] **Step 1:** `AbilityMastery` singleton tracks `int masteryXp[ability]` persisted in Preferences. Level = `floor(sqrt(xp/10))`. +- [ ] **Step 2:** Increment 10 XP per cast; 50 XP per "meaningful kill" (freeze of 5+, explosion of 10+ etc.) +- [ ] **Step 3:** Unlocks per level: + - Freeze L2: +30% radius; L4: +25% duration; L6: enemies under Freeze take +25% bullet damage. + - Explosion L2: +30% radius; L4: applies brief slow; L6: double damage vs frozen (stacks with Phase 1 synergy). + - Shield L2: +1s duration; L4: reflects projectiles; L6: explosion on expire. + - Impulse L2: +30% force; L4: +50% damage to pushed enemies; L6: also applies freeze 500ms. + - Dash L2: +150ms i-frames; L4: leaves fire trail; L6: double-dash (2 charges). + - Pull L2: +30% radius; L4: pulled enemies take +20% damage for 2s; L6: pulls bosses briefly. + - Turret L2: +3s lifetime; L4: +25% fire rate; L6: turret shots pierce +1. +- [ ] **Step 4: Visualise** — in `AbilitySelectionScreen`, show level number on each selected ability card. +- [ ] **Step 5: Commit.** + +--- + +# Area K — Test harness (optional, enabler for future work) + +Not required for any Phase 3 task but strongly suggested before Phase 4. + +- [ ] **Step 1:** Add JUnit 5 to `core/build.gradle`: `testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0'`. +- [ ] **Step 2:** Headless libGDX test scaffolding via `Headless Application` — enough for `PlayerStats`, `Achievements`, `TalentTree`, `DailySeed` logic tests. +- [ ] **Step 3:** One sample test per class to prove the pattern. +- [ ] **Step 4:** Document in README the `gradle test` command. +- [ ] **Step 5: Commit.** + +--- + +## Priority summary + +| Priority | Area | Rationale | +| --- | --- | --- | +| P1 | A1 Healer, A3 Sniper, B1 Enrage, E1 Biomes | Cheap, high-impact variety; no new assets | +| P1 | F1 Tutorial, F2 Tooltips | Fixes onboarding (agent's #3 critical gap) | +| P1 | G1 Achievements (core only) | Fixes retention goals (agent's #2 critical gap) | +| P2 | A2 Dasher, A4 Gnat, C1 Homing, C2 Chain | More variety + keystone payoffs | +| P2 | H1 Daily seed | Flywheel for returning players | +| P2 | I Save mid-run | Mobile QoL | +| P3 | D1 Audio pack | Requires assets | +| P3 | B2 Boss de-templating | Large rework, lower leverage | +| P3 | J Ability mastery | Late-tier depth | +| P3 | E2 Wave modifiers, H2 Pre-run modifiers | Nice-to-have | +| P4 | K Test harness | Enabler for Phase 4 | + +Implement top-to-bottom. P1 block (A1+A3+B1+E1+F1+F2+G1) is the minimum that addresses all three agent reports. + +--- + +## Self-review notes + +- **Spec coverage:** Every top-level gap from the three agent reports maps to at least one task. Elite modifier and 3 new abilities (Dash/Pull/Turret) from Phase 2 aren't re-planned here; homing is the only `BulletData` flag that was dormant. +- **Placeholder scan:** Task B2 is the only explicitly-deferred item; flagged P3. All other code steps show the code. +- **Type consistency:** `HealerCircleData.getHealRadius()` used from `FirstScreen` matches class definition. `BiomePalette.ALL` array is the public API used in `startNextWave`. `Achievements.increment(String key)` signature referenced in Task G1.step3 matches Task G1.step2. +- **Out-of-scope:** Multiplayer, shader-based bloom, and new fonts are not in this plan. Boss entry cinematics, cosmetic particle trails, and lore text are intentionally skipped — re-open after data shows engagement. + +--- + +## Execution handoff + +This plan is saved as `docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md`. When you're ready to execute: + +1. **Subagent-Driven (recommended):** Fresh subagent per task group (A, B, ... J), review between groups. Use `superpowers:subagent-driven-development`. +2. **Inline Execution:** Step through in-session with `superpowers:executing-plans`, checkpoint after each area. + +Tell Claude which approach and which priority tier (P1 only? P1+P2? full sweep?) to run. diff --git a/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness-completion.md b/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness-completion.md new file mode 100644 index 0000000..d9aa41f --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness-completion.md @@ -0,0 +1,127 @@ +# Phase 3.K.1 — Completion Report + +**Date completed:** 2026-04-17 +**Source plan:** `2026-04-17-phase3-k1-test-harness.md` +**Source spec:** `../specs/2026-04-17-phase3-k1-test-harness-design.md` +**Execution mode:** subagent-driven-development (fresh subagent per task, two-stage review). + +## Status + +- **Code:** ✅ Shipped. All 11 tasks complete. +- **Automated verification:** ✅ `./gradlew core:test` → 58/58 green. `./gradlew :core:build :lwjgl3:build` → BUILD SUCCESSFUL. +- **Manual smoke test:** ⏳ Pending user — see checklist below. +- **Git:** project is not a git repository; no commits were made. All work lives in the working tree. + +## What was delivered + +### New files (10) + +Production (1): +- `core/src/main/java/ru/project/tower/GameContext.java` — composition-root holder for shared persistent-state services. `public final` fields for `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, and two `Preferences` refs. + +Tests (9): +- `core/src/test/java/ru/project/tower/HarnessSanityTest.java` +- `core/src/test/java/ru/project/tower/FakePreferences.java` — in-memory `Preferences` impl, reused by every downstream test. +- `core/src/test/java/ru/project/tower/FakePreferencesTest.java` +- `core/src/test/java/ru/project/tower/PlayerStatsTest.java` +- `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java` +- `core/src/test/java/ru/project/tower/GameStateTest.java` +- `core/src/test/java/ru/project/tower/TalentTreeTest.java` +- `core/src/test/java/ru/project/tower/TalentDataTest.java` +- `core/src/test/java/ru/project/tower/GameContextTest.java` + +### Modified files (10) + +Production classes (singletons → DI): +- `PlayerStats.java` — public `PlayerStats(Preferences)` ctor, `getInstance()` deleted, `instance` field deleted, `PREFS_NAME` deleted, `Gdx` import deleted, `preferences` field made `final`. +- `PlayerAbilities.java` — public `PlayerAbilities(Preferences)` ctor, `getInstance()` deleted, `instance` field deleted, `PREFS_NAME` deleted, `preferences` field made `final`. `Gdx` import kept (used by `Gdx.app.error(...)`). +- `GameState.java` — ctor made public, `getInstance()` deleted, `instance` field deleted. +- `TalentTree.java` — public `TalentTree(PlayerStats)` ctor, `getInstance()` deleted, `instance` field deleted, two internal `PlayerStats.getInstance()` calls replaced with injected `stats` field, `talents`/`roots` made `final`. + +Composition root + screens: +- `Main.java` — rewritten; builds `GameContext` from two `Preferences`, passes to `MainScreen`. +- `MainScreen.java` — 2-arg ctor, 3 downstream `new X(game)` → `new X(game, ctx)`. +- `FirstScreen.java` — 2-arg ctor, 3 new cached fields (`ctx`, `gameState`, `talentTree`), 4 cached services assigned at ctor top (closed an NPE window), 13 `getInstance()` call-sites replaced, internal `new MainScreen(game)` → `new MainScreen(game, ctx)`, 2 redundant local shadows removed. +- `AbilityShopScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(game, ctx)`. +- `AbilitySelectionScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(gameInstance, ctx)` + `new FirstScreen(gameInstance, ctx)`. +- `TalentTreeScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(game, ctx)`. + +Build: +- `core/build.gradle` — `testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'`, `testImplementation 'org.assertj:assertj-core:3.26.3'`, `test { useJUnitPlatform() }`. + +### Test counts (by class) + +| Test class | Tests | +|---|---| +| HarnessSanityTest | 1 | +| FakePreferencesTest | 7 | +| PlayerStatsTest | 14 | +| PlayerAbilitiesTest | 8 | +| GameStateTest | 6 | +| TalentTreeTest | 12 | +| TalentDataTest | 6 | +| GameContextTest | 4 | +| **Total** | **58** | + +### Invariants verified at completion + +- `grep "getInstance(" core/src/main/java/ru/project/tower -r` → 0 matches. +- `grep "private static \w+ instance;" core/src/main/java/ru/project/tower -r` → 0 matches. +- `./gradlew core:test` → 58 passed, 0 failed. +- `./gradlew :core:build :lwjgl3:build` → BUILD SUCCESSFUL. + +## Manual smoke test — ⏳ pending + +Run `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher` and verify: + +- [ ] Main menu renders with persisted currency + max-wave. +- [ ] Talent Tree: purchased levels display, new purchase deducts currency, back returns to menu. +- [ ] Ability Shop: owned abilities display correctly, new purchase (e.g., Shield) persists after a restart. +- [ ] Ability Selection → Start Game → wave 1 plays, firing kills enemies, death returns to menu, currency reflects loot. +- [ ] Mid-run shop flow: save/restore works (upgrade survives shop round-trip). +- [ ] Close and relaunch: all persisted state (currency, max wave, unlocked abilities, talent levels) survives. + +If any check fails, report back — most likely a missed call-site or a `ctx` threading bug; fix scope is minutes, not hours. + +## Carry-over items (non-blocking) + +These were flagged by reviewers during K.1 execution but are either pre-existing (not regressions introduced by K.1) or are tiny cleanups better folded into the next spec. They do not block K.2. + +### Pre-existing design issues surfaced by the new test harness + +- **`PlayerStats.getRegenTalentLevel()` sums only `regen_1`.** Other aggregate getters sum two nodes. If a `regen_2` node is added later, this getter silently reports the wrong value. Add a `regen_2` node or a comment explaining the omission. (Flagged in Task 3 review.) +- **`PlayerStats.incrementTalentNode(String)` accepts any string, including typos.** Phantom nodes are created in the in-memory `talentLevels` map but not persisted (saveData iterates `TALENT_NODE_IDS`). Consider a validation guard. (Flagged in Task 3 review.) +- **`PlayerStats.resetData()` Javadoc says "для тестирования"** but the test suite doesn't call it — callers live elsewhere. Clarify the comment or remove the method. (Task 3 review.) +- **`PlayerAbilities.resetData()` clears everything, including `freeze` and `explosion` which default to `true` on construction.** Intentional per the existing code, but the name implies "factory defaults" which it isn't. Rename or add a comment. (Task 4 review.) +- **`GameState.resetGameState()` doesn't reset `spawnedBosses` or the gameplay stat fields** (money, squareHealth, bulletSpeed, etc.). If this method is the "start new run" boundary, that's a latent bug. (Task 5 review.) +- **`FirstScreen.java` field declarations near lines 429–435 are non-final** while the `ctx` field is `final`. Consistency drift inside one file. (Final review.) +- **`Main.ctx` is `public` non-final.** Safe given libGDX `Game.create()` semantics, but `public final` would document intent. (Final review.) + +### Test-coverage gaps (not bugs) + +- `TalentTreeTest.getStatBonus_*` exercises only the `damage_1` multiplier. A second multiplier (cooldown_1, health_1, etc.) would lock more of the formula. (Task 6 review.) +- `TalentDataTest.enum_whenKeystone_thenEnumMatches` is a tautology (`KEYSTONE != DAMAGE`). Safe to delete. (Final review.) +- `PlayerAbilitiesTest.purchaseAbility_whenUnknownType_thenNoChange` doesn't assert that the unknown key wasn't written to Preferences. (Task 4 review.) +- `PlayerAbilitiesTest.construct_whenPreferencesPrePopulated_thenHonoursStored` seeds only `freeze` and `shield` — doesn't verify that absent `explosion` key falls through to the `true` default. (Task 4 review.) + +### FakePreferences polish + +- `FakePreferences.put(Map)` bulk-put isn't guarded — values inserted through this path could be types no getter recognises (e.g., `Double`, `Short`). No test exercises this. (Task 2 review.) +- `FakePreferences.getFloat`/`getLong` and the no-default single-arg getters have no tests. (Task 2 review.) + +## What's next + +K.1 is the foundation. The remaining specs in the K-series each handle one subsystem extraction out of `FirstScreen`'s god-class. Each follows the same brainstorm → spec → plan → subagent-driven-implementation cycle. + +| Spec | Scope | Dependency | +|---|---|---| +| **K.2** | `BulletSystem` extraction — bullet tick, bullet-vs-circle collision, homing/pierce/aoe/chain resolution | K.1 | +| **K.3** | `SpawnController` extraction — `spawnCircle()` cascade | K.1 | +| **K.4** | `WaveController` extraction — `startNextWave` / `endWave` / scaling | K.1 | +| **K.5** | `EnemyBehaviour` tests — `tick()` for `HealerCircleData`, `DasherCircleData`, `SniperCircleData` (and others) | K.1 + Phase 3 A1/A2/A3 | +| **K.6** | `AbilitySystem` extraction — cooldowns, activations, effects | K.1 | +| **K.7** | `UpgradeSystem` extraction — mid-run upgrade pick, money, apply-effect | K.1 | + +Recommended next step: brainstorm **K.2** (`BulletSystem`) — highest-value extraction because bullet logic is central to the game, has the most flags (`piercesLeft`, `homing`, `aoeRadius`, `chainJumps`), and is the first place Phase 3's `homing` keystone + C2 chain work will land. + +Phase 3 gameplay tasks (A–J from the parent Phase 3 plan) remain untouched and can proceed in parallel with K.2+ when desired. diff --git a/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness.md b/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness.md new file mode 100644 index 0000000..d493b23 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness.md @@ -0,0 +1,1594 @@ +# Phase 3.K.1 — Test harness foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Introduce JUnit 5 + AssertJ test infrastructure, refactor four persistent-state singletons (`PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`) to constructor injection via a new `GameContext` holder, and author ~30 unit tests that will serve as the safety net for subsequent K.2–K.7 logic-extraction specs. + +**Architecture:** Each singleton gains a public constructor that accepts its dependencies; the legacy `getInstance()` methods are kept in a transitional state until every call-site is switched, then deleted in one sweep. `Main.create()` becomes the composition root, building a `GameContext` and passing it to every screen constructor. Tests use an in-memory `FakePreferences` that implements `com.badlogic.gdx.Preferences`, so no headless libGDX runtime is required. + +**Tech Stack:** Java 8, libGDX 1.13.1, Gradle, JUnit Jupiter 5.10.2, AssertJ 3.26.3. + +--- + +## Design reference + +The authoritative design is `docs/superpowers/specs/2026-04-17-phase3-k1-test-harness-design.md`. Read it once before beginning. Key constraints: + +- `GameContext` has `public final` fields (Google Java Style §5.3 idiom for immutable data carrier). +- Tests are named `methodName_whenCondition_thenExpected`. +- Bodies use Arrange / Act / Assert separated by blank lines, no `// Arrange` comments. +- No code coverage gate (no JaCoCo), no CI wiring. +- Headless libGDX is explicitly **not** used. + +## Project realities + +- **No git repository.** `Is a git repository: false` in the environment. Every "Commit" step below should be executed as a checkpoint: confirm the change set is clean, then run the verification. If you initialize git before starting, run `git init` once and treat commits as real; otherwise use each commit step as a mental save-point and keep moving. +- **No existing test infrastructure.** Task 1 is the first point at which `./gradlew test` becomes a meaningful command. +- **TDD applies to test-bearing tasks (Tasks 2–8).** Tasks 9–14 are mechanical refactors with no new behaviour — their verification is *compile passes* + *existing tests still pass* + *desktop launcher still runs correctly*. The plan calls this out per-task. +- **Call-site counts (verified via grep, 2026-04-17):** 21 total `getInstance()` calls across 6 files: + - `FirstScreen.java`: 13 (lines 179, 502, 579, 617, 644, 645, 1366, 1474, 1497, 1794, 2634, 5737, 6756) + - `AbilityShopScreen.java`: 2 (lines 69, 70) + - `AbilitySelectionScreen.java`: 2 (lines 171, 340) + - `TalentTreeScreen.java`: 2 (lines 98, 99) + - `TalentTree.java`: 2 (lines 133, 138) — self-references for `PlayerStats` + +Line numbers may drift as prior tasks add imports; use the tagged symbol names rather than hardcoded line numbers when editing. + +## File structure + +**New files (production):** +- `core/src/main/java/ru/project/tower/GameContext.java` — DI holder. + +**New files (test):** +- `core/src/test/java/ru/project/tower/FakePreferences.java` — in-memory `Preferences` implementation. +- `core/src/test/java/ru/project/tower/FakePreferencesTest.java` +- `core/src/test/java/ru/project/tower/PlayerStatsTest.java` +- `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java` +- `core/src/test/java/ru/project/tower/GameStateTest.java` +- `core/src/test/java/ru/project/tower/TalentTreeTest.java` +- `core/src/test/java/ru/project/tower/TalentDataTest.java` +- `core/src/test/java/ru/project/tower/GameContextTest.java` + +**Modified files:** +- `core/build.gradle` — add `testImplementation` deps and `useJUnitPlatform()`. +- `core/src/main/java/ru/project/tower/PlayerStats.java` — add public ctor; remove `getInstance()` (Task 10). +- `core/src/main/java/ru/project/tower/PlayerAbilities.java` — add public ctor; remove `getInstance()`. +- `core/src/main/java/ru/project/tower/GameState.java` — make ctor public; remove `getInstance()`. +- `core/src/main/java/ru/project/tower/TalentTree.java` — add `TalentTree(PlayerStats)` ctor; remove `getInstance()`; replace 2 internal `PlayerStats.getInstance()` calls with field. +- `core/src/main/java/ru/project/tower/Main.java` — composition root. +- `core/src/main/java/ru/project/tower/MainScreen.java` — take `GameContext` in ctor; pass to downstream screens. +- `core/src/main/java/ru/project/tower/FirstScreen.java` — take `GameContext`; cache services in fields; replace 13 call-sites. +- `core/src/main/java/ru/project/tower/AbilityShopScreen.java` — take `GameContext`; replace 2 call-sites. +- `core/src/main/java/ru/project/tower/AbilitySelectionScreen.java` — take `GameContext`; replace 2 call-sites; update internal `new FirstScreen(...)` and `new MainScreen(...)` construction. +- `core/src/main/java/ru/project/tower/TalentTreeScreen.java` — take `GameContext`; replace 2 call-sites; update internal `new MainScreen(...)`. + +--- + +## Task 1: Build config for test infrastructure + +**Files:** +- Modify: `core/build.gradle` +- Create: `core/src/test/java/ru/project/tower/HarnessSanityTest.java` + +- [ ] **Step 1: Add JUnit 5 + AssertJ dependencies to `core/build.gradle`** + +Replace the current `dependencies` block in `core/build.gradle`: + +```gradle +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() +} +``` + +- [ ] **Step 2: Create the test source tree and a sanity test** + +Create `core/src/test/java/ru/project/tower/HarnessSanityTest.java` (verifies JUnit + AssertJ actually wire up): + +```java +package ru.project.tower; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HarnessSanityTest { + + @Test + void assertj_whenUsed_thenWorks() { + assertThat(1 + 1).isEqualTo(2); + } +} +``` + +- [ ] **Step 3: Run the test** + +Run: `./gradlew core:test --info` + +Expected: `BUILD SUCCESSFUL`, `HarnessSanityTest > assertj_whenUsed_thenWorks() PASSED`. + +If you get `Could not resolve org.junit.jupiter:junit-jupiter:5.10.2` — check the subprojects repositories in the root `build.gradle` include `mavenCentral()` (they already do as of 2026-04-17). + +- [ ] **Step 4: Commit** + +```bash +git add core/build.gradle core/src/test/java/ru/project/tower/HarnessSanityTest.java +git commit -m "feat(test): add JUnit 5 + AssertJ test harness to core module" +``` + +--- + +## Task 2: `FakePreferences` helper + +**Files:** +- Create: `core/src/test/java/ru/project/tower/FakePreferences.java` +- Create: `core/src/test/java/ru/project/tower/FakePreferencesTest.java` + +- [ ] **Step 1: Write the failing test** + +Create `core/src/test/java/ru/project/tower/FakePreferencesTest.java`: + +```java +package ru.project.tower; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FakePreferencesTest { + + @Test + void putInteger_whenRead_thenReturnsSameValue() { + FakePreferences prefs = new FakePreferences(); + + prefs.putInteger("k", 42); + + assertThat(prefs.getInteger("k", -1)).isEqualTo(42); + } + + @Test + void getInteger_whenMissing_thenReturnsDefault() { + FakePreferences prefs = new FakePreferences(); + + assertThat(prefs.getInteger("missing", 7)).isEqualTo(7); + } + + @Test + void putBoolean_whenRead_thenReturnsSameValue() { + FakePreferences prefs = new FakePreferences(); + + prefs.putBoolean("flag", true); + + assertThat(prefs.getBoolean("flag", false)).isTrue(); + } + + @Test + void putString_whenRead_thenReturnsSameValue() { + FakePreferences prefs = new FakePreferences(); + + prefs.putString("name", "neo"); + + assertThat(prefs.getString("name", "")).isEqualTo("neo"); + } + + @Test + void contains_whenKeyPresent_thenTrue() { + FakePreferences prefs = new FakePreferences(); + + prefs.putInteger("k", 1); + + assertThat(prefs.contains("k")).isTrue(); + assertThat(prefs.contains("other")).isFalse(); + } + + @Test + void clear_whenInvoked_thenEmptiesStore() { + FakePreferences prefs = new FakePreferences(); + prefs.putInteger("k", 1); + + prefs.clear(); + + assertThat(prefs.contains("k")).isFalse(); + } + + @Test + void remove_whenKeyExists_thenRemovesOnlyThatKey() { + FakePreferences prefs = new FakePreferences(); + prefs.putInteger("a", 1); + prefs.putInteger("b", 2); + + prefs.remove("a"); + + assertThat(prefs.contains("a")).isFalse(); + assertThat(prefs.getInteger("b", -1)).isEqualTo(2); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew core:test --tests FakePreferencesTest` +Expected: `FAIL` with "cannot find symbol: class FakePreferences". + +- [ ] **Step 3: Implement `FakePreferences`** + +Create `core/src/test/java/ru/project/tower/FakePreferences.java`. This is a full implementation of `com.badlogic.gdx.Preferences`. Every method delegates to a `HashMap`. `flush()` is a no-op. + +```java +package ru.project.tower; + +import com.badlogic.gdx.Preferences; + +import java.util.HashMap; +import java.util.Map; + +/** + * In-memory test double for {@link Preferences}. No disk I/O, no libGDX + * runtime required. {@link #flush()} is a no-op. + */ +public class FakePreferences implements Preferences { + + private final Map store = new HashMap<>(); + + @Override public Preferences putBoolean(String key, boolean val) { store.put(key, val); return this; } + @Override public Preferences putInteger(String key, int val) { store.put(key, val); return this; } + @Override public Preferences putLong(String key, long val) { store.put(key, val); return this; } + @Override public Preferences putFloat(String key, float val) { store.put(key, val); return this; } + @Override public Preferences putString(String key, String val) { store.put(key, val); return this; } + + @Override + public Preferences put(Map vals) { + store.putAll(vals); + return this; + } + + @Override public boolean getBoolean(String key) { return getBoolean(key, false); } + @Override public int getInteger(String key) { return getInteger(key, 0); } + @Override public long getLong(String key) { return getLong(key, 0L); } + @Override public float getFloat(String key) { return getFloat(key, 0f); } + @Override public String getString(String key) { return getString(key, ""); } + + @Override + public boolean getBoolean(String key, boolean defValue) { + Object v = store.get(key); + return v instanceof Boolean ? (Boolean) v : defValue; + } + + @Override + public int getInteger(String key, int defValue) { + Object v = store.get(key); + return v instanceof Integer ? (Integer) v : defValue; + } + + @Override + public long getLong(String key, long defValue) { + Object v = store.get(key); + return v instanceof Long ? (Long) v : defValue; + } + + @Override + public float getFloat(String key, float defValue) { + Object v = store.get(key); + return v instanceof Float ? (Float) v : defValue; + } + + @Override + public String getString(String key, String defValue) { + Object v = store.get(key); + return v instanceof String ? (String) v : defValue; + } + + @Override + public Map get() { + return new HashMap<>(store); + } + + @Override public boolean contains(String key) { return store.containsKey(key); } + @Override public void clear() { store.clear(); } + @Override public void remove(String key) { store.remove(key); } + @Override public void flush() { /* no-op */ } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew core:test --tests FakePreferencesTest` +Expected: all 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/test/java/ru/project/tower/FakePreferences.java \ + core/src/test/java/ru/project/tower/FakePreferencesTest.java +git commit -m "test: add FakePreferences in-memory double" +``` + +--- + +## Task 3: `PlayerStats` — DI refactor + tests + +**Files:** +- Modify: `core/src/main/java/ru/project/tower/PlayerStats.java` +- Create: `core/src/test/java/ru/project/tower/PlayerStatsTest.java` + +- [ ] **Step 1: Write the failing tests** + +Create `core/src/test/java/ru/project/tower/PlayerStatsTest.java`: + +```java +package ru.project.tower; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlayerStatsTest { + + private FakePreferences prefs; + private PlayerStats stats; + + @BeforeEach + void setUp() { + prefs = new FakePreferences(); + stats = new PlayerStats(prefs); + } + + @Test + void getCurrency_whenNotYetSet_thenReturnsZero() { + assertThat(stats.getCurrency()).isZero(); + } + + @Test + void addCurrency_whenPositiveAmount_thenIncrementsAndPersists() { + stats.addCurrency(50); + + assertThat(stats.getCurrency()).isEqualTo(50); + assertThat(prefs.getInteger("player_currency", -1)).isEqualTo(50); + } + + @Test + void addCurrency_whenNonPositive_thenNoChange() { + stats.addCurrency(0); + stats.addCurrency(-10); + + assertThat(stats.getCurrency()).isZero(); + } + + @Test + void spendCurrency_whenInsufficient_thenReturnsFalseAndLeavesBalance() { + stats.addCurrency(10); + + boolean result = stats.spendCurrency(25); + + assertThat(result).isFalse(); + assertThat(stats.getCurrency()).isEqualTo(10); + } + + @Test + void spendCurrency_whenSufficient_thenReturnsTrueAndDeducts() { + stats.addCurrency(100); + + boolean result = stats.spendCurrency(30); + + assertThat(result).isTrue(); + assertThat(stats.getCurrency()).isEqualTo(70); + assertThat(prefs.getInteger("player_currency", -1)).isEqualTo(70); + } + + @Test + void spendCurrency_whenZeroOrNegative_thenReturnsTrueWithoutDeducting() { + stats.addCurrency(10); + + assertThat(stats.spendCurrency(0)).isTrue(); + assertThat(stats.spendCurrency(-5)).isTrue(); + assertThat(stats.getCurrency()).isEqualTo(10); + } + + @Test + void updateMaxWave_whenHigher_thenUpdatesAndPersists() { + stats.updateMaxWave(5); + stats.updateMaxWave(12); + + assertThat(stats.getMaxWaveReached()).isEqualTo(12); + assertThat(prefs.getInteger("max_wave_reached", -1)).isEqualTo(12); + } + + @Test + void updateMaxWave_whenLowerOrEqual_thenIgnored() { + stats.updateMaxWave(10); + + stats.updateMaxWave(10); + stats.updateMaxWave(3); + + assertThat(stats.getMaxWaveReached()).isEqualTo(10); + } + + @Test + void getTalentNodeLevel_whenUnseen_thenZero() { + assertThat(stats.getTalentNodeLevel("damage_1")).isZero(); + } + + @Test + void incrementTalentNode_whenCalled_thenLevelOneAndPersisted() { + stats.incrementTalentNode("damage_1"); + + assertThat(stats.getTalentNodeLevel("damage_1")).isEqualTo(1); + assertThat(prefs.getInteger("talent_node_damage_1", -1)).isEqualTo(1); + } + + @Test + void incrementTalentNode_whenCalledTwice_thenLevelTwo() { + stats.incrementTalentNode("damage_1"); + stats.incrementTalentNode("damage_1"); + + assertThat(stats.getTalentNodeLevel("damage_1")).isEqualTo(2); + } + + @Test + void legacyAggregateGetter_whenMultipleTiers_thenReturnsSumAcrossNodes() { + stats.incrementTalentNode("damage_1"); + stats.incrementTalentNode("damage_1"); + stats.incrementTalentNode("damage_2"); + + assertThat(stats.getDamageTalentLevel()).isEqualTo(3); + } + + @Test + void construct_whenPreferencesPrePopulated_thenRoundTripsValues() { + FakePreferences seeded = new FakePreferences(); + seeded.putInteger("player_currency", 250); + seeded.putInteger("max_wave_reached", 17); + seeded.putInteger("talent_node_keystone_pierce", 1); + + PlayerStats fresh = new PlayerStats(seeded); + + assertThat(fresh.getCurrency()).isEqualTo(250); + assertThat(fresh.getMaxWaveReached()).isEqualTo(17); + assertThat(fresh.getTalentNodeLevel("keystone_pierce")).isEqualTo(1); + } + + @Test + void construct_whenLegacyDamageKeyPresent_thenSeedsDamage1() { + FakePreferences seeded = new FakePreferences(); + seeded.putInteger("talent_damage_level", 3); + + PlayerStats fresh = new PlayerStats(seeded); + + assertThat(fresh.getTalentNodeLevel("damage_1")).isEqualTo(3); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./gradlew core:test --tests PlayerStatsTest` +Expected: `FAIL` with "constructor PlayerStats(FakePreferences) not found" (ctor is currently private & argumentless). + +- [ ] **Step 3: Refactor `PlayerStats`** + +Modify `core/src/main/java/ru/project/tower/PlayerStats.java`. The change has three parts: add a public ctor that accepts `Preferences`, remove the old private ctor (its body moves into the public one), and rewrite `getInstance()` to delegate via the new ctor. `getInstance()` stays alive until Task 10 deletes it after all call-sites are switched. + +Replace the existing ctor block and `getInstance()` (lines 37–75 in the current file) with: + +```java + /** + * DI-friendly constructor. Used by {@link GameContext} and tests. + */ + public PlayerStats(Preferences preferences) { + this.preferences = preferences; + + currency = preferences.getInteger(CURRENCY_KEY, 0); + maxWaveReached = preferences.getInteger(MAX_WAVE_KEY, 0); + + for (String id : TALENT_NODE_IDS) { + talentLevels.put(id, preferences.getInteger(TALENT_NODE_KEY_PREFIX + id, 0)); + } + seedLegacyLevel("damage_1", DAMAGE_TALENT_KEY); + seedLegacyLevel("health_1", HEALTH_TALENT_KEY); + seedLegacyLevel("speed_1", SPEED_TALENT_KEY); + seedLegacyLevel("cooldown_1", COOLDOWN_TALENT_KEY); + seedLegacyLevel("regen_1", REGEN_TALENT_KEY); + } + + /** + * Legacy singleton accessor. Delete in Task 10 once call-sites use GameContext. + */ + public static PlayerStats getInstance() { + if (instance == null) { + instance = new PlayerStats(Gdx.app.getPreferences(PREFS_NAME)); + } + return instance; + } +``` + +The old `private PlayerStats()` constructor is gone — its body moved verbatim into the public one, minus the first line which was `preferences = Gdx.app.getPreferences(PREFS_NAME);`. The `preferences` field stays, `PREFS_NAME` stays (still used by `getInstance()`). + +Leave `instance`, `loadData`-adjacent behaviour, `saveData`, `resetData`, and all public accessors unchanged. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew core:test --tests PlayerStatsTest` +Expected: all 13 tests pass. + +Also run the full suite: `./gradlew core:test` +Expected: `FakePreferencesTest` + `HarnessSanityTest` + `PlayerStatsTest` all pass. + +- [ ] **Step 5: Verify production code still compiles** + +Run: `./gradlew core:compileJava` +Expected: `BUILD SUCCESSFUL` (existing `PlayerStats.getInstance()` callers in `AbilityShopScreen`, `FirstScreen`, `TalentTree`, `TalentTreeScreen` are untouched and still compile). + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/ru/project/tower/PlayerStats.java \ + core/src/test/java/ru/project/tower/PlayerStatsTest.java +git commit -m "refactor(PlayerStats): expose public ctor + add unit tests" +``` + +--- + +## Task 4: `PlayerAbilities` — DI refactor + tests + +**Files:** +- Modify: `core/src/main/java/ru/project/tower/PlayerAbilities.java` +- Create: `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java` + +- [ ] **Step 1: Write the failing tests** + +Create `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java`: + +```java +package ru.project.tower; + +import com.badlogic.gdx.utils.Array; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlayerAbilitiesTest { + + private FakePreferences prefs; + private PlayerAbilities abilities; + + @BeforeEach + void setUp() { + prefs = new FakePreferences(); + abilities = new PlayerAbilities(prefs); + } + + @Test + void defaults_whenFreshPreferences_thenFreezeAndExplosionUnlocked() { + // Defaults in current code: freeze=true, explosion=true, others=false. + assertThat(abilities.hasAbility("freeze")).isTrue(); + assertThat(abilities.hasAbility("explosion")).isTrue(); + assertThat(abilities.hasAbility("shield")).isFalse(); + assertThat(abilities.hasAbility("impulse")).isFalse(); + assertThat(abilities.hasAbility("dash")).isFalse(); + assertThat(abilities.hasAbility("pull")).isFalse(); + assertThat(abilities.hasAbility("turret")).isFalse(); + } + + @Test + void purchaseAbility_whenShield_thenUnlocksAndPersists() { + abilities.purchaseAbility("shield"); + + assertThat(abilities.hasAbility("shield")).isTrue(); + assertThat(prefs.getBoolean("has_shield_ability", false)).isTrue(); + } + + @Test + void purchaseAbility_whenUnknownType_thenNoChange() { + abilities.purchaseAbility("rocket_launcher"); + + // No crash, no state change to known abilities. + assertThat(abilities.hasAbility("shield")).isFalse(); + } + + @Test + void hasAbility_whenUnknownType_thenFalse() { + assertThat(abilities.hasAbility("rocket_launcher")).isFalse(); + } + + @Test + void getPurchasedAbilities_whenDefaults_thenContainsFreezeAndExplosion() { + Array list = abilities.getPurchasedAbilities(); + + assertThat(list).containsExactly("freeze", "explosion"); + } + + @Test + void getPurchasedAbilities_afterShieldPurchase_thenIncludesShield() { + abilities.purchaseAbility("shield"); + + Array list = abilities.getPurchasedAbilities(); + + assertThat(list).contains("shield"); + } + + @Test + void construct_whenPreferencesPrePopulated_thenHonoursStored() { + FakePreferences seeded = new FakePreferences(); + seeded.putBoolean("has_freeze_ability", false); + seeded.putBoolean("has_shield_ability", true); + + PlayerAbilities fresh = new PlayerAbilities(seeded); + + assertThat(fresh.hasAbility("freeze")).isFalse(); + assertThat(fresh.hasAbility("shield")).isTrue(); + } + + @Test + void resetData_whenInvoked_thenAllFalseAndPersisted() { + abilities.purchaseAbility("shield"); + + abilities.resetData(); + + assertThat(abilities.hasAbility("freeze")).isFalse(); + assertThat(abilities.hasAbility("explosion")).isFalse(); + assertThat(abilities.hasAbility("shield")).isFalse(); + assertThat(prefs.getBoolean("has_shield_ability", true)).isFalse(); + } +} +``` + +Note on `containsExactly`: AssertJ's support for libGDX `Array` works because AssertJ accepts any `Iterable`; `com.badlogic.gdx.utils.Array` implements `Iterable` since libGDX 1.x. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./gradlew core:test --tests PlayerAbilitiesTest` +Expected: `FAIL` with "constructor PlayerAbilities(FakePreferences) not found". + +- [ ] **Step 3: Refactor `PlayerAbilities`** + +Modify `core/src/main/java/ru/project/tower/PlayerAbilities.java`. Same shape as `PlayerStats`: add public ctor, delegate the legacy private ctor. + +Replace the existing ctor block and `getInstance()` (lines 33–77 in the current file) with: + +```java + /** + * DI-friendly constructor. Used by {@link GameContext} and tests. + */ + public PlayerAbilities(Preferences preferences) { + try { + DebugLog.log("PlayerAbilities", "Initializing PlayerAbilities"); + this.preferences = preferences; + DebugLog.log("PlayerAbilities", "Got preferences"); + + hasFreezeAbility = preferences.getBoolean(FREEZE_ABILITY_KEY, true); + hasExplosionAbility = preferences.getBoolean(EXPLOSION_ABILITY_KEY, true); + hasShieldAbility = preferences.getBoolean(SHIELD_ABILITY_KEY, false); + hasImpulseAbility = preferences.getBoolean(IMPULSE_ABILITY_KEY, false); + hasDashAbility = preferences.getBoolean(DASH_ABILITY_KEY, false); + hasPullAbility = preferences.getBoolean(PULL_ABILITY_KEY, false); + hasTurretAbility = preferences.getBoolean(TURRET_ABILITY_KEY, false); + + DebugLog.log("PlayerAbilities", String.format( + "Loaded abilities - Freeze: %b, Explosion: %b, Shield: %b, Impulse: %b, Dash: %b, Pull: %b, Turret: %b", + hasFreezeAbility, hasExplosionAbility, hasShieldAbility, hasImpulseAbility, + hasDashAbility, hasPullAbility, hasTurretAbility + )); + } catch (Exception e) { + Gdx.app.error("PlayerAbilities", "Error in constructor: ", e); + throw e; + } + } + + /** + * Legacy singleton accessor. Delete in Task 10 once call-sites use GameContext. + */ + public static PlayerAbilities getInstance() { + try { + if (instance == null) { + DebugLog.log("PlayerAbilities", "Creating new instance"); + instance = new PlayerAbilities(Gdx.app.getPreferences(PREFS_NAME)); + DebugLog.log("PlayerAbilities", "Instance created"); + } + return instance; + } catch (Exception e) { + Gdx.app.error("PlayerAbilities", "Error in getInstance: ", e); + throw e; + } + } +``` + +Constants `PREFS_NAME`, `FREEZE_ABILITY_KEY`, etc. remain as-is. The `private PlayerAbilities()` ctor is replaced. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew core:test --tests PlayerAbilitiesTest` +Expected: all 8 tests pass. + +- [ ] **Step 5: Compile check** + +Run: `./gradlew core:compileJava` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/ru/project/tower/PlayerAbilities.java \ + core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java +git commit -m "refactor(PlayerAbilities): expose public ctor + add unit tests" +``` + +--- + +## Task 5: `GameState` — DI refactor + tests + +**Files:** +- Modify: `core/src/main/java/ru/project/tower/GameState.java` +- Create: `core/src/test/java/ru/project/tower/GameStateTest.java` + +`GameState` has no `Preferences`; the refactor simply makes the private ctor public. Tests avoid methods that take a `FirstScreen` argument (those are coupled to libGDX; they stay covered by the manual smoke test in Task 11). + +- [ ] **Step 1: Write the failing tests** + +Create `core/src/test/java/ru/project/tower/GameStateTest.java`: + +```java +package ru.project.tower; + +import com.badlogic.gdx.utils.Array; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameStateTest { + + private GameState state; + + @BeforeEach + void setUp() { + state = new GameState(); + } + + @Test + void freshState_whenCreated_thenNotReturnedFromShop() { + assertThat(state.isReturnedFromShop()).isFalse(); + } + + @Test + void freshState_whenCreated_thenSelectedAbilitiesEmpty() { + assertThat(state.getSelectedAbilities().size).isZero(); + } + + @Test + void freshState_whenCreated_thenSpawnedBossesEmpty() { + assertThat(state.getSpawnedBosses().size).isZero(); + } + + @Test + void setSelectedAbilities_whenCalled_thenReplacesPreviousList() { + Array first = new Array<>(); + first.add("freeze"); + state.setSelectedAbilities(first); + + Array second = new Array<>(); + second.add("dash"); + second.add("pull"); + state.setSelectedAbilities(second); + + Array stored = state.getSelectedAbilities(); + assertThat(stored.size).isEqualTo(2); + assertThat(stored).contains("dash", "pull"); + } + + @Test + void resetGameState_whenInvoked_thenClearsReturnFlagAndAbilities() { + Array initial = new Array<>(); + initial.add("freeze"); + state.setSelectedAbilities(initial); + // No public setter for returnedFromShop; the flag flips to true in + // saveGameState(FirstScreen), which we cannot call without the screen. + // We therefore verify the reset clears the abilities list. + + state.resetGameState(); + + assertThat(state.getSelectedAbilities().size).isZero(); + assertThat(state.isReturnedFromShop()).isFalse(); + } + + @Test + void setSpawnedBosses_whenCalled_thenGetSpawnedBossesReturnsSame() { + Array bosses = new Array<>(); + bosses.add("juggernaut"); + + state.setSpawnedBosses(bosses); + + assertThat(state.getSpawnedBosses()).isSameAs(bosses); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./gradlew core:test --tests GameStateTest` +Expected: `FAIL` with "GameState() has private access". + +- [ ] **Step 3: Refactor `GameState`** + +In `core/src/main/java/ru/project/tower/GameState.java`, change line 45 (`private GameState() { }`) to: + +```java + public GameState() { + } +``` + +That is literally the entire production change for this task. Leave `instance`, `getInstance()`, and every other method untouched. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew core:test --tests GameStateTest` +Expected: all 6 tests pass. + +- [ ] **Step 5: Compile check** + +Run: `./gradlew core:compileJava` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/ru/project/tower/GameState.java \ + core/src/test/java/ru/project/tower/GameStateTest.java +git commit -m "refactor(GameState): expose public ctor + add unit tests" +``` + +--- + +## Task 6: `TalentTree` — DI refactor + tests + +**Files:** +- Modify: `core/src/main/java/ru/project/tower/TalentTree.java` +- Create: `core/src/test/java/ru/project/tower/TalentTreeTest.java` + +`TalentTree` must hold a `PlayerStats` reference. After this task, the class has both its legacy zero-arg ctor (delegating via `PlayerStats.getInstance()`) and the new DI ctor. + +- [ ] **Step 1: Write the failing tests** + +Create `core/src/test/java/ru/project/tower/TalentTreeTest.java`: + +```java +package ru.project.tower; + +import com.badlogic.gdx.utils.Array; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TalentTreeTest { + + private FakePreferences prefs; + private PlayerStats stats; + private TalentTree tree; + + @BeforeEach + void setUp() { + prefs = new FakePreferences(); + stats = new PlayerStats(prefs); + tree = new TalentTree(stats); + } + + @Test + void getTalent_whenHealth1_thenReturnsRootNode() { + TalentData root = tree.getTalent("health_1"); + + assertThat(root).isNotNull(); + assertThat(root.getType()).isEqualTo(TalentData.TalentType.HEALTH); + assertThat(root.getDependencyIds().size).isZero(); + } + + @Test + void getAllTalents_thenContainsAllExpectedIds() { + Array all = tree.getAllTalents(); + + assertThat(all.size).isEqualTo(12); + } + + @Test + void isUnlocked_whenRoot_thenTrue() { + assertThat(tree.isUnlocked("health_1")).isTrue(); + } + + @Test + void isUnlocked_whenDependencyNotMet_thenFalse() { + assertThat(tree.isUnlocked("damage_1")).isFalse(); + } + + @Test + void isUnlocked_whenDependencyHasLevel_thenTrue() { + stats.incrementTalentNode("health_1"); + + assertThat(tree.isUnlocked("damage_1")).isTrue(); + } + + @Test + void getCurrentLevel_whenDelegatedToStats_thenMatches() { + stats.incrementTalentNode("damage_1"); + stats.incrementTalentNode("damage_1"); + + assertThat(tree.getCurrentLevel("damage_1")).isEqualTo(2); + } + + @Test + void getCurrentLevel_whenUnknownTalent_thenZero() { + assertThat(tree.getCurrentLevel("nonexistent")).isZero(); + } + + @Test + void upgradeTalent_whenCalled_thenStatsLevelIncrements() { + tree.upgradeTalent("damage_1"); + + assertThat(stats.getTalentNodeLevel("damage_1")).isEqualTo(1); + } + + @Test + void upgradeTalent_whenUnknownId_thenNoCrash() { + tree.upgradeTalent("nonexistent"); + // No assertion needed; the test passes if no exception was thrown. + } + + @Test + void keystoneNodes_thenAllRegistered() { + assertThat(tree.getTalent("keystone_pierce")).isNotNull(); + assertThat(tree.getTalent("keystone_crit")).isNotNull(); + assertThat(tree.getTalent("keystone_aoe")).isNotNull(); + } + + @Test + void getStatBonus_whenDamage1HasLevel_thenBonusOnePerLevel() { + stats.incrementTalentNode("damage_1"); + stats.incrementTalentNode("damage_1"); + + float bonus = tree.getStatBonus(TalentData.TalentType.DAMAGE); + + assertThat(bonus).isEqualTo(2f); + } + + @Test + void getStatBonus_whenNoLevels_thenZero() { + assertThat(tree.getStatBonus(TalentData.TalentType.DAMAGE)).isZero(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./gradlew core:test --tests TalentTreeTest` +Expected: `FAIL` with "constructor TalentTree(PlayerStats) not found". + +- [ ] **Step 3: Refactor `TalentTree`** + +In `core/src/main/java/ru/project/tower/TalentTree.java`: + +1. Add a `private final PlayerStats stats;` field. +2. Replace the single constructor (lines 14–18) and `getInstance()` (lines 20–25): + +```java + private final PlayerStats stats; + + /** + * DI-friendly constructor. Used by {@link GameContext} and tests. + */ + public TalentTree(PlayerStats stats) { + this.stats = stats; + this.talents = new ObjectMap<>(); + this.roots = new Array<>(); + initializeTree(); + } + + /** + * Legacy singleton accessor. Delete in Task 10 once call-sites use GameContext. + */ + public static TalentTree getInstance() { + if (instance == null) { + instance = new TalentTree(PlayerStats.getInstance()); + } + return instance; + } +``` + +3. Replace line 133 (`return PlayerStats.getInstance().getTalentNodeLevel(talentId);`) with: + +```java + return stats.getTalentNodeLevel(talentId); +``` + +4. Replace line 138 (`PlayerStats.getInstance().incrementTalentNode(talentId);`) with: + +```java + stats.incrementTalentNode(talentId); +``` + +Leave the rest of the file (tree initialization, `getStatBonus`, etc.) unchanged. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew core:test --tests TalentTreeTest` +Expected: all 12 tests pass. + +- [ ] **Step 5: Compile check** + +Run: `./gradlew core:compileJava` +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/ru/project/tower/TalentTree.java \ + core/src/test/java/ru/project/tower/TalentTreeTest.java +git commit -m "refactor(TalentTree): inject PlayerStats via ctor + add unit tests" +``` + +--- + +## Task 7: `TalentData` — tests only + +**Files:** +- Create: `core/src/test/java/ru/project/tower/TalentDataTest.java` + +No refactor needed; `TalentData` is a plain data class. + +- [ ] **Step 1: Write the tests** + +Create `core/src/test/java/ru/project/tower/TalentDataTest.java`: + +```java +package ru.project.tower; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TalentDataTest { + + @Test + void getters_whenConstructed_thenReturnProvidedValues() { + TalentData data = new TalentData( + "id_x", "Name", "Desc", TalentData.TalentType.DAMAGE, + 10f, 20f, 5, 100, 1.5f); + + assertThat(data.getId()).isEqualTo("id_x"); + assertThat(data.getName()).isEqualTo("Name"); + assertThat(data.getDescription()).isEqualTo("Desc"); + assertThat(data.getType()).isEqualTo(TalentData.TalentType.DAMAGE); + assertThat(data.getPosition().x).isEqualTo(10f); + assertThat(data.getPosition().y).isEqualTo(20f); + assertThat(data.getMaxLevel()).isEqualTo(5); + } + + @Test + void addDependency_whenCalled_thenPresentInDependencyIds() { + TalentData data = new TalentData( + "child", "", "", TalentData.TalentType.HEALTH, 0, 0, 1, 10, 1f); + + data.addDependency("parent"); + + assertThat(data.getDependencyIds()).containsExactly("parent"); + } + + @Test + void getCost_whenLevelZero_thenBaseCost() { + TalentData data = new TalentData( + "id_x", "", "", TalentData.TalentType.DAMAGE, + 0, 0, 5, 100, 1.5f); + + assertThat(data.getCost(0)).isEqualTo(100); + } + + @Test + void getCost_whenLevelOne_thenBaseCostTimesMultiplier() { + TalentData data = new TalentData( + "id_x", "", "", TalentData.TalentType.DAMAGE, + 0, 0, 5, 100, 1.5f); + + // 100 * 1.5^1 = 150 (int cast) + assertThat(data.getCost(1)).isEqualTo(150); + } + + @Test + void getCost_whenLevelTwo_thenAppliesExponent() { + TalentData data = new TalentData( + "id_x", "", "", TalentData.TalentType.DAMAGE, + 0, 0, 5, 100, 1.5f); + + // 100 * 1.5^2 = 225 (int cast) + assertThat(data.getCost(2)).isEqualTo(225); + } + + @Test + void enum_whenKeystone_thenEnumMatches() { + assertThat(TalentData.TalentType.KEYSTONE).isNotEqualTo(TalentData.TalentType.DAMAGE); + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `./gradlew core:test --tests TalentDataTest` +Expected: all 6 tests pass (no production change needed). + +- [ ] **Step 3: Commit** + +```bash +git add core/src/test/java/ru/project/tower/TalentDataTest.java +git commit -m "test(TalentData): add unit tests for data accessors and cost formula" +``` + +--- + +## Task 8: `GameContext` + its test + +**Files:** +- Create: `core/src/main/java/ru/project/tower/GameContext.java` +- Create: `core/src/test/java/ru/project/tower/GameContextTest.java` + +- [ ] **Step 1: Write the failing test** + +Create `core/src/test/java/ru/project/tower/GameContextTest.java`: + +```java +package ru.project.tower; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameContextTest { + + @Test + void construct_whenGivenTwoPreferences_thenAllServicesNonNull() { + FakePreferences playerData = new FakePreferences(); + FakePreferences abilities = new FakePreferences(); + + GameContext ctx = new GameContext(playerData, abilities); + + assertThat(ctx.playerStats).isNotNull(); + assertThat(ctx.playerAbilities).isNotNull(); + assertThat(ctx.gameState).isNotNull(); + assertThat(ctx.talentTree).isNotNull(); + assertThat(ctx.playerDataPrefs).isSameAs(playerData); + assertThat(ctx.abilitiesPrefs).isSameAs(abilities); + } + + @Test + void construct_whenTalentTreeQueriesPlayerStats_thenReadsInjectedInstance() { + FakePreferences playerData = new FakePreferences(); + FakePreferences abilities = new FakePreferences(); + + GameContext ctx = new GameContext(playerData, abilities); + ctx.playerStats.incrementTalentNode("damage_1"); + + assertThat(ctx.talentTree.getCurrentLevel("damage_1")).isEqualTo(1); + } + + @Test + void construct_whenPlayerDataPrefsSeeded_thenPlayerStatsReflects() { + FakePreferences playerData = new FakePreferences(); + playerData.putInteger("player_currency", 500); + FakePreferences abilities = new FakePreferences(); + + GameContext ctx = new GameContext(playerData, abilities); + + assertThat(ctx.playerStats.getCurrency()).isEqualTo(500); + } + + @Test + void construct_whenAbilitiesPrefsSeeded_thenPlayerAbilitiesReflects() { + FakePreferences playerData = new FakePreferences(); + FakePreferences abilities = new FakePreferences(); + abilities.putBoolean("has_shield_ability", true); + + GameContext ctx = new GameContext(playerData, abilities); + + assertThat(ctx.playerAbilities.hasAbility("shield")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew core:test --tests GameContextTest` +Expected: `FAIL` with "cannot find symbol: class GameContext". + +- [ ] **Step 3: Create `GameContext`** + +Create `core/src/main/java/ru/project/tower/GameContext.java`: + +```java +package ru.project.tower; + +import com.badlogic.gdx.Preferences; + +/** + * Composition-root holder for shared persistent-state services. + * Construction order is load-bearing: {@link TalentTree} depends on {@link PlayerStats}. + */ +public class GameContext { + + public final Preferences playerDataPrefs; + public final Preferences abilitiesPrefs; + public final PlayerStats playerStats; + public final PlayerAbilities playerAbilities; + public final GameState gameState; + public final TalentTree talentTree; + + public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) { + this.playerDataPrefs = playerDataPrefs; + this.abilitiesPrefs = abilitiesPrefs; + this.playerStats = new PlayerStats(playerDataPrefs); + this.playerAbilities = new PlayerAbilities(abilitiesPrefs); + this.gameState = new GameState(); + this.talentTree = new TalentTree(this.playerStats); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew core:test --tests GameContextTest` +Expected: all 4 tests pass. + +Run the full suite: `./gradlew core:test` +Expected: all accumulated tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/java/ru/project/tower/GameContext.java \ + core/src/test/java/ru/project/tower/GameContextTest.java +git commit -m "feat: add GameContext composition-root holder" +``` + +--- + +## Task 9: Thread `GameContext` through `Main` and all five screens (atomic refactor) + +**Files:** +- Modify: `core/src/main/java/ru/project/tower/Main.java` +- Modify: `core/src/main/java/ru/project/tower/MainScreen.java` +- Modify: `core/src/main/java/ru/project/tower/FirstScreen.java` +- Modify: `core/src/main/java/ru/project/tower/AbilityShopScreen.java` +- Modify: `core/src/main/java/ru/project/tower/AbilitySelectionScreen.java` +- Modify: `core/src/main/java/ru/project/tower/TalentTreeScreen.java` + +This is a single atomic refactor: every screen constructor changes signature in one sweep so there is **no intermediate transition state** where some screens use `ctx` and others use `getInstance()`. Without atomicity, `GameState` (which is in-memory only, not `Preferences`-backed) would drift across two live instances during the transition and break the shop save/restore flow. + +The `getInstance()` methods themselves are not removed here — they stay alive but unused until Task 10. That keeps the diff focused on the wiring and lets Task 10 be a pure deletion. + +Mid-task the build will be red because each file edit leaves downstream call-sites broken until the next file is updated. That is expected — the build goes green only after all sub-steps are applied. + +- [ ] **Step 1: `Main.java` — build `GameContext` and pass it to `MainScreen`** + +Replace the entire file with: + +```java +package ru.project.tower; + +import com.badlogic.gdx.Game; +import com.badlogic.gdx.Gdx; + + +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"; + + public GameContext ctx; + + @Override + public void create() { + ctx = new GameContext( + Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME), + Gdx.app.getPreferences(ABILITIES_PREFS_NAME) + ); + setScreen(new MainScreen(this, ctx)); + } +} +``` + +- [ ] **Step 2: `MainScreen.java` — accept `GameContext`, pass to downstream screens** + +In `core/src/main/java/ru/project/tower/MainScreen.java`: + +1. Add a field near the existing private fields: + +```java + private final GameContext ctx; +``` + +2. Change the ctor signature at line 87 from `public MainScreen(Game game) {` to: + +```java + public MainScreen(Game game, GameContext ctx) { + this.ctx = ctx; +``` + +Leave the rest of the body identical (only the first assignment is added). + +3. Update downstream screen-construction call-sites: + +| Line | Before | After | +|---|---|---| +| 222 | `game.setScreen(new AbilitySelectionScreen(game));` | `game.setScreen(new AbilitySelectionScreen(game, ctx));` | +| 231 | `game.setScreen(new TalentTreeScreen(game));` | `game.setScreen(new TalentTreeScreen(game, ctx));` | +| 251 | `game.setScreen(new AbilityShopScreen(game));` | `game.setScreen(new AbilityShopScreen(game, ctx));` | + +- [ ] **Step 3: `FirstScreen.java` — accept `GameContext`, cache four services, replace 13 call-sites** + +In `core/src/main/java/ru/project/tower/FirstScreen.java`: + +1. Add fields in the field-declarations block (roughly near line 490 where `PlayerStats playerStats` and `PlayerAbilities playerAbilities` are already declared): + +```java + private final GameContext ctx; + private GameState gameState; + private TalentTree talentTree; +``` + +If `playerStats` and `playerAbilities` are already declared with `private` but without `final`, leave them as-is. The existing code later assigns them via `= PlayerStats.getInstance()`; we redirect those assignments below. + +2. Change the ctor signature at line 496 from `public FirstScreen(Game game) {` to: + +```java + public FirstScreen(Game game, GameContext ctx) { + this.ctx = ctx; +``` + +Leave the rest of the ctor body identical except for the field-wiring update in Step 3.4 below. + +3. Replace the 13 `getInstance()` call-sites. Line numbers are from the pre-refactor state; after Steps 1–2 lines drift, so search by symbol. + +| Current line | Before | After | +|---|---|---| +| 179 | `return (long) TalentTree.getInstance().getStatBonus(TalentData.TalentType.COOLDOWN);` | `return (long) talentTree.getStatBonus(TalentData.TalentType.COOLDOWN);` | +| 502 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | +| 579 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | +| 617 | `TalentTree talentTree = TalentTree.getInstance();` | `TalentTree talentTree = ctx.talentTree;` | +| 644 | `playerStats = PlayerStats.getInstance();` | `playerStats = ctx.playerStats;` | +| 645 | `playerAbilities = PlayerAbilities.getInstance();` | `playerAbilities = ctx.playerAbilities;` | +| 1366 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | +| 1474 | `TalentTree talentTree = TalentTree.getInstance();` | `TalentTree talentTree = ctx.talentTree;` | +| 1497 | `GameState.getInstance().resetGameState();` | `ctx.gameState.resetGameState();` | +| 1794 | `TalentTree tt = TalentTree.getInstance();` | `TalentTree tt = ctx.talentTree;` | +| 2634 | `GameState.getInstance().setSpawnedBosses(spawnedBosses);` | `ctx.gameState.setSpawnedBosses(spawnedBosses);` | +| 5737 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | +| 6756 | `int critKeystone = TalentTree.getInstance().getCurrentLevel("keystone_crit");` | `int critKeystone = ctx.talentTree.getCurrentLevel("keystone_crit");` | + +4. **Immediately after line 645 (where `playerAbilities = ctx.playerAbilities;` now lives)**, initialize the new cached fields so later code that references the local `gameState` / `talentTree` can fall back to them when not shadowed: + +```java + gameState = ctx.gameState; + talentTree = ctx.talentTree; +``` + +5. Update the internal screen-construction call in `FirstScreen`: + +| Line | Before | After | +|---|---|---| +| 6066 | `game.setScreen(new MainScreen(game));` | `game.setScreen(new MainScreen(game, ctx));` | + +- [ ] **Step 4: `AbilityShopScreen.java` — accept `GameContext`, replace 2 call-sites** + +In `core/src/main/java/ru/project/tower/AbilityShopScreen.java`: + +1. Add field: `private final GameContext ctx;`. +2. Change ctor signature at line 65 to `public AbilityShopScreen(Game game, GameContext ctx) {` and add `this.ctx = ctx;` as the first line of the body. +3. Replace call-sites: + +| Line | Before | After | +|---|---|---| +| 69 | `this.playerStats = PlayerStats.getInstance();` | `this.playerStats = ctx.playerStats;` | +| 70 | `this.playerAbilities = PlayerAbilities.getInstance();` | `this.playerAbilities = ctx.playerAbilities;` | +| 209 | `game.setScreen(new MainScreen(game));` | `game.setScreen(new MainScreen(game, ctx));` | + +- [ ] **Step 5: `AbilitySelectionScreen.java` — accept `GameContext`, replace 2 call-sites + 2 screen-construction sites** + +In `core/src/main/java/ru/project/tower/AbilitySelectionScreen.java`: + +1. Add field: `private final GameContext ctx;`. +2. Change ctor signature at line 94 to `public AbilitySelectionScreen(Game game, GameContext ctx) {` and add `this.ctx = ctx;` as the first line of the body. +3. Replace call-sites: + +| Line | Before | After | +|---|---|---| +| 171 | `PlayerAbilities playerAbilities = PlayerAbilities.getInstance();` | `PlayerAbilities playerAbilities = ctx.playerAbilities;` | +| 219 | `gameInstance.setScreen(new MainScreen(gameInstance));` | `gameInstance.setScreen(new MainScreen(gameInstance, ctx));` | +| 340 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | +| 371 | `FirstScreen firstScreen = new FirstScreen(gameInstance);` | `FirstScreen firstScreen = new FirstScreen(gameInstance, ctx);` | + +- [ ] **Step 6: `TalentTreeScreen.java` — accept `GameContext`, replace 2 call-sites** + +In `core/src/main/java/ru/project/tower/TalentTreeScreen.java`: + +1. Add field: `private final GameContext ctx;`. +2. Change ctor signature at line 65 to `public TalentTreeScreen(Game game, GameContext ctx) {` and add `this.ctx = ctx;` as the first line of the body. +3. Replace call-sites: + +| Line | Before | After | +|---|---|---| +| 98 | `this.talentTree = TalentTree.getInstance();` | `this.talentTree = ctx.talentTree;` | +| 99 | `this.playerStats = PlayerStats.getInstance();` | `this.playerStats = ctx.playerStats;` | +| 248 | `game.setScreen(new MainScreen(game));` | `game.setScreen(new MainScreen(game, ctx));` | + +- [ ] **Step 7: Compile check** + +Run: `./gradlew core:compileJava` +Expected: `BUILD SUCCESSFUL`. All screen ctors take two args, all call-sites pass two args. + +If compile fails, search for missed `new XxxScreen(` patterns: `rg 'new (Main|First|AbilityShop|AbilitySelection|TalentTree)Screen\(' core/src/main | rg -v 'ctx'` should return zero hits. + +- [ ] **Step 8: Run test suite (no regression)** + +Run: `./gradlew core:test` +Expected: all tests pass. + +Tests do not depend on screens, so the only way this fails is if a prior task's test was accidentally broken while touching singleton files. Unlikely, but good to verify. + +- [ ] **Step 9: Manual smoke test** + +Launch `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`. + +Verify: +- Main menu renders with persisted currency / max wave. +- Navigate: main menu → Ability Shop → back. +- Navigate: main menu → Talent Tree → buy one level → back. Currency decremented. +- Navigate: main menu → Ability Selection → pick freeze + explosion → Start Game. Wave 1 plays. +- During wave: fire bullets, kill enemies. Enter shop mid-run if the game allows it; buy an upgrade; return to game. Confirm the upgrade applied (shop save/restore uses in-memory `GameState` — if `returnedFromShop` flag is lost, stats will reset on return). +- Die. Return to menu. Currency reflects collected loot. + +- [ ] **Step 10: Commit** + +```bash +git add core/src/main/java/ru/project/tower/Main.java \ + core/src/main/java/ru/project/tower/MainScreen.java \ + core/src/main/java/ru/project/tower/FirstScreen.java \ + core/src/main/java/ru/project/tower/AbilityShopScreen.java \ + core/src/main/java/ru/project/tower/AbilitySelectionScreen.java \ + core/src/main/java/ru/project/tower/TalentTreeScreen.java +git commit -m "refactor: thread GameContext through Main and all screens" +``` + +--- + +## Task 10: Remove legacy `getInstance()` methods and `instance` fields + +Every non-test call-site now uses `ctx.x` after Task 9. Time to delete the legacy paths. + +**Files:** +- Modify: `core/src/main/java/ru/project/tower/PlayerStats.java` +- Modify: `core/src/main/java/ru/project/tower/PlayerAbilities.java` +- Modify: `core/src/main/java/ru/project/tower/GameState.java` +- Modify: `core/src/main/java/ru/project/tower/TalentTree.java` + +- [ ] **Step 1: Verify no call-sites remain in production code** + +Run: `rg 'getInstance\(' core/src/main` +Expected: zero matches. + +If any match appears, it's a missed call-site in Task 9 — fix it and re-run before continuing. + +- [ ] **Step 2: Delete `getInstance()` and `private static instance` from each singleton** + +In each of `PlayerStats.java`, `PlayerAbilities.java`, `GameState.java`, `TalentTree.java`: + +1. Delete the `private static instance;` field. +2. Delete the `public static getInstance()` method. + +In `PlayerStats.java`, also delete: +- The `PREFS_NAME` constant (the literal moved to `Main.java` in Task 9). +- The `import com.badlogic.gdx.Gdx;` line — no longer used after removing `getInstance()`. + +In `PlayerAbilities.java`, also delete: +- The `PREFS_NAME` constant. +- Keep the `import com.badlogic.gdx.Gdx;` line — the ctor still logs errors via `Gdx.app.error(...)`. + +- [ ] **Step 3: Compile check** + +Run: `./gradlew core:compileJava` +Expected: `BUILD SUCCESSFUL`. No call-sites, no references to the deleted symbols. + +- [ ] **Step 4: Run test suite** + +Run: `./gradlew core:test` +Expected: all tests pass. + +The tests themselves do not call `getInstance()` (they construct services directly) — if any test is red here, it's a regression in how a service is built. Most likely culprit: accidentally deleted `preferences` field instead of just `instance`. + +- [ ] **Step 5: Grep-gate to confirm removal** + +Run: `rg 'getInstance\(' core/src/main` +Expected: zero matches. + +Run: `rg 'private static \w+ instance;' core/src/main` +Expected: zero matches. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/ru/project/tower/PlayerStats.java \ + core/src/main/java/ru/project/tower/PlayerAbilities.java \ + core/src/main/java/ru/project/tower/GameState.java \ + core/src/main/java/ru/project/tower/TalentTree.java +git commit -m "refactor: remove legacy singleton getInstance methods" +``` + +--- + +## Task 11: Final verification + +No code change — confirmation that the refactor is complete and the game still runs. + +- [ ] **Step 1: Full test run** + +Run: `./gradlew core:test` +Expected: all tests pass. Count should be roughly: `HarnessSanityTest (1)` + `FakePreferencesTest (7)` + `PlayerStatsTest (13)` + `PlayerAbilitiesTest (8)` + `GameStateTest (6)` + `TalentTreeTest (12)` + `TalentDataTest (6)` + `GameContextTest (4)` = **57 tests**, all green. + +- [ ] **Step 2: Full build** + +Run: `./gradlew build` +Expected: `BUILD SUCCESSFUL`. `lwjgl3` and `android` modules compile. + +- [ ] **Step 3: Desktop launcher smoke test** + +Launch `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`. + +Verify: +- Main menu renders. Currency and max-wave values show up (non-zero if a previous session existed; otherwise zero — try the game once to seed them before running this check if you started from a clean `Preferences`). +- Click "Talent Tree" → tree displays. Purchase one talent level. Return to menu — currency decremented. +- Click "Ability Shop" → shop displays owned abilities with correct enabled state. +- Click "Start Game" → ability-selection → first wave plays. Fire bullets, kill an enemy, collect currency, die. Return to menu — currency increased. +- Close and reopen the launcher. Previous currency is persisted. + +- [ ] **Step 4: Final commit checkpoint** + +```bash +git add -A +git status # should show clean working tree — no stray modifications +git commit --allow-empty -m "chore(K.1): Phase 3.K.1 complete — test harness + DI refactor" +``` + +(If the project is not a git repository, skip the commit and treat this as a mental save-point — confirm that `git status` equivalent in your workflow shows the expected file set.) + +--- + +## Spec coverage + +Mapping from the spec's requirements to the tasks that satisfy them: + +| Spec requirement | Task(s) | +|---|---| +| DI refactor: `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree` | 3, 4, 5, 6 | +| `GameContext` holder (public final fields, construction order) | 8 | +| `Main` as composition root | 9 | +| Screen ctors accept `GameContext` | 9 | +| `FakePreferences` test helper | 2 | +| JUnit 5 + AssertJ build config | 1 | +| Test class layout under `core/src/test/java/ru/project/tower/` | all tasks 2+ | +| `PlayerStatsTest` per spec test list | 3 | +| `PlayerAbilitiesTest` per spec test list | 4 | +| `GameStateTest` per spec test list | 5 | +| `TalentTreeTest` per spec test list | 6 | +| `TalentDataTest` per spec test list | 7 | +| `GameContextTest` per spec test list | 8 | +| Deletion of `getInstance()` methods | 10 | +| Manual desktop-launcher smoke test | 9, 11 | +| Headless libGDX *not* used | (entire plan — proved by use of `FakePreferences` only) | + +All spec requirements have at least one task. No placeholders. No references to methods introduced in later tasks without prior definition. diff --git a/docs/superpowers/specs/2026-04-17-phase3-k1-test-harness-design.md b/docs/superpowers/specs/2026-04-17-phase3-k1-test-harness-design.md new file mode 100644 index 0000000..0125b76 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-phase3-k1-test-harness-design.md @@ -0,0 +1,312 @@ +# Phase 3.K.1 — Test harness foundation (DI refactor + pure-logic tests) + +**Status:** Design, awaiting review +**Date:** 2026-04-17 +**Parent:** Phase 3, Area K (Test harness). Originally a single P4 task in `2026-04-17-phase3-content-meta-retention.md`; expanded into the K.x series after brainstorming. + +--- + +## Goal + +Establish a test harness in the TowerAn project that serves as a **safety net for future refactoring**. Introduce unit tests for pure-logic singletons (`PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, `TalentData`) so that subsequent extraction work in the `FirstScreen` god-class (planned as spec series K.2–K.7) can proceed without fear of silent regressions. + +The spec also delivers the architectural prerequisite for testability: **removing the `getInstance()` singleton pattern** from the persistent-state classes, replacing it with constructor injection via a lightweight `GameContext` holder. + +## Non-goals + +- Any extraction of logic out of `FirstScreen`. Those are separate specs (K.2 `BulletSystem`, K.3 `SpawnController`, K.4 `WaveController`, K.5 `EnemyBehaviour`, K.6 `AbilitySystem`, K.7 `UpgradeSystem`). K.1 only prepares the ground. +- Tests for `Achievements` and `DailySeed`. These classes do not exist yet (Phase 3 Tasks G1 / H1); their tests are authored alongside the classes, not in K.1. +- Tests for enemy behaviour (`BaseCircleData` and subclasses). Deferred to K.5 because the new Phase 3 archetypes (Healer, Dasher, Sniper, Gnat) are not yet implemented. +- Code coverage gating (JaCoCo). Introducing coverage thresholds without baseline history is premature. +- CI integration (GitHub Actions or similar). Scope is local `./gradlew test`. CI wiring can be a tiny follow-up once K.1 is merged. +- Headless libGDX (`HeadlessApplication`). Not needed because the DI refactor lets tests supply an in-memory `Preferences` directly. + +## Background + +TowerAn is a libGDX 1.13.1 game on Java 8, gdx-liftoff template, flat `ru.project.tower` package. There are currently **no tests of any kind** — no JUnit, no Spock, no manual harness. Verification has been purely visual: launch `Lwjgl3Launcher`, play, watch. + +Five classes hold persistent or shared state through a static-singleton pattern: + +- `PlayerStats` — currency, max wave, talent node levels. Backed by `Preferences` (`tower_attack_player_data`). `getInstance()` eagerly calls `Gdx.app.getPreferences(...)`. +- `PlayerAbilities` — unlocked abilities, levels. Backed by `Preferences` (`tower_attack_player_abilities`). Same pattern. +- `GameState` — in-memory run state (current wave, square health, money, run bonuses). No `Preferences`. +- `TalentTree` — tree structure + level queries that call `PlayerStats.getInstance()` internally (lines 133, 138). +- `TalentData` — plain data class used by `TalentTree`. No singleton; already testable but currently untested. + +`getInstance()` usage is concentrated in five files (15 calls): `AbilitySelectionScreen` (2), `AbilityShopScreen` (2), `FirstScreen` (8), `TalentTreeScreen` (1), `TalentTree` (2). + +Because singletons eagerly touch `Gdx.app` and `Preferences` on first `getInstance()`, any test that instantiates them fails outside a running libGDX context. The options are (a) reflection-nuking the `instance` field between tests, (b) `@VisibleForTesting resetForTests()` helpers, or (c) breaking the pattern. We chose (c) — it's a one-time architectural change that makes tests clean and unlocks downstream `FirstScreen` extraction work in K.2+. + +## Architecture overview + +Three layers, implemented in this order (later layers depend on earlier ones): + +1. **DI refactor.** Delete `private static instance` and `public static getInstance()` from `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`. Make constructors public and accept their dependencies explicitly. +2. **Composition root.** Introduce `GameContext` as a holder for the refactored singletons. `Main.create()` constructs two `Preferences` instances, wraps them in a `GameContext`, and passes the `GameContext` to each screen via constructor injection. +3. **Test harness.** Add JUnit 5 + AssertJ to `core/build.gradle`. Create an in-memory `FakePreferences` helper in `core/src/test/java`. Write unit tests for the five target classes. + +Nothing in the rendering layer, the screen hierarchy, the enemy hierarchy, or the gameplay loop changes beyond mechanical `X.getInstance()` → `ctx.x` substitutions. + +## Components + +### `GameContext` (new, `core/src/main/java/ru/project/tower/GameContext.java`) + +A tiny holder with `public final` fields, idiomatic Java for an immutable data carrier (Google Java Style §5.3 allows `public final` for this role). + +```java +public class GameContext { + public final Preferences playerDataPrefs; + public final Preferences abilitiesPrefs; + public final PlayerStats playerStats; + public final PlayerAbilities playerAbilities; + public final GameState gameState; + public final TalentTree talentTree; + + public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) { + this.playerDataPrefs = playerDataPrefs; + this.abilitiesPrefs = abilitiesPrefs; + this.playerStats = new PlayerStats(playerDataPrefs); + this.playerAbilities = new PlayerAbilities(abilitiesPrefs); + this.gameState = new GameState(); + this.talentTree = new TalentTree(this.playerStats); + } +} +``` + +Construction order matters: `TalentTree` depends on `PlayerStats`, so `playerStats` is created first. `Preferences` references are retained on the holder so future services (e.g., `Achievements`, `DailySeed` in Phase 3 G/H) can reuse them without re-plumbing `Main`. + +### Singleton-to-DI conversions + +For each target, the change is the same mechanical shape. Example for `PlayerStats`: + +```java +// Before +private static PlayerStats instance; +private Preferences preferences; + +private PlayerStats() { + preferences = Gdx.app.getPreferences(PREFS_NAME); + loadData(); +} + +public static PlayerStats getInstance() { + if (instance == null) instance = new PlayerStats(); + return instance; +} + +// After +private final Preferences preferences; + +public PlayerStats(Preferences preferences) { + this.preferences = preferences; + loadData(); +} +``` + +The private `PREFS_NAME = "tower_attack_player_data"` constant is deleted; the same literal moves to `Main` as `private static final String PLAYER_DATA_PREFS_NAME`. `PlayerAbilities` gets analogous treatment with `ABILITIES_PREFS_NAME`. + +`GameState` has no `Preferences`, so its constructor is simply made public and argumentless. `TalentTree(PlayerStats stats)` stores the reference and replaces the two `PlayerStats.getInstance()` calls at lines 133 / 138 with `this.stats.getTalentNodeLevel(...)` and `this.stats.incrementTalentNode(...)`. + +### `Main` as composition root + +```java +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"; + + public GameContext ctx; + + @Override + public void create() { + ctx = new GameContext( + Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME), + Gdx.app.getPreferences(ABILITIES_PREFS_NAME) + ); + setScreen(new MainScreen(this, ctx)); + } +} +``` + +`ctx` is a public field on `Main` so screens that already hold a `Main` reference can read it if needed, but the preferred path is receiving `GameContext` in the constructor. + +### Screen constructor changes + +Every screen class currently instantiated with `new Xxx(game)` becomes `new Xxx(game, ctx)`: + +- `MainScreen(Main game, GameContext ctx)` +- `FirstScreen(Main game, GameContext ctx)` +- `AbilityShopScreen(Main game, GameContext ctx)` +- `AbilitySelectionScreen(Main game, GameContext ctx)` +- `TalentTreeScreen(Main game, GameContext ctx)` + +Every `X.getInstance()` call becomes `ctx.x`. In `FirstScreen`, where there are 8 calls, cache into fields (`private final PlayerStats playerStats;` etc.) during the constructor to avoid `ctx.playerStats` repetition. + +### `FakePreferences` (test helper, `core/src/test/java/ru/project/tower/FakePreferences.java`) + +An in-memory implementation of `com.badlogic.gdx.Preferences` backed by `HashMap`. Approximately 30 trivial methods — every `putX(k, v)` stores in the map, every `getX(k)` / `getX(k, default)` reads. `flush()` is a no-op. `clear()` clears the map. `contains(k)` delegates to `map.containsKey`. + +One shared helper serves every test class. Chosen over Mockito because Mockito for a single deterministic interface adds a dependency and weaker error messages for a trivial case. + +## Data flow + +**Production:** +``` +Main.create() + └─ creates two Preferences via Gdx.app.getPreferences(...) + └─ new GameContext(playerDataPrefs, abilitiesPrefs) + └─ setScreen(new MainScreen(this, ctx)) + │ + └─ ctx propagates to every downstream screen constructor + └─ screens access ctx.playerStats, ctx.talentTree, etc. +``` + +**Tests:** +``` +@BeforeEach + └─ new FakePreferences() + └─ new PlayerStats(fakePrefs) // or the class under test, injected with fakes +@Test + └─ AAA: arrange state, invoke method, assert outcome + (both on the object's API and on the fake Preferences where persistence matters) +``` + +## Test conventions + +- **Framework:** JUnit 5 (`org.junit.jupiter:junit-jupiter:5.10.2`). +- **Assertions:** AssertJ (`org.assertj:assertj-core:3.26.3`). All assertions use `assertThat(...)` — no `assertEquals`. Rationale: fluent API, clearer messages, industry standard in modern Java. +- **Test-method naming:** `methodName_whenCondition_thenExpected`. Sorts naturally by method in IDE, reads like a sentence. + - `incrementTalentNode_whenNewNode_thenStoredInPreferences` + - `getCurrency_whenNotYetSet_thenReturnsZero` +- **Test-class naming:** `Test` (e.g., `PlayerStatsTest.java`). +- **Body structure:** Arrange / Act / Assert separated by blank lines. No `// Arrange` comments. +- **Isolation:** JUnit 5's default new-instance-per-test + `@BeforeEach` for setup. No static state between tests. +- **One behaviour per test.** If a test has three `assertThat(...)` lines, they should all be aspects of the same behaviour (e.g., state returned _and_ persisted). Don't bundle unrelated scenarios. + +## Test coverage targets + +Approximately 30–35 test methods across 6 test classes, as sketched below. This is a floor, not a ceiling — prefer covering each public-API method with at least one happy-path test and one edge case. + +### `PlayerStatsTest` +- `getCurrency_whenNotYetSet_thenReturnsZero` +- `addCurrency_whenCalled_thenIncrementsAndPersists` +- `spendCurrency_whenInsufficient_thenReturnsFalseAndDoesNotDeduct` +- `spendCurrency_whenSufficient_thenReturnsTrueAndDeducts` +- `getMaxWaveReached_thenReadsFromPreferences` +- `updateMaxWaveReached_whenNewHigher_thenUpdates` +- `updateMaxWaveReached_whenNewLower_thenIgnored` +- `getTalentNodeLevel_whenUnseen_thenZero` +- `incrementTalentNode_whenCalled_thenLevelOneAndPersisted` +- `incrementTalentNode_whenCalledTwice_thenLevelTwo` +- Legacy aggregate getter(s): one test covering the wrapper still reports the right total. +- A test that constructs `PlayerStats` from pre-populated `FakePreferences` and reads back expected state (persistence round-trip). + +### `PlayerAbilitiesTest` +- Every ability-unlock flag, get/set round-trip through `FakePreferences`. +- Unlock toggles don't affect unrelated flags. +- Default-state test (fresh `FakePreferences` gives the expected initial roster). + +### `GameStateTest` +- Initial values (wave, square health, money). +- Wave increment. +- Phase 2 `run*` bonus fields (damage, pierce, etc.) accumulate correctly. +- Reset-for-new-run behaviour. The implementation plan must first confirm whether `GameState` has a reset API; if not, the plan adds a small one as part of this spec (this is a test-enabling concession, not feature work). + +### `TalentTreeTest` +- `initializeTree` creates expected roots and node IDs. +- `getTalentLevel(id)` delegates to injected `PlayerStats`. +- `incrementTalent(id)` delegates to injected `PlayerStats`. +- Keystone node IDs (`keystone_pierce`, `keystone_crit`, `keystone_aoe`) are all registered. + +### `TalentDataTest` +- Construction: id, name, description, type, position, max level, base cost, cost multiplier all round-trip via getters. +- Cost-at-level formula round-trip. The exact formula is read from the current `TalentData` source during implementation; the test locks in whatever that formula is, not a newly-invited one. + +### `GameContextTest` +- Construction with two `FakePreferences` yields non-null services. +- `ctx.talentTree.getTalentLevel(someId)` reads from the same `PlayerStats` that `ctx.playerStats` exposes (i.e., wiring is correct). +- Construction order: `TalentTree` sees the injected `PlayerStats`, not a new one. + +## Build configuration + +`core/build.gradle`: + +```gradle +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() +} +``` + +Java 8 compatibility is preserved: JUnit 5.10 and AssertJ 3.26 both still ship Java-8-compatible bytecode. + +## File layout + +``` +core/src/ + main/java/ru/project/tower/ + GameContext.java [NEW] + PlayerStats.java [MODIFIED — constructor + remove getInstance] + PlayerAbilities.java [MODIFIED — constructor + remove getInstance] + GameState.java [MODIFIED — constructor public + remove getInstance] + TalentTree.java [MODIFIED — constructor + remove getInstance] + Main.java [MODIFIED — composition root] + MainScreen.java [MODIFIED — constructor takes ctx] + FirstScreen.java [MODIFIED — constructor takes ctx, replace 8 getInstance] + AbilityShopScreen.java [MODIFIED — constructor takes ctx] + AbilitySelectionScreen.java [MODIFIED — constructor takes ctx] + TalentTreeScreen.java [MODIFIED — constructor takes ctx] + test/java/ru/project/tower/ [NEW TREE] + FakePreferences.java + PlayerStatsTest.java + PlayerAbilitiesTest.java + GameStateTest.java + TalentTreeTest.java + TalentDataTest.java + GameContextTest.java +``` + +## Error handling + +There is one edge where the refactor can silently change behaviour: `getInstance()` lazily creates the singleton on first call. A class that was instantiated *before* `Gdx.app` was ready (unlikely in this codebase but worth naming) would now fail eagerly at `Main.create()` instead of at first use. Since `Main.create()` is the libGDX entry point and `Gdx.app` is guaranteed set by the backend before `create()` runs, this is safe. + +All other error paths are unchanged — `Preferences.putX`/`getX` throw the same exceptions; `loadData()` runs the same logic. + +## Verification + +Because K.1 introduces the test harness itself, verification is both **automated** (the tests) and **manual** (the DI refactor must not break the running game): + +- `./gradlew test` runs cleanly, all tests pass. +- Launch `Lwjgl3Launcher`. Confirm: + - Main menu renders; currency and max-wave values read from existing preferences. + - Entering the talent tree shows purchased levels unchanged. + - Starting a run, buying an upgrade, dying, and returning to menu preserves currency. + - Ability shop displays owned abilities correctly. + +Any of these failing indicates the `getInstance()` → `ctx.x` substitution dropped a wire. + +## Risks and mitigations + +- **Missed call-site.** 15 `getInstance()` calls plus possibly some we didn't count (constants, static initializers). Mitigation: after the refactor, `grep -r "getInstance()" core/src/main` must return zero hits. Automated in implementation plan. +- **Screen-constructor signature changes break something external.** `lwjgl3/` and `android/` only touch `Main`, not individual screens. Mitigation: grep across all modules for `new MainScreen(`, `new FirstScreen(`, etc. after the refactor. +- **Hidden `Preferences`-name constants.** We found two (`tower_attack_player_data`, `tower_attack_player_abilities`). If Phase 3 work introduces a third, the spec still works — just add another `Preferences` parameter to `GameContext`. Not a risk for K.1 itself. +- **Test flakiness from order dependence.** Prevented by JUnit 5 per-test-instance default + fresh `FakePreferences` in `@BeforeEach`. + +## Out of scope (reminder) + +K.1 delivers the harness and proves the pattern on five pure-logic classes. Every subsequent K.x spec (K.2–K.7) builds on this to extract and test real gameplay subsystems. K.1 is merged when: + +1. All 15+ `getInstance()` calls are gone. +2. `GameContext` is the sole composition point for persistent services. +3. `./gradlew test` runs and passes all 30+ tests authored in this spec. +4. The game still boots and plays correctly on the desktop launcher. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..535a929 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.daemon=false +org.gradle.jvmargs=-Xms512M -Xmx1G -Dfile.encoding=UTF-8 -Dconsole.encoding=UTF-8 +org.gradle.configureondemand=false +graalHelperVersion=2.0.1 +enableGraalNative=false +android.useAndroidX=true +android.enableR8.fullMode=false +gdxVersion=1.13.1 +projectVersion=1.0.0 diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..e6b22c8 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/538ec8b643622db586076dcc6de01496/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/3c821b3d2a2ed31cd255a8836df5ca27/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/538ec8b643622db586076dcc6de01496/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/3c821b3d2a2ed31cd255a8836df5ca27/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4eb299e08ff72ece03792f6745206490/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/240ef070840c3324981ae8cc2985a9e6/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/538ec8b643622db586076dcc6de01496/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/3c821b3d2a2ed31cd255a8836df5ca27/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ad1096e6e0353fb5900e1a5ece7d864a/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/41f6d00ddc15a000f14896a6d3bfb1b8/redirect +toolchainVendor=BELLSOFT +toolchainVersion=17 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lwjgl3/build.gradle b/lwjgl3/build.gradle new file mode 100644 index 0000000..f871d9f --- /dev/null +++ b/lwjgl3/build.gradle @@ -0,0 +1,180 @@ + +buildscript { + repositories { + gradlePluginPortal() + } + dependencies { + classpath "io.github.fourlastor:construo:1.7.1" + if(enableGraalNative == 'true') { + classpath "org.graalvm.buildtools.native:org.graalvm.buildtools.native.gradle.plugin:0.9.28" + } + } +} +plugins { + id "application" +} +apply plugin: 'io.github.fourlastor.construo' + + +import io.github.fourlastor.construo.Target + +sourceSets.main.resources.srcDirs += [ rootProject.file('assets').path ] +mainClassName = 'ru.project.tower.lwjgl3.Lwjgl3Launcher' +application.setMainClass(mainClassName) +eclipse.project.name = appName + '-lwjgl3' +java.sourceCompatibility = 8 +java.targetCompatibility = 8 +if (JavaVersion.current().isJava9Compatible()) { + compileJava.options.release.set(8) +} + +dependencies { + implementation "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion" + implementation "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-desktop" + implementation "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" + implementation project(':core') + + if(enableGraalNative == 'true') { + implementation "io.github.berstanio:gdx-svmhelper-backend-lwjgl3:$graalHelperVersion" + implementation "io.github.berstanio:gdx-svmhelper-extension-freetype:$graalHelperVersion" + + } +} + +def os = System.properties['os.name'].toLowerCase() + +run { + workingDir = rootProject.file('assets').path +// You can uncomment the next line if your IDE claims a build failure even when the app closed properly. + //setIgnoreExitValue(true) + + if (os.contains('mac')) jvmArgs += "-XstartOnFirstThread" +} + +jar { +// sets the name of the .jar file this produces to the name of the game or app, with the version after. + archiveFileName.set("${appName}-${projectVersion}.jar") +// the duplicatesStrategy matters starting in Gradle 7.0; this setting works. + duplicatesStrategy(DuplicatesStrategy.EXCLUDE) + dependsOn configurations.runtimeClasspath + from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } } +// these "exclude" lines remove some unnecessary duplicate files in the output JAR. + exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + exclude('META-INF/INDEX.LIST', 'META-INF/maven/**') + } +// setting the manifest makes the JAR runnable. + manifest { + attributes 'Main-Class': project.mainClassName + } +// this last step may help on some OSes that need extra instruction to make runnable JARs. + doLast { + file(archiveFile).setExecutable(true, false) + } +} + +// Builds a JAR that only includes the files needed to run on macOS, not Windows or Linux. +// The file size for a Mac-only JAR is about 7MB smaller than a cross-platform JAR. +tasks.register("jarMac") { + dependsOn("jar") + group("build") + jar.archiveFileName.set("${appName}-${projectVersion}-mac.jar") + jar.exclude("windows/x86/**", "windows/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", "**/*.dll", "**/*.so", + 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + jar.exclude("windows/x86/**", "windows/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", + 'META-INF/INDEX.LIST', 'META-INF/maven/**') + } +} + +// Builds a JAR that only includes the files needed to run on Linux, not Windows or macOS. +// The file size for a Linux-only JAR is about 5MB smaller than a cross-platform JAR. +tasks.register("jarLinux") { + dependsOn("jar") + group("build") + jar.archiveFileName.set("${appName}-${projectVersion}-linux.jar") + jar.exclude("windows/x86/**", "windows/x64/**", "macos/arm64/**", "macos/x64/**", "**/*.dll", "**/*.dylib", + 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + jar.exclude("windows/x86/**", "windows/x64/**", "macos/arm64/**", "macos/x64/**", + 'META-INF/INDEX.LIST', 'META-INF/maven/**') + } +} + +// Builds a JAR that only includes the files needed to run on Windows, not Linux or macOS. +// The file size for a Windows-only JAR is about 6MB smaller than a cross-platform JAR. +tasks.register("jarWin") { + dependsOn("jar") + group("build") + jar.archiveFileName.set("${appName}-${projectVersion}-win.jar") + jar.exclude("macos/arm64/**", "macos/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", "**/*.dylib", "**/*.so", + 'META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + jar.exclude("macos/arm64/**", "macos/x64/**", "linux/arm32/**", "linux/arm64/**", "linux/x64/**", + 'META-INF/INDEX.LIST', 'META-INF/maven/**') + } +} + +construo { + // name of the executable + name.set(appName) + // human-readable name, used for example in the `.app` name for macOS + humanName.set(appName) + // Optional, defaults to project version property + version.set("$projectVersion") + + targets.configure { + create("linuxX64", Target.Linux) { + architecture.set(Target.Architecture.X86_64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz") + } + create("macM1", Target.MacOs) { + architecture.set(Target.Architecture.AARCH64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.12_7.tar.gz") + // macOS needs an identifier + identifier.set("ru.project.tower." + appName) + // Optional: icon for macOS + macIcon.set(project.file("icons/logo.icns")) + } + create("macX64", Target.MacOs) { + architecture.set(Target.Architecture.X86_64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.12_7.tar.gz") + // macOS needs an identifier + identifier.set("ru.project.tower." + appName) + // Optional: icon for macOS + macIcon.set(project.file("icons/logo.icns")) + } + create("winX64", Target.Windows) { + architecture.set(Target.Architecture.X86_64) + jdkUrl.set("https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_windows_hotspot_17.0.12_7.zip") + // Uncomment the next line to show a console when the game runs, to print messages. + //useConsole.set(true) + } + } +} + +// Equivalent to the jar task; here for compatibility with gdx-setup. +tasks.register('dist') { + dependsOn 'jar' +} + +distributions { + main { + contents { + into('libs') { + project.configurations.runtimeClasspath.files.findAll { file -> + file.getName() != project.tasks.jar.outputs.files.singleFile.name + }.each { file -> + exclude file.name + } + } + } + } +} + +startScripts.dependsOn(':lwjgl3:jar') +startScripts.classpath = project.tasks.jar.outputs.files + +if(enableGraalNative == 'true') { + apply from: file("nativeimage.gradle") +} diff --git a/lwjgl3/icons/logo.icns b/lwjgl3/icons/logo.icns new file mode 100644 index 0000000..5e41ad7 Binary files /dev/null and b/lwjgl3/icons/logo.icns differ diff --git a/lwjgl3/icons/logo.ico b/lwjgl3/icons/logo.ico new file mode 100644 index 0000000..c4f2d5e Binary files /dev/null and b/lwjgl3/icons/logo.ico differ diff --git a/lwjgl3/icons/logo.png b/lwjgl3/icons/logo.png new file mode 100644 index 0000000..788f542 Binary files /dev/null and b/lwjgl3/icons/logo.png differ diff --git a/lwjgl3/nativeimage.gradle b/lwjgl3/nativeimage.gradle new file mode 100644 index 0000000..ff349fd --- /dev/null +++ b/lwjgl3/nativeimage.gradle @@ -0,0 +1,54 @@ + +project(":lwjgl3") { + apply plugin: "org.graalvm.buildtools.native" + + graalvmNative { + binaries { + main { + imageName = appName + mainClass = project.mainClassName + requiredVersion = '23.0' + buildArgs.add("-march=compatibility") + jvmArgs.addAll("-Dfile.encoding=UTF8") + sharedLibrary = false + resources.autodetect() + } + } + } + + run { + doNotTrackState("Running the app should not be affected by Graal.") + } + + // Modified from https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/ ; thanks again, Lyze! + // This creates a resource-config.json file based on the contents of the assets folder (and the libGDX icons). + // This file is used by Graal Native to embed those specific files. + // This has to run before nativeCompile, so it runs at the start of an unrelated resource-handling command. + generateResourcesConfigFile.doFirst { + def assetsFolder = new File("${project.rootDir}/assets/") + def lwjgl3 = project(':lwjgl3') + def resFolder = new File("${lwjgl3.projectDir}/src/main/resources/META-INF/native-image/${lwjgl3.ext.appName}") + resFolder.mkdirs() + def resFile = new File(resFolder, "resource-config.json") + resFile.delete() + resFile.append( + """{ + "resources":{ + "includes":[ + { + "pattern": ".*(""") + // This adds every filename in the assets/ folder to a pattern that adds those files as resources. + fileTree(assetsFolder).each { + // The backslash-Q and backslash-E escape the start and end of a literal string, respectively. + resFile.append("\\\\Q${it.name}\\\\E|") + } + // We also match all of the window icon images this way and the font files that are part of libGDX. + resFile.append( + """libgdx.+\\\\.png|lsans.+)" + } + ]}, + "bundles":[] +}""" + ) + } +} diff --git a/lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher.java b/lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher.java new file mode 100644 index 0000000..3c55a5d --- /dev/null +++ b/lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher.java @@ -0,0 +1,37 @@ +package ru.project.tower.lwjgl3; + +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application; +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration; +import ru.project.tower.Main; + +/** Launches the desktop (LWJGL3) application. */ +public class Lwjgl3Launcher { + public static void main(String[] args) { + if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows. + createApplication(); + } + + private static Lwjgl3Application createApplication() { + return new Lwjgl3Application(new Main(), getDefaultConfiguration()); + } + + private static Lwjgl3ApplicationConfiguration getDefaultConfiguration() { + Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration(); + configuration.setTitle("Square Defense"); + //// Vsync limits the frames per second to what your hardware can display, and helps eliminate + //// screen tearing. This setting doesn't always work on Linux, so the line after is a safeguard. + configuration.useVsync(true); + //// Limits FPS to the refresh rate of the currently active monitor, plus 1 to try to match fractional + //// refresh rates. The Vsync setting above should limit the actual FPS to match the monitor. + configuration.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate + 1); + //// If you remove the above line and set Vsync to false, you can get unlimited FPS, which can be + //// useful for testing performance, but can also be very stressful to some hardware. + //// You may also need to configure GPU drivers to fully disable Vsync; this can cause screen tearing. + + configuration.setWindowedMode(540, 960); + //// You can change these files; they are in lwjgl3/src/main/resources/ . + //// They can also be loaded from the root of assets/ . + configuration.setWindowIcon("libgdx128.png", "libgdx64.png", "libgdx32.png", "libgdx16.png"); + return configuration; + } +} diff --git a/lwjgl3/src/main/java/ru/project/tower/lwjgl3/StartupHelper.java b/lwjgl3/src/main/java/ru/project/tower/lwjgl3/StartupHelper.java new file mode 100644 index 0000000..7599a51 --- /dev/null +++ b/lwjgl3/src/main/java/ru/project/tower/lwjgl3/StartupHelper.java @@ -0,0 +1,204 @@ +/* + * Copyright 2020 damios + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//Note, the above license and copyright applies to this file only. + +package ru.project.tower.lwjgl3; + +import com.badlogic.gdx.Version; +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3NativesLoader; +import org.lwjgl.system.macosx.LibC; +import org.lwjgl.system.macosx.ObjCRuntime; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; + +import static org.lwjgl.system.JNI.invokePPP; +import static org.lwjgl.system.JNI.invokePPZ; +import static org.lwjgl.system.macosx.ObjCRuntime.objc_getClass; +import static org.lwjgl.system.macosx.ObjCRuntime.sel_getUid; + +/** + * Adds some utilities to ensure that the JVM was started with the + * {@code -XstartOnFirstThread} argument, which is required on macOS for LWJGL 3 + * to function. Also helps on Windows when users have names with characters from + * outside the Latin alphabet, a common cause of startup crashes. + *
+ *
Based on this java-gaming.org post by kappa + * @author damios + */ +public class StartupHelper { + + private static final String JVM_RESTARTED_ARG = "jvmIsRestarted"; + + private StartupHelper() { + throw new UnsupportedOperationException(); + } + + /** + * Starts a new JVM if the application was started on macOS without the + * {@code -XstartOnFirstThread} argument. This also includes some code for + * Windows, for the case where the user's home directory includes certain + * non-Latin-alphabet characters (without this code, most LWJGL3 apps fail + * immediately for those users). Returns whether a new JVM was started and + * thus no code should be executed. + *

+ * Usage: + * + *


+     * public static void main(String... args) {
+     * 	if (StartupHelper.startNewJvmIfRequired(true)) return; // This handles macOS support and helps on Windows.
+     * 	// after this is the actual main method code
+     * }
+     * 
+ * + * @param redirectOutput + * whether the output of the new JVM should be rerouted to the + * old JVM, so it can be accessed in the same place; keeps the + * old JVM running if enabled + * @return whether a new JVM was started and thus no code should be executed + * in this one + */ + public static boolean startNewJvmIfRequired(boolean redirectOutput) { + String osName = System.getProperty("os.name").toLowerCase(); + if (!osName.contains("mac")) { + if (osName.contains("windows")) { +// Here, we are trying to work around an issue with how LWJGL3 loads its extracted .dll files. +// By default, LWJGL3 extracts to the directory specified by "java.io.tmpdir", which is usually the user's home. +// If the user's name has non-ASCII (or some non-alphanumeric) characters in it, that would fail. +// By extracting to the relevant "ProgramData" folder, which is usually "C:\ProgramData", we avoid this. +// We also temporarily change the "user.name" property to one without any chars that would be invalid. +// We revert our changes immediately after loading LWJGL3 natives. + String programData = System.getenv("ProgramData"); + if(programData == null) programData = "C:\\Temp\\"; // if ProgramData isn't set, try some fallback. + String prevTmpDir = System.getProperty("java.io.tmpdir", programData); + String prevUser = System.getProperty("user.name", "libGDX_User"); + System.setProperty("java.io.tmpdir", programData + "/libGDX-temp"); + System.setProperty("user.name", ("User_" + prevUser.hashCode() + "_GDX" + Version.VERSION).replace('.', '_')); + Lwjgl3NativesLoader.load(); + System.setProperty("java.io.tmpdir", prevTmpDir); + System.setProperty("user.name", prevUser); + } + return false; + } + + // There is no need for -XstartOnFirstThread on Graal native image + if (!System.getProperty("org.graalvm.nativeimage.imagecode", "").isEmpty()) { + return false; + } + + // Checks if we are already on the main thread, such as from running via Construo. + long objc_msgSend = ObjCRuntime.getLibrary().getFunctionAddress("objc_msgSend"); + long NSThread = objc_getClass("NSThread"); + long currentThread = invokePPP(NSThread, sel_getUid("currentThread"), objc_msgSend); + boolean isMainThread = invokePPZ(currentThread, sel_getUid("isMainThread"), objc_msgSend); + if(isMainThread) return false; + + long pid = LibC.getpid(); + + // check whether -XstartOnFirstThread is enabled + if ("1".equals(System.getenv("JAVA_STARTED_ON_FIRST_THREAD_" + pid))) { + return false; + } + + // check whether the JVM was previously restarted + // avoids looping, but most certainly leads to a crash + if ("true".equals(System.getProperty(JVM_RESTARTED_ARG))) { + System.err.println( + "There was a problem evaluating whether the JVM was started with the -XstartOnFirstThread argument."); + return false; + } + + // Restart the JVM with -XstartOnFirstThread + ArrayList jvmArgs = new ArrayList<>(); + String separator = System.getProperty("file.separator", "/"); + // The following line is used assuming you target Java 8, the minimum for LWJGL3. + String javaExecPath = System.getProperty("java.home") + separator + "bin" + separator + "java"; + // If targeting Java 9 or higher, you could use the following instead of the above line: + //String javaExecPath = ProcessHandle.current().info().command().orElseThrow(); + + if (!(new File(javaExecPath)).exists()) { + System.err.println( + "A Java installation could not be found. If you are distributing this app with a bundled JRE, be sure to set the -XstartOnFirstThread argument manually!"); + return false; + } + + jvmArgs.add(javaExecPath); + jvmArgs.add("-XstartOnFirstThread"); + jvmArgs.add("-D" + JVM_RESTARTED_ARG + "=true"); + jvmArgs.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments()); + jvmArgs.add("-cp"); + jvmArgs.add(System.getProperty("java.class.path")); + String mainClass = System.getenv("JAVA_MAIN_CLASS_" + pid); + if (mainClass == null) { + StackTraceElement[] trace = Thread.currentThread().getStackTrace(); + if (trace.length > 0) { + mainClass = trace[trace.length - 1].getClassName(); + } else { + System.err.println("The main class could not be determined."); + return false; + } + } + jvmArgs.add(mainClass); + + try { + if (!redirectOutput) { + ProcessBuilder processBuilder = new ProcessBuilder(jvmArgs); + processBuilder.start(); + } else { + Process process = (new ProcessBuilder(jvmArgs)) + .redirectErrorStream(true).start(); + BufferedReader processOutput = new BufferedReader( + new InputStreamReader(process.getInputStream())); + String line; + + while ((line = processOutput.readLine()) != null) { + System.out.println(line); + } + + process.waitFor(); + } + } catch (Exception e) { + System.err.println("There was a problem restarting the JVM"); + e.printStackTrace(); + } + + return true; + } + + /** + * Starts a new JVM if the application was started on macOS without the + * {@code -XstartOnFirstThread} argument. Returns whether a new JVM was + * started and thus no code should be executed. Redirects the output of the + * new JVM to the old one. + *

+ * Usage: + * + *

+     * public static void main(String... args) {
+     * 	if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows.
+     * 	// the actual main method code
+     * }
+     * 
+ * + * @return whether a new JVM was started and thus no code should be executed + * in this one + */ + public static boolean startNewJvmIfRequired() { + return startNewJvmIfRequired(true); + } +} \ No newline at end of file diff --git a/lwjgl3/src/main/resources/libgdx128.png b/lwjgl3/src/main/resources/libgdx128.png new file mode 100644 index 0000000..788f542 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx128.png differ diff --git a/lwjgl3/src/main/resources/libgdx16.png b/lwjgl3/src/main/resources/libgdx16.png new file mode 100644 index 0000000..47af189 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx16.png differ diff --git a/lwjgl3/src/main/resources/libgdx32.png b/lwjgl3/src/main/resources/libgdx32.png new file mode 100644 index 0000000..4cf903a Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx32.png differ diff --git a/lwjgl3/src/main/resources/libgdx64.png b/lwjgl3/src/main/resources/libgdx64.png new file mode 100644 index 0000000..ebcd8f1 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx64.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..52ceff2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +plugins { + // Applies the foojay-resolver plugin to allow automatic download of JDKs. + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} +// A list of which subprojects to load as part of the same larger project. +// You can remove Strings from the list and reload the Gradle project +// if you want to temporarily disable a subproject. +include 'lwjgl3', 'core', 'android'